init
This commit is contained in:
+12
@@ -0,0 +1,12 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.env
|
||||
*.pyc
|
||||
__pycache__/
|
||||
*.db
|
||||
*.sqlite
|
||||
data/
|
||||
.DS_Store
|
||||
.vscode/
|
||||
*.log
|
||||
.idea/
|
||||
@@ -0,0 +1,17 @@
|
||||
FROM python:3.10-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
gcc \
|
||||
libpq-dev \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY backend/requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY backend/ .
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
CMD ["uvicorn", "app.main:socket_app", "--host", "0.0.0.0", "--port", "8000", "--reload"]
|
||||
@@ -0,0 +1,13 @@
|
||||
FROM node:20-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY frontend/package.json frontend/package-lock.json* ./
|
||||
|
||||
RUN npm install
|
||||
|
||||
COPY frontend/ .
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0", "--port", "3000"]
|
||||
@@ -0,0 +1,11 @@
|
||||
FROM python:3.10-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN pip install --no-cache-dir hermes-agent
|
||||
|
||||
RUN mkdir -p /root/.hermes/profiles
|
||||
|
||||
EXPOSE 11434
|
||||
|
||||
CMD ["hermes", "serve", "--host", "0.0.0.0", "--port", "11434"]
|
||||
@@ -0,0 +1,50 @@
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from pydantic_settings import BaseSettings
|
||||
|
||||
from app.routes import script, chat, game, character, clue
|
||||
from app.models.database import init_db
|
||||
from app.models import orm
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
hermes_api_url: str = "http://localhost:11434/v1"
|
||||
hermes_model: str = "hermes-3"
|
||||
database_url: str = "postgresql+asyncpg://hermes:hermes123@localhost:5432/hermes_live"
|
||||
secret_key: str = "change-me-in-production"
|
||||
cors_origins: str = "http://localhost:3000,http://localhost:5173"
|
||||
log_level: str = "INFO"
|
||||
|
||||
model_config = {"env_file": ".env", "env_file_encoding": "utf-8"}
|
||||
|
||||
|
||||
settings = Settings()
|
||||
|
||||
app = FastAPI(title="Hermes Live Show", version="0.1.0")
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=settings.cors_origins.split(","),
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
app.include_router(script.router, prefix="/api/scripts", tags=["scripts"])
|
||||
app.include_router(chat.router, prefix="/api", tags=["messages"])
|
||||
app.include_router(game.router, prefix="/api/game", tags=["game"])
|
||||
app.include_router(character.router, prefix="/api/characters", tags=["characters"])
|
||||
app.include_router(clue.router, prefix="/api", tags=["clues"])
|
||||
|
||||
from app.sockets.handlers import create_socket_app
|
||||
socket_app = create_socket_app(app)
|
||||
|
||||
|
||||
@app.on_event("startup")
|
||||
async def startup():
|
||||
await init_db(settings.database_url)
|
||||
|
||||
|
||||
@app.get("/api/health")
|
||||
def health_check():
|
||||
return {"status": "ok", "version": "0.1.0"}
|
||||
@@ -0,0 +1,41 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import String, Text, DateTime, Integer, ForeignKey, JSON, Enum as SAEnum
|
||||
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker, AsyncSession
|
||||
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
pass
|
||||
|
||||
|
||||
def gen_uuid():
|
||||
return str(uuid.uuid4())
|
||||
|
||||
|
||||
engine = None
|
||||
async_session_maker: async_sessionmaker[AsyncSession] | None = None
|
||||
|
||||
|
||||
async def init_db(database_url: str):
|
||||
global engine, async_session_maker
|
||||
|
||||
engine = create_async_engine(
|
||||
database_url,
|
||||
echo=False,
|
||||
pool_size=20,
|
||||
max_overflow=10,
|
||||
pool_pre_ping=True,
|
||||
)
|
||||
async_session_maker = async_sessionmaker(engine, expire_on_commit=False)
|
||||
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
|
||||
|
||||
async def get_session() -> AsyncSession:
|
||||
if async_session_maker is None:
|
||||
raise RuntimeError("Database not initialized")
|
||||
async with async_session_maker() as session:
|
||||
yield session
|
||||
@@ -0,0 +1,111 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import String, Text, DateTime, Integer, ForeignKey, JSON, Enum as SAEnum
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.models.database import Base, gen_uuid
|
||||
from app.models.schemas import GamePhase, CharacterRole, CharacterStatus, ClueType, ClueVisibility
|
||||
|
||||
|
||||
class Script(Base):
|
||||
__tablename__ = "scripts"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=gen_uuid)
|
||||
title: Mapped[str] = mapped_column(String(200), nullable=False)
|
||||
background: Mapped[str] = mapped_column(Text, default="")
|
||||
raw_content: Mapped[str] = mapped_column(Text, default="")
|
||||
parsed_data: Mapped[dict] = mapped_column(JSON, default=dict)
|
||||
character_count: Mapped[int] = mapped_column(Integer, default=0)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||
|
||||
characters: Mapped[list["Character"]] = relationship(back_populates="script", cascade="all, delete-orphan")
|
||||
clues: Mapped[list["Clue"]] = relationship(back_populates="script", cascade="all, delete-orphan")
|
||||
|
||||
|
||||
class Character(Base):
|
||||
__tablename__ = "characters"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=gen_uuid)
|
||||
script_id: Mapped[str] = mapped_column(String(36), ForeignKey("scripts.id"), nullable=False)
|
||||
name: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||
role: Mapped[CharacterRole] = mapped_column(SAEnum(CharacterRole), default=CharacterRole.SUSPECT)
|
||||
status: Mapped[CharacterStatus] = mapped_column(SAEnum(CharacterStatus), default=CharacterStatus.ALIVE)
|
||||
personality: Mapped[str] = mapped_column(Text, default="")
|
||||
speaking_style: Mapped[str] = mapped_column(Text, default="")
|
||||
background: Mapped[str] = mapped_column(Text, default="")
|
||||
secret: Mapped[str] = mapped_column(Text, default="")
|
||||
motive: Mapped[str] = mapped_column(Text, default="")
|
||||
avatar_url: Mapped[str] = mapped_column(String(500), default="")
|
||||
hermes_profile: Mapped[str] = mapped_column(String(100), default="")
|
||||
soul_md: Mapped[str] = mapped_column(Text, default="")
|
||||
knowledge_base: Mapped[str] = mapped_column(Text, default="")
|
||||
is_revealed_killer: Mapped[bool] = mapped_column(default=False)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||
|
||||
script: Mapped["Script"] = relationship(back_populates="characters")
|
||||
messages: Mapped[list["Message"]] = relationship(back_populates="character", cascade="all, delete-orphan")
|
||||
|
||||
|
||||
class Message(Base):
|
||||
__tablename__ = "messages"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=gen_uuid)
|
||||
session_id: Mapped[str] = mapped_column(String(36), default="")
|
||||
character_id: Mapped[str] = mapped_column(String(36), ForeignKey("characters.id"), nullable=True)
|
||||
game_phase: Mapped[str] = mapped_column(String(50), default="")
|
||||
msg_type: Mapped[str] = mapped_column(String(50), default="character")
|
||||
content: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
target_character_id: Mapped[str] = mapped_column(String(36), nullable=True)
|
||||
clue_id: Mapped[str] = mapped_column(String(36), nullable=True)
|
||||
metadata: Mapped[dict] = mapped_column(JSON, default=dict)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||
|
||||
character: Mapped["Character"] = relationship(back_populates="messages")
|
||||
|
||||
|
||||
class Clue(Base):
|
||||
__tablename__ = "clues"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=gen_uuid)
|
||||
script_id: Mapped[str] = mapped_column(String(36), ForeignKey("scripts.id"), nullable=False)
|
||||
name: Mapped[str] = mapped_column(String(200), nullable=False)
|
||||
content: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
clue_type: Mapped[ClueType] = mapped_column(SAEnum(ClueType), default=ClueType.PHYSICAL)
|
||||
owner_id: Mapped[str] = mapped_column(String(36), nullable=True)
|
||||
phase: Mapped[str] = mapped_column(String(50), default="round1_search")
|
||||
visibility: Mapped[ClueVisibility] = mapped_column(SAEnum(ClueVisibility), default=ClueVisibility.ALL)
|
||||
visible_to: Mapped[list] = mapped_column(JSON, default=list)
|
||||
is_unlocked: Mapped[bool] = mapped_column(default=False)
|
||||
unlocked_by: Mapped[str] = mapped_column(String(36), nullable=True)
|
||||
unlocked_at: Mapped[datetime] = mapped_column(DateTime, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||
|
||||
script: Mapped["Script"] = relationship(back_populates="clues")
|
||||
|
||||
|
||||
class GameState(Base):
|
||||
__tablename__ = "game_state"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=gen_uuid)
|
||||
current_phase: Mapped[str] = mapped_column(String(50), default="intro")
|
||||
current_speaker_id: Mapped[str] = mapped_column(String(36), nullable=True)
|
||||
speaker_order: Mapped[list] = mapped_column(JSON, default=list)
|
||||
active_script_id: Mapped[str] = mapped_column(String(36), nullable=True)
|
||||
is_running: Mapped[bool] = mapped_column(default=False)
|
||||
is_paused: Mapped[bool] = mapped_column(default=False)
|
||||
phase_started_at: Mapped[datetime] = mapped_column(DateTime, nullable=True)
|
||||
config: Mapped[dict] = mapped_column(JSON, default=dict)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
|
||||
class Vote(Base):
|
||||
__tablename__ = "votes"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=gen_uuid)
|
||||
voter_id: Mapped[str] = mapped_column(String(36), nullable=False)
|
||||
target_id: Mapped[str] = mapped_column(String(36), nullable=False)
|
||||
round_number: Mapped[int] = mapped_column(Integer, default=1)
|
||||
reason: Mapped[str] = mapped_column(Text, default="")
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||
@@ -0,0 +1,237 @@
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
from typing import Optional
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class GamePhase(str, Enum):
|
||||
INTRO = "intro"
|
||||
ROUND1_SPEAK = "round1_speak"
|
||||
ROUND1_SEARCH = "round1_search"
|
||||
ROUND2_SPEAK = "round2_speak"
|
||||
ROUND2_SEARCH = "round2_search"
|
||||
FINAL_DISCUSS = "final_discuss"
|
||||
VOTING = "voting"
|
||||
REVEAL = "reveal"
|
||||
|
||||
@classmethod
|
||||
def ordered_phases(cls) -> list[str]:
|
||||
return [
|
||||
"intro", "round1_speak", "round1_search",
|
||||
"round2_speak", "round2_search",
|
||||
"final_discuss", "voting", "reveal"
|
||||
]
|
||||
|
||||
@classmethod
|
||||
def phase_count(cls) -> int:
|
||||
return len(cls.ordered_phases())
|
||||
|
||||
|
||||
class CharacterRole(str, Enum):
|
||||
DETECTIVE = "detective"
|
||||
SUSPECT = "suspect"
|
||||
WITNESS = "witness"
|
||||
VICTIM = "victim"
|
||||
KILLER = "killer"
|
||||
|
||||
|
||||
class CharacterStatus(str, Enum):
|
||||
ALIVE = "alive"
|
||||
DEAD = "dead"
|
||||
INACTIVE = "inactive"
|
||||
|
||||
|
||||
class MessageType(str, Enum):
|
||||
SYSTEM = "system"
|
||||
DM = "dm"
|
||||
CHARACTER = "character"
|
||||
VOTE = "vote"
|
||||
CLUE = "clue"
|
||||
|
||||
|
||||
class ClueType(str, Enum):
|
||||
PHYSICAL = "physical"
|
||||
TESTIMONY = "testimony"
|
||||
MOTIVE = "motive"
|
||||
ALIBI = "alibi"
|
||||
FORENSIC = "forensic"
|
||||
|
||||
|
||||
class ClueVisibility(str, Enum):
|
||||
ALL = "all"
|
||||
SPECIFIC = "specific"
|
||||
HIDDEN = "hidden"
|
||||
|
||||
|
||||
class ScriptUploadRequest(BaseModel):
|
||||
title: str
|
||||
content: str
|
||||
file_type: str = "natural_language"
|
||||
|
||||
|
||||
class ScriptParsePreview(BaseModel):
|
||||
title: str
|
||||
background: str
|
||||
characters: list[dict]
|
||||
clues: list[dict]
|
||||
phases: list[dict]
|
||||
|
||||
|
||||
class ScriptImportRequest(BaseModel):
|
||||
title: str
|
||||
background: str = ""
|
||||
characters: list[dict] = Field(default_factory=list)
|
||||
clues: list[dict] = Field(default_factory=list)
|
||||
phases: list[dict] = Field(default_factory=list)
|
||||
|
||||
|
||||
class ScriptResponse(BaseModel):
|
||||
id: str
|
||||
title: str
|
||||
background: str
|
||||
character_count: int
|
||||
created_at: datetime
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class CharacterCreate(BaseModel):
|
||||
name: str
|
||||
role: CharacterRole = CharacterRole.SUSPECT
|
||||
personality: str = ""
|
||||
speaking_style: str = ""
|
||||
background: str = ""
|
||||
secret: str = ""
|
||||
motive: str = ""
|
||||
avatar_url: str = ""
|
||||
|
||||
|
||||
class CharacterResponse(BaseModel):
|
||||
id: str
|
||||
script_id: str
|
||||
name: str
|
||||
role: CharacterRole
|
||||
status: CharacterStatus
|
||||
personality: str
|
||||
speaking_style: str
|
||||
background: str
|
||||
secret: str
|
||||
motive: str
|
||||
avatar_url: str
|
||||
hermes_profile: str
|
||||
soul_md: str
|
||||
is_revealed_killer: bool
|
||||
created_at: datetime
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class SoulUpdateRequest(BaseModel):
|
||||
soul_md: str
|
||||
knowledge_base: str = ""
|
||||
|
||||
|
||||
class CharacterSpeakRequest(BaseModel):
|
||||
prompt: str = ""
|
||||
target_character_id: Optional[str] = None
|
||||
|
||||
|
||||
class CharacterGenerateRequest(BaseModel):
|
||||
script_id: str
|
||||
character_names: list[str]
|
||||
|
||||
|
||||
class MessageCreate(BaseModel):
|
||||
session_id: str = ""
|
||||
character_id: Optional[str] = None
|
||||
game_phase: str = ""
|
||||
msg_type: str = "character"
|
||||
content: str
|
||||
target_character_id: Optional[str] = None
|
||||
|
||||
|
||||
class MessageResponse(BaseModel):
|
||||
id: str
|
||||
session_id: str
|
||||
character_id: Optional[str] = None
|
||||
game_phase: str
|
||||
msg_type: str
|
||||
content: str
|
||||
target_character_id: Optional[str] = None
|
||||
clue_id: Optional[str] = None
|
||||
metadata: dict
|
||||
created_at: datetime
|
||||
character_name: Optional[str] = None
|
||||
character_role: Optional[CharacterRole] = None
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class ClueCreate(BaseModel):
|
||||
script_id: str
|
||||
name: str
|
||||
content: str
|
||||
clue_type: ClueType = ClueType.PHYSICAL
|
||||
owner_id: Optional[str] = None
|
||||
phase: str = "round1_search"
|
||||
visibility: ClueVisibility = ClueVisibility.ALL
|
||||
visible_to: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class ClueUnlockRequest(BaseModel):
|
||||
character_id: str
|
||||
|
||||
|
||||
class ClueResponse(BaseModel):
|
||||
id: str
|
||||
script_id: str
|
||||
name: str
|
||||
content: str
|
||||
clue_type: ClueType
|
||||
owner_id: Optional[str] = None
|
||||
phase: str
|
||||
visibility: ClueVisibility
|
||||
visible_to: list
|
||||
is_unlocked: bool
|
||||
unlocked_by: Optional[str] = None
|
||||
unlocked_at: Optional[datetime] = None
|
||||
created_at: datetime
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class DmSpeakRequest(BaseModel):
|
||||
content: str
|
||||
phase: str = ""
|
||||
|
||||
|
||||
class GameStateResponse(BaseModel):
|
||||
id: str
|
||||
current_phase: GamePhase
|
||||
current_speaker_id: Optional[str] = None
|
||||
speaker_order: list
|
||||
active_script_id: Optional[str] = None
|
||||
is_running: bool
|
||||
is_paused: bool
|
||||
phase_started_at: Optional[datetime] = None
|
||||
config: dict
|
||||
progress_percent: float = 0.0
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class VoteCreate(BaseModel):
|
||||
voter_id: str
|
||||
target_id: str
|
||||
reason: str = ""
|
||||
|
||||
|
||||
class VoteResponse(BaseModel):
|
||||
id: str
|
||||
voter_id: str
|
||||
target_id: str
|
||||
round_number: int
|
||||
reason: str
|
||||
created_at: datetime
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
@@ -0,0 +1,140 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.database import get_session
|
||||
from app.models.orm import Character
|
||||
from app.models.schemas import (
|
||||
CharacterResponse,
|
||||
CharacterCreate,
|
||||
CharacterStatus,
|
||||
CharacterSpeakRequest,
|
||||
SoulUpdateRequest,
|
||||
CharacterGenerateRequest,
|
||||
)
|
||||
from app.services.character_generator import character_generator
|
||||
from app.services.hermes_client import hermes_client
|
||||
from app.services.soul_generator import soul_generator
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("", response_model=list[CharacterResponse])
|
||||
async def list_characters(session: AsyncSession = Depends(get_session)):
|
||||
result = await session.execute(select(Character))
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
@router.get("/{character_id}", response_model=CharacterResponse)
|
||||
async def get_character(character_id: str, session: AsyncSession = Depends(get_session)):
|
||||
result = await session.execute(select(Character).where(Character.id == character_id))
|
||||
character = result.scalars().first()
|
||||
if not character:
|
||||
raise HTTPException(status_code=404, detail="Character not found")
|
||||
return character
|
||||
|
||||
|
||||
@router.put("/{character_id}/soul")
|
||||
async def update_character_soul(
|
||||
character_id: str,
|
||||
req: SoulUpdateRequest,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
result = await session.execute(select(Character).where(Character.id == character_id))
|
||||
character = result.scalars().first()
|
||||
if not character:
|
||||
raise HTTPException(status_code=404, detail="Character not found")
|
||||
|
||||
previous = character.hermes_profile
|
||||
character.soul_md = req.soul_md
|
||||
character.knowledge_base = req.knowledge_base
|
||||
|
||||
await hermes_client.create_profile(previous, req.soul_md)
|
||||
|
||||
await session.commit()
|
||||
await session.refresh(character)
|
||||
return {"ok": True, "character_id": character_id}
|
||||
|
||||
|
||||
@router.post("/{character_id}/speak")
|
||||
async def character_speak(
|
||||
character_id: str,
|
||||
req: CharacterSpeakRequest,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
result = await session.execute(select(Character).where(Character.id == character_id))
|
||||
character = result.scalars().first()
|
||||
if not character:
|
||||
raise HTTPException(status_code=404, detail="Character not found")
|
||||
|
||||
prompt = req.prompt or "根据当前剧本进度自然发言"
|
||||
response = await hermes_client.chat(character.hermes_profile, prompt)
|
||||
|
||||
return {
|
||||
"character_id": character_id,
|
||||
"character_name": character.name,
|
||||
"response": response,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/{character_id}/profile")
|
||||
async def create_character_profile(
|
||||
character_id: str,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
result = await session.execute(select(Character).where(Character.id == character_id))
|
||||
character = result.scalars().first()
|
||||
if not character:
|
||||
raise HTTPException(status_code=404, detail="Character not found")
|
||||
|
||||
success = await character_generator.create_profile(character)
|
||||
await session.commit()
|
||||
return {"ok": success, "character_id": character_id}
|
||||
|
||||
|
||||
@router.post("/generate", response_model=list[CharacterResponse])
|
||||
async def generate_characters(
|
||||
req: CharacterGenerateRequest,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
result = await session.execute(select(Character).where(Character.script_id == req.script_id))
|
||||
existing = list(result.scalars().all())
|
||||
for char in existing:
|
||||
await session.delete(char)
|
||||
await session.commit()
|
||||
|
||||
character_data = [{"name": name} for name in req.character_names]
|
||||
characters = await character_generator.generate_characters(
|
||||
session, req.script_id, character_data
|
||||
)
|
||||
return characters
|
||||
|
||||
|
||||
@router.post("/{character_id}/status")
|
||||
async def update_character_status(
|
||||
character_id: str,
|
||||
status: CharacterStatus,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
result = await session.execute(select(Character).where(Character.id == character_id))
|
||||
character = result.scalars().first()
|
||||
if not character:
|
||||
raise HTTPException(status_code=404, detail="Character not found")
|
||||
|
||||
character.status = status
|
||||
await session.commit()
|
||||
await session.refresh(character)
|
||||
return {"ok": True, "status": character.status.value}
|
||||
|
||||
|
||||
@router.delete("/{character_id}")
|
||||
async def delete_character(character_id: str, session: AsyncSession = Depends(get_session)):
|
||||
result = await session.execute(select(Character).where(Character.id == character_id))
|
||||
character = result.scalars().first()
|
||||
if not character:
|
||||
raise HTTPException(status_code=404, detail="Character not found")
|
||||
|
||||
await character_generator.delete_profile(character)
|
||||
await session.delete(character)
|
||||
await session.commit()
|
||||
return {"ok": True}
|
||||
@@ -0,0 +1,78 @@
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy import select, func
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.database import get_session
|
||||
from app.models.orm import Message, Character
|
||||
from app.models.schemas import MessageCreate, MessageResponse
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/messages", response_model=list[MessageResponse])
|
||||
async def list_messages(
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
result = await session.execute(
|
||||
select(Message).order_by(Message.created_at.asc())
|
||||
)
|
||||
messages = list(result.scalars().all())
|
||||
return [
|
||||
MessageResponse(
|
||||
id=m.id,
|
||||
session_id=m.session_id,
|
||||
character_id=m.character_id,
|
||||
game_phase=m.game_phase,
|
||||
msg_type=m.msg_type,
|
||||
content=m.content,
|
||||
target_character_id=m.target_character_id,
|
||||
clue_id=m.clue_id,
|
||||
metadata=m.metadata or {},
|
||||
created_at=m.created_at,
|
||||
character_name=m.character.name if m.character else None,
|
||||
character_role=m.character.role if m.character else None,
|
||||
)
|
||||
for m in messages
|
||||
]
|
||||
|
||||
|
||||
@router.post("/messages", response_model=MessageResponse)
|
||||
async def create_message(
|
||||
req: MessageCreate,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
message = Message(
|
||||
session_id=req.session_id,
|
||||
character_id=req.character_id,
|
||||
game_phase=req.game_phase,
|
||||
msg_type=req.msg_type,
|
||||
content=req.content,
|
||||
target_character_id=req.target_character_id,
|
||||
)
|
||||
session.add(message)
|
||||
await session.commit()
|
||||
await session.refresh(message)
|
||||
|
||||
char_name = None
|
||||
char_role = None
|
||||
if message.character_id:
|
||||
result = await session.execute(select(Character).where(Character.id == message.character_id))
|
||||
char = result.scalars().first()
|
||||
if char:
|
||||
char_name = char.name
|
||||
char_role = char.role
|
||||
|
||||
return MessageResponse(
|
||||
id=message.id,
|
||||
session_id=message.session_id,
|
||||
character_id=message.character_id,
|
||||
game_phase=message.game_phase,
|
||||
msg_type=message.msg_type,
|
||||
content=message.content,
|
||||
target_character_id=message.target_character_id,
|
||||
clue_id=message.clue_id,
|
||||
metadata=message.metadata or {},
|
||||
created_at=message.created_at,
|
||||
character_name=char_name,
|
||||
character_role=char_role,
|
||||
)
|
||||
@@ -0,0 +1,83 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.database import get_session
|
||||
from app.models.orm import Clue, Vote, Character
|
||||
from app.models.schemas import ClueCreate, ClueResponse, ClueUnlockRequest, VoteCreate, VoteResponse
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/{script_id}/clues", response_model=list[ClueResponse])
|
||||
async def list_clues(script_id: str, session: AsyncSession = Depends(get_session)):
|
||||
result = await session.execute(select(Clue).where(Clue.script_id == script_id))
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
@router.post("/{script_id}/clues", response_model=ClueResponse)
|
||||
async def create_clue(script_id: str, req: ClueCreate, session: AsyncSession = Depends(get_session)):
|
||||
clue = Clue(
|
||||
script_id=script_id,
|
||||
name=req.name,
|
||||
content=req.content,
|
||||
clue_type=req.clue_type,
|
||||
owner_id=req.owner_id,
|
||||
phase=req.phase,
|
||||
visibility=req.visibility,
|
||||
visible_to=req.visible_to,
|
||||
)
|
||||
session.add(clue)
|
||||
await session.commit()
|
||||
await session.refresh(clue)
|
||||
return clue
|
||||
|
||||
|
||||
@router.post("/clues/{clue_id}/unlock")
|
||||
async def unlock_clue(
|
||||
clue_id: str,
|
||||
req: ClueUnlockRequest,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
result = await session.execute(select(Clue).where(Clue.id == clue_id))
|
||||
clue = result.scalars().first()
|
||||
if not clue:
|
||||
raise HTTPException(status_code=404, detail="Clue not found")
|
||||
|
||||
from datetime import datetime
|
||||
clue.is_unlocked = True
|
||||
clue.unlocked_by = req.character_id
|
||||
clue.unlocked_at = datetime.utcnow()
|
||||
await session.commit()
|
||||
await session.refresh(clue)
|
||||
return {"ok": True, "clue": ClueResponse.model_validate(clue)}
|
||||
|
||||
|
||||
@router.delete("/clues/{clue_id}")
|
||||
async def delete_clue(clue_id: str, session: AsyncSession = Depends(get_session)):
|
||||
result = await session.execute(select(Clue).where(Clue.id == clue_id))
|
||||
clue = result.scalars().first()
|
||||
if not clue:
|
||||
raise HTTPException(status_code=404, detail="Clue not found")
|
||||
await session.delete(clue)
|
||||
await session.commit()
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.get("/votes", response_model=list[VoteResponse])
|
||||
async def list_votes(round_number: int = 1, session: AsyncSession = Depends(get_session)):
|
||||
result = await session.execute(select(Vote).where(Vote.round_number == round_number))
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
@router.post("/votes", response_model=VoteResponse)
|
||||
async def create_vote(req: VoteCreate, session: AsyncSession = Depends(get_session)):
|
||||
vote = Vote(
|
||||
voter_id=req.voter_id,
|
||||
target_id=req.target_id,
|
||||
reason=req.reason,
|
||||
)
|
||||
session.add(vote)
|
||||
await session.commit()
|
||||
await session.refresh(vote)
|
||||
return vote
|
||||
@@ -0,0 +1,78 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, Body
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.models.database import get_session
|
||||
from app.models.schemas import GameStateResponse, DmSpeakRequest
|
||||
from app.services.game_state_manager import game_state_manager
|
||||
from app.services.agent_scheduler import agent_scheduler
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class StartGameRequest(BaseModel):
|
||||
script_id: str
|
||||
|
||||
|
||||
class SetSpeakerRequest(BaseModel):
|
||||
character_id: str
|
||||
|
||||
|
||||
@router.get("/state", response_model=GameStateResponse)
|
||||
async def get_state(session: AsyncSession = Depends(get_session)):
|
||||
state = await game_state_manager.get_or_create_state(session)
|
||||
return game_state_manager.to_response(state)
|
||||
|
||||
|
||||
@router.post("/start", response_model=GameStateResponse)
|
||||
async def start_game(
|
||||
req: StartGameRequest,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
state = await game_state_manager.start_game(session, req.script_id)
|
||||
return game_state_manager.to_response(state)
|
||||
|
||||
|
||||
@router.post("/pause", response_model=GameStateResponse)
|
||||
async def pause_game(session: AsyncSession = Depends(get_session)):
|
||||
state = await game_state_manager.pause_game(session)
|
||||
return game_state_manager.to_response(state)
|
||||
|
||||
|
||||
@router.post("/resume", response_model=GameStateResponse)
|
||||
async def resume_game(session: AsyncSession = Depends(get_session)):
|
||||
state = await game_state_manager.resume_game(session)
|
||||
return game_state_manager.to_response(state)
|
||||
|
||||
|
||||
@router.post("/phase/next", response_model=GameStateResponse)
|
||||
async def phase_next(session: AsyncSession = Depends(get_session)):
|
||||
state = await game_state_manager.next_phase(session)
|
||||
return game_state_manager.to_response(state)
|
||||
|
||||
|
||||
@router.post("/phase/prev", response_model=GameStateResponse)
|
||||
async def phase_prev(session: AsyncSession = Depends(get_session)):
|
||||
state = await game_state_manager.prev_phase(session)
|
||||
return game_state_manager.to_response(state)
|
||||
|
||||
|
||||
@router.post("/dm/speak")
|
||||
async def dm_speak(req: DmSpeakRequest):
|
||||
result = await agent_scheduler.dm_speak(req.content)
|
||||
return {"content": result, "msg_type": "dm", "phase": req.phase}
|
||||
|
||||
|
||||
@router.post("/speaker/set", response_model=GameStateResponse)
|
||||
async def set_speaker(
|
||||
req: SetSpeakerRequest,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
state = await game_state_manager.set_speaker(session, req.character_id)
|
||||
return game_state_manager.to_response(state)
|
||||
|
||||
|
||||
@router.post("/reset", response_model=GameStateResponse)
|
||||
async def reset_game(session: AsyncSession = Depends(get_session)):
|
||||
state = await game_state_manager.reset(session)
|
||||
return game_state_manager.to_response(state)
|
||||
@@ -0,0 +1,93 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.database import get_session
|
||||
from app.models.orm import Script
|
||||
from app.models.schemas import ScriptUploadRequest, ScriptParsePreview, ScriptImportRequest, ScriptResponse
|
||||
from app.services.script_parser import script_parser
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.post("/upload", response_model=ScriptParsePreview)
|
||||
async def upload_script(
|
||||
req: ScriptUploadRequest,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
parsed = await script_parser.parse(req.content, req.file_type)
|
||||
return ScriptParsePreview(
|
||||
title=parsed["title"],
|
||||
background=parsed["background"],
|
||||
characters=parsed["characters"],
|
||||
clues=parsed["clues"],
|
||||
phases=parsed["phases"],
|
||||
)
|
||||
|
||||
|
||||
@router.post("/import", response_model=ScriptResponse)
|
||||
async def import_script(
|
||||
req: ScriptImportRequest,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
script = Script(
|
||||
title=req.title,
|
||||
background=req.background,
|
||||
raw_content="",
|
||||
parsed_data={
|
||||
"characters": req.characters,
|
||||
"clues": req.clues,
|
||||
"phases": req.phases,
|
||||
},
|
||||
character_count=len(req.characters),
|
||||
)
|
||||
session.add(script)
|
||||
await session.commit()
|
||||
await session.refresh(script)
|
||||
return script
|
||||
|
||||
|
||||
@router.get("/preview")
|
||||
async def preview_scripts(session: AsyncSession = Depends(get_session)):
|
||||
result = await session.execute(
|
||||
select(Script).order_by(Script.created_at.desc())
|
||||
)
|
||||
scripts = list(result.scalars().all())
|
||||
return [
|
||||
{
|
||||
"id": s.id,
|
||||
"title": s.title,
|
||||
"background": s.background,
|
||||
"character_count": s.character_count,
|
||||
"parsed_data": s.parsed_data,
|
||||
}
|
||||
for s in scripts
|
||||
]
|
||||
|
||||
|
||||
@router.get("", response_model=list[ScriptResponse])
|
||||
async def list_scripts(session: AsyncSession = Depends(get_session)):
|
||||
result = await session.execute(
|
||||
select(Script).order_by(Script.created_at.desc())
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
@router.get("/{script_id}", response_model=ScriptResponse)
|
||||
async def get_script(script_id: str, session: AsyncSession = Depends(get_session)):
|
||||
result = await session.execute(select(Script).where(Script.id == script_id))
|
||||
script = result.scalars().first()
|
||||
if not script:
|
||||
raise HTTPException(status_code=404, detail="Script not found")
|
||||
return script
|
||||
|
||||
|
||||
@router.delete("/{script_id}")
|
||||
async def delete_script(script_id: str, session: AsyncSession = Depends(get_session)):
|
||||
result = await session.execute(select(Script).where(Script.id == script_id))
|
||||
script = result.scalars().first()
|
||||
if not script:
|
||||
raise HTTPException(status_code=404, detail="Script not found")
|
||||
await session.delete(script)
|
||||
await session.commit()
|
||||
return {"ok": True}
|
||||
@@ -0,0 +1,255 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import random
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.orm import Character, Message
|
||||
from app.services.hermes_client import hermes_client
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AgentScheduler:
|
||||
def __init__(self):
|
||||
self._running = False
|
||||
self._agents: dict[str, dict] = {}
|
||||
self._speaker_queue: list[str] = []
|
||||
self._current_speaker_index: int = 0
|
||||
|
||||
def init_agents(self, characters: list[Character]):
|
||||
self._agents = {}
|
||||
self._speaker_queue = []
|
||||
for char in characters:
|
||||
if char.hermes_profile:
|
||||
self._agents[char.id] = {
|
||||
"name": char.name,
|
||||
"profile": char.hermes_profile,
|
||||
"character_id": char.id,
|
||||
}
|
||||
self._speaker_queue.append(char.id)
|
||||
|
||||
async def ask_agent(
|
||||
self,
|
||||
name: str,
|
||||
message: str,
|
||||
character_id: Optional[str] = None,
|
||||
) -> str:
|
||||
profile_name = name.lower().replace(" ", "_").replace("·", "_")
|
||||
try:
|
||||
return await hermes_client.chat(profile_name, message)
|
||||
except Exception as e:
|
||||
logger.error(f"ask_agent failed for '{name}': {e}")
|
||||
return f"({name}暂时无法回应)"
|
||||
|
||||
async def dm_speak(self, content: str) -> str:
|
||||
return f"【主持人】:{content}"
|
||||
|
||||
def get_next_speaker(self) -> Optional[str]:
|
||||
if not self._speaker_queue:
|
||||
return None
|
||||
idx = self._current_speaker_index % len(self._speaker_queue)
|
||||
self._current_speaker_index += 1
|
||||
return self._speaker_queue[idx]
|
||||
|
||||
def reset_speakers(self):
|
||||
self._current_speaker_index = 0
|
||||
|
||||
def set_speaker_order(self, order: list[str]):
|
||||
self._speaker_queue = [cid for cid in order if cid in self._agents]
|
||||
self._current_speaker_index = 0
|
||||
|
||||
async def ask_current_speaker(
|
||||
self,
|
||||
session: AsyncSession,
|
||||
script_id: str,
|
||||
context: str,
|
||||
) -> Optional[dict]:
|
||||
speaker_id = self.get_next_speaker()
|
||||
if not speaker_id or speaker_id not in self._agents:
|
||||
return None
|
||||
|
||||
agent = self._agents[speaker_id]
|
||||
message = f"""当前讨论内容:
|
||||
{context}
|
||||
|
||||
请以{agent['name']}的身份发言。保持在角色中,用中文回复2-4句话。"""
|
||||
|
||||
try:
|
||||
response = await self.ask_agent(agent["name"], message, speaker_id)
|
||||
|
||||
msg = Message(
|
||||
character_id=speaker_id,
|
||||
game_phase="round1_speak",
|
||||
msg_type="character",
|
||||
content=response.strip(),
|
||||
)
|
||||
session.add(msg)
|
||||
await session.commit()
|
||||
await session.refresh(msg)
|
||||
|
||||
return {
|
||||
"id": msg.id,
|
||||
"character_id": speaker_id,
|
||||
"character_name": agent["name"],
|
||||
"content": response.strip(),
|
||||
"msg_type": "character",
|
||||
"game_phase": "round1_speak",
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Speaker error for {agent['name']}: {e}")
|
||||
return None
|
||||
|
||||
async def run_auto_speaking(
|
||||
self,
|
||||
session: AsyncSession,
|
||||
script_id: str,
|
||||
interval: float = 8.0,
|
||||
rounds: int = 1,
|
||||
socket_emit=None,
|
||||
):
|
||||
self._running = True
|
||||
characters_result = await session.execute(
|
||||
select(Character).where(Character.script_id == script_id)
|
||||
)
|
||||
characters = list(characters_result.scalars().all())
|
||||
self.init_agents(characters)
|
||||
|
||||
for _ in range(rounds):
|
||||
if not self._running:
|
||||
break
|
||||
self.reset_speakers()
|
||||
|
||||
for _ in range(len(self._speaker_queue)):
|
||||
if not self._running:
|
||||
break
|
||||
|
||||
speaker_id = self.get_next_speaker()
|
||||
agent = self._agents.get(speaker_id)
|
||||
if not agent:
|
||||
continue
|
||||
|
||||
msgs_result = await session.execute(
|
||||
select(Message).order_by(Message.created_at.desc()).limit(20)
|
||||
)
|
||||
recent = list(msgs_result.scalars().all())
|
||||
context = "\n".join([
|
||||
f"{m.character.name if m.character else '系统'}: {m.content}"
|
||||
for m in reversed(recent)
|
||||
])
|
||||
|
||||
try:
|
||||
response = await self.ask_agent(agent["name"], context, speaker_id)
|
||||
msg = Message(
|
||||
character_id=speaker_id,
|
||||
game_phase="round1_speak",
|
||||
msg_type="character",
|
||||
content=response.strip(),
|
||||
)
|
||||
session.add(msg)
|
||||
await session.commit()
|
||||
await session.refresh(msg)
|
||||
|
||||
if socket_emit:
|
||||
await socket_emit("new_message", {
|
||||
"id": msg.id,
|
||||
"character_id": speaker_id,
|
||||
"character_name": agent["name"],
|
||||
"content": response.strip(),
|
||||
"msg_type": "character",
|
||||
"game_phase": "round1_speak",
|
||||
})
|
||||
await socket_emit("speaker_change", {
|
||||
"current_speaker_id": self._speaker_queue[
|
||||
self._current_speaker_index % len(self._speaker_queue)
|
||||
] if self._speaker_queue else None,
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Auto speak error for {agent['name']}: {e}")
|
||||
|
||||
await asyncio.sleep(interval)
|
||||
|
||||
async def trigger_voting(
|
||||
self,
|
||||
session: AsyncSession,
|
||||
script_id: str,
|
||||
socket_emit=None,
|
||||
) -> list[dict]:
|
||||
characters_result = await session.execute(
|
||||
select(Character).where(Character.script_id == script_id)
|
||||
)
|
||||
characters = list(characters_result.scalars().all())
|
||||
|
||||
msgs_result = await session.execute(
|
||||
select(Message).order_by(Message.created_at.desc()).limit(50)
|
||||
)
|
||||
messages = list(msgs_result.scalars().all())
|
||||
summary = "\n".join([
|
||||
f"{m.character.name if m.character else '系统'}: {m.content[:100]}"
|
||||
for m in reversed(messages)
|
||||
])
|
||||
|
||||
character_names = [c.name for c in characters]
|
||||
votes = []
|
||||
|
||||
for character in characters:
|
||||
if not character.hermes_profile:
|
||||
continue
|
||||
|
||||
vote_prompt = f"""当前是投票阶段。以下是讨论摘要:
|
||||
|
||||
{summary}
|
||||
|
||||
可用角色:{', '.join(n for n in character_names if n != character.name)}
|
||||
|
||||
请以{character.name}的身份投票选出你认为的凶手。只回复JSON格式:
|
||||
{{"target": "角色名", "reason": "投票理由"}}"""
|
||||
|
||||
try:
|
||||
response = await self.ask_agent(character.name, vote_prompt, character.id)
|
||||
import json
|
||||
try:
|
||||
decision = json.loads(response)
|
||||
except json.JSONDecodeError:
|
||||
decision = {"target": "", "reason": response}
|
||||
|
||||
target_name = decision.get("target", "")
|
||||
reason = decision.get("reason", "")
|
||||
target_char = next((c for c in characters if c.name == target_name), None)
|
||||
if not target_char:
|
||||
others = [c for c in characters if c.id != character.id]
|
||||
if others:
|
||||
target_char = random.choice(others)
|
||||
reason = "随机投票"
|
||||
|
||||
if target_char:
|
||||
votes.append({
|
||||
"voter_id": character.id,
|
||||
"voter_name": character.name,
|
||||
"target_id": target_char.id,
|
||||
"target_name": target_char.name,
|
||||
"reason": reason,
|
||||
})
|
||||
|
||||
if socket_emit:
|
||||
await socket_emit("vote_cast", {
|
||||
"voter_id": character.id,
|
||||
"voter_name": character.name,
|
||||
"target_id": target_char.id,
|
||||
"target_name": target_char.name,
|
||||
"reason": reason,
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Voting error for {character.name}: {e}")
|
||||
|
||||
return votes
|
||||
|
||||
def stop(self):
|
||||
self._running = False
|
||||
|
||||
|
||||
agent_scheduler = AgentScheduler()
|
||||
@@ -0,0 +1,102 @@
|
||||
import logging
|
||||
import random
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.orm import Character
|
||||
from app.models.schemas import CharacterRole
|
||||
from app.services.hermes_client import hermes_client
|
||||
from app.services.soul_generator import soul_generator
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class CharacterGenerator:
|
||||
async def generate_characters(
|
||||
self,
|
||||
session: AsyncSession,
|
||||
script_id: str,
|
||||
characters_data: list[dict],
|
||||
is_json: bool = False,
|
||||
) -> list[Character]:
|
||||
characters = []
|
||||
killer_index = random.randint(0, len(characters_data) - 1) if len(characters_data) > 1 else 0
|
||||
|
||||
for i, char_data in enumerate(characters_data):
|
||||
role = CharacterRole.KILLER if i == killer_index else CharacterRole.SUSPECT
|
||||
if is_json and "role" in char_data:
|
||||
role = CharacterRole(char_data["role"]) if char_data["role"] in [r.value for r in CharacterRole] else role
|
||||
|
||||
name = char_data.get("name", f"角色{i + 1}")
|
||||
profile_name = name.lower().replace(" ", "_").replace("·", "_")
|
||||
|
||||
character = Character(
|
||||
script_id=script_id,
|
||||
name=name,
|
||||
role=role,
|
||||
personality=char_data.get("personality", ""),
|
||||
speaking_style=char_data.get("speaking_style", ""),
|
||||
background=char_data.get("background", ""),
|
||||
secret=char_data.get("secret", ""),
|
||||
motive=char_data.get("motive", ""),
|
||||
hermes_profile=profile_name,
|
||||
is_revealed_killer=False,
|
||||
)
|
||||
session.add(character)
|
||||
characters.append(character)
|
||||
|
||||
await session.commit()
|
||||
for character in characters:
|
||||
await session.refresh(character)
|
||||
|
||||
return characters
|
||||
|
||||
async def create_profile(self, character: Character) -> bool:
|
||||
soul_content = soul_generator.generate(
|
||||
name=character.name,
|
||||
personality=character.personality,
|
||||
speaking_style=character.speaking_style,
|
||||
background=character.background,
|
||||
secret=character.secret,
|
||||
motive=character.motive,
|
||||
knowledge_base=character.knowledge_base,
|
||||
)
|
||||
|
||||
success = await hermes_client.create_profile(character.hermes_profile, soul_content)
|
||||
if success:
|
||||
character.soul_md = soul_content
|
||||
return success
|
||||
|
||||
async def create_all_profiles(self, session: AsyncSession, script_id: str) -> int:
|
||||
result = await session.execute(
|
||||
select(Character).where(Character.script_id == script_id)
|
||||
)
|
||||
characters = list(result.scalars().all())
|
||||
created = 0
|
||||
|
||||
for character in characters:
|
||||
try:
|
||||
if await self.create_profile(character):
|
||||
created += 1
|
||||
character.soul_md = soul_generator.generate(
|
||||
name=character.name,
|
||||
personality=character.personality,
|
||||
speaking_style=character.speaking_style,
|
||||
background=character.background,
|
||||
secret=character.secret,
|
||||
motive=character.motive,
|
||||
knowledge_base=character.knowledge_base,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to create profile for {character.name}: {e}")
|
||||
|
||||
await session.commit()
|
||||
logger.info(f"Created {created}/{len(characters)} profiles for script {script_id}")
|
||||
return created
|
||||
|
||||
async def delete_profile(self, character: Character) -> bool:
|
||||
return await hermes_client.delete_profile(character.hermes_profile)
|
||||
|
||||
|
||||
character_generator = CharacterGenerator()
|
||||
@@ -0,0 +1,142 @@
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.orm import GameState
|
||||
from app.models.schemas import GamePhase, GameStateResponse
|
||||
|
||||
|
||||
class GameStateManager:
|
||||
PHASE_ORDER = GamePhase.ordered_phases()
|
||||
PHASE_COUNT = len(PHASE_ORDER)
|
||||
|
||||
async def get_state(self, session: AsyncSession) -> Optional[GameState]:
|
||||
result = await session.execute(
|
||||
select(GameState).order_by(GameState.created_at.desc()).limit(1)
|
||||
)
|
||||
return result.scalars().first()
|
||||
|
||||
async def get_or_create_state(self, session: AsyncSession) -> GameState:
|
||||
state = await self.get_state(session)
|
||||
if state is None:
|
||||
state = GameState(
|
||||
current_phase=GamePhase.INTRO.value,
|
||||
is_running=False,
|
||||
is_paused=False,
|
||||
)
|
||||
session.add(state)
|
||||
await session.commit()
|
||||
await session.refresh(state)
|
||||
return state
|
||||
|
||||
async def next_phase(self, session: AsyncSession) -> GameState:
|
||||
state = await self.get_or_create_state(session)
|
||||
current = state.current_phase
|
||||
try:
|
||||
idx = self.PHASE_ORDER.index(current)
|
||||
if idx < self.PHASE_COUNT - 1:
|
||||
state.current_phase = self.PHASE_ORDER[idx + 1]
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
state.phase_started_at = datetime.utcnow()
|
||||
state.updated_at = datetime.utcnow()
|
||||
await session.commit()
|
||||
await session.refresh(state)
|
||||
return state
|
||||
|
||||
async def prev_phase(self, session: AsyncSession) -> GameState:
|
||||
state = await self.get_or_create_state(session)
|
||||
current = state.current_phase
|
||||
try:
|
||||
idx = self.PHASE_ORDER.index(current)
|
||||
if idx > 0:
|
||||
state.current_phase = self.PHASE_ORDER[idx - 1]
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
state.phase_started_at = datetime.utcnow()
|
||||
state.updated_at = datetime.utcnow()
|
||||
await session.commit()
|
||||
await session.refresh(state)
|
||||
return state
|
||||
|
||||
def get_current_phase(self, state: GameState) -> str:
|
||||
return state.current_phase
|
||||
|
||||
def get_progress_percent(self, state: GameState) -> float:
|
||||
try:
|
||||
idx = self.PHASE_ORDER.index(state.current_phase)
|
||||
return (idx / max(self.PHASE_COUNT - 1, 1)) * 100
|
||||
except ValueError:
|
||||
return 0.0
|
||||
|
||||
async def start_game(self, session: AsyncSession, script_id: str) -> GameState:
|
||||
state = await self.get_or_create_state(session)
|
||||
state.active_script_id = script_id
|
||||
state.current_phase = GamePhase.INTRO.value
|
||||
state.is_running = True
|
||||
state.is_paused = False
|
||||
state.phase_started_at = datetime.utcnow()
|
||||
state.updated_at = datetime.utcnow()
|
||||
await session.commit()
|
||||
await session.refresh(state)
|
||||
return state
|
||||
|
||||
async def pause_game(self, session: AsyncSession) -> GameState:
|
||||
state = await self.get_or_create_state(session)
|
||||
state.is_paused = True
|
||||
state.updated_at = datetime.utcnow()
|
||||
await session.commit()
|
||||
await session.refresh(state)
|
||||
return state
|
||||
|
||||
async def resume_game(self, session: AsyncSession) -> GameState:
|
||||
state = await self.get_or_create_state(session)
|
||||
state.is_paused = False
|
||||
state.updated_at = datetime.utcnow()
|
||||
await session.commit()
|
||||
await session.refresh(state)
|
||||
return state
|
||||
|
||||
async def reset(self, session: AsyncSession) -> GameState:
|
||||
state = await self.get_or_create_state(session)
|
||||
state.current_phase = GamePhase.INTRO.value
|
||||
state.current_speaker_id = None
|
||||
state.speaker_order = []
|
||||
state.active_script_id = None
|
||||
state.is_running = False
|
||||
state.is_paused = False
|
||||
state.phase_started_at = None
|
||||
state.config = {}
|
||||
state.updated_at = datetime.utcnow()
|
||||
await session.commit()
|
||||
await session.refresh(state)
|
||||
return state
|
||||
|
||||
async def set_speaker(self, session: AsyncSession, character_id: str) -> GameState:
|
||||
state = await self.get_or_create_state(session)
|
||||
state.current_speaker_id = character_id
|
||||
state.updated_at = datetime.utcnow()
|
||||
await session.commit()
|
||||
await session.refresh(state)
|
||||
return state
|
||||
|
||||
def to_response(self, state: GameState) -> GameStateResponse:
|
||||
return GameStateResponse(
|
||||
id=state.id,
|
||||
current_phase=GamePhase(state.current_phase),
|
||||
current_speaker_id=state.current_speaker_id,
|
||||
speaker_order=state.speaker_order or [],
|
||||
active_script_id=state.active_script_id,
|
||||
is_running=state.is_running,
|
||||
is_paused=state.is_paused,
|
||||
phase_started_at=state.phase_started_at,
|
||||
config=state.config or {},
|
||||
progress_percent=self.get_progress_percent(state),
|
||||
)
|
||||
|
||||
|
||||
game_state_manager = GameStateManager()
|
||||
@@ -0,0 +1,106 @@
|
||||
import logging
|
||||
import os
|
||||
from typing import Optional
|
||||
|
||||
import httpx
|
||||
|
||||
from app.main import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
HERMES_PROFILES_DIR = os.path.expanduser("~/.hermes/profiles")
|
||||
HERMES_TIMEOUT = 30
|
||||
|
||||
DEGRADED_RESPONSE = "(系统提示:AI引擎暂时不可用,请稍后再试)"
|
||||
DEGRADED_PROFILE_LIST: list[str] = []
|
||||
|
||||
|
||||
class HermesClient:
|
||||
def __init__(self):
|
||||
self.api_url = settings.hermes_api_url.rstrip("/")
|
||||
self.model = settings.hermes_model
|
||||
self.profiles_dir = HERMES_PROFILES_DIR
|
||||
self.timeout = HERMES_TIMEOUT
|
||||
|
||||
async def chat(self, profile_name: str, message: str) -> str:
|
||||
soul = await self.read_soul(profile_name)
|
||||
|
||||
messages = []
|
||||
if soul:
|
||||
messages.append({"role": "system", "content": soul})
|
||||
messages.append({"role": "user", "content": message})
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
||||
response = await client.post(
|
||||
f"{self.api_url}/chat/completions",
|
||||
json={
|
||||
"model": self.model,
|
||||
"messages": messages,
|
||||
"temperature": 0.85,
|
||||
"max_tokens": 1024,
|
||||
},
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
choice = data.get("choices", [{}])[0]
|
||||
return choice.get("message", {}).get("content", "").strip() or DEGRADED_RESPONSE
|
||||
|
||||
except httpx.TimeoutException:
|
||||
logger.warning(f"Hermes API timeout after {self.timeout}s for profile '{profile_name}'")
|
||||
return DEGRADED_RESPONSE
|
||||
except httpx.ConnectError:
|
||||
logger.error(f"Hermes API unreachable at '{self.api_url}'")
|
||||
return DEGRADED_RESPONSE
|
||||
except Exception as e:
|
||||
logger.error(f"Hermes API error for '{profile_name}': {e}")
|
||||
return DEGRADED_RESPONSE
|
||||
|
||||
async def create_profile(self, name: str, soul_content: str) -> bool:
|
||||
profile_dir = os.path.join(self.profiles_dir, name)
|
||||
os.makedirs(profile_dir, exist_ok=True)
|
||||
|
||||
soul_path = os.path.join(profile_dir, "SOUL.md")
|
||||
with open(soul_path, "w", encoding="utf-8") as f:
|
||||
f.write(soul_content)
|
||||
|
||||
logger.info(f"Profile created: {name} -> {profile_dir}")
|
||||
return True
|
||||
|
||||
async def delete_profile(self, name: str) -> bool:
|
||||
import shutil
|
||||
|
||||
profile_dir = os.path.join(self.profiles_dir, name)
|
||||
if os.path.exists(profile_dir):
|
||||
shutil.rmtree(profile_dir)
|
||||
logger.info(f"Profile deleted: {name}")
|
||||
return True
|
||||
logger.warning(f"Profile not found for deletion: {name}")
|
||||
return False
|
||||
|
||||
async def get_profile_list(self) -> list[str]:
|
||||
global DEGRADED_PROFILE_LIST
|
||||
if not os.path.exists(self.profiles_dir):
|
||||
return []
|
||||
try:
|
||||
dirs = [
|
||||
d
|
||||
for d in os.listdir(self.profiles_dir)
|
||||
if os.path.isdir(os.path.join(self.profiles_dir, d))
|
||||
and os.path.exists(os.path.join(self.profiles_dir, d, "SOUL.md"))
|
||||
]
|
||||
DEGRADED_PROFILE_LIST = dirs
|
||||
return dirs
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to list profiles: {e}")
|
||||
return DEGRADED_PROFILE_LIST
|
||||
|
||||
async def read_soul(self, profile_name: str) -> str:
|
||||
soul_path = os.path.join(self.profiles_dir, profile_name, "SOUL.md")
|
||||
if os.path.exists(soul_path):
|
||||
with open(soul_path, "r", encoding="utf-8") as f:
|
||||
return f.read()
|
||||
return ""
|
||||
|
||||
|
||||
hermes_client = HermesClient()
|
||||
@@ -0,0 +1,153 @@
|
||||
import json
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
from app.services.hermes_client import hermes_client
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ScriptParser:
|
||||
async def parse(self, content: str, file_type: str = "natural_language") -> dict:
|
||||
if file_type == "json":
|
||||
return self._parse_json(content)
|
||||
return await self._parse_natural_language(content)
|
||||
|
||||
def _parse_json(self, content: str) -> dict:
|
||||
try:
|
||||
data = json.loads(content)
|
||||
return {
|
||||
"title": data.get("title", "未命名剧本"),
|
||||
"background": data.get("background", ""),
|
||||
"characters": data.get("characters", []),
|
||||
"clues": data.get("clues", []),
|
||||
"phases": data.get("phases", [
|
||||
{"name": "intro", "order": 0, "duration": 120},
|
||||
{"name": "round1_speak", "order": 1, "duration": 300},
|
||||
{"name": "round1_search", "order": 2, "duration": 180},
|
||||
{"name": "round2_speak", "order": 3, "duration": 300},
|
||||
{"name": "round2_search", "order": 4, "duration": 180},
|
||||
{"name": "final_discuss", "order": 5, "duration": 300},
|
||||
{"name": "voting", "order": 6, "duration": 120},
|
||||
{"name": "reveal", "order": 7, "duration": 120},
|
||||
]),
|
||||
"character_count": len(data.get("characters", [])),
|
||||
}
|
||||
except json.JSONDecodeError as e:
|
||||
logger.error(f"JSON parse error: {e}")
|
||||
return {"title": "未命名剧本", "background": "", "characters": [], "clues": [], "phases": [], "character_count": 0}
|
||||
|
||||
async def _parse_natural_language(self, content: str) -> dict:
|
||||
prompt = f"""你是一个剧本杀解析器。请分析以下自然语言描述的剧本,提取结构化信息。
|
||||
|
||||
输出格式必须是合法的JSON:
|
||||
{{
|
||||
"title": "剧本名称(从内容推断)",
|
||||
"background": "案件背景",
|
||||
"characters": [
|
||||
{{
|
||||
"name": "角色名",
|
||||
"personality": "性格特征",
|
||||
"speaking_style": "说话风格",
|
||||
"secret": "该角色隐藏的秘密",
|
||||
"motive": "该角色的动机"
|
||||
}}
|
||||
],
|
||||
"clues": [
|
||||
{{
|
||||
"id": "线索ID",
|
||||
"content": "线索内容",
|
||||
"owner": "线索属于哪个角色",
|
||||
"phase": "线索在哪个阶段可用(round1_search/round2_search)"
|
||||
}}
|
||||
],
|
||||
"phases": [
|
||||
{{"name": "intro", "order": 0, "duration": 120}},
|
||||
{{"name": "round1_speak", "order": 1, "duration": 300}},
|
||||
{{"name": "round1_search", "order": 2, "duration": 180}},
|
||||
{{"name": "round2_speak", "order": 3, "duration": 300}},
|
||||
{{"name": "round2_search", "order": 4, "duration": 180}},
|
||||
{{"name": "final_discuss", "order": 5, "duration": 300}},
|
||||
{{"name": "voting", "order": 6, "duration": 120}},
|
||||
{{"name": "reveal", "order": 7, "duration": 120}}
|
||||
]
|
||||
}}
|
||||
|
||||
剧本内容:
|
||||
{content[:8000]}
|
||||
|
||||
请只返回JSON,不要包含其他解释文字。"""
|
||||
|
||||
try:
|
||||
response = await hermes_client.chat(profile_name="default", message=prompt)
|
||||
response = response.strip()
|
||||
if response.startswith("```"):
|
||||
lines = response.split("\n")
|
||||
response = "\n".join(lines[1:-1]) if len(lines) >= 3 else response
|
||||
data = json.loads(response)
|
||||
data.setdefault("title", "未命名剧本")
|
||||
data.setdefault("background", "")
|
||||
data.setdefault("characters", [])
|
||||
data.setdefault("clues", [])
|
||||
data["character_count"] = len(data.get("characters", []))
|
||||
if not data.get("phases"):
|
||||
data["phases"] = [
|
||||
{"name": "intro", "order": 0, "duration": 120},
|
||||
{"name": "round1_speak", "order": 1, "duration": 300},
|
||||
{"name": "round1_search", "order": 2, "duration": 180},
|
||||
{"name": "round2_speak", "order": 3, "duration": 300},
|
||||
{"name": "round2_search", "order": 4, "duration": 180},
|
||||
{"name": "final_discuss", "order": 5, "duration": 300},
|
||||
{"name": "voting", "order": 6, "duration": 120},
|
||||
{"name": "reveal", "order": 7, "duration": 120},
|
||||
]
|
||||
return data
|
||||
except (json.JSONDecodeError, Exception) as e:
|
||||
logger.warning(f"LLM parse failed: {e}, using regex fallback")
|
||||
return self._regex_fallback(content)
|
||||
|
||||
def _regex_fallback(self, content: str) -> dict:
|
||||
import re
|
||||
|
||||
title = "未命名剧本"
|
||||
first_line = content.strip().split("\n")[0].strip()
|
||||
if len(first_line) <= 50:
|
||||
title = first_line.lstrip("#").strip()
|
||||
|
||||
characters = []
|
||||
char_pattern = re.compile(r"^[#\-\*]*\s*(.{1,10})(?:[::]\s*(.+))?$", re.MULTILINE)
|
||||
name_keywords = re.compile(r"(角色|人物|嫌疑人|侦探|凶手|死者|被害人)", re.IGNORECASE)
|
||||
|
||||
char_section = False
|
||||
for line in content.split("\n"):
|
||||
line = line.strip()
|
||||
if name_keywords.search(line):
|
||||
char_section = True
|
||||
continue
|
||||
if char_section and line.startswith("#"):
|
||||
break
|
||||
if char_section and len(line) <= 30 and line:
|
||||
name = re.sub(r"[-::\s].*$", "", line).strip()
|
||||
if name and len(name) <= 10:
|
||||
characters.append({"name": name, "personality": "", "speaking_style": "", "secret": "", "motive": ""})
|
||||
|
||||
return {
|
||||
"title": title,
|
||||
"background": content[:200],
|
||||
"characters": characters,
|
||||
"clues": [],
|
||||
"phases": [
|
||||
{"name": "intro", "order": 0, "duration": 120},
|
||||
{"name": "round1_speak", "order": 1, "duration": 300},
|
||||
{"name": "round1_search", "order": 2, "duration": 180},
|
||||
{"name": "round2_speak", "order": 3, "duration": 300},
|
||||
{"name": "round2_search", "order": 4, "duration": 180},
|
||||
{"name": "final_discuss", "order": 5, "duration": 300},
|
||||
{"name": "voting", "order": 6, "duration": 120},
|
||||
{"name": "reveal", "order": 7, "duration": 120},
|
||||
],
|
||||
"character_count": len(characters),
|
||||
}
|
||||
|
||||
|
||||
script_parser = ScriptParser()
|
||||
@@ -0,0 +1,55 @@
|
||||
import os
|
||||
|
||||
SOUL_MD_TEMPLATE = """# 角色定位
|
||||
你是{name},{personality}
|
||||
|
||||
## 行为风格
|
||||
- 语气:{speaking_style}
|
||||
- 性格特征:{personality}
|
||||
|
||||
## 人物背景
|
||||
{background}
|
||||
|
||||
## 沟通边界
|
||||
- 绝不说:现代用语、脏话、直接承认有罪
|
||||
- 必回应:被质疑时、被点名时
|
||||
|
||||
## 剧本杀专属规则
|
||||
- 秘密:{secret}
|
||||
- 动机:{motive}
|
||||
- 披露策略:只有在证据确凿时才承认关键信息
|
||||
|
||||
## 知识库
|
||||
{knowledge_base}
|
||||
"""
|
||||
|
||||
|
||||
class SoulGenerator:
|
||||
def generate(
|
||||
self,
|
||||
name: str,
|
||||
personality: str = "",
|
||||
speaking_style: str = "",
|
||||
background: str = "",
|
||||
secret: str = "",
|
||||
motive: str = "",
|
||||
knowledge_base: str = "",
|
||||
) -> str:
|
||||
return SOUL_MD_TEMPLATE.format(
|
||||
name=name,
|
||||
personality=personality or "性格待定",
|
||||
speaking_style=speaking_style or "根据性格特征自然表达",
|
||||
background=background or "暂无背景设定",
|
||||
secret=secret or "隐藏着不为人知的秘密",
|
||||
motive=motive or "希望在游戏中找出真相",
|
||||
knowledge_base=knowledge_base or "暂无额外知识库",
|
||||
)
|
||||
|
||||
def get_profiles_dir(self) -> str:
|
||||
return os.path.expanduser("~/.hermes/profiles")
|
||||
|
||||
def get_soul_path(self, name: str) -> str:
|
||||
return os.path.join(self.get_profiles_dir(), name, "SOUL.md")
|
||||
|
||||
|
||||
soul_generator = SoulGenerator()
|
||||
@@ -0,0 +1,169 @@
|
||||
import asyncio
|
||||
import logging
|
||||
from datetime import datetime
|
||||
|
||||
import socketio
|
||||
|
||||
from app.models.database import async_session_maker
|
||||
from app.models.orm import Character, Clue, Message, Vote
|
||||
from app.models.schemas import GamePhase
|
||||
from app.services.agent_scheduler import agent_scheduler
|
||||
from app.services.game_state_manager import game_state_manager
|
||||
from sqlalchemy import select
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
sio = socketio.AsyncServer(
|
||||
async_mode="asgi",
|
||||
cors_allowed_origins="*",
|
||||
logger=False,
|
||||
)
|
||||
|
||||
connected_clients: dict[str, dict] = {}
|
||||
_tracked_tasks: set[asyncio.Task] = set()
|
||||
|
||||
|
||||
@sio.event
|
||||
async def connect(sid, environ, auth):
|
||||
connected_clients[sid] = {"sid": sid, "role": "viewer"}
|
||||
await sio.emit("system", {"message": "Connected to Hermes Live Show"}, to=sid)
|
||||
|
||||
|
||||
@sio.event
|
||||
async def disconnect(sid):
|
||||
connected_clients.pop(sid, None)
|
||||
|
||||
|
||||
@sio.event
|
||||
async def set_role(sid, data):
|
||||
if sid in connected_clients:
|
||||
connected_clients[sid]["role"] = data.get("role", "viewer")
|
||||
|
||||
|
||||
@sio.event
|
||||
async def send_message(sid, data):
|
||||
character_id = data.get("character_id")
|
||||
content = data.get("content", "")
|
||||
msg_type = data.get("msg_type", "character")
|
||||
game_phase = data.get("game_phase", "")
|
||||
target_character_id = data.get("target_character_id")
|
||||
|
||||
if async_session_maker:
|
||||
async with async_session_maker() as session:
|
||||
msg = Message(
|
||||
character_id=character_id,
|
||||
game_phase=game_phase,
|
||||
msg_type=msg_type,
|
||||
content=content,
|
||||
target_character_id=target_character_id,
|
||||
)
|
||||
session.add(msg)
|
||||
await session.commit()
|
||||
|
||||
await sio.emit("new_message", {
|
||||
"character_id": character_id,
|
||||
"content": content,
|
||||
"msg_type": msg_type,
|
||||
"game_phase": game_phase,
|
||||
"target_character_id": target_character_id,
|
||||
})
|
||||
|
||||
|
||||
@sio.event
|
||||
async def dm_command(sid, data):
|
||||
command = data.get("command", "")
|
||||
args = data.get("args", {})
|
||||
|
||||
if async_session_maker:
|
||||
async with async_session_maker() as session:
|
||||
if command == "next_phase":
|
||||
state = await game_state_manager.next_phase(session)
|
||||
await sio.emit("state_change", {
|
||||
"phase": state.current_phase,
|
||||
"progress": game_state_manager.get_progress_percent(state),
|
||||
})
|
||||
|
||||
elif command == "prev_phase":
|
||||
state = await game_state_manager.prev_phase(session)
|
||||
await sio.emit("state_change", {
|
||||
"phase": state.current_phase,
|
||||
"progress": game_state_manager.get_progress_percent(state),
|
||||
})
|
||||
|
||||
elif command == "unlock_clue":
|
||||
clue_id = args.get("clue_id")
|
||||
character_id = args.get("character_id")
|
||||
if clue_id and character_id:
|
||||
result = await session.execute(select(Clue).where(Clue.id == clue_id))
|
||||
clue = result.scalars().first()
|
||||
if clue:
|
||||
clue.is_unlocked = True
|
||||
clue.unlocked_by = character_id
|
||||
clue.unlocked_at = datetime.utcnow()
|
||||
await session.commit()
|
||||
await sio.emit("clue_unlocked", {
|
||||
"clue_id": clue.id,
|
||||
"clue_name": clue.name,
|
||||
"content": clue.content,
|
||||
"unlocked_by": character_id,
|
||||
})
|
||||
|
||||
elif command == "start_auto":
|
||||
state = await game_state_manager.get_state(session)
|
||||
script_id = args.get("script_id") or (state.active_script_id if state else None)
|
||||
if script_id:
|
||||
interval = float(args.get("interval", 8.0))
|
||||
rounds = int(args.get("rounds", 1))
|
||||
|
||||
async def emit(event, data):
|
||||
await sio.emit(event, data)
|
||||
|
||||
task = asyncio.create_task(
|
||||
agent_scheduler.run_auto_speaking(session, script_id, interval, rounds, emit)
|
||||
)
|
||||
_tracked_tasks.add(task)
|
||||
task.add_done_callback(_tracked_tasks.discard)
|
||||
await sio.emit("system", {"message": "Auto speaking started"})
|
||||
|
||||
elif command == "stop_auto":
|
||||
agent_scheduler.stop()
|
||||
await sio.emit("system", {"message": "Auto speaking stopped"})
|
||||
|
||||
elif command == "trigger_vote":
|
||||
state = await game_state_manager.get_state(session)
|
||||
script_id = args.get("script_id") or (state.active_script_id if state else None)
|
||||
if script_id:
|
||||
async def emit(event, data):
|
||||
await sio.emit(event, data)
|
||||
|
||||
votes = await agent_scheduler.trigger_voting(session, script_id, emit)
|
||||
|
||||
current_state = await game_state_manager.get_or_create_state(session)
|
||||
round_num = current_state.round_number if hasattr(current_state, 'round_number') else 1
|
||||
for v in votes:
|
||||
session.add(Vote(
|
||||
voter_id=v["voter_id"],
|
||||
target_id=v["target_id"],
|
||||
round_number=round_num,
|
||||
reason=v.get("reason", ""),
|
||||
))
|
||||
await session.commit()
|
||||
|
||||
await sio.emit("voting_results", {"votes": votes})
|
||||
|
||||
|
||||
@sio.event
|
||||
async def request_state(sid, data):
|
||||
if async_session_maker:
|
||||
async with async_session_maker() as session:
|
||||
state = await game_state_manager.get_or_create_state(session)
|
||||
await sio.emit("state_change", {
|
||||
"phase": state.current_phase,
|
||||
"progress": game_state_manager.get_progress_percent(state),
|
||||
"is_running": state.is_running,
|
||||
"is_paused": state.is_paused,
|
||||
}, to=sid)
|
||||
|
||||
|
||||
def create_socket_app(app):
|
||||
return socketio.ASGIApp(sio, other_app=app)
|
||||
@@ -0,0 +1,12 @@
|
||||
import json
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
|
||||
def json_serializer(obj: Any) -> str:
|
||||
def default(o: Any):
|
||||
if isinstance(o, datetime):
|
||||
return o.isoformat()
|
||||
raise TypeError(f"Object of type {type(o).__name__} is not JSON serializable")
|
||||
|
||||
return json.dumps(obj, default=default, ensure_ascii=False)
|
||||
@@ -0,0 +1,10 @@
|
||||
fastapi==0.115.0
|
||||
uvicorn[standard]==0.30.0
|
||||
python-socketio==5.11.0
|
||||
sqlalchemy==2.0.35
|
||||
asyncpg==0.29.0
|
||||
pydantic==2.9.0
|
||||
pydantic-settings==2.5.0
|
||||
httpx==0.27.0
|
||||
python-dotenv==1.0.1
|
||||
python-multipart==0.0.9
|
||||
@@ -0,0 +1,39 @@
|
||||
import asyncio
|
||||
import httpx
|
||||
|
||||
|
||||
async def create_hermes_profiles(characters: list[dict], base_url: str = "http://localhost:11434/v1"):
|
||||
async with httpx.AsyncClient(timeout=60.0) as client:
|
||||
for char in characters:
|
||||
profile_name = char["name"].lower().replace(" ", "_")
|
||||
print(f"Creating profile: {profile_name}")
|
||||
|
||||
try:
|
||||
response = await client.post(
|
||||
f"{base_url}/models",
|
||||
json={
|
||||
"name": profile_name,
|
||||
"system_prompt": f"你是{char['name']},{char.get('personality', '')},{char.get('background', '')}",
|
||||
},
|
||||
)
|
||||
if response.status_code == 200:
|
||||
print(f" Created: {profile_name}")
|
||||
else:
|
||||
print(f" Warning ({response.status_code}): {profile_name} - {response.text[:200]}")
|
||||
except Exception as e:
|
||||
print(f" Error creating {profile_name}: {e}")
|
||||
|
||||
print("Profile creation completed.")
|
||||
|
||||
|
||||
SAMPLE_CHARACTERS = [
|
||||
{"name": "侦探_张", "personality": "冷静理智,善于观察和分析", "background": "著名私家侦探"},
|
||||
{"name": "富商_李", "personality": "精明世故,看透人心", "background": "本地富商,经营古董店"},
|
||||
{"name": "医生_王", "personality": "温柔体贴,善于倾听", "background": "在本地开诊所的医生"},
|
||||
{"name": "记者_赵", "personality": "活泼开朗,和所有人关系都很好", "background": "专门报道离奇案件的记者"},
|
||||
{"name": "艺术家_陈", "personality": "阴郁深沉,似乎藏着秘密", "background": "性格独特的艺术家"},
|
||||
{"name": "教师_林", "personality": "急躁易怒,说话带刺", "background": "学校老师"},
|
||||
]
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(create_hermes_profiles(SAMPLE_CHARACTERS))
|
||||
@@ -0,0 +1,13 @@
|
||||
import asyncio
|
||||
from app.models.database import init_db
|
||||
from app.main import settings
|
||||
from app.models import orm
|
||||
|
||||
|
||||
async def init_database():
|
||||
await init_db(settings.database_url)
|
||||
print("Database initialized successfully.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(init_database())
|
||||
@@ -0,0 +1,73 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
environment:
|
||||
POSTGRES_USER: hermes
|
||||
POSTGRES_PASSWORD: hermes123
|
||||
POSTGRES_DB: hermes_live
|
||||
ports:
|
||||
- "5432:5432"
|
||||
volumes:
|
||||
- pgdata:/var/lib/postgresql/data
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U hermes -d hermes_live"]
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 5
|
||||
|
||||
hermes:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile.hermes
|
||||
ports:
|
||||
- "11434:11434"
|
||||
volumes:
|
||||
- hermes_profiles:/root/.hermes/profiles
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "curl -f http://localhost:11434/v1/models || exit 1"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
start_period: 60s
|
||||
|
||||
backend:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile.backend
|
||||
ports:
|
||||
- "8000:8000"
|
||||
volumes:
|
||||
- ./backend:/app
|
||||
- hermes_profiles:/root/.hermes/profiles
|
||||
env_file:
|
||||
- ./backend/.env
|
||||
environment:
|
||||
DATABASE_URL: postgresql+asyncpg://hermes:hermes123@postgres:5432/hermes_live
|
||||
HERMES_API_URL: http://hermes:11434/v1
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
hermes:
|
||||
condition: service_healthy
|
||||
restart: unless-stopped
|
||||
|
||||
frontend:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile.frontend
|
||||
ports:
|
||||
- "3000:3000"
|
||||
volumes:
|
||||
- ./frontend:/app
|
||||
- /app/node_modules
|
||||
depends_on:
|
||||
- backend
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
pgdata:
|
||||
hermes_profiles:
|
||||
@@ -0,0 +1,13 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Hermes Live Show - AI剧本杀</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"name": "hermes-live-show",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vue-tsc -b && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"pinia": "^2.1.7",
|
||||
"socket.io-client": "^4.7.5",
|
||||
"vue": "^3.4.38",
|
||||
"vue-router": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^5.1.3",
|
||||
"typescript": "~5.5.4",
|
||||
"vite": "^5.4.3",
|
||||
"vue-tsc": "^2.1.6"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
|
||||
<defs>
|
||||
<linearGradient id="g" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" style="stop-color:#6c63ff"/>
|
||||
<stop offset="100%" style="stop-color:#4ecdc4"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<circle cx="50" cy="50" r="45" fill="url(#g)"/>
|
||||
<text x="50" y="62" text-anchor="middle" font-size="40" font-weight="bold" fill="white" font-family="sans-serif">H</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 457 B |
@@ -0,0 +1,8 @@
|
||||
<template>
|
||||
<div id="app-root">
|
||||
<router-view />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
</script>
|
||||
@@ -0,0 +1,109 @@
|
||||
<template>
|
||||
<div class="character-card" :class="{ selected: isSelected, dead: character.status === 'dead' }" @click="$emit('select')">
|
||||
<div class="card-avatar">
|
||||
<div class="avatar-placeholder">{{ character.name[0] }}</div>
|
||||
</div>
|
||||
<div class="card-info">
|
||||
<div class="card-name">{{ character.name }}</div>
|
||||
<div class="card-role">{{ roleLabel }}</div>
|
||||
</div>
|
||||
<div class="card-status" v-if="character.status === 'dead'">
|
||||
<span class="dead-badge">已出局</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import type { Character } from '../../types/character'
|
||||
|
||||
const props = defineProps<{
|
||||
character: Character
|
||||
isSelected: boolean
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
select: []
|
||||
}>()
|
||||
|
||||
const ROLE_MAP: Record<string, string> = {
|
||||
detective: '🔍 侦探',
|
||||
suspect: '🤔 嫌疑人',
|
||||
witness: '👁️ 目击者',
|
||||
victim: '💀 被害人',
|
||||
killer: '🔪 凶手',
|
||||
}
|
||||
|
||||
const roleLabel = computed(() => ROLE_MAP[props.character.role] || props.character.role)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.character-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem;
|
||||
border-radius: var(--border-radius);
|
||||
background: var(--bg-card);
|
||||
border: 2px solid transparent;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.character-card:hover {
|
||||
background: var(--bg-card-hover);
|
||||
border-color: var(--accent-primary);
|
||||
}
|
||||
|
||||
.character-card.selected {
|
||||
border-color: var(--accent-primary);
|
||||
background: rgba(108, 99, 255, 0.1);
|
||||
}
|
||||
|
||||
.character-card.dead {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.card-avatar {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.avatar-placeholder {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, var(--accent-primary), var(--accent-secondary));
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: 700;
|
||||
font-size: 1.1rem;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.card-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.card-name {
|
||||
font-weight: 600;
|
||||
font-size: 0.9rem;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.card-role {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.dead-badge {
|
||||
font-size: 0.65rem;
|
||||
padding: 0.15rem 0.5rem;
|
||||
border-radius: 10px;
|
||||
background: rgba(255, 107, 107, 0.2);
|
||||
color: var(--accent-danger);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,69 @@
|
||||
<template>
|
||||
<div class="character-list">
|
||||
<div class="list-header">
|
||||
<h3>登场角色</h3>
|
||||
<span class="count-badge">{{ characters.length }}</span>
|
||||
</div>
|
||||
<div class="list-body">
|
||||
<CharacterCard
|
||||
v-for="char in characters"
|
||||
:key="char.id"
|
||||
:character="char"
|
||||
:is-selected="char.id === selectedId"
|
||||
@select="$emit('select-character', char.id)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Character } from '../../types/character'
|
||||
import CharacterCard from './CharacterCard.vue'
|
||||
|
||||
defineProps<{
|
||||
characters: Character[]
|
||||
selectedId: string | null
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
'select-character': [id: string]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.character-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.list-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.75rem 1rem;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.list-header h3 {
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.count-badge {
|
||||
font-size: 0.7rem;
|
||||
padding: 0.15rem 0.5rem;
|
||||
border-radius: 10px;
|
||||
background: var(--accent-primary);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.list-body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 0.5rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,65 @@
|
||||
<template>
|
||||
<div class="chat-log" ref="chatContainer">
|
||||
<div class="chat-messages" v-if="messages.length > 0">
|
||||
<MessageBubble
|
||||
v-for="msg in messages"
|
||||
:key="msg.id"
|
||||
:message="msg"
|
||||
/>
|
||||
</div>
|
||||
<div class="chat-empty" v-else>
|
||||
<div class="empty-icon">💬</div>
|
||||
<p>暂无消息,等待角色互动...</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { watch, ref, nextTick } from 'vue'
|
||||
import type { Message } from '../../types/message'
|
||||
import MessageBubble from './MessageBubble.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
messages: Message[]
|
||||
}>()
|
||||
|
||||
const chatContainer = ref<HTMLElement | null>(null)
|
||||
|
||||
watch(
|
||||
() => props.messages.length,
|
||||
async () => {
|
||||
await nextTick()
|
||||
if (chatContainer.value) {
|
||||
chatContainer.value.scrollTop = chatContainer.value.scrollHeight
|
||||
}
|
||||
}
|
||||
)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.chat-log {
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.chat-messages {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.chat-empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.empty-icon {
|
||||
font-size: 3rem;
|
||||
margin-bottom: 1rem;
|
||||
opacity: 0.5;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,58 @@
|
||||
<template>
|
||||
<div class="clue-board">
|
||||
<div class="board-header">
|
||||
<h3>📋 线索本</h3>
|
||||
<span class="clue-count">{{ unlockedCount }}/{{ clues.length }}</span>
|
||||
</div>
|
||||
<div class="board-body">
|
||||
<div v-if="clues.length === 0" class="board-empty">暂无线索</div>
|
||||
<div
|
||||
v-for="clue in clues"
|
||||
:key="clue.id"
|
||||
class="clue-item"
|
||||
:class="{ unlocked: clue.is_unlocked }"
|
||||
>
|
||||
<div class="clue-header">
|
||||
<span class="clue-type">{{ TYPE_ICONS[clue.clue_type] || '📌' }}</span>
|
||||
<span class="clue-name">{{ clue.name }}</span>
|
||||
<span v-if="!clue.is_unlocked" class="clue-hidden">🔒</span>
|
||||
<span v-else class="clue-unlocked">🔓</span>
|
||||
</div>
|
||||
<div v-if="clue.is_unlocked" class="clue-desc">{{ clue.content }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import type { Clue } from '../../stores/clueStore'
|
||||
|
||||
const props = defineProps<{
|
||||
clues: Clue[]
|
||||
}>()
|
||||
|
||||
const TYPE_ICONS: Record<string, string> = {
|
||||
physical: '🔧',
|
||||
testimony: '💬',
|
||||
motive: '💰',
|
||||
alibi: '⏰',
|
||||
forensic: '🔬',
|
||||
}
|
||||
|
||||
const unlockedCount = computed(() => props.clues.filter((c) => c.is_unlocked).length)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.clue-board { display: flex; flex-direction: column; height: 100%; }
|
||||
.board-header { display: flex; justify-content: space-between; align-items: center; padding: 0.75rem 1rem; border-bottom: 1px solid var(--border-color); }
|
||||
.board-header h3 { font-size: 0.9rem; font-weight: 600; }
|
||||
.clue-count { font-size: 0.7rem; color: var(--text-secondary); }
|
||||
.board-body { flex: 1; overflow-y: auto; padding: 0.5rem; }
|
||||
.board-empty { display: flex; align-items: center; justify-content: center; height: 80px; color: var(--text-muted); font-size: 0.8rem; }
|
||||
.clue-item { padding: 0.5rem; margin-bottom: 0.5rem; border-radius: var(--border-radius); background: var(--bg-card); border: 1px solid var(--border-color); transition: all 0.3s ease; }
|
||||
.clue-item.unlocked { border-color: var(--accent-success); background: rgba(107, 203, 119, 0.05); }
|
||||
.clue-header { display: flex; align-items: center; gap: 0.5rem; }
|
||||
.clue-name { flex: 1; font-size: 0.85rem; font-weight: 500; }
|
||||
.clue-desc { margin-top: 0.4rem; padding-top: 0.4rem; border-top: 1px solid var(--border-color); font-size: 0.8rem; color: var(--text-secondary); line-height: 1.5; }
|
||||
</style>
|
||||
@@ -0,0 +1,131 @@
|
||||
<template>
|
||||
<div
|
||||
class="message-bubble"
|
||||
:class="[
|
||||
`msg-${message.msg_type}`,
|
||||
{ 'is-self': false }
|
||||
]"
|
||||
>
|
||||
<div class="bubble-header" v-if="message.msg_type !== 'system' && message.msg_type !== 'narrator'">
|
||||
<span class="character-name">{{ message.character_name || '未知角色' }}</span>
|
||||
<span class="role-badge" :class="message.character_role">
|
||||
{{ roleLabel(message.character_role) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="bubble-content">
|
||||
{{ message.content }}
|
||||
</div>
|
||||
<div class="bubble-time">
|
||||
{{ formatTime(message.created_at) }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Message } from '../../types/message'
|
||||
import type { CharacterRole } from '../../types/game'
|
||||
|
||||
const props = defineProps<{
|
||||
message: Message
|
||||
}>()
|
||||
|
||||
const ROLE_LABELS: Record<string, string> = {
|
||||
detective: '侦探',
|
||||
suspect: '嫌疑人',
|
||||
witness: '目击者',
|
||||
victim: '被害人',
|
||||
killer: '凶手',
|
||||
}
|
||||
|
||||
function roleLabel(role?: CharacterRole): string {
|
||||
return role ? ROLE_LABELS[role] || role : '未知'
|
||||
}
|
||||
|
||||
function formatTime(iso: string): string {
|
||||
const d = new Date(iso)
|
||||
return d.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit', second: '2-digit' })
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.message-bubble {
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-radius: var(--border-radius);
|
||||
margin-bottom: 0.5rem;
|
||||
animation: fadeIn 0.3s ease;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; transform: translateY(8px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
.msg-system {
|
||||
text-align: center;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.8rem;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.msg-narrator {
|
||||
background: linear-gradient(135deg, #1a1a3e, #2a1a3e);
|
||||
border-left: 3px solid var(--accent-secondary);
|
||||
}
|
||||
|
||||
.msg-character {
|
||||
background: var(--bg-card);
|
||||
border-left: 3px solid var(--accent-primary);
|
||||
}
|
||||
|
||||
.msg-vote {
|
||||
background: var(--bg-card);
|
||||
border-left: 3px solid var(--accent-warning);
|
||||
}
|
||||
|
||||
.msg-clue {
|
||||
background: var(--bg-card);
|
||||
border-left: 3px solid var(--accent-success);
|
||||
}
|
||||
|
||||
.bubble-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.character-name {
|
||||
font-weight: 600;
|
||||
font-size: 0.85rem;
|
||||
color: var(--accent-primary);
|
||||
}
|
||||
|
||||
.role-badge {
|
||||
font-size: 0.65rem;
|
||||
padding: 0.1rem 0.4rem;
|
||||
border-radius: 10px;
|
||||
background: rgba(108, 99, 255, 0.2);
|
||||
color: var(--accent-primary);
|
||||
}
|
||||
|
||||
.role-badge.killer {
|
||||
background: rgba(255, 107, 107, 0.2);
|
||||
color: var(--accent-danger);
|
||||
}
|
||||
|
||||
.role-badge.detective {
|
||||
background: rgba(78, 205, 196, 0.2);
|
||||
color: var(--accent-secondary);
|
||||
}
|
||||
|
||||
.bubble-content {
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.bubble-time {
|
||||
font-size: 0.65rem;
|
||||
color: var(--text-muted);
|
||||
margin-top: 0.25rem;
|
||||
text-align: right;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,117 @@
|
||||
<template>
|
||||
<div class="phase-control">
|
||||
<div class="control-header">
|
||||
<h3>🎮 阶段控制</h3>
|
||||
</div>
|
||||
<div class="control-body">
|
||||
<button
|
||||
v-for="phase in phases"
|
||||
:key="phase.key"
|
||||
class="phase-btn"
|
||||
:class="{ active: phase.key === currentPhase }"
|
||||
@click="$emit('change-phase', phase.key)"
|
||||
>
|
||||
{{ phase.label }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="control-actions">
|
||||
<button class="action-btn start-btn" @click="$emit('start-game')" v-if="!isRunning">
|
||||
▶ 开始游戏
|
||||
</button>
|
||||
<button class="action-btn pause-btn" @click="$emit('pause-game')" v-if="isRunning && !isPaused">
|
||||
⏸ 暂停
|
||||
</button>
|
||||
<button class="action-btn resume-btn" @click="$emit('resume-game')" v-if="isPaused">
|
||||
▶ 继续
|
||||
</button>
|
||||
<button class="action-btn start-btn" @click="$emit('start-auto')" v-if="isRunning && !autoActive">
|
||||
🤖 自动发言
|
||||
</button>
|
||||
<button class="action-btn stop-btn" @click="$emit('stop-auto')" v-if="autoActive">
|
||||
⏹ 停止
|
||||
</button>
|
||||
<button class="action-btn vote-btn" @click="$emit('trigger-vote')" v-if="isRunning">
|
||||
🗳 投票
|
||||
</button>
|
||||
<button class="action-btn reset-btn" @click="$emit('reset-game')">
|
||||
🔄 重置
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { GamePhase } from '../../types/game'
|
||||
|
||||
defineProps<{
|
||||
currentPhase: GamePhase
|
||||
isRunning: boolean
|
||||
isPaused: boolean
|
||||
autoActive: boolean
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
'change-phase': [phase: GamePhase]
|
||||
'start-game': []
|
||||
'pause-game': []
|
||||
'resume-game': []
|
||||
'start-auto': []
|
||||
'stop-auto': []
|
||||
'trigger-vote': []
|
||||
'reset-game': []
|
||||
}>()
|
||||
|
||||
const phases = [
|
||||
{ key: 'intro' as GamePhase, label: '🎬 开场介绍' },
|
||||
{ key: 'round1_speak' as GamePhase, label: '💬 第一轮发言' },
|
||||
{ key: 'round1_search' as GamePhase, label: '🔍 第一轮搜证' },
|
||||
{ key: 'round2_speak' as GamePhase, label: '💬 第二轮发言' },
|
||||
{ key: 'round2_search' as GamePhase, label: '🔍 第二轮搜证' },
|
||||
{ key: 'final_discuss' as GamePhase, label: '🗣 最终讨论' },
|
||||
{ key: 'voting' as GamePhase, label: '🗳 投票' },
|
||||
{ key: 'reveal' as GamePhase, label: '🔎 揭晓真凶' },
|
||||
]
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.phase-control { padding: 1rem; }
|
||||
.control-header { margin-bottom: 0.75rem; }
|
||||
.control-header h3 { font-size: 0.9rem; font-weight: 600; }
|
||||
.control-body { display: flex; flex-wrap: wrap; gap: 0.4rem; margin-bottom: 1rem; }
|
||||
|
||||
.phase-btn {
|
||||
padding: 0.35rem 0.7rem;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
background: var(--bg-card);
|
||||
color: var(--text-primary);
|
||||
font-size: 0.75rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
.phase-btn:hover { border-color: var(--accent-primary); }
|
||||
.phase-btn.active { background: var(--accent-primary); border-color: var(--accent-primary); color: white; }
|
||||
|
||||
.control-actions { display: flex; flex-wrap: wrap; gap: 0.5rem; }
|
||||
.action-btn {
|
||||
padding: 0.5rem 1rem;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
.start-btn { background: var(--accent-success); color: #fff; }
|
||||
.start-btn:hover { opacity: 0.85; }
|
||||
.pause-btn { background: var(--accent-warning); color: #1a1a2e; }
|
||||
.pause-btn:hover { opacity: 0.85; }
|
||||
.resume-btn { background: var(--accent-secondary); color: #fff; }
|
||||
.resume-btn:hover { opacity: 0.85; }
|
||||
.stop-btn { background: var(--accent-danger); color: #fff; }
|
||||
.stop-btn:hover { opacity: 0.85; }
|
||||
.vote-btn { background: var(--accent-warning); color: #1a1a2e; }
|
||||
.vote-btn:hover { opacity: 0.85; }
|
||||
.reset-btn { background: var(--bg-card); color: var(--text-secondary); border: 1px solid var(--border-color); }
|
||||
.reset-btn:hover { border-color: var(--accent-danger); color: var(--accent-danger); }
|
||||
</style>
|
||||
@@ -0,0 +1,78 @@
|
||||
<template>
|
||||
<div class="progress-bar">
|
||||
<div class="phase-steps">
|
||||
<div
|
||||
v-for="phase in phases"
|
||||
:key="phase.key"
|
||||
class="phase-step"
|
||||
:class="{
|
||||
completed: phaseOrder.indexOf(phase.key) < phaseOrder.indexOf(currentPhase),
|
||||
active: phase.key === currentPhase,
|
||||
upcoming: phaseOrder.indexOf(phase.key) > phaseOrder.indexOf(currentPhase),
|
||||
}"
|
||||
>
|
||||
<div class="step-dot">
|
||||
<span v-if="phaseOrder.indexOf(phase.key) < phaseOrder.indexOf(currentPhase)">✓</span>
|
||||
<span v-else-if="phase.key === currentPhase">{{ phaseOrder.indexOf(currentPhase) + 1 }}</span>
|
||||
</div>
|
||||
<div class="step-label">{{ phase.label }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="progress-track">
|
||||
<div
|
||||
class="progress-fill"
|
||||
:style="{ width: progressPercent + '%' }"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import type { GamePhase } from '../../types/game'
|
||||
|
||||
const props = defineProps<{
|
||||
currentPhase: GamePhase
|
||||
}>()
|
||||
|
||||
const phases = [
|
||||
{ key: 'intro' as GamePhase, label: '开场' },
|
||||
{ key: 'round1_speak' as GamePhase, label: '发言1' },
|
||||
{ key: 'round1_search' as GamePhase, label: '搜证1' },
|
||||
{ key: 'round2_speak' as GamePhase, label: '发言2' },
|
||||
{ key: 'round2_search' as GamePhase, label: '搜证2' },
|
||||
{ key: 'final_discuss' as GamePhase, label: '讨论' },
|
||||
{ key: 'voting' as GamePhase, label: '投票' },
|
||||
{ key: 'reveal' as GamePhase, label: '揭晓' },
|
||||
]
|
||||
|
||||
const phaseOrder: GamePhase[] = phases.map((p) => p.key)
|
||||
|
||||
const progressPercent = computed(() => {
|
||||
const idx = phaseOrder.indexOf(props.currentPhase)
|
||||
return idx >= 0 ? (idx / (phaseOrder.length - 1)) * 100 : 0
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.progress-bar { padding: 1rem; }
|
||||
.phase-steps { display: flex; justify-content: space-between; margin-bottom: 0.5rem; }
|
||||
.phase-step { display: flex; flex-direction: column; align-items: center; gap: 0.25rem; opacity: 0.4; transition: opacity 0.3s ease; }
|
||||
.phase-step.completed { opacity: 0.6; }
|
||||
.phase-step.active { opacity: 1; }
|
||||
.step-dot {
|
||||
width: 28px; height: 28px; border-radius: 50%;
|
||||
background: var(--bg-card); border: 2px solid var(--border-color);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-size: 0.65rem; font-weight: 700; transition: all 0.3s ease;
|
||||
}
|
||||
.phase-step.completed .step-dot { background: var(--accent-success); border-color: var(--accent-success); color: white; }
|
||||
.phase-step.active .step-dot { background: var(--accent-primary); border-color: var(--accent-primary); color: white; }
|
||||
.step-label { font-size: 0.55rem; text-align: center; white-space: nowrap; }
|
||||
.progress-track { height: 4px; background: var(--border-color); border-radius: 2px; overflow: hidden; }
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, var(--accent-primary), var(--accent-secondary));
|
||||
border-radius: 2px; transition: width 0.5s ease;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,174 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div class="modal-overlay" v-if="visible" @click.self="$emit('close')">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h2>{{ character.name }}</h2>
|
||||
<button class="modal-close" @click="$emit('close')">×</button>
|
||||
</div>
|
||||
<div class="modal-body" v-if="character">
|
||||
<div class="info-section">
|
||||
<div class="info-row">
|
||||
<span class="info-label">角色定位</span>
|
||||
<span class="info-value">{{ roleLabel }}</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="info-label">状态</span>
|
||||
<span class="info-value" :class="character.status">{{ statusLabel }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="info-section">
|
||||
<h4>性格特征</h4>
|
||||
<p>{{ character.personality || '暂无设定' }}</p>
|
||||
</div>
|
||||
<div class="info-section">
|
||||
<h4>人物背景</h4>
|
||||
<p>{{ character.background || '暂无设定' }}</p>
|
||||
</div>
|
||||
<div class="info-section" v-if="character.soul_md">
|
||||
<h4>SOUL.md</h4>
|
||||
<pre class="soul-content">{{ character.soul_md }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import type { Character } from '../../types/character'
|
||||
|
||||
const props = defineProps<{
|
||||
character: Character | null
|
||||
visible: boolean
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
close: []
|
||||
}>()
|
||||
|
||||
const ROLE_MAP: Record<string, string> = {
|
||||
detective: '侦探',
|
||||
suspect: '嫌疑人',
|
||||
witness: '目击者',
|
||||
victim: '被害人',
|
||||
killer: '凶手',
|
||||
}
|
||||
|
||||
const STATUS_MAP: Record<string, string> = {
|
||||
alive: '✅ 存活',
|
||||
dead: '💀 已出局',
|
||||
inactive: '😴 未激活',
|
||||
}
|
||||
|
||||
const roleLabel = computed(() => props.character ? ROLE_MAP[props.character.role] || props.character.role : '')
|
||||
const statusLabel = computed(() => props.character ? STATUS_MAP[props.character.status] || props.character.status : '')
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 12px;
|
||||
width: 90%;
|
||||
max-width: 520px;
|
||||
max-height: 80vh;
|
||||
overflow-y: auto;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 1.25rem 1.5rem;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.modal-header h2 {
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.modal-close {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-muted);
|
||||
font-size: 1.5rem;
|
||||
cursor: pointer;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.modal-close:hover {
|
||||
background: rgba(255, 107, 107, 0.2);
|
||||
color: var(--accent-danger);
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.info-section {
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
.info-section:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.info-section h4 {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.info-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0.5rem 0;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.info-label {
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.info-value {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.info-value.dead {
|
||||
color: var(--accent-danger);
|
||||
}
|
||||
|
||||
.soul-content {
|
||||
background: var(--bg-primary);
|
||||
padding: 0.75rem;
|
||||
border-radius: 6px;
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.6;
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
white-space: pre-wrap;
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
</style>
|
||||
Vendored
+7
@@ -0,0 +1,7 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
declare module '*.vue' {
|
||||
import type { DefineComponent } from 'vue'
|
||||
const component: DefineComponent<{}, {}, any>
|
||||
export default component
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
import { createRouter, createWebHashHistory } from 'vue-router'
|
||||
import App from './App.vue'
|
||||
import './styles/main.css'
|
||||
|
||||
import LiveView from './views/LiveView.vue'
|
||||
import ControlView from './views/ControlView.vue'
|
||||
import ScriptImportView from './views/ScriptImportView.vue'
|
||||
|
||||
const routes = [
|
||||
{ path: '/', redirect: '/live' },
|
||||
{ path: '/live', component: LiveView },
|
||||
{ path: '/control', component: ControlView },
|
||||
{ path: '/script-import', component: ScriptImportView },
|
||||
]
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHashHistory(),
|
||||
routes,
|
||||
})
|
||||
|
||||
const app = createApp(App)
|
||||
app.use(createPinia())
|
||||
app.use(router)
|
||||
app.mount('#app')
|
||||
@@ -0,0 +1,32 @@
|
||||
const BASE_URL = '/api'
|
||||
|
||||
async function request<T>(path: string, options?: RequestInit): Promise<T> {
|
||||
const url = `${BASE_URL}${path}`
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
...options,
|
||||
})
|
||||
if (!response.ok) {
|
||||
const err = await response.text()
|
||||
throw new Error(`API Error ${response.status}: ${err}`)
|
||||
}
|
||||
return response.json()
|
||||
}
|
||||
|
||||
export const api = {
|
||||
get: <T>(path: string) => request<T>(path),
|
||||
post: <T>(path: string, body: unknown) =>
|
||||
request<T>(path, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
put: <T>(path: string, body: unknown) =>
|
||||
request<T>(path, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
delete: <T>(path: string) =>
|
||||
request<T>(path, { method: 'DELETE' }),
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { io, Socket } from 'socket.io-client'
|
||||
|
||||
const SOCKET_URL = window.location.origin
|
||||
|
||||
let socket: Socket | null = null
|
||||
|
||||
export function connectSocket(): Socket {
|
||||
if (!socket) {
|
||||
socket = io(SOCKET_URL, {
|
||||
transports: ['websocket', 'polling'],
|
||||
autoConnect: true,
|
||||
reconnection: true,
|
||||
reconnectionDelay: 1000,
|
||||
reconnectionAttempts: 10,
|
||||
})
|
||||
|
||||
socket.on('connect', () => {
|
||||
console.log('[Socket] Connected:', socket?.id)
|
||||
})
|
||||
|
||||
socket.on('disconnect', (reason) => {
|
||||
console.log('[Socket] Disconnected:', reason)
|
||||
})
|
||||
|
||||
socket.on('connect_error', (err) => {
|
||||
console.warn('[Socket] Connection error:', err.message)
|
||||
})
|
||||
}
|
||||
return socket
|
||||
}
|
||||
|
||||
export function getSocket(): Socket | null {
|
||||
return socket
|
||||
}
|
||||
|
||||
export function disconnectSocket(): void {
|
||||
if (socket) {
|
||||
socket.disconnect()
|
||||
socket = null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import { api } from '../services/api'
|
||||
import type { Character, CharacterGenerateRequest } from '../types/character'
|
||||
|
||||
export const useCharacterStore = defineStore('character', () => {
|
||||
const characters = ref<Character[]>([])
|
||||
const selectedCharacterId = ref<string | null>(null)
|
||||
|
||||
const selectedCharacter = computed(() =>
|
||||
characters.value.find((c) => c.id === selectedCharacterId.value) ?? null
|
||||
)
|
||||
|
||||
const characterMap = computed(() => {
|
||||
const map: Record<string, Character> = {}
|
||||
characters.value.forEach((c) => { map[c.id] = c })
|
||||
return map
|
||||
})
|
||||
|
||||
async function fetchCharacters() {
|
||||
characters.value = await api.get<Character[]>('/characters')
|
||||
}
|
||||
|
||||
async function generateCharacters(scriptId: string, names: string[]) {
|
||||
characters.value = await api.post<Character[]>(
|
||||
'/characters/generate',
|
||||
{ script_id: scriptId, character_names: names } as CharacterGenerateRequest
|
||||
)
|
||||
}
|
||||
|
||||
async function createProfile(characterId: string) {
|
||||
await api.post(`/characters/${characterId}/profile`)
|
||||
}
|
||||
|
||||
async function speakCharacter(characterId: string, prompt: string, targetId?: string) {
|
||||
const result = await api.post<{ character_id: string; character_name: string; response: string }>(
|
||||
`/characters/${characterId}/speak`,
|
||||
{ prompt, target_character_id: targetId }
|
||||
)
|
||||
return result
|
||||
}
|
||||
|
||||
function selectCharacter(id: string | null) {
|
||||
selectedCharacterId.value = id
|
||||
}
|
||||
|
||||
function clearCharacters() {
|
||||
characters.value = []
|
||||
selectedCharacterId.value = null
|
||||
}
|
||||
|
||||
return {
|
||||
characters,
|
||||
selectedCharacterId,
|
||||
selectedCharacter,
|
||||
characterMap,
|
||||
fetchCharacters,
|
||||
generateCharacters,
|
||||
createProfile,
|
||||
speakCharacter,
|
||||
selectCharacter,
|
||||
clearCharacters,
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,74 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import { api } from '../services/api'
|
||||
import type { ClueType, ClueVisibility } from '../types/game'
|
||||
|
||||
export interface Clue {
|
||||
id: string
|
||||
script_id: string
|
||||
name: string
|
||||
content: string
|
||||
clue_type: ClueType
|
||||
owner_id: string | null
|
||||
phase: string
|
||||
visibility: ClueVisibility
|
||||
visible_to: string[]
|
||||
is_unlocked: boolean
|
||||
unlocked_by: string | null
|
||||
unlocked_at: string | null
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface ClueCreateRequest {
|
||||
script_id: string
|
||||
name: string
|
||||
content: string
|
||||
clue_type: ClueType
|
||||
owner_id?: string
|
||||
phase?: string
|
||||
visibility?: ClueVisibility
|
||||
visible_to?: string[]
|
||||
}
|
||||
|
||||
export const useClueStore = defineStore('clue', () => {
|
||||
const clues = ref<Clue[]>([])
|
||||
|
||||
async function fetchClues(scriptId: string) {
|
||||
clues.value = await api.get<Clue[]>(`/${scriptId}/clues`)
|
||||
}
|
||||
|
||||
async function createClue(req: ClueCreateRequest) {
|
||||
const clue = await api.post<Clue>(`/${req.script_id}/clues`, req)
|
||||
clues.value.push(clue)
|
||||
return clue
|
||||
}
|
||||
|
||||
async function unlockClue(clueId: string, characterId: string) {
|
||||
const result = await api.post<{ ok: boolean; clue: Clue }>(
|
||||
`/clues/${clueId}/unlock`,
|
||||
{ character_id: characterId }
|
||||
)
|
||||
const idx = clues.value.findIndex((c) => c.id === clueId)
|
||||
if (idx !== -1 && result.clue) {
|
||||
clues.value[idx] = result.clue
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteClue(clueId: string) {
|
||||
await api.delete(`/clues/${clueId}`)
|
||||
clues.value = clues.value.filter((c) => c.id !== clueId)
|
||||
}
|
||||
|
||||
function clearClues() {
|
||||
clues.value = []
|
||||
}
|
||||
|
||||
return {
|
||||
clues,
|
||||
fetchClues,
|
||||
createClue,
|
||||
unlockClue,
|
||||
deleteClue,
|
||||
clearClues,
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,91 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import { api } from '../services/api'
|
||||
import type { GamePhase } from '../types/game'
|
||||
|
||||
interface GameState {
|
||||
id: string
|
||||
current_phase: GamePhase
|
||||
current_speaker_id: string | null
|
||||
speaker_order: string[]
|
||||
active_script_id: string | null
|
||||
is_running: boolean
|
||||
is_paused: boolean
|
||||
phase_started_at: string | null
|
||||
config: Record<string, any>
|
||||
progress_percent: number
|
||||
}
|
||||
|
||||
export const useGameStore = defineStore('game', () => {
|
||||
const state = ref<GameState>({
|
||||
id: '',
|
||||
current_phase: 'intro',
|
||||
current_speaker_id: null,
|
||||
speaker_order: [],
|
||||
active_script_id: null,
|
||||
is_running: false,
|
||||
is_paused: false,
|
||||
phase_started_at: null,
|
||||
config: {},
|
||||
progress_percent: 0,
|
||||
})
|
||||
|
||||
const autoChatActive = ref(false)
|
||||
|
||||
const currentPhase = computed(() => state.value.current_phase)
|
||||
const isRunning = computed(() => state.value.is_running)
|
||||
const activeScriptId = computed(() => state.value.active_script_id)
|
||||
|
||||
async function fetchState() {
|
||||
state.value = await api.get<GameState>('/game/state')
|
||||
}
|
||||
|
||||
async function nextPhase() {
|
||||
state.value = await api.post<GameState>('/game/phase/next')
|
||||
}
|
||||
|
||||
async function prevPhase() {
|
||||
state.value = await api.post<GameState>('/game/phase/prev')
|
||||
}
|
||||
|
||||
async function resetGame() {
|
||||
state.value = await api.post<GameState>('/game/reset')
|
||||
}
|
||||
|
||||
async function startGame(scriptId: string) {
|
||||
state.value = await api.post<GameState>('/game/start', { script_id: scriptId })
|
||||
}
|
||||
|
||||
async function pauseGame() {
|
||||
state.value = await api.post<GameState>('/game/pause')
|
||||
}
|
||||
|
||||
async function resumeGame() {
|
||||
state.value = await api.post<GameState>('/game/resume')
|
||||
}
|
||||
|
||||
function setAutoChatActive(active: boolean) {
|
||||
autoChatActive.value = active
|
||||
}
|
||||
|
||||
function setState(newState: GameState) {
|
||||
state.value = newState
|
||||
}
|
||||
|
||||
return {
|
||||
state,
|
||||
autoChatActive,
|
||||
currentPhase,
|
||||
isRunning,
|
||||
activeScriptId,
|
||||
fetchState,
|
||||
nextPhase,
|
||||
prevPhase,
|
||||
resetGame,
|
||||
startGame,
|
||||
pauseGame,
|
||||
resumeGame,
|
||||
setAutoChatActive,
|
||||
setState,
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,46 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import { api } from '../services/api'
|
||||
import type { Message } from '../types/message'
|
||||
|
||||
export const useMessageStore = defineStore('message', () => {
|
||||
const messages = ref<Message[]>([])
|
||||
|
||||
const lastMessage = computed(() =>
|
||||
messages.value.length > 0 ? messages.value[messages.value.length - 1] : null
|
||||
)
|
||||
|
||||
function addMessage(msg: Message) {
|
||||
messages.value.push(msg)
|
||||
}
|
||||
|
||||
async function fetchMessages() {
|
||||
messages.value = await api.get<Message[]>('/messages')
|
||||
}
|
||||
|
||||
async function sendMessage(req: {
|
||||
session_id?: string
|
||||
character_id?: string
|
||||
game_phase: string
|
||||
msg_type: string
|
||||
content: string
|
||||
target_character_id?: string
|
||||
}) {
|
||||
const msg = await api.post<Message>('/messages', req)
|
||||
messages.value.push(msg)
|
||||
return msg
|
||||
}
|
||||
|
||||
function clearMessages() {
|
||||
messages.value = []
|
||||
}
|
||||
|
||||
return {
|
||||
messages,
|
||||
lastMessage,
|
||||
addMessage,
|
||||
fetchMessages,
|
||||
sendMessage,
|
||||
clearMessages,
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,61 @@
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
:root {
|
||||
--bg-primary: #0f0f1a;
|
||||
--bg-secondary: #1a1a2e;
|
||||
--bg-card: #16213e;
|
||||
--bg-card-hover: #1c2a4a;
|
||||
--text-primary: #e8e8f0;
|
||||
--text-secondary: #a0a0b8;
|
||||
--text-muted: #6a6a80;
|
||||
--accent-primary: #6c63ff;
|
||||
--accent-secondary: #4ecdc4;
|
||||
--accent-danger: #ff6b6b;
|
||||
--accent-warning: #ffd93d;
|
||||
--accent-success: #6bcb77;
|
||||
--border-color: #2a2a40;
|
||||
--border-radius: 8px;
|
||||
--shadow: 0 4px 24px rgba(0, 0, 0, 0.3);
|
||||
--font-mono: 'Cascadia Code', 'Fira Code', 'JetBrains Mono', monospace;
|
||||
--font-sans: 'PingFang SC', 'Microsoft YaHei', 'Helvetica Neue', sans-serif;
|
||||
}
|
||||
|
||||
html, body {
|
||||
height: 100%;
|
||||
font-family: var(--font-sans);
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
line-height: 1.6;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#app {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
#app-root {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--border-color);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--text-muted);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { CharacterRole, CharacterStatus } from './game'
|
||||
|
||||
export interface Character {
|
||||
id: string
|
||||
script_id: string
|
||||
name: string
|
||||
role: CharacterRole
|
||||
status: CharacterStatus
|
||||
personality: string
|
||||
speaking_style: string
|
||||
background: string
|
||||
secret: string
|
||||
motive: string
|
||||
avatar_url: string
|
||||
hermes_profile: string
|
||||
soul_md: string
|
||||
is_revealed_killer: boolean
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface CharacterGenerateRequest {
|
||||
script_id: string
|
||||
character_names: string[]
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
export type GamePhase =
|
||||
| 'intro'
|
||||
| 'round1_speak'
|
||||
| 'round1_search'
|
||||
| 'round2_speak'
|
||||
| 'round2_search'
|
||||
| 'final_discuss'
|
||||
| 'voting'
|
||||
| 'reveal'
|
||||
|
||||
export type CharacterRole =
|
||||
| 'detective'
|
||||
| 'suspect'
|
||||
| 'witness'
|
||||
| 'victim'
|
||||
| 'killer'
|
||||
|
||||
export type CharacterStatus = 'alive' | 'dead' | 'inactive'
|
||||
|
||||
export type MessageType = 'system' | 'dm' | 'character' | 'vote' | 'clue'
|
||||
|
||||
export type ClueType = 'physical' | 'testimony' | 'motive' | 'alibi' | 'forensic'
|
||||
|
||||
export type ClueVisibility = 'all' | 'specific' | 'hidden'
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { GamePhase, MessageType, CharacterRole } from './game'
|
||||
|
||||
export interface Message {
|
||||
id: string
|
||||
session_id: string
|
||||
character_id: string | null
|
||||
game_phase: GamePhase
|
||||
msg_type: MessageType
|
||||
content: string
|
||||
target_character_id: string | null
|
||||
clue_id: string | null
|
||||
metadata: Record<string, any>
|
||||
created_at: string
|
||||
character_name?: string
|
||||
character_role?: CharacterRole
|
||||
}
|
||||
|
||||
export interface MessageCreateRequest {
|
||||
session_id?: string
|
||||
character_id?: string
|
||||
game_phase: string
|
||||
msg_type: string
|
||||
content: string
|
||||
target_character_id?: string
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
export interface Script {
|
||||
id: string
|
||||
title: string
|
||||
background: string
|
||||
character_count: number
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface ScriptParsePreview {
|
||||
title: string
|
||||
background: string
|
||||
characters: Array<{
|
||||
name: string
|
||||
personality: string
|
||||
speaking_style: string
|
||||
secret: string
|
||||
motive: string
|
||||
}>
|
||||
clues: Array<{
|
||||
id?: string
|
||||
content: string
|
||||
owner: string
|
||||
phase: string
|
||||
}>
|
||||
phases: Array<{
|
||||
name: string
|
||||
order: number
|
||||
duration: number
|
||||
}>
|
||||
}
|
||||
|
||||
export interface ScriptImportRequest {
|
||||
title: string
|
||||
background: string
|
||||
characters: Array<Record<string, any>>
|
||||
clues: Array<Record<string, any>>
|
||||
phases: Array<Record<string, any>>
|
||||
}
|
||||
|
||||
export interface ScriptUploadRequest {
|
||||
title: string
|
||||
content: string
|
||||
file_type: string
|
||||
}
|
||||
@@ -0,0 +1,282 @@
|
||||
<template>
|
||||
<div class="control-view">
|
||||
<div class="control-topbar">
|
||||
<div class="topbar-left">
|
||||
<h2>🎮 主播控制台</h2>
|
||||
<span class="connection-dot" :class="{ connected: socketConnected }"></span>
|
||||
</div>
|
||||
<div class="topbar-right">
|
||||
<router-link to="/live" class="nav-link">📺 观众视角</router-link>
|
||||
<router-link to="/script-import" class="nav-link">📜 剧本管理</router-link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="control-layout">
|
||||
<div class="panel panel-left">
|
||||
<section class="panel-section">
|
||||
<PhaseControl
|
||||
:current-phase="gameStore.currentPhase"
|
||||
:is-running="gameStore.isRunning"
|
||||
:is-paused="gameStore.state.is_paused"
|
||||
:auto-active="gameStore.autoChatActive"
|
||||
@change-phase="changePhase"
|
||||
@start-game="startGame"
|
||||
@pause-game="pauseGame"
|
||||
@resume-game="resumeGame"
|
||||
@start-auto="startAutoChat"
|
||||
@stop-auto="stopAutoChat"
|
||||
@trigger-vote="triggerVote"
|
||||
@reset-game="resetGame"
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section class="panel-section">
|
||||
<ProgressBar :current-phase="gameStore.currentPhase" />
|
||||
</section>
|
||||
|
||||
<section class="panel-section">
|
||||
<div class="inject-section">
|
||||
<h3>✏️ 手动注入</h3>
|
||||
<div class="inject-form">
|
||||
<select v-model="injectCharacterId" class="form-select">
|
||||
<option value="">选择角色</option>
|
||||
<option v-for="char in characterStore.characters" :key="char.id" :value="char.id">
|
||||
{{ char.name }}
|
||||
</option>
|
||||
</select>
|
||||
<input
|
||||
v-model="injectPrompt"
|
||||
type="text"
|
||||
placeholder="输入提示词,让角色回应..."
|
||||
class="form-input"
|
||||
@keyup.enter="injectMessage"
|
||||
/>
|
||||
<button class="inject-btn" @click="injectMessage" :disabled="!injectCharacterId || !injectPrompt">
|
||||
发送
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="panel panel-center">
|
||||
<div class="chat-container">
|
||||
<ChatLog :messages="messageStore.messages" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel panel-right">
|
||||
<section class="panel-section">
|
||||
<CharacterList
|
||||
:characters="characterStore.characters"
|
||||
:selected-id="characterStore.selectedCharacterId"
|
||||
@select-character="selectCharacter"
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section class="panel-section">
|
||||
<div class="quick-actions">
|
||||
<button class="qa-btn" @click="createAllProfiles">🎭 生成Profile</button>
|
||||
<button class="qa-btn" @click="refreshData">🔄 刷新</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel-section">
|
||||
<ClueBoard :clues="clueStore.clues" />
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<RoleModal
|
||||
:character="modalCharacter"
|
||||
:visible="modalVisible"
|
||||
@close="modalVisible = false"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted } from 'vue'
|
||||
import { useMessageStore } from '../stores/messageStore'
|
||||
import { useCharacterStore } from '../stores/characterStore'
|
||||
import { useClueStore } from '../stores/clueStore'
|
||||
import { useGameStore } from '../stores/gameStore'
|
||||
import { connectSocket, disconnectSocket, getSocket } from '../services/socket'
|
||||
import ChatLog from '../components/ChatLog.vue'
|
||||
import CharacterList from '../components/CharacterList.vue'
|
||||
import ProgressBar from '../components/ProgressBar.vue'
|
||||
import ClueBoard from '../components/ClueBoard.vue'
|
||||
import PhaseControl from '../components/PhaseControl.vue'
|
||||
import RoleModal from '../components/RoleModal.vue'
|
||||
import type { Message } from '../types/message'
|
||||
import type { Character } from '../types/character'
|
||||
import type { GamePhase } from '../types/game'
|
||||
|
||||
const messageStore = useMessageStore()
|
||||
const characterStore = useCharacterStore()
|
||||
const clueStore = useClueStore()
|
||||
const gameStore = useGameStore()
|
||||
|
||||
const socketConnected = ref(false)
|
||||
const modalVisible = ref(false)
|
||||
const modalCharacter = ref<Character | null>(null)
|
||||
const injectCharacterId = ref('')
|
||||
const injectPrompt = ref('')
|
||||
|
||||
onMounted(async () => {
|
||||
const socket = connectSocket()
|
||||
socket.on('connect', () => { socketConnected.value = true })
|
||||
socket.on('disconnect', () => { socketConnected.value = false })
|
||||
|
||||
socket.on('new_message', (msg: Message) => { messageStore.addMessage(msg) })
|
||||
socket.on('state_change', (data: { phase: GamePhase; progress: number }) => {
|
||||
gameStore.state.current_phase = data.phase
|
||||
gameStore.state.progress_percent = data.progress
|
||||
})
|
||||
socket.on('clue_unlocked', () => { refreshData() })
|
||||
socket.on('speaker_change', (data: { current_speaker_id: string | null }) => {
|
||||
gameStore.state.current_speaker_id = data.current_speaker_id
|
||||
})
|
||||
|
||||
await gameStore.fetchState()
|
||||
if (gameStore.activeScriptId) {
|
||||
await loadScriptData(gameStore.activeScriptId)
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => { disconnectSocket() })
|
||||
|
||||
async function loadScriptData(scriptId: string) {
|
||||
await Promise.all([
|
||||
characterStore.fetchCharacters(),
|
||||
messageStore.fetchMessages(),
|
||||
clueStore.fetchClues(scriptId),
|
||||
])
|
||||
}
|
||||
|
||||
async function selectCharacter(id: string) {
|
||||
characterStore.selectCharacter(id)
|
||||
modalCharacter.value = characterStore.characters.find((c) => c.id === id) ?? null
|
||||
modalVisible.value = true
|
||||
}
|
||||
|
||||
async function changePhase(phase: GamePhase) {
|
||||
if (phase === gameStore.currentPhase) return
|
||||
if (phase === 'intro') {
|
||||
getSocket()?.emit('dm_command', { command: 'prev_phase' })
|
||||
} else {
|
||||
getSocket()?.emit('dm_command', { command: 'next_phase' })
|
||||
}
|
||||
}
|
||||
|
||||
async function startGame() {
|
||||
const scriptId = gameStore.activeScriptId
|
||||
if (!scriptId) return
|
||||
await gameStore.startGame(scriptId)
|
||||
getSocket()?.emit('dm_command', { command: 'next_phase' })
|
||||
}
|
||||
|
||||
async function pauseGame() { await gameStore.pauseGame() }
|
||||
async function resumeGame() { await gameStore.resumeGame() }
|
||||
|
||||
async function resetGame() {
|
||||
await gameStore.resetGame()
|
||||
messageStore.clearMessages()
|
||||
}
|
||||
|
||||
function startAutoChat() {
|
||||
gameStore.setAutoChatActive(true)
|
||||
getSocket()?.emit('dm_command', {
|
||||
command: 'start_auto',
|
||||
args: { script_id: gameStore.activeScriptId, interval: 8, rounds: 1 },
|
||||
})
|
||||
}
|
||||
|
||||
function stopAutoChat() {
|
||||
gameStore.setAutoChatActive(false)
|
||||
getSocket()?.emit('dm_command', { command: 'stop_auto' })
|
||||
}
|
||||
|
||||
function triggerVote() {
|
||||
getSocket()?.emit('dm_command', {
|
||||
command: 'trigger_vote',
|
||||
args: { script_id: gameStore.activeScriptId },
|
||||
})
|
||||
}
|
||||
|
||||
async function injectMessage() {
|
||||
if (!injectCharacterId.value || !injectPrompt.value) return
|
||||
const result = await characterStore.speakCharacter(injectCharacterId.value, injectPrompt.value)
|
||||
if (result) {
|
||||
messageStore.addMessage({
|
||||
id: crypto.randomUUID(),
|
||||
session_id: '',
|
||||
character_id: injectCharacterId.value,
|
||||
game_phase: gameStore.currentPhase,
|
||||
msg_type: 'dm',
|
||||
content: injectPrompt.value,
|
||||
target_character_id: null,
|
||||
clue_id: null,
|
||||
metadata: {},
|
||||
created_at: new Date().toISOString(),
|
||||
character_name: '主持人',
|
||||
})
|
||||
messageStore.addMessage({
|
||||
id: crypto.randomUUID(),
|
||||
session_id: '',
|
||||
character_id: injectCharacterId.value,
|
||||
game_phase: gameStore.currentPhase,
|
||||
msg_type: 'character',
|
||||
content: result.response,
|
||||
target_character_id: null,
|
||||
clue_id: null,
|
||||
metadata: {},
|
||||
created_at: new Date().toISOString(),
|
||||
character_name: result.character_name,
|
||||
})
|
||||
}
|
||||
injectPrompt.value = ''
|
||||
}
|
||||
|
||||
async function createAllProfiles() {
|
||||
for (const char of characterStore.characters) {
|
||||
await characterStore.createProfile(char.id)
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshData() {
|
||||
if (gameStore.activeScriptId) {
|
||||
await loadScriptData(gameStore.activeScriptId)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.control-view { display: flex; flex-direction: column; height: 100%; }
|
||||
.control-topbar { display: flex; justify-content: space-between; align-items: center; padding: 0.6rem 1.5rem; background: var(--bg-secondary); border-bottom: 1px solid var(--border-color); }
|
||||
.topbar-left { display: flex; align-items: center; gap: 0.75rem; }
|
||||
.topbar-left h2 { font-size: 1.1rem; font-weight: 600; }
|
||||
.connection-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--accent-danger); transition: background 0.3s; }
|
||||
.connection-dot.connected { background: var(--accent-success); }
|
||||
.topbar-right { display: flex; gap: 1rem; }
|
||||
.nav-link { color: var(--text-secondary); text-decoration: none; font-size: 0.85rem; padding: 0.3rem 0.6rem; border-radius: 4px; transition: all 0.2s; }
|
||||
.nav-link:hover { color: var(--accent-primary); background: rgba(108, 99, 255, 0.1); }
|
||||
.control-layout { flex: 1; display: grid; grid-template-columns: 280px 1fr 280px; overflow: hidden; }
|
||||
.panel { overflow-y: auto; border-right: 1px solid var(--border-color); }
|
||||
.panel:last-child { border-right: none; border-left: 1px solid var(--border-color); }
|
||||
.panel-section { border-bottom: 1px solid var(--border-color); }
|
||||
.panel-section:last-child { border-bottom: none; }
|
||||
.panel-center { display: flex; flex-direction: column; overflow: hidden; }
|
||||
.chat-container { flex: 1; overflow: hidden; }
|
||||
.inject-section { padding: 1rem; }
|
||||
.inject-section h3 { font-size: 0.85rem; margin-bottom: 0.75rem; }
|
||||
.inject-form { display: flex; gap: 0.4rem; }
|
||||
.form-select, .form-input { padding: 0.4rem 0.6rem; border: 1px solid var(--border-color); border-radius: 4px; background: var(--bg-card); color: var(--text-primary); font-size: 0.8rem; }
|
||||
.form-select { min-width: 90px; }
|
||||
.form-input { flex: 1; }
|
||||
.inject-btn { padding: 0.4rem 0.8rem; border: none; border-radius: 4px; background: var(--accent-primary); color: white; font-size: 0.8rem; cursor: pointer; }
|
||||
.inject-btn:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||
.quick-actions { padding: 0.75rem 1rem; display: flex; gap: 0.5rem; }
|
||||
.qa-btn { flex: 1; padding: 0.4rem 0.5rem; border: 1px solid var(--border-color); border-radius: 4px; background: var(--bg-card); color: var(--text-secondary); font-size: 0.7rem; cursor: pointer; transition: all 0.2s; }
|
||||
.qa-btn:hover { border-color: var(--accent-primary); color: var(--accent-primary); }
|
||||
</style>
|
||||
@@ -0,0 +1,130 @@
|
||||
<template>
|
||||
<div class="live-view">
|
||||
<div class="live-header">
|
||||
<div class="show-title">
|
||||
<h1>Hermes Live Show</h1>
|
||||
<span class="live-badge" v-if="gameStore.isRunning">● LIVE</span>
|
||||
</div>
|
||||
<div class="show-phase">{{ phaseLabel }}</div>
|
||||
</div>
|
||||
|
||||
<div class="live-body">
|
||||
<div class="live-main">
|
||||
<ChatLog :messages="messageStore.messages" />
|
||||
</div>
|
||||
|
||||
<div class="live-sidebar">
|
||||
<div class="sidebar-section">
|
||||
<div class="section-title">登场角色</div>
|
||||
<div class="live-characters">
|
||||
<div
|
||||
v-for="char in characterStore.characters"
|
||||
:key="char.id"
|
||||
class="live-char-chip"
|
||||
:class="{ dead: char.status === 'dead' }"
|
||||
>
|
||||
<div class="chip-avatar">{{ char.name[0] }}</div>
|
||||
<span>{{ char.name }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sidebar-section">
|
||||
<ProgressBar :current-phase="gameStore.currentPhase" />
|
||||
</div>
|
||||
|
||||
<div class="sidebar-section" v-if="clueStore.clues.length > 0">
|
||||
<div class="section-title">线索</div>
|
||||
<div class="live-clues">
|
||||
<div
|
||||
v-for="clue in clueStore.clues.filter(c => c.is_unlocked)"
|
||||
:key="clue.id"
|
||||
class="live-clue-item"
|
||||
>
|
||||
<span class="clue-icon">🔍</span>
|
||||
<span>{{ clue.name }}</span>
|
||||
</div>
|
||||
<div v-if="clueStore.clues.filter(c => !c.is_unlocked).length > 0" class="clue-remaining">
|
||||
还有 {{ clueStore.clues.filter(c => !c.is_unlocked).length }} 条线索待解锁
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, onUnmounted, computed } from 'vue'
|
||||
import { useMessageStore } from '../stores/messageStore'
|
||||
import { useCharacterStore } from '../stores/characterStore'
|
||||
import { useClueStore } from '../stores/clueStore'
|
||||
import { useGameStore } from '../stores/gameStore'
|
||||
import { connectSocket, disconnectSocket } from '../services/socket'
|
||||
import ChatLog from '../components/ChatLog.vue'
|
||||
import ProgressBar from '../components/ProgressBar.vue'
|
||||
import type { Message } from '../types/message'
|
||||
import type { GamePhase } from '../types/game'
|
||||
|
||||
const messageStore = useMessageStore()
|
||||
const characterStore = useCharacterStore()
|
||||
const clueStore = useClueStore()
|
||||
const gameStore = useGameStore()
|
||||
|
||||
const PHASE_LABELS: Record<GamePhase, string> = {
|
||||
intro: '🎬 开场介绍',
|
||||
round1_speak: '💬 第一轮发言',
|
||||
round1_search: '🔍 第一轮搜证',
|
||||
round2_speak: '💬 第二轮发言',
|
||||
round2_search: '🔍 第二轮搜证',
|
||||
final_discuss: '🗣 最终讨论',
|
||||
voting: '🗳 投票环节',
|
||||
reveal: '🔎 揭晓真凶',
|
||||
}
|
||||
|
||||
const phaseLabel = computed(() => PHASE_LABELS[gameStore.currentPhase] || '准备中')
|
||||
|
||||
onMounted(async () => {
|
||||
const socket = connectSocket()
|
||||
socket.on('new_message', (msg: Message) => { messageStore.addMessage(msg) })
|
||||
socket.on('state_change', (data: { phase: GamePhase }) => { gameStore.state.current_phase = data.phase })
|
||||
socket.on('clue_unlocked', () => {
|
||||
if (gameStore.activeScriptId) {
|
||||
clueStore.fetchClues(gameStore.activeScriptId)
|
||||
}
|
||||
})
|
||||
|
||||
await gameStore.fetchState()
|
||||
if (gameStore.activeScriptId) {
|
||||
await Promise.all([
|
||||
characterStore.fetchCharacters(),
|
||||
messageStore.fetchMessages(),
|
||||
clueStore.fetchClues(gameStore.activeScriptId),
|
||||
])
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => { disconnectSocket() })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.live-view { display: flex; flex-direction: column; height: 100%; background: var(--bg-primary); }
|
||||
.live-header { display: flex; justify-content: space-between; align-items: center; padding: 0.75rem 1.5rem; background: var(--bg-secondary); border-bottom: 1px solid var(--border-color); }
|
||||
.show-title { display: flex; align-items: center; gap: 0.75rem; }
|
||||
.show-title h1 { font-size: 1.2rem; font-weight: 700; background: linear-gradient(135deg, var(--accent-primary), var(--accent-secondary)); -webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text; }
|
||||
.live-badge { color: var(--accent-danger); font-weight: 700; font-size: 0.8rem; animation: pulse 1.5s ease-in-out infinite; }
|
||||
@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.5; } }
|
||||
.show-phase { font-size: 0.9rem; color: var(--text-secondary); font-weight: 500; }
|
||||
.live-body { flex: 1; display: flex; overflow: hidden; }
|
||||
.live-main { flex: 1; overflow: hidden; }
|
||||
.live-sidebar { width: 220px; background: var(--bg-secondary); border-left: 1px solid var(--border-color); overflow-y: auto; }
|
||||
.sidebar-section { padding: 0.75rem; border-bottom: 1px solid var(--border-color); }
|
||||
.section-title { font-size: 0.75rem; font-weight: 600; color: var(--text-secondary); margin-bottom: 0.5rem; text-transform: uppercase; letter-spacing: 0.5px; }
|
||||
.live-characters { display: flex; flex-direction: column; gap: 0.4rem; }
|
||||
.live-char-chip { display: flex; align-items: center; gap: 0.5rem; padding: 0.35rem 0.5rem; border-radius: 6px; background: var(--bg-card); font-size: 0.8rem; }
|
||||
.live-char-chip.dead { opacity: 0.4; }
|
||||
.chip-avatar { width: 24px; height: 24px; border-radius: 50%; background: linear-gradient(135deg, var(--accent-primary), var(--accent-secondary)); display: flex; align-items: center; justify-content: center; font-size: 0.65rem; font-weight: 700; color: white; }
|
||||
.live-clues { display: flex; flex-direction: column; gap: 0.3rem; }
|
||||
.live-clue-item { font-size: 0.75rem; display: flex; align-items: center; gap: 0.3rem; }
|
||||
.clue-remaining { font-size: 0.7rem; color: var(--text-muted); font-style: italic; }
|
||||
</style>
|
||||
@@ -0,0 +1,208 @@
|
||||
<template>
|
||||
<div class="script-import">
|
||||
<div class="import-header">
|
||||
<h2>📜 剧本导入</h2>
|
||||
<router-link to="/control" class="back-link">← 返回控制台</router-link>
|
||||
</div>
|
||||
|
||||
<div class="import-body">
|
||||
<div class="import-form">
|
||||
<div class="form-group">
|
||||
<label>剧本标题</label>
|
||||
<input v-model="title" type="text" placeholder="输入剧本标题" class="form-input" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>文件类型</label>
|
||||
<select v-model="fileType" class="form-input">
|
||||
<option value="natural_language">自然语言(自动解析)</option>
|
||||
<option value="json">JSON 格式</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>剧本内容</label>
|
||||
<textarea
|
||||
v-model="content"
|
||||
placeholder="在此粘贴完整剧本内容..."
|
||||
class="form-textarea"
|
||||
rows="15"
|
||||
></textarea>
|
||||
</div>
|
||||
<button class="import-btn" :disabled="!title || !content || uploading" @click="doUpload">
|
||||
{{ uploading ? '解析中...' : '📤 上传解析' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="import-preview" v-if="preview">
|
||||
<h3>解析预览</h3>
|
||||
<div class="preview-section">
|
||||
<h4>📖 {{ preview.title }}</h4>
|
||||
<p class="preview-bg">{{ preview.background }}</p>
|
||||
</div>
|
||||
<div class="preview-section">
|
||||
<h4>角色 ({{ preview.characters.length }})</h4>
|
||||
<ul>
|
||||
<li v-for="(char, idx) in preview.characters" :key="idx">
|
||||
<strong>{{ char.name }}</strong>
|
||||
<span v-if="char.personality"> — {{ char.personality }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="preview-section">
|
||||
<h4>线索 ({{ preview.clues.length }})</h4>
|
||||
<ul>
|
||||
<li v-for="(clue, idx) in preview.clues" :key="idx">
|
||||
{{ clue.content }}
|
||||
<span class="clue-meta">({{ clue.owner }} · {{ clue.phase }})</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<button class="confirm-btn" :disabled="importing" @click="doImport">
|
||||
{{ importing ? '导入中...' : '✅ 确认导入' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="import-scripts">
|
||||
<h3>已导入剧本</h3>
|
||||
<div class="script-list" v-if="scripts.length > 0">
|
||||
<div
|
||||
v-for="script in scripts"
|
||||
:key="script.id"
|
||||
class="script-card"
|
||||
:class="{ active: gameStore.activeScriptId === script.id }"
|
||||
>
|
||||
<div class="script-info">
|
||||
<span class="script-title">{{ script.title }}</span>
|
||||
<span class="script-meta">{{ script.character_count }} 角色 · {{ formatDate(script.created_at) }}</span>
|
||||
</div>
|
||||
<div class="script-actions">
|
||||
<button class="action-link" @click="selectScript(script.id)">
|
||||
{{ gameStore.activeScriptId === script.id ? '已选中' : '选择' }}
|
||||
</button>
|
||||
<button class="action-link danger" @click="deleteScript(script.id)">删除</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="script-empty" v-else>暂未导入剧本</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { api } from '../services/api'
|
||||
import { useGameStore } from '../stores/gameStore'
|
||||
import type { Script, ScriptParsePreview, ScriptImportRequest } from '../types/script'
|
||||
|
||||
const gameStore = useGameStore()
|
||||
|
||||
const title = ref('')
|
||||
const content = ref('')
|
||||
const fileType = ref('natural_language')
|
||||
const uploading = ref(false)
|
||||
const importing = ref(false)
|
||||
const preview = ref<ScriptParsePreview | null>(null)
|
||||
const scripts = ref<Script[]>([])
|
||||
|
||||
async function doUpload() {
|
||||
if (!title.value || !content.value) return
|
||||
uploading.value = true
|
||||
try {
|
||||
preview.value = await api.post<ScriptParsePreview>('/scripts/upload', {
|
||||
title: title.value,
|
||||
content: content.value,
|
||||
file_type: fileType.value,
|
||||
})
|
||||
} catch (e) {
|
||||
console.error('Upload failed:', e)
|
||||
} finally {
|
||||
uploading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function doImport() {
|
||||
if (!preview.value) return
|
||||
importing.value = true
|
||||
try {
|
||||
const req: ScriptImportRequest = {
|
||||
title: preview.value.title,
|
||||
background: preview.value.background,
|
||||
characters: preview.value.characters,
|
||||
clues: preview.value.clues,
|
||||
phases: preview.value.phases,
|
||||
}
|
||||
await api.post<Script>('/scripts/import', req)
|
||||
preview.value = null
|
||||
resetForm()
|
||||
await loadScripts()
|
||||
} catch (e) {
|
||||
console.error('Import failed:', e)
|
||||
} finally {
|
||||
importing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadScripts() {
|
||||
scripts.value = await api.get<Script[]>('/scripts')
|
||||
}
|
||||
|
||||
async function selectScript(scriptId: string) {
|
||||
await gameStore.fetchState()
|
||||
await gameStore.startGame(scriptId)
|
||||
}
|
||||
|
||||
async function deleteScript(scriptId: string) {
|
||||
await api.delete(`/scripts/${scriptId}`)
|
||||
await loadScripts()
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
title.value = ''
|
||||
content.value = ''
|
||||
}
|
||||
|
||||
function formatDate(iso: string): string {
|
||||
return new Date(iso).toLocaleDateString('zh-CN')
|
||||
}
|
||||
|
||||
onMounted(() => { loadScripts() })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.script-import { height: 100%; overflow-y: auto; padding: 2rem; max-width: 1000px; margin: 0 auto; }
|
||||
.import-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 2rem; }
|
||||
.import-header h2 { font-size: 1.4rem; }
|
||||
.back-link { color: var(--accent-primary); text-decoration: none; font-size: 0.9rem; }
|
||||
.back-link:hover { text-decoration: underline; }
|
||||
.import-body { display: grid; grid-template-columns: 1fr 1fr; gap: 2rem; margin-bottom: 2rem; }
|
||||
.form-group { margin-bottom: 1.25rem; }
|
||||
.form-group label { display: block; font-size: 0.85rem; font-weight: 500; margin-bottom: 0.4rem; color: var(--text-secondary); }
|
||||
.form-input, .form-textarea { width: 100%; padding: 0.6rem 0.8rem; border: 1px solid var(--border-color); border-radius: var(--border-radius); background: var(--bg-card); color: var(--text-primary); font-size: 0.9rem; font-family: var(--font-sans); transition: border-color 0.2s; }
|
||||
.form-input:focus, .form-textarea:focus { outline: none; border-color: var(--accent-primary); }
|
||||
.form-textarea { resize: vertical; font-family: var(--font-mono); font-size: 0.8rem; line-height: 1.6; }
|
||||
.import-btn, .confirm-btn { padding: 0.7rem 1.5rem; border: none; border-radius: var(--border-radius); font-size: 0.9rem; font-weight: 600; cursor: pointer; transition: opacity 0.2s; }
|
||||
.import-btn { background: var(--accent-primary); color: white; }
|
||||
.import-btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
.confirm-btn { background: var(--accent-success); color: white; }
|
||||
.confirm-btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
.import-preview { background: var(--bg-card); border: 1px solid var(--border-color); border-radius: var(--border-radius); padding: 1rem; }
|
||||
.import-preview h3 { font-size: 0.95rem; margin-bottom: 1rem; }
|
||||
.preview-section { margin-bottom: 1rem; }
|
||||
.preview-section h4 { font-size: 0.8rem; color: var(--text-secondary); margin-bottom: 0.4rem; }
|
||||
.preview-bg { font-size: 0.8rem; color: var(--text-secondary); line-height: 1.5; }
|
||||
.preview-section ul { list-style: none; padding: 0; }
|
||||
.preview-section li { font-size: 0.8rem; padding: 0.15rem 0; }
|
||||
.clue-meta { color: var(--text-muted); font-size: 0.7rem; }
|
||||
.import-scripts { margin-top: 2rem; }
|
||||
.import-scripts h3 { font-size: 1rem; margin-bottom: 1rem; }
|
||||
.script-list { display: flex; flex-direction: column; gap: 0.5rem; }
|
||||
.script-card { display: flex; justify-content: space-between; align-items: center; padding: 0.75rem 1rem; background: var(--bg-card); border: 1px solid var(--border-color); border-radius: var(--border-radius); }
|
||||
.script-card.active { border-color: var(--accent-primary); }
|
||||
.script-title { font-weight: 500; }
|
||||
.script-meta { font-size: 0.75rem; color: var(--text-secondary); margin-left: 0.75rem; }
|
||||
.script-actions { display: flex; gap: 0.5rem; }
|
||||
.action-link { background: none; border: 1px solid var(--border-color); padding: 0.25rem 0.6rem; border-radius: 4px; font-size: 0.75rem; color: var(--text-secondary); cursor: pointer; transition: all 0.2s; }
|
||||
.action-link:hover { border-color: var(--accent-primary); color: var(--accent-primary); }
|
||||
.action-link.danger:hover { border-color: var(--accent-danger); color: var(--accent-danger); }
|
||||
.script-empty { color: var(--text-muted); font-size: 0.9rem; padding: 2rem; text-align: center; }
|
||||
</style>
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"module": "ESNext",
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "preserve",
|
||||
"strict": true,
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
},
|
||||
"baseUrl": "."
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue", "src/env.d.ts"]
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:8000',
|
||||
changeOrigin: true,
|
||||
},
|
||||
'/socket.io': {
|
||||
target: 'http://localhost:8000',
|
||||
changeOrigin: true,
|
||||
ws: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,23 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
echo "=== Hermes Live Show ==="
|
||||
|
||||
echo ""
|
||||
echo "[1/3] Installing backend dependencies..."
|
||||
cd backend
|
||||
pip install -r requirements.txt -q
|
||||
cd ..
|
||||
|
||||
echo "[2/3] Installing frontend dependencies..."
|
||||
cd frontend
|
||||
npm install --silent
|
||||
cd ..
|
||||
|
||||
echo "[3/3] Initializing database..."
|
||||
cd backend
|
||||
python -m scripts.init_db
|
||||
cd ..
|
||||
|
||||
echo ""
|
||||
echo "Setup complete! Run ./scripts/start.sh to start the application."
|
||||
@@ -0,0 +1,27 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
echo "Starting Hermes Live Show..."
|
||||
|
||||
echo "Starting backend server..."
|
||||
cd "$(dirname "$0")/../backend"
|
||||
uvicorn app.main:socket_app --host 0.0.0.0 --port 8000 --reload &
|
||||
BACKEND_PID=$!
|
||||
cd ../..
|
||||
|
||||
echo "Starting frontend dev server..."
|
||||
cd "$(dirname "$0")/../frontend"
|
||||
npm run dev &
|
||||
FRONTEND_PID=$!
|
||||
cd ../..
|
||||
|
||||
echo ""
|
||||
echo "Backend: http://localhost:8000"
|
||||
echo "Frontend: http://localhost:5173"
|
||||
echo "Control: http://localhost:5173/#/control"
|
||||
echo "Live: http://localhost:5173/#/live"
|
||||
echo ""
|
||||
echo "Press Ctrl+C to stop all services."
|
||||
|
||||
trap "kill $BACKEND_PID $FRONTEND_PID 2>/dev/null; exit" SIGINT SIGTERM
|
||||
wait
|
||||
@@ -0,0 +1,5 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
echo "Starting Hermes backend..."
|
||||
cd "$(dirname "$0")/../backend"
|
||||
uvicorn app.main:socket_app --host 0.0.0.0 --port 8000 --reload
|
||||
@@ -0,0 +1,5 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
echo "Starting Hermes frontend..."
|
||||
cd "$(dirname "$0")/../frontend"
|
||||
npm run dev
|
||||
Reference in New Issue
Block a user