From 0908495d6fc5c48bd8b3907ef51efe175dbead53 Mon Sep 17 00:00:00 2001 From: chenjw28 <792430652@qq.com> Date: Thu, 4 Jun 2026 17:59:53 +0800 Subject: [PATCH] init --- .gitignore | 12 + Dockerfile.backend | 17 ++ Dockerfile.frontend | 13 + Dockerfile.hermes | 11 + backend/app/__init__.py | 0 backend/app/main.py | 50 ++++ backend/app/models/__init__.py | 0 backend/app/models/database.py | 41 +++ backend/app/models/orm.py | 111 ++++++++ backend/app/models/schemas.py | 237 ++++++++++++++++ backend/app/routes/__init__.py | 0 backend/app/routes/character.py | 140 ++++++++++ backend/app/routes/chat.py | 78 ++++++ backend/app/routes/clue.py | 83 ++++++ backend/app/routes/game.py | 78 ++++++ backend/app/routes/script.py | 93 +++++++ backend/app/services/__init__.py | 0 backend/app/services/agent_scheduler.py | 255 ++++++++++++++++++ backend/app/services/character_generator.py | 102 +++++++ backend/app/services/game_state_manager.py | 142 ++++++++++ backend/app/services/hermes_client.py | 106 ++++++++ backend/app/services/script_parser.py | 153 +++++++++++ backend/app/services/soul_generator.py | 55 ++++ backend/app/sockets/__init__.py | 0 backend/app/sockets/handlers.py | 169 ++++++++++++ backend/app/utils/__init__.py | 0 backend/app/utils/helpers.py | 12 + backend/requirements.txt | 10 + backend/scripts/__init__.py | 0 backend/scripts/create_profiles.py | 39 +++ backend/scripts/init_db.py | 13 + docker-compose.yml | 73 +++++ frontend/index.html | 13 + frontend/package.json | 23 ++ frontend/public/vite.svg | 10 + frontend/src/App.vue | 8 + frontend/src/components/CharacterCard.vue | 109 ++++++++ frontend/src/components/CharacterList.vue | 69 +++++ frontend/src/components/ChatLog.vue | 65 +++++ frontend/src/components/ClueBoard.vue | 58 ++++ frontend/src/components/MessageBubble.vue | 131 +++++++++ frontend/src/components/PhaseControl.vue | 117 ++++++++ frontend/src/components/ProgressBar.vue | 78 ++++++ frontend/src/components/RoleModal.vue | 174 ++++++++++++ frontend/src/env.d.ts | 7 + frontend/src/main.ts | 26 ++ frontend/src/services/api.ts | 32 +++ frontend/src/services/socket.ts | 41 +++ frontend/src/stores/characterStore.ts | 64 +++++ frontend/src/stores/clueStore.ts | 74 +++++ frontend/src/stores/gameStore.ts | 91 +++++++ frontend/src/stores/messageStore.ts | 46 ++++ frontend/src/styles/main.css | 61 +++++ frontend/src/types/character.ts | 24 ++ frontend/src/types/game.ts | 24 ++ frontend/src/types/message.ts | 25 ++ frontend/src/types/script.ts | 44 +++ frontend/src/views/ControlView.vue | 282 ++++++++++++++++++++ frontend/src/views/LiveView.vue | 130 +++++++++ frontend/src/views/ScriptImportView.vue | 208 +++++++++++++++ frontend/tsconfig.json | 23 ++ frontend/vite.config.ts | 20 ++ scripts/setup.sh | 23 ++ scripts/start.sh | 27 ++ scripts/start_backend.sh | 5 + scripts/start_frontend.sh | 5 + 66 files changed, 4230 insertions(+) create mode 100644 .gitignore create mode 100644 Dockerfile.backend create mode 100644 Dockerfile.frontend create mode 100644 Dockerfile.hermes create mode 100644 backend/app/__init__.py create mode 100644 backend/app/main.py create mode 100644 backend/app/models/__init__.py create mode 100644 backend/app/models/database.py create mode 100644 backend/app/models/orm.py create mode 100644 backend/app/models/schemas.py create mode 100644 backend/app/routes/__init__.py create mode 100644 backend/app/routes/character.py create mode 100644 backend/app/routes/chat.py create mode 100644 backend/app/routes/clue.py create mode 100644 backend/app/routes/game.py create mode 100644 backend/app/routes/script.py create mode 100644 backend/app/services/__init__.py create mode 100644 backend/app/services/agent_scheduler.py create mode 100644 backend/app/services/character_generator.py create mode 100644 backend/app/services/game_state_manager.py create mode 100644 backend/app/services/hermes_client.py create mode 100644 backend/app/services/script_parser.py create mode 100644 backend/app/services/soul_generator.py create mode 100644 backend/app/sockets/__init__.py create mode 100644 backend/app/sockets/handlers.py create mode 100644 backend/app/utils/__init__.py create mode 100644 backend/app/utils/helpers.py create mode 100644 backend/requirements.txt create mode 100644 backend/scripts/__init__.py create mode 100644 backend/scripts/create_profiles.py create mode 100644 backend/scripts/init_db.py create mode 100644 docker-compose.yml create mode 100644 frontend/index.html create mode 100644 frontend/package.json create mode 100644 frontend/public/vite.svg create mode 100644 frontend/src/App.vue create mode 100644 frontend/src/components/CharacterCard.vue create mode 100644 frontend/src/components/CharacterList.vue create mode 100644 frontend/src/components/ChatLog.vue create mode 100644 frontend/src/components/ClueBoard.vue create mode 100644 frontend/src/components/MessageBubble.vue create mode 100644 frontend/src/components/PhaseControl.vue create mode 100644 frontend/src/components/ProgressBar.vue create mode 100644 frontend/src/components/RoleModal.vue create mode 100644 frontend/src/env.d.ts create mode 100644 frontend/src/main.ts create mode 100644 frontend/src/services/api.ts create mode 100644 frontend/src/services/socket.ts create mode 100644 frontend/src/stores/characterStore.ts create mode 100644 frontend/src/stores/clueStore.ts create mode 100644 frontend/src/stores/gameStore.ts create mode 100644 frontend/src/stores/messageStore.ts create mode 100644 frontend/src/styles/main.css create mode 100644 frontend/src/types/character.ts create mode 100644 frontend/src/types/game.ts create mode 100644 frontend/src/types/message.ts create mode 100644 frontend/src/types/script.ts create mode 100644 frontend/src/views/ControlView.vue create mode 100644 frontend/src/views/LiveView.vue create mode 100644 frontend/src/views/ScriptImportView.vue create mode 100644 frontend/tsconfig.json create mode 100644 frontend/vite.config.ts create mode 100644 scripts/setup.sh create mode 100644 scripts/start.sh create mode 100644 scripts/start_backend.sh create mode 100644 scripts/start_frontend.sh diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..53d3d3f --- /dev/null +++ b/.gitignore @@ -0,0 +1,12 @@ +node_modules/ +dist/ +.env +*.pyc +__pycache__/ +*.db +*.sqlite +data/ +.DS_Store +.vscode/ +*.log +.idea/ diff --git a/Dockerfile.backend b/Dockerfile.backend new file mode 100644 index 0000000..fbc646b --- /dev/null +++ b/Dockerfile.backend @@ -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"] diff --git a/Dockerfile.frontend b/Dockerfile.frontend new file mode 100644 index 0000000..4e99624 --- /dev/null +++ b/Dockerfile.frontend @@ -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"] diff --git a/Dockerfile.hermes b/Dockerfile.hermes new file mode 100644 index 0000000..05370cb --- /dev/null +++ b/Dockerfile.hermes @@ -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"] diff --git a/backend/app/__init__.py b/backend/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/main.py b/backend/app/main.py new file mode 100644 index 0000000..8120cfb --- /dev/null +++ b/backend/app/main.py @@ -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"} diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/models/database.py b/backend/app/models/database.py new file mode 100644 index 0000000..7245bab --- /dev/null +++ b/backend/app/models/database.py @@ -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 diff --git a/backend/app/models/orm.py b/backend/app/models/orm.py new file mode 100644 index 0000000..d805653 --- /dev/null +++ b/backend/app/models/orm.py @@ -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) diff --git a/backend/app/models/schemas.py b/backend/app/models/schemas.py new file mode 100644 index 0000000..3bdf1c4 --- /dev/null +++ b/backend/app/models/schemas.py @@ -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} diff --git a/backend/app/routes/__init__.py b/backend/app/routes/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/routes/character.py b/backend/app/routes/character.py new file mode 100644 index 0000000..c6d4ec7 --- /dev/null +++ b/backend/app/routes/character.py @@ -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} diff --git a/backend/app/routes/chat.py b/backend/app/routes/chat.py new file mode 100644 index 0000000..24b8cff --- /dev/null +++ b/backend/app/routes/chat.py @@ -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, + ) diff --git a/backend/app/routes/clue.py b/backend/app/routes/clue.py new file mode 100644 index 0000000..92f8ee5 --- /dev/null +++ b/backend/app/routes/clue.py @@ -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 diff --git a/backend/app/routes/game.py b/backend/app/routes/game.py new file mode 100644 index 0000000..2a1bdee --- /dev/null +++ b/backend/app/routes/game.py @@ -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) diff --git a/backend/app/routes/script.py b/backend/app/routes/script.py new file mode 100644 index 0000000..a9ccd00 --- /dev/null +++ b/backend/app/routes/script.py @@ -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} diff --git a/backend/app/services/__init__.py b/backend/app/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/services/agent_scheduler.py b/backend/app/services/agent_scheduler.py new file mode 100644 index 0000000..0583df5 --- /dev/null +++ b/backend/app/services/agent_scheduler.py @@ -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() diff --git a/backend/app/services/character_generator.py b/backend/app/services/character_generator.py new file mode 100644 index 0000000..e3d80de --- /dev/null +++ b/backend/app/services/character_generator.py @@ -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() diff --git a/backend/app/services/game_state_manager.py b/backend/app/services/game_state_manager.py new file mode 100644 index 0000000..0bed996 --- /dev/null +++ b/backend/app/services/game_state_manager.py @@ -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() diff --git a/backend/app/services/hermes_client.py b/backend/app/services/hermes_client.py new file mode 100644 index 0000000..87f84d4 --- /dev/null +++ b/backend/app/services/hermes_client.py @@ -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() diff --git a/backend/app/services/script_parser.py b/backend/app/services/script_parser.py new file mode 100644 index 0000000..1b3bcca --- /dev/null +++ b/backend/app/services/script_parser.py @@ -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() diff --git a/backend/app/services/soul_generator.py b/backend/app/services/soul_generator.py new file mode 100644 index 0000000..8a89d81 --- /dev/null +++ b/backend/app/services/soul_generator.py @@ -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() diff --git a/backend/app/sockets/__init__.py b/backend/app/sockets/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/sockets/handlers.py b/backend/app/sockets/handlers.py new file mode 100644 index 0000000..429020a --- /dev/null +++ b/backend/app/sockets/handlers.py @@ -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) diff --git a/backend/app/utils/__init__.py b/backend/app/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/utils/helpers.py b/backend/app/utils/helpers.py new file mode 100644 index 0000000..f95b4da --- /dev/null +++ b/backend/app/utils/helpers.py @@ -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) diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..e90895f --- /dev/null +++ b/backend/requirements.txt @@ -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 diff --git a/backend/scripts/__init__.py b/backend/scripts/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/scripts/create_profiles.py b/backend/scripts/create_profiles.py new file mode 100644 index 0000000..c54c40f --- /dev/null +++ b/backend/scripts/create_profiles.py @@ -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)) diff --git a/backend/scripts/init_db.py b/backend/scripts/init_db.py new file mode 100644 index 0000000..7786804 --- /dev/null +++ b/backend/scripts/init_db.py @@ -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()) diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..62c1279 --- /dev/null +++ b/docker-compose.yml @@ -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: diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..980fdac --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,13 @@ + + + + + + + Hermes Live Show - AI剧本杀 + + +
+ + + diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..06b9518 --- /dev/null +++ b/frontend/package.json @@ -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" + } +} diff --git a/frontend/public/vite.svg b/frontend/public/vite.svg new file mode 100644 index 0000000..60d6c40 --- /dev/null +++ b/frontend/public/vite.svg @@ -0,0 +1,10 @@ + + + + + + + + + H + diff --git a/frontend/src/App.vue b/frontend/src/App.vue new file mode 100644 index 0000000..d3f1cde --- /dev/null +++ b/frontend/src/App.vue @@ -0,0 +1,8 @@ + + + diff --git a/frontend/src/components/CharacterCard.vue b/frontend/src/components/CharacterCard.vue new file mode 100644 index 0000000..2dba21d --- /dev/null +++ b/frontend/src/components/CharacterCard.vue @@ -0,0 +1,109 @@ + + + + + diff --git a/frontend/src/components/CharacterList.vue b/frontend/src/components/CharacterList.vue new file mode 100644 index 0000000..e7ce0ad --- /dev/null +++ b/frontend/src/components/CharacterList.vue @@ -0,0 +1,69 @@ + + + + + diff --git a/frontend/src/components/ChatLog.vue b/frontend/src/components/ChatLog.vue new file mode 100644 index 0000000..0fa9e89 --- /dev/null +++ b/frontend/src/components/ChatLog.vue @@ -0,0 +1,65 @@ + + + + + diff --git a/frontend/src/components/ClueBoard.vue b/frontend/src/components/ClueBoard.vue new file mode 100644 index 0000000..da6609d --- /dev/null +++ b/frontend/src/components/ClueBoard.vue @@ -0,0 +1,58 @@ + + + + + diff --git a/frontend/src/components/MessageBubble.vue b/frontend/src/components/MessageBubble.vue new file mode 100644 index 0000000..49e7bfa --- /dev/null +++ b/frontend/src/components/MessageBubble.vue @@ -0,0 +1,131 @@ + + + + + diff --git a/frontend/src/components/PhaseControl.vue b/frontend/src/components/PhaseControl.vue new file mode 100644 index 0000000..cc4aad4 --- /dev/null +++ b/frontend/src/components/PhaseControl.vue @@ -0,0 +1,117 @@ + + + + + diff --git a/frontend/src/components/ProgressBar.vue b/frontend/src/components/ProgressBar.vue new file mode 100644 index 0000000..a5ac4d6 --- /dev/null +++ b/frontend/src/components/ProgressBar.vue @@ -0,0 +1,78 @@ + + + + + diff --git a/frontend/src/components/RoleModal.vue b/frontend/src/components/RoleModal.vue new file mode 100644 index 0000000..9d08b85 --- /dev/null +++ b/frontend/src/components/RoleModal.vue @@ -0,0 +1,174 @@ + + + + + diff --git a/frontend/src/env.d.ts b/frontend/src/env.d.ts new file mode 100644 index 0000000..323c78a --- /dev/null +++ b/frontend/src/env.d.ts @@ -0,0 +1,7 @@ +/// + +declare module '*.vue' { + import type { DefineComponent } from 'vue' + const component: DefineComponent<{}, {}, any> + export default component +} diff --git a/frontend/src/main.ts b/frontend/src/main.ts new file mode 100644 index 0000000..42c292c --- /dev/null +++ b/frontend/src/main.ts @@ -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') diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts new file mode 100644 index 0000000..d272122 --- /dev/null +++ b/frontend/src/services/api.ts @@ -0,0 +1,32 @@ +const BASE_URL = '/api' + +async function request(path: string, options?: RequestInit): Promise { + 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: (path: string) => request(path), + post: (path: string, body: unknown) => + request(path, { + method: 'POST', + body: JSON.stringify(body), + }), + put: (path: string, body: unknown) => + request(path, { + method: 'PUT', + body: JSON.stringify(body), + }), + delete: (path: string) => + request(path, { method: 'DELETE' }), +} diff --git a/frontend/src/services/socket.ts b/frontend/src/services/socket.ts new file mode 100644 index 0000000..d8bd600 --- /dev/null +++ b/frontend/src/services/socket.ts @@ -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 + } +} diff --git a/frontend/src/stores/characterStore.ts b/frontend/src/stores/characterStore.ts new file mode 100644 index 0000000..94b673f --- /dev/null +++ b/frontend/src/stores/characterStore.ts @@ -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([]) + const selectedCharacterId = ref(null) + + const selectedCharacter = computed(() => + characters.value.find((c) => c.id === selectedCharacterId.value) ?? null + ) + + const characterMap = computed(() => { + const map: Record = {} + characters.value.forEach((c) => { map[c.id] = c }) + return map + }) + + async function fetchCharacters() { + characters.value = await api.get('/characters') + } + + async function generateCharacters(scriptId: string, names: string[]) { + characters.value = await api.post( + '/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, + } +}) diff --git a/frontend/src/stores/clueStore.ts b/frontend/src/stores/clueStore.ts new file mode 100644 index 0000000..055ac9b --- /dev/null +++ b/frontend/src/stores/clueStore.ts @@ -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([]) + + async function fetchClues(scriptId: string) { + clues.value = await api.get(`/${scriptId}/clues`) + } + + async function createClue(req: ClueCreateRequest) { + const clue = await api.post(`/${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, + } +}) diff --git a/frontend/src/stores/gameStore.ts b/frontend/src/stores/gameStore.ts new file mode 100644 index 0000000..70f0f06 --- /dev/null +++ b/frontend/src/stores/gameStore.ts @@ -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 + progress_percent: number +} + +export const useGameStore = defineStore('game', () => { + const state = ref({ + 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('/game/state') + } + + async function nextPhase() { + state.value = await api.post('/game/phase/next') + } + + async function prevPhase() { + state.value = await api.post('/game/phase/prev') + } + + async function resetGame() { + state.value = await api.post('/game/reset') + } + + async function startGame(scriptId: string) { + state.value = await api.post('/game/start', { script_id: scriptId }) + } + + async function pauseGame() { + state.value = await api.post('/game/pause') + } + + async function resumeGame() { + state.value = await api.post('/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, + } +}) diff --git a/frontend/src/stores/messageStore.ts b/frontend/src/stores/messageStore.ts new file mode 100644 index 0000000..525489f --- /dev/null +++ b/frontend/src/stores/messageStore.ts @@ -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([]) + + 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('/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('/messages', req) + messages.value.push(msg) + return msg + } + + function clearMessages() { + messages.value = [] + } + + return { + messages, + lastMessage, + addMessage, + fetchMessages, + sendMessage, + clearMessages, + } +}) diff --git a/frontend/src/styles/main.css b/frontend/src/styles/main.css new file mode 100644 index 0000000..5d5a7e4 --- /dev/null +++ b/frontend/src/styles/main.css @@ -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); +} diff --git a/frontend/src/types/character.ts b/frontend/src/types/character.ts new file mode 100644 index 0000000..b5b792f --- /dev/null +++ b/frontend/src/types/character.ts @@ -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[] +} diff --git a/frontend/src/types/game.ts b/frontend/src/types/game.ts new file mode 100644 index 0000000..9d1a70f --- /dev/null +++ b/frontend/src/types/game.ts @@ -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' diff --git a/frontend/src/types/message.ts b/frontend/src/types/message.ts new file mode 100644 index 0000000..04b17d6 --- /dev/null +++ b/frontend/src/types/message.ts @@ -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 + 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 +} diff --git a/frontend/src/types/script.ts b/frontend/src/types/script.ts new file mode 100644 index 0000000..fb3688f --- /dev/null +++ b/frontend/src/types/script.ts @@ -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> + clues: Array> + phases: Array> +} + +export interface ScriptUploadRequest { + title: string + content: string + file_type: string +} diff --git a/frontend/src/views/ControlView.vue b/frontend/src/views/ControlView.vue new file mode 100644 index 0000000..8adf4a1 --- /dev/null +++ b/frontend/src/views/ControlView.vue @@ -0,0 +1,282 @@ + + + + + diff --git a/frontend/src/views/LiveView.vue b/frontend/src/views/LiveView.vue new file mode 100644 index 0000000..1081b5b --- /dev/null +++ b/frontend/src/views/LiveView.vue @@ -0,0 +1,130 @@ + + + + + diff --git a/frontend/src/views/ScriptImportView.vue b/frontend/src/views/ScriptImportView.vue new file mode 100644 index 0000000..6c54737 --- /dev/null +++ b/frontend/src/views/ScriptImportView.vue @@ -0,0 +1,208 @@ + + + + + diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..436a9a3 --- /dev/null +++ b/frontend/tsconfig.json @@ -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"] +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts new file mode 100644 index 0000000..9a62b2b --- /dev/null +++ b/frontend/vite.config.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, + }, + }, + }, +}) diff --git a/scripts/setup.sh b/scripts/setup.sh new file mode 100644 index 0000000..c4107ec --- /dev/null +++ b/scripts/setup.sh @@ -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." diff --git a/scripts/start.sh b/scripts/start.sh new file mode 100644 index 0000000..c900b9d --- /dev/null +++ b/scripts/start.sh @@ -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 diff --git a/scripts/start_backend.sh b/scripts/start_backend.sh new file mode 100644 index 0000000..4f5ce60 --- /dev/null +++ b/scripts/start_backend.sh @@ -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 diff --git a/scripts/start_frontend.sh b/scripts/start_frontend.sh new file mode 100644 index 0000000..c501923 --- /dev/null +++ b/scripts/start_frontend.sh @@ -0,0 +1,5 @@ +#!/bin/bash +set -e +echo "Starting Hermes frontend..." +cd "$(dirname "$0")/../frontend" +npm run dev