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 @@ + + +
+ + + +暂无消息,等待角色互动...
+{{ preview.background }}
+