This commit is contained in:
2026-06-04 17:59:53 +08:00
parent e6c27ac662
commit 0908495d6f
66 changed files with 4230 additions and 0 deletions
+12
View File
@@ -0,0 +1,12 @@
node_modules/
dist/
.env
*.pyc
__pycache__/
*.db
*.sqlite
data/
.DS_Store
.vscode/
*.log
.idea/
+17
View File
@@ -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"]
+13
View File
@@ -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"]
+11
View File
@@ -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"]
View File
+50
View File
@@ -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"}
View File
+41
View File
@@ -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
+111
View File
@@ -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)
+237
View File
@@ -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}
View File
+140
View File
@@ -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}
+78
View File
@@ -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,
)
+83
View File
@@ -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
+78
View File
@@ -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)
+93
View File
@@ -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}
View File
+255
View File
@@ -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()
+102
View File
@@ -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()
+142
View File
@@ -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()
+106
View File
@@ -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()
+153
View File
@@ -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()
+55
View File
@@ -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()
View File
+169
View File
@@ -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)
View File
+12
View File
@@ -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)
+10
View File
@@ -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
View File
+39
View File
@@ -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))
+13
View File
@@ -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())
+73
View File
@@ -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:
+13
View File
@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Hermes Live Show - AI剧本杀</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+23
View File
@@ -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"
}
}
+10
View File
@@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
<defs>
<linearGradient id="g" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#6c63ff"/>
<stop offset="100%" style="stop-color:#4ecdc4"/>
</linearGradient>
</defs>
<circle cx="50" cy="50" r="45" fill="url(#g)"/>
<text x="50" y="62" text-anchor="middle" font-size="40" font-weight="bold" fill="white" font-family="sans-serif">H</text>
</svg>

After

Width:  |  Height:  |  Size: 457 B

+8
View File
@@ -0,0 +1,8 @@
<template>
<div id="app-root">
<router-view />
</div>
</template>
<script setup lang="ts">
</script>
+109
View File
@@ -0,0 +1,109 @@
<template>
<div class="character-card" :class="{ selected: isSelected, dead: character.status === 'dead' }" @click="$emit('select')">
<div class="card-avatar">
<div class="avatar-placeholder">{{ character.name[0] }}</div>
</div>
<div class="card-info">
<div class="card-name">{{ character.name }}</div>
<div class="card-role">{{ roleLabel }}</div>
</div>
<div class="card-status" v-if="character.status === 'dead'">
<span class="dead-badge">已出局</span>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import type { Character } from '../../types/character'
const props = defineProps<{
character: Character
isSelected: boolean
}>()
defineEmits<{
select: []
}>()
const ROLE_MAP: Record<string, string> = {
detective: '🔍 侦探',
suspect: '🤔 嫌疑人',
witness: '👁️ 目击者',
victim: '💀 被害人',
killer: '🔪 凶手',
}
const roleLabel = computed(() => ROLE_MAP[props.character.role] || props.character.role)
</script>
<style scoped>
.character-card {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem;
border-radius: var(--border-radius);
background: var(--bg-card);
border: 2px solid transparent;
cursor: pointer;
transition: all 0.2s ease;
}
.character-card:hover {
background: var(--bg-card-hover);
border-color: var(--accent-primary);
}
.character-card.selected {
border-color: var(--accent-primary);
background: rgba(108, 99, 255, 0.1);
}
.character-card.dead {
opacity: 0.5;
}
.card-avatar {
flex-shrink: 0;
}
.avatar-placeholder {
width: 40px;
height: 40px;
border-radius: 50%;
background: linear-gradient(135deg, var(--accent-primary), var(--accent-secondary));
display: flex;
align-items: center;
justify-content: center;
font-weight: 700;
font-size: 1.1rem;
color: white;
}
.card-info {
flex: 1;
min-width: 0;
}
.card-name {
font-weight: 600;
font-size: 0.9rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.card-role {
font-size: 0.75rem;
color: var(--text-secondary);
}
.dead-badge {
font-size: 0.65rem;
padding: 0.15rem 0.5rem;
border-radius: 10px;
background: rgba(255, 107, 107, 0.2);
color: var(--accent-danger);
}
</style>
+69
View File
@@ -0,0 +1,69 @@
<template>
<div class="character-list">
<div class="list-header">
<h3>登场角色</h3>
<span class="count-badge">{{ characters.length }}</span>
</div>
<div class="list-body">
<CharacterCard
v-for="char in characters"
:key="char.id"
:character="char"
:is-selected="char.id === selectedId"
@select="$emit('select-character', char.id)"
/>
</div>
</div>
</template>
<script setup lang="ts">
import type { Character } from '../../types/character'
import CharacterCard from './CharacterCard.vue'
defineProps<{
characters: Character[]
selectedId: string | null
}>()
defineEmits<{
'select-character': [id: string]
}>()
</script>
<style scoped>
.character-list {
display: flex;
flex-direction: column;
height: 100%;
}
.list-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.75rem 1rem;
border-bottom: 1px solid var(--border-color);
}
.list-header h3 {
font-size: 0.9rem;
font-weight: 600;
}
.count-badge {
font-size: 0.7rem;
padding: 0.15rem 0.5rem;
border-radius: 10px;
background: var(--accent-primary);
color: white;
}
.list-body {
flex: 1;
overflow-y: auto;
padding: 0.5rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
</style>
+65
View File
@@ -0,0 +1,65 @@
<template>
<div class="chat-log" ref="chatContainer">
<div class="chat-messages" v-if="messages.length > 0">
<MessageBubble
v-for="msg in messages"
:key="msg.id"
:message="msg"
/>
</div>
<div class="chat-empty" v-else>
<div class="empty-icon">💬</div>
<p>暂无消息,等待角色互动...</p>
</div>
</div>
</template>
<script setup lang="ts">
import { watch, ref, nextTick } from 'vue'
import type { Message } from '../../types/message'
import MessageBubble from './MessageBubble.vue'
const props = defineProps<{
messages: Message[]
}>()
const chatContainer = ref<HTMLElement | null>(null)
watch(
() => props.messages.length,
async () => {
await nextTick()
if (chatContainer.value) {
chatContainer.value.scrollTop = chatContainer.value.scrollHeight
}
}
)
</script>
<style scoped>
.chat-log {
height: 100%;
overflow-y: auto;
padding: 1rem;
}
.chat-messages {
max-width: 800px;
margin: 0 auto;
}
.chat-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
color: var(--text-muted);
}
.empty-icon {
font-size: 3rem;
margin-bottom: 1rem;
opacity: 0.5;
}
</style>
+58
View File
@@ -0,0 +1,58 @@
<template>
<div class="clue-board">
<div class="board-header">
<h3>📋 线索本</h3>
<span class="clue-count">{{ unlockedCount }}/{{ clues.length }}</span>
</div>
<div class="board-body">
<div v-if="clues.length === 0" class="board-empty">暂无线索</div>
<div
v-for="clue in clues"
:key="clue.id"
class="clue-item"
:class="{ unlocked: clue.is_unlocked }"
>
<div class="clue-header">
<span class="clue-type">{{ TYPE_ICONS[clue.clue_type] || '📌' }}</span>
<span class="clue-name">{{ clue.name }}</span>
<span v-if="!clue.is_unlocked" class="clue-hidden">🔒</span>
<span v-else class="clue-unlocked">🔓</span>
</div>
<div v-if="clue.is_unlocked" class="clue-desc">{{ clue.content }}</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import type { Clue } from '../../stores/clueStore'
const props = defineProps<{
clues: Clue[]
}>()
const TYPE_ICONS: Record<string, string> = {
physical: '🔧',
testimony: '💬',
motive: '💰',
alibi: '⏰',
forensic: '🔬',
}
const unlockedCount = computed(() => props.clues.filter((c) => c.is_unlocked).length)
</script>
<style scoped>
.clue-board { display: flex; flex-direction: column; height: 100%; }
.board-header { display: flex; justify-content: space-between; align-items: center; padding: 0.75rem 1rem; border-bottom: 1px solid var(--border-color); }
.board-header h3 { font-size: 0.9rem; font-weight: 600; }
.clue-count { font-size: 0.7rem; color: var(--text-secondary); }
.board-body { flex: 1; overflow-y: auto; padding: 0.5rem; }
.board-empty { display: flex; align-items: center; justify-content: center; height: 80px; color: var(--text-muted); font-size: 0.8rem; }
.clue-item { padding: 0.5rem; margin-bottom: 0.5rem; border-radius: var(--border-radius); background: var(--bg-card); border: 1px solid var(--border-color); transition: all 0.3s ease; }
.clue-item.unlocked { border-color: var(--accent-success); background: rgba(107, 203, 119, 0.05); }
.clue-header { display: flex; align-items: center; gap: 0.5rem; }
.clue-name { flex: 1; font-size: 0.85rem; font-weight: 500; }
.clue-desc { margin-top: 0.4rem; padding-top: 0.4rem; border-top: 1px solid var(--border-color); font-size: 0.8rem; color: var(--text-secondary); line-height: 1.5; }
</style>
+131
View File
@@ -0,0 +1,131 @@
<template>
<div
class="message-bubble"
:class="[
`msg-${message.msg_type}`,
{ 'is-self': false }
]"
>
<div class="bubble-header" v-if="message.msg_type !== 'system' && message.msg_type !== 'narrator'">
<span class="character-name">{{ message.character_name || '未知角色' }}</span>
<span class="role-badge" :class="message.character_role">
{{ roleLabel(message.character_role) }}
</span>
</div>
<div class="bubble-content">
{{ message.content }}
</div>
<div class="bubble-time">
{{ formatTime(message.created_at) }}
</div>
</div>
</template>
<script setup lang="ts">
import type { Message } from '../../types/message'
import type { CharacterRole } from '../../types/game'
const props = defineProps<{
message: Message
}>()
const ROLE_LABELS: Record<string, string> = {
detective: '侦探',
suspect: '嫌疑人',
witness: '目击者',
victim: '被害人',
killer: '凶手',
}
function roleLabel(role?: CharacterRole): string {
return role ? ROLE_LABELS[role] || role : '未知'
}
function formatTime(iso: string): string {
const d = new Date(iso)
return d.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit', second: '2-digit' })
}
</script>
<style scoped>
.message-bubble {
padding: 0.5rem 0.75rem;
border-radius: var(--border-radius);
margin-bottom: 0.5rem;
animation: fadeIn 0.3s ease;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
}
.msg-system {
text-align: center;
color: var(--text-muted);
font-size: 0.8rem;
font-style: italic;
}
.msg-narrator {
background: linear-gradient(135deg, #1a1a3e, #2a1a3e);
border-left: 3px solid var(--accent-secondary);
}
.msg-character {
background: var(--bg-card);
border-left: 3px solid var(--accent-primary);
}
.msg-vote {
background: var(--bg-card);
border-left: 3px solid var(--accent-warning);
}
.msg-clue {
background: var(--bg-card);
border-left: 3px solid var(--accent-success);
}
.bubble-header {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.25rem;
}
.character-name {
font-weight: 600;
font-size: 0.85rem;
color: var(--accent-primary);
}
.role-badge {
font-size: 0.65rem;
padding: 0.1rem 0.4rem;
border-radius: 10px;
background: rgba(108, 99, 255, 0.2);
color: var(--accent-primary);
}
.role-badge.killer {
background: rgba(255, 107, 107, 0.2);
color: var(--accent-danger);
}
.role-badge.detective {
background: rgba(78, 205, 196, 0.2);
color: var(--accent-secondary);
}
.bubble-content {
line-height: 1.5;
}
.bubble-time {
font-size: 0.65rem;
color: var(--text-muted);
margin-top: 0.25rem;
text-align: right;
}
</style>
+117
View File
@@ -0,0 +1,117 @@
<template>
<div class="phase-control">
<div class="control-header">
<h3>🎮 阶段控制</h3>
</div>
<div class="control-body">
<button
v-for="phase in phases"
:key="phase.key"
class="phase-btn"
:class="{ active: phase.key === currentPhase }"
@click="$emit('change-phase', phase.key)"
>
{{ phase.label }}
</button>
</div>
<div class="control-actions">
<button class="action-btn start-btn" @click="$emit('start-game')" v-if="!isRunning">
▶ 开始游戏
</button>
<button class="action-btn pause-btn" @click="$emit('pause-game')" v-if="isRunning && !isPaused">
⏸ 暂停
</button>
<button class="action-btn resume-btn" @click="$emit('resume-game')" v-if="isPaused">
▶ 继续
</button>
<button class="action-btn start-btn" @click="$emit('start-auto')" v-if="isRunning && !autoActive">
🤖 自动发言
</button>
<button class="action-btn stop-btn" @click="$emit('stop-auto')" v-if="autoActive">
⏹ 停止
</button>
<button class="action-btn vote-btn" @click="$emit('trigger-vote')" v-if="isRunning">
🗳 投票
</button>
<button class="action-btn reset-btn" @click="$emit('reset-game')">
🔄 重置
</button>
</div>
</div>
</template>
<script setup lang="ts">
import type { GamePhase } from '../../types/game'
defineProps<{
currentPhase: GamePhase
isRunning: boolean
isPaused: boolean
autoActive: boolean
}>()
defineEmits<{
'change-phase': [phase: GamePhase]
'start-game': []
'pause-game': []
'resume-game': []
'start-auto': []
'stop-auto': []
'trigger-vote': []
'reset-game': []
}>()
const phases = [
{ key: 'intro' as GamePhase, label: '🎬 开场介绍' },
{ key: 'round1_speak' as GamePhase, label: '💬 第一轮发言' },
{ key: 'round1_search' as GamePhase, label: '🔍 第一轮搜证' },
{ key: 'round2_speak' as GamePhase, label: '💬 第二轮发言' },
{ key: 'round2_search' as GamePhase, label: '🔍 第二轮搜证' },
{ key: 'final_discuss' as GamePhase, label: '🗣 最终讨论' },
{ key: 'voting' as GamePhase, label: '🗳 投票' },
{ key: 'reveal' as GamePhase, label: '🔎 揭晓真凶' },
]
</script>
<style scoped>
.phase-control { padding: 1rem; }
.control-header { margin-bottom: 0.75rem; }
.control-header h3 { font-size: 0.9rem; font-weight: 600; }
.control-body { display: flex; flex-wrap: wrap; gap: 0.4rem; margin-bottom: 1rem; }
.phase-btn {
padding: 0.35rem 0.7rem;
border: 1px solid var(--border-color);
border-radius: 6px;
background: var(--bg-card);
color: var(--text-primary);
font-size: 0.75rem;
cursor: pointer;
transition: all 0.2s ease;
}
.phase-btn:hover { border-color: var(--accent-primary); }
.phase-btn.active { background: var(--accent-primary); border-color: var(--accent-primary); color: white; }
.control-actions { display: flex; flex-wrap: wrap; gap: 0.5rem; }
.action-btn {
padding: 0.5rem 1rem;
border: none;
border-radius: 6px;
font-size: 0.8rem;
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
}
.start-btn { background: var(--accent-success); color: #fff; }
.start-btn:hover { opacity: 0.85; }
.pause-btn { background: var(--accent-warning); color: #1a1a2e; }
.pause-btn:hover { opacity: 0.85; }
.resume-btn { background: var(--accent-secondary); color: #fff; }
.resume-btn:hover { opacity: 0.85; }
.stop-btn { background: var(--accent-danger); color: #fff; }
.stop-btn:hover { opacity: 0.85; }
.vote-btn { background: var(--accent-warning); color: #1a1a2e; }
.vote-btn:hover { opacity: 0.85; }
.reset-btn { background: var(--bg-card); color: var(--text-secondary); border: 1px solid var(--border-color); }
.reset-btn:hover { border-color: var(--accent-danger); color: var(--accent-danger); }
</style>
+78
View File
@@ -0,0 +1,78 @@
<template>
<div class="progress-bar">
<div class="phase-steps">
<div
v-for="phase in phases"
:key="phase.key"
class="phase-step"
:class="{
completed: phaseOrder.indexOf(phase.key) < phaseOrder.indexOf(currentPhase),
active: phase.key === currentPhase,
upcoming: phaseOrder.indexOf(phase.key) > phaseOrder.indexOf(currentPhase),
}"
>
<div class="step-dot">
<span v-if="phaseOrder.indexOf(phase.key) < phaseOrder.indexOf(currentPhase)">✓</span>
<span v-else-if="phase.key === currentPhase">{{ phaseOrder.indexOf(currentPhase) + 1 }}</span>
</div>
<div class="step-label">{{ phase.label }}</div>
</div>
</div>
<div class="progress-track">
<div
class="progress-fill"
:style="{ width: progressPercent + '%' }"
></div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import type { GamePhase } from '../../types/game'
const props = defineProps<{
currentPhase: GamePhase
}>()
const phases = [
{ key: 'intro' as GamePhase, label: '开场' },
{ key: 'round1_speak' as GamePhase, label: '发言1' },
{ key: 'round1_search' as GamePhase, label: '搜证1' },
{ key: 'round2_speak' as GamePhase, label: '发言2' },
{ key: 'round2_search' as GamePhase, label: '搜证2' },
{ key: 'final_discuss' as GamePhase, label: '讨论' },
{ key: 'voting' as GamePhase, label: '投票' },
{ key: 'reveal' as GamePhase, label: '揭晓' },
]
const phaseOrder: GamePhase[] = phases.map((p) => p.key)
const progressPercent = computed(() => {
const idx = phaseOrder.indexOf(props.currentPhase)
return idx >= 0 ? (idx / (phaseOrder.length - 1)) * 100 : 0
})
</script>
<style scoped>
.progress-bar { padding: 1rem; }
.phase-steps { display: flex; justify-content: space-between; margin-bottom: 0.5rem; }
.phase-step { display: flex; flex-direction: column; align-items: center; gap: 0.25rem; opacity: 0.4; transition: opacity 0.3s ease; }
.phase-step.completed { opacity: 0.6; }
.phase-step.active { opacity: 1; }
.step-dot {
width: 28px; height: 28px; border-radius: 50%;
background: var(--bg-card); border: 2px solid var(--border-color);
display: flex; align-items: center; justify-content: center;
font-size: 0.65rem; font-weight: 700; transition: all 0.3s ease;
}
.phase-step.completed .step-dot { background: var(--accent-success); border-color: var(--accent-success); color: white; }
.phase-step.active .step-dot { background: var(--accent-primary); border-color: var(--accent-primary); color: white; }
.step-label { font-size: 0.55rem; text-align: center; white-space: nowrap; }
.progress-track { height: 4px; background: var(--border-color); border-radius: 2px; overflow: hidden; }
.progress-fill {
height: 100%;
background: linear-gradient(90deg, var(--accent-primary), var(--accent-secondary));
border-radius: 2px; transition: width 0.5s ease;
}
</style>
+174
View File
@@ -0,0 +1,174 @@
<template>
<Teleport to="body">
<div class="modal-overlay" v-if="visible" @click.self="$emit('close')">
<div class="modal-content">
<div class="modal-header">
<h2>{{ character.name }}</h2>
<button class="modal-close" @click="$emit('close')">&times;</button>
</div>
<div class="modal-body" v-if="character">
<div class="info-section">
<div class="info-row">
<span class="info-label">角色定位</span>
<span class="info-value">{{ roleLabel }}</span>
</div>
<div class="info-row">
<span class="info-label">状态</span>
<span class="info-value" :class="character.status">{{ statusLabel }}</span>
</div>
</div>
<div class="info-section">
<h4>性格特征</h4>
<p>{{ character.personality || '暂无设定' }}</p>
</div>
<div class="info-section">
<h4>人物背景</h4>
<p>{{ character.background || '暂无设定' }}</p>
</div>
<div class="info-section" v-if="character.soul_md">
<h4>SOUL.md</h4>
<pre class="soul-content">{{ character.soul_md }}</pre>
</div>
</div>
</div>
</div>
</Teleport>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import type { Character } from '../../types/character'
const props = defineProps<{
character: Character | null
visible: boolean
}>()
defineEmits<{
close: []
}>()
const ROLE_MAP: Record<string, string> = {
detective: '侦探',
suspect: '嫌疑人',
witness: '目击者',
victim: '被害人',
killer: '凶手',
}
const STATUS_MAP: Record<string, string> = {
alive: '✅ 存活',
dead: '💀 已出局',
inactive: '😴 未激活',
}
const roleLabel = computed(() => props.character ? ROLE_MAP[props.character.role] || props.character.role : '')
const statusLabel = computed(() => props.character ? STATUS_MAP[props.character.status] || props.character.status : '')
</script>
<style scoped>
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.7);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
backdrop-filter: blur(4px);
}
.modal-content {
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 12px;
width: 90%;
max-width: 520px;
max-height: 80vh;
overflow-y: auto;
box-shadow: var(--shadow);
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1.25rem 1.5rem;
border-bottom: 1px solid var(--border-color);
}
.modal-header h2 {
font-size: 1.1rem;
}
.modal-close {
background: none;
border: none;
color: var(--text-muted);
font-size: 1.5rem;
cursor: pointer;
width: 36px;
height: 36px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s;
}
.modal-close:hover {
background: rgba(255, 107, 107, 0.2);
color: var(--accent-danger);
}
.modal-body {
padding: 1.5rem;
}
.info-section {
margin-bottom: 1.25rem;
}
.info-section:last-child {
margin-bottom: 0;
}
.info-section h4 {
font-size: 0.8rem;
color: var(--text-secondary);
margin-bottom: 0.5rem;
}
.info-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.5rem 0;
border-bottom: 1px solid var(--border-color);
}
.info-label {
color: var(--text-secondary);
font-size: 0.85rem;
}
.info-value {
font-weight: 500;
}
.info-value.dead {
color: var(--accent-danger);
}
.soul-content {
background: var(--bg-primary);
padding: 0.75rem;
border-radius: 6px;
font-size: 0.75rem;
line-height: 1.6;
max-height: 200px;
overflow-y: auto;
white-space: pre-wrap;
font-family: var(--font-mono);
}
</style>
+7
View File
@@ -0,0 +1,7 @@
/// <reference types="vite/client" />
declare module '*.vue' {
import type { DefineComponent } from 'vue'
const component: DefineComponent<{}, {}, any>
export default component
}
+26
View File
@@ -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')
+32
View File
@@ -0,0 +1,32 @@
const BASE_URL = '/api'
async function request<T>(path: string, options?: RequestInit): Promise<T> {
const url = `${BASE_URL}${path}`
const response = await fetch(url, {
headers: {
'Content-Type': 'application/json',
},
...options,
})
if (!response.ok) {
const err = await response.text()
throw new Error(`API Error ${response.status}: ${err}`)
}
return response.json()
}
export const api = {
get: <T>(path: string) => request<T>(path),
post: <T>(path: string, body: unknown) =>
request<T>(path, {
method: 'POST',
body: JSON.stringify(body),
}),
put: <T>(path: string, body: unknown) =>
request<T>(path, {
method: 'PUT',
body: JSON.stringify(body),
}),
delete: <T>(path: string) =>
request<T>(path, { method: 'DELETE' }),
}
+41
View File
@@ -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
}
}
+64
View File
@@ -0,0 +1,64 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { api } from '../services/api'
import type { Character, CharacterGenerateRequest } from '../types/character'
export const useCharacterStore = defineStore('character', () => {
const characters = ref<Character[]>([])
const selectedCharacterId = ref<string | null>(null)
const selectedCharacter = computed(() =>
characters.value.find((c) => c.id === selectedCharacterId.value) ?? null
)
const characterMap = computed(() => {
const map: Record<string, Character> = {}
characters.value.forEach((c) => { map[c.id] = c })
return map
})
async function fetchCharacters() {
characters.value = await api.get<Character[]>('/characters')
}
async function generateCharacters(scriptId: string, names: string[]) {
characters.value = await api.post<Character[]>(
'/characters/generate',
{ script_id: scriptId, character_names: names } as CharacterGenerateRequest
)
}
async function createProfile(characterId: string) {
await api.post(`/characters/${characterId}/profile`)
}
async function speakCharacter(characterId: string, prompt: string, targetId?: string) {
const result = await api.post<{ character_id: string; character_name: string; response: string }>(
`/characters/${characterId}/speak`,
{ prompt, target_character_id: targetId }
)
return result
}
function selectCharacter(id: string | null) {
selectedCharacterId.value = id
}
function clearCharacters() {
characters.value = []
selectedCharacterId.value = null
}
return {
characters,
selectedCharacterId,
selectedCharacter,
characterMap,
fetchCharacters,
generateCharacters,
createProfile,
speakCharacter,
selectCharacter,
clearCharacters,
}
})
+74
View File
@@ -0,0 +1,74 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { api } from '../services/api'
import type { ClueType, ClueVisibility } from '../types/game'
export interface Clue {
id: string
script_id: string
name: string
content: string
clue_type: ClueType
owner_id: string | null
phase: string
visibility: ClueVisibility
visible_to: string[]
is_unlocked: boolean
unlocked_by: string | null
unlocked_at: string | null
created_at: string
}
export interface ClueCreateRequest {
script_id: string
name: string
content: string
clue_type: ClueType
owner_id?: string
phase?: string
visibility?: ClueVisibility
visible_to?: string[]
}
export const useClueStore = defineStore('clue', () => {
const clues = ref<Clue[]>([])
async function fetchClues(scriptId: string) {
clues.value = await api.get<Clue[]>(`/${scriptId}/clues`)
}
async function createClue(req: ClueCreateRequest) {
const clue = await api.post<Clue>(`/${req.script_id}/clues`, req)
clues.value.push(clue)
return clue
}
async function unlockClue(clueId: string, characterId: string) {
const result = await api.post<{ ok: boolean; clue: Clue }>(
`/clues/${clueId}/unlock`,
{ character_id: characterId }
)
const idx = clues.value.findIndex((c) => c.id === clueId)
if (idx !== -1 && result.clue) {
clues.value[idx] = result.clue
}
}
async function deleteClue(clueId: string) {
await api.delete(`/clues/${clueId}`)
clues.value = clues.value.filter((c) => c.id !== clueId)
}
function clearClues() {
clues.value = []
}
return {
clues,
fetchClues,
createClue,
unlockClue,
deleteClue,
clearClues,
}
})
+91
View File
@@ -0,0 +1,91 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { api } from '../services/api'
import type { GamePhase } from '../types/game'
interface GameState {
id: string
current_phase: GamePhase
current_speaker_id: string | null
speaker_order: string[]
active_script_id: string | null
is_running: boolean
is_paused: boolean
phase_started_at: string | null
config: Record<string, any>
progress_percent: number
}
export const useGameStore = defineStore('game', () => {
const state = ref<GameState>({
id: '',
current_phase: 'intro',
current_speaker_id: null,
speaker_order: [],
active_script_id: null,
is_running: false,
is_paused: false,
phase_started_at: null,
config: {},
progress_percent: 0,
})
const autoChatActive = ref(false)
const currentPhase = computed(() => state.value.current_phase)
const isRunning = computed(() => state.value.is_running)
const activeScriptId = computed(() => state.value.active_script_id)
async function fetchState() {
state.value = await api.get<GameState>('/game/state')
}
async function nextPhase() {
state.value = await api.post<GameState>('/game/phase/next')
}
async function prevPhase() {
state.value = await api.post<GameState>('/game/phase/prev')
}
async function resetGame() {
state.value = await api.post<GameState>('/game/reset')
}
async function startGame(scriptId: string) {
state.value = await api.post<GameState>('/game/start', { script_id: scriptId })
}
async function pauseGame() {
state.value = await api.post<GameState>('/game/pause')
}
async function resumeGame() {
state.value = await api.post<GameState>('/game/resume')
}
function setAutoChatActive(active: boolean) {
autoChatActive.value = active
}
function setState(newState: GameState) {
state.value = newState
}
return {
state,
autoChatActive,
currentPhase,
isRunning,
activeScriptId,
fetchState,
nextPhase,
prevPhase,
resetGame,
startGame,
pauseGame,
resumeGame,
setAutoChatActive,
setState,
}
})
+46
View File
@@ -0,0 +1,46 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { api } from '../services/api'
import type { Message } from '../types/message'
export const useMessageStore = defineStore('message', () => {
const messages = ref<Message[]>([])
const lastMessage = computed(() =>
messages.value.length > 0 ? messages.value[messages.value.length - 1] : null
)
function addMessage(msg: Message) {
messages.value.push(msg)
}
async function fetchMessages() {
messages.value = await api.get<Message[]>('/messages')
}
async function sendMessage(req: {
session_id?: string
character_id?: string
game_phase: string
msg_type: string
content: string
target_character_id?: string
}) {
const msg = await api.post<Message>('/messages', req)
messages.value.push(msg)
return msg
}
function clearMessages() {
messages.value = []
}
return {
messages,
lastMessage,
addMessage,
fetchMessages,
sendMessage,
clearMessages,
}
})
+61
View File
@@ -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);
}
+24
View File
@@ -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[]
}
+24
View File
@@ -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'
+25
View File
@@ -0,0 +1,25 @@
import type { GamePhase, MessageType, CharacterRole } from './game'
export interface Message {
id: string
session_id: string
character_id: string | null
game_phase: GamePhase
msg_type: MessageType
content: string
target_character_id: string | null
clue_id: string | null
metadata: Record<string, any>
created_at: string
character_name?: string
character_role?: CharacterRole
}
export interface MessageCreateRequest {
session_id?: string
character_id?: string
game_phase: string
msg_type: string
content: string
target_character_id?: string
}
+44
View File
@@ -0,0 +1,44 @@
export interface Script {
id: string
title: string
background: string
character_count: number
created_at: string
}
export interface ScriptParsePreview {
title: string
background: string
characters: Array<{
name: string
personality: string
speaking_style: string
secret: string
motive: string
}>
clues: Array<{
id?: string
content: string
owner: string
phase: string
}>
phases: Array<{
name: string
order: number
duration: number
}>
}
export interface ScriptImportRequest {
title: string
background: string
characters: Array<Record<string, any>>
clues: Array<Record<string, any>>
phases: Array<Record<string, any>>
}
export interface ScriptUploadRequest {
title: string
content: string
file_type: string
}
+282
View File
@@ -0,0 +1,282 @@
<template>
<div class="control-view">
<div class="control-topbar">
<div class="topbar-left">
<h2>🎮 主播控制台</h2>
<span class="connection-dot" :class="{ connected: socketConnected }"></span>
</div>
<div class="topbar-right">
<router-link to="/live" class="nav-link">📺 观众视角</router-link>
<router-link to="/script-import" class="nav-link">📜 剧本管理</router-link>
</div>
</div>
<div class="control-layout">
<div class="panel panel-left">
<section class="panel-section">
<PhaseControl
:current-phase="gameStore.currentPhase"
:is-running="gameStore.isRunning"
:is-paused="gameStore.state.is_paused"
:auto-active="gameStore.autoChatActive"
@change-phase="changePhase"
@start-game="startGame"
@pause-game="pauseGame"
@resume-game="resumeGame"
@start-auto="startAutoChat"
@stop-auto="stopAutoChat"
@trigger-vote="triggerVote"
@reset-game="resetGame"
/>
</section>
<section class="panel-section">
<ProgressBar :current-phase="gameStore.currentPhase" />
</section>
<section class="panel-section">
<div class="inject-section">
<h3>✏️ 手动注入</h3>
<div class="inject-form">
<select v-model="injectCharacterId" class="form-select">
<option value="">选择角色</option>
<option v-for="char in characterStore.characters" :key="char.id" :value="char.id">
{{ char.name }}
</option>
</select>
<input
v-model="injectPrompt"
type="text"
placeholder="输入提示词,让角色回应..."
class="form-input"
@keyup.enter="injectMessage"
/>
<button class="inject-btn" @click="injectMessage" :disabled="!injectCharacterId || !injectPrompt">
发送
</button>
</div>
</div>
</section>
</div>
<div class="panel panel-center">
<div class="chat-container">
<ChatLog :messages="messageStore.messages" />
</div>
</div>
<div class="panel panel-right">
<section class="panel-section">
<CharacterList
:characters="characterStore.characters"
:selected-id="characterStore.selectedCharacterId"
@select-character="selectCharacter"
/>
</section>
<section class="panel-section">
<div class="quick-actions">
<button class="qa-btn" @click="createAllProfiles">🎭 生成Profile</button>
<button class="qa-btn" @click="refreshData">🔄 刷新</button>
</div>
</section>
<section class="panel-section">
<ClueBoard :clues="clueStore.clues" />
</section>
</div>
</div>
<RoleModal
:character="modalCharacter"
:visible="modalVisible"
@close="modalVisible = false"
/>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue'
import { useMessageStore } from '../stores/messageStore'
import { useCharacterStore } from '../stores/characterStore'
import { useClueStore } from '../stores/clueStore'
import { useGameStore } from '../stores/gameStore'
import { connectSocket, disconnectSocket, getSocket } from '../services/socket'
import ChatLog from '../components/ChatLog.vue'
import CharacterList from '../components/CharacterList.vue'
import ProgressBar from '../components/ProgressBar.vue'
import ClueBoard from '../components/ClueBoard.vue'
import PhaseControl from '../components/PhaseControl.vue'
import RoleModal from '../components/RoleModal.vue'
import type { Message } from '../types/message'
import type { Character } from '../types/character'
import type { GamePhase } from '../types/game'
const messageStore = useMessageStore()
const characterStore = useCharacterStore()
const clueStore = useClueStore()
const gameStore = useGameStore()
const socketConnected = ref(false)
const modalVisible = ref(false)
const modalCharacter = ref<Character | null>(null)
const injectCharacterId = ref('')
const injectPrompt = ref('')
onMounted(async () => {
const socket = connectSocket()
socket.on('connect', () => { socketConnected.value = true })
socket.on('disconnect', () => { socketConnected.value = false })
socket.on('new_message', (msg: Message) => { messageStore.addMessage(msg) })
socket.on('state_change', (data: { phase: GamePhase; progress: number }) => {
gameStore.state.current_phase = data.phase
gameStore.state.progress_percent = data.progress
})
socket.on('clue_unlocked', () => { refreshData() })
socket.on('speaker_change', (data: { current_speaker_id: string | null }) => {
gameStore.state.current_speaker_id = data.current_speaker_id
})
await gameStore.fetchState()
if (gameStore.activeScriptId) {
await loadScriptData(gameStore.activeScriptId)
}
})
onUnmounted(() => { disconnectSocket() })
async function loadScriptData(scriptId: string) {
await Promise.all([
characterStore.fetchCharacters(),
messageStore.fetchMessages(),
clueStore.fetchClues(scriptId),
])
}
async function selectCharacter(id: string) {
characterStore.selectCharacter(id)
modalCharacter.value = characterStore.characters.find((c) => c.id === id) ?? null
modalVisible.value = true
}
async function changePhase(phase: GamePhase) {
if (phase === gameStore.currentPhase) return
if (phase === 'intro') {
getSocket()?.emit('dm_command', { command: 'prev_phase' })
} else {
getSocket()?.emit('dm_command', { command: 'next_phase' })
}
}
async function startGame() {
const scriptId = gameStore.activeScriptId
if (!scriptId) return
await gameStore.startGame(scriptId)
getSocket()?.emit('dm_command', { command: 'next_phase' })
}
async function pauseGame() { await gameStore.pauseGame() }
async function resumeGame() { await gameStore.resumeGame() }
async function resetGame() {
await gameStore.resetGame()
messageStore.clearMessages()
}
function startAutoChat() {
gameStore.setAutoChatActive(true)
getSocket()?.emit('dm_command', {
command: 'start_auto',
args: { script_id: gameStore.activeScriptId, interval: 8, rounds: 1 },
})
}
function stopAutoChat() {
gameStore.setAutoChatActive(false)
getSocket()?.emit('dm_command', { command: 'stop_auto' })
}
function triggerVote() {
getSocket()?.emit('dm_command', {
command: 'trigger_vote',
args: { script_id: gameStore.activeScriptId },
})
}
async function injectMessage() {
if (!injectCharacterId.value || !injectPrompt.value) return
const result = await characterStore.speakCharacter(injectCharacterId.value, injectPrompt.value)
if (result) {
messageStore.addMessage({
id: crypto.randomUUID(),
session_id: '',
character_id: injectCharacterId.value,
game_phase: gameStore.currentPhase,
msg_type: 'dm',
content: injectPrompt.value,
target_character_id: null,
clue_id: null,
metadata: {},
created_at: new Date().toISOString(),
character_name: '主持人',
})
messageStore.addMessage({
id: crypto.randomUUID(),
session_id: '',
character_id: injectCharacterId.value,
game_phase: gameStore.currentPhase,
msg_type: 'character',
content: result.response,
target_character_id: null,
clue_id: null,
metadata: {},
created_at: new Date().toISOString(),
character_name: result.character_name,
})
}
injectPrompt.value = ''
}
async function createAllProfiles() {
for (const char of characterStore.characters) {
await characterStore.createProfile(char.id)
}
}
async function refreshData() {
if (gameStore.activeScriptId) {
await loadScriptData(gameStore.activeScriptId)
}
}
</script>
<style scoped>
.control-view { display: flex; flex-direction: column; height: 100%; }
.control-topbar { display: flex; justify-content: space-between; align-items: center; padding: 0.6rem 1.5rem; background: var(--bg-secondary); border-bottom: 1px solid var(--border-color); }
.topbar-left { display: flex; align-items: center; gap: 0.75rem; }
.topbar-left h2 { font-size: 1.1rem; font-weight: 600; }
.connection-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--accent-danger); transition: background 0.3s; }
.connection-dot.connected { background: var(--accent-success); }
.topbar-right { display: flex; gap: 1rem; }
.nav-link { color: var(--text-secondary); text-decoration: none; font-size: 0.85rem; padding: 0.3rem 0.6rem; border-radius: 4px; transition: all 0.2s; }
.nav-link:hover { color: var(--accent-primary); background: rgba(108, 99, 255, 0.1); }
.control-layout { flex: 1; display: grid; grid-template-columns: 280px 1fr 280px; overflow: hidden; }
.panel { overflow-y: auto; border-right: 1px solid var(--border-color); }
.panel:last-child { border-right: none; border-left: 1px solid var(--border-color); }
.panel-section { border-bottom: 1px solid var(--border-color); }
.panel-section:last-child { border-bottom: none; }
.panel-center { display: flex; flex-direction: column; overflow: hidden; }
.chat-container { flex: 1; overflow: hidden; }
.inject-section { padding: 1rem; }
.inject-section h3 { font-size: 0.85rem; margin-bottom: 0.75rem; }
.inject-form { display: flex; gap: 0.4rem; }
.form-select, .form-input { padding: 0.4rem 0.6rem; border: 1px solid var(--border-color); border-radius: 4px; background: var(--bg-card); color: var(--text-primary); font-size: 0.8rem; }
.form-select { min-width: 90px; }
.form-input { flex: 1; }
.inject-btn { padding: 0.4rem 0.8rem; border: none; border-radius: 4px; background: var(--accent-primary); color: white; font-size: 0.8rem; cursor: pointer; }
.inject-btn:disabled { opacity: 0.4; cursor: not-allowed; }
.quick-actions { padding: 0.75rem 1rem; display: flex; gap: 0.5rem; }
.qa-btn { flex: 1; padding: 0.4rem 0.5rem; border: 1px solid var(--border-color); border-radius: 4px; background: var(--bg-card); color: var(--text-secondary); font-size: 0.7rem; cursor: pointer; transition: all 0.2s; }
.qa-btn:hover { border-color: var(--accent-primary); color: var(--accent-primary); }
</style>
+130
View File
@@ -0,0 +1,130 @@
<template>
<div class="live-view">
<div class="live-header">
<div class="show-title">
<h1>Hermes Live Show</h1>
<span class="live-badge" v-if="gameStore.isRunning">● LIVE</span>
</div>
<div class="show-phase">{{ phaseLabel }}</div>
</div>
<div class="live-body">
<div class="live-main">
<ChatLog :messages="messageStore.messages" />
</div>
<div class="live-sidebar">
<div class="sidebar-section">
<div class="section-title">登场角色</div>
<div class="live-characters">
<div
v-for="char in characterStore.characters"
:key="char.id"
class="live-char-chip"
:class="{ dead: char.status === 'dead' }"
>
<div class="chip-avatar">{{ char.name[0] }}</div>
<span>{{ char.name }}</span>
</div>
</div>
</div>
<div class="sidebar-section">
<ProgressBar :current-phase="gameStore.currentPhase" />
</div>
<div class="sidebar-section" v-if="clueStore.clues.length > 0">
<div class="section-title">线索</div>
<div class="live-clues">
<div
v-for="clue in clueStore.clues.filter(c => c.is_unlocked)"
:key="clue.id"
class="live-clue-item"
>
<span class="clue-icon">🔍</span>
<span>{{ clue.name }}</span>
</div>
<div v-if="clueStore.clues.filter(c => !c.is_unlocked).length > 0" class="clue-remaining">
还有 {{ clueStore.clues.filter(c => !c.is_unlocked).length }} 条线索待解锁
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { onMounted, onUnmounted, computed } from 'vue'
import { useMessageStore } from '../stores/messageStore'
import { useCharacterStore } from '../stores/characterStore'
import { useClueStore } from '../stores/clueStore'
import { useGameStore } from '../stores/gameStore'
import { connectSocket, disconnectSocket } from '../services/socket'
import ChatLog from '../components/ChatLog.vue'
import ProgressBar from '../components/ProgressBar.vue'
import type { Message } from '../types/message'
import type { GamePhase } from '../types/game'
const messageStore = useMessageStore()
const characterStore = useCharacterStore()
const clueStore = useClueStore()
const gameStore = useGameStore()
const PHASE_LABELS: Record<GamePhase, string> = {
intro: '🎬 开场介绍',
round1_speak: '💬 第一轮发言',
round1_search: '🔍 第一轮搜证',
round2_speak: '💬 第二轮发言',
round2_search: '🔍 第二轮搜证',
final_discuss: '🗣 最终讨论',
voting: '🗳 投票环节',
reveal: '🔎 揭晓真凶',
}
const phaseLabel = computed(() => PHASE_LABELS[gameStore.currentPhase] || '准备中')
onMounted(async () => {
const socket = connectSocket()
socket.on('new_message', (msg: Message) => { messageStore.addMessage(msg) })
socket.on('state_change', (data: { phase: GamePhase }) => { gameStore.state.current_phase = data.phase })
socket.on('clue_unlocked', () => {
if (gameStore.activeScriptId) {
clueStore.fetchClues(gameStore.activeScriptId)
}
})
await gameStore.fetchState()
if (gameStore.activeScriptId) {
await Promise.all([
characterStore.fetchCharacters(),
messageStore.fetchMessages(),
clueStore.fetchClues(gameStore.activeScriptId),
])
}
})
onUnmounted(() => { disconnectSocket() })
</script>
<style scoped>
.live-view { display: flex; flex-direction: column; height: 100%; background: var(--bg-primary); }
.live-header { display: flex; justify-content: space-between; align-items: center; padding: 0.75rem 1.5rem; background: var(--bg-secondary); border-bottom: 1px solid var(--border-color); }
.show-title { display: flex; align-items: center; gap: 0.75rem; }
.show-title h1 { font-size: 1.2rem; font-weight: 700; background: linear-gradient(135deg, var(--accent-primary), var(--accent-secondary)); -webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text; }
.live-badge { color: var(--accent-danger); font-weight: 700; font-size: 0.8rem; animation: pulse 1.5s ease-in-out infinite; }
@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.5; } }
.show-phase { font-size: 0.9rem; color: var(--text-secondary); font-weight: 500; }
.live-body { flex: 1; display: flex; overflow: hidden; }
.live-main { flex: 1; overflow: hidden; }
.live-sidebar { width: 220px; background: var(--bg-secondary); border-left: 1px solid var(--border-color); overflow-y: auto; }
.sidebar-section { padding: 0.75rem; border-bottom: 1px solid var(--border-color); }
.section-title { font-size: 0.75rem; font-weight: 600; color: var(--text-secondary); margin-bottom: 0.5rem; text-transform: uppercase; letter-spacing: 0.5px; }
.live-characters { display: flex; flex-direction: column; gap: 0.4rem; }
.live-char-chip { display: flex; align-items: center; gap: 0.5rem; padding: 0.35rem 0.5rem; border-radius: 6px; background: var(--bg-card); font-size: 0.8rem; }
.live-char-chip.dead { opacity: 0.4; }
.chip-avatar { width: 24px; height: 24px; border-radius: 50%; background: linear-gradient(135deg, var(--accent-primary), var(--accent-secondary)); display: flex; align-items: center; justify-content: center; font-size: 0.65rem; font-weight: 700; color: white; }
.live-clues { display: flex; flex-direction: column; gap: 0.3rem; }
.live-clue-item { font-size: 0.75rem; display: flex; align-items: center; gap: 0.3rem; }
.clue-remaining { font-size: 0.7rem; color: var(--text-muted); font-style: italic; }
</style>
+208
View File
@@ -0,0 +1,208 @@
<template>
<div class="script-import">
<div class="import-header">
<h2>📜 剧本导入</h2>
<router-link to="/control" class="back-link">← 返回控制台</router-link>
</div>
<div class="import-body">
<div class="import-form">
<div class="form-group">
<label>剧本标题</label>
<input v-model="title" type="text" placeholder="输入剧本标题" class="form-input" />
</div>
<div class="form-group">
<label>文件类型</label>
<select v-model="fileType" class="form-input">
<option value="natural_language">自然语言(自动解析)</option>
<option value="json">JSON 格式</option>
</select>
</div>
<div class="form-group">
<label>剧本内容</label>
<textarea
v-model="content"
placeholder="在此粘贴完整剧本内容..."
class="form-textarea"
rows="15"
></textarea>
</div>
<button class="import-btn" :disabled="!title || !content || uploading" @click="doUpload">
{{ uploading ? '解析中...' : '📤 上传解析' }}
</button>
</div>
<div class="import-preview" v-if="preview">
<h3>解析预览</h3>
<div class="preview-section">
<h4>📖 {{ preview.title }}</h4>
<p class="preview-bg">{{ preview.background }}</p>
</div>
<div class="preview-section">
<h4>角色 ({{ preview.characters.length }})</h4>
<ul>
<li v-for="(char, idx) in preview.characters" :key="idx">
<strong>{{ char.name }}</strong>
<span v-if="char.personality"> — {{ char.personality }}</span>
</li>
</ul>
</div>
<div class="preview-section">
<h4>线索 ({{ preview.clues.length }})</h4>
<ul>
<li v-for="(clue, idx) in preview.clues" :key="idx">
{{ clue.content }}
<span class="clue-meta">({{ clue.owner }} · {{ clue.phase }})</span>
</li>
</ul>
</div>
<button class="confirm-btn" :disabled="importing" @click="doImport">
{{ importing ? '导入中...' : '✅ 确认导入' }}
</button>
</div>
</div>
<div class="import-scripts">
<h3>已导入剧本</h3>
<div class="script-list" v-if="scripts.length > 0">
<div
v-for="script in scripts"
:key="script.id"
class="script-card"
:class="{ active: gameStore.activeScriptId === script.id }"
>
<div class="script-info">
<span class="script-title">{{ script.title }}</span>
<span class="script-meta">{{ script.character_count }} 角色 · {{ formatDate(script.created_at) }}</span>
</div>
<div class="script-actions">
<button class="action-link" @click="selectScript(script.id)">
{{ gameStore.activeScriptId === script.id ? '已选中' : '选择' }}
</button>
<button class="action-link danger" @click="deleteScript(script.id)">删除</button>
</div>
</div>
</div>
<div class="script-empty" v-else>暂未导入剧本</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { api } from '../services/api'
import { useGameStore } from '../stores/gameStore'
import type { Script, ScriptParsePreview, ScriptImportRequest } from '../types/script'
const gameStore = useGameStore()
const title = ref('')
const content = ref('')
const fileType = ref('natural_language')
const uploading = ref(false)
const importing = ref(false)
const preview = ref<ScriptParsePreview | null>(null)
const scripts = ref<Script[]>([])
async function doUpload() {
if (!title.value || !content.value) return
uploading.value = true
try {
preview.value = await api.post<ScriptParsePreview>('/scripts/upload', {
title: title.value,
content: content.value,
file_type: fileType.value,
})
} catch (e) {
console.error('Upload failed:', e)
} finally {
uploading.value = false
}
}
async function doImport() {
if (!preview.value) return
importing.value = true
try {
const req: ScriptImportRequest = {
title: preview.value.title,
background: preview.value.background,
characters: preview.value.characters,
clues: preview.value.clues,
phases: preview.value.phases,
}
await api.post<Script>('/scripts/import', req)
preview.value = null
resetForm()
await loadScripts()
} catch (e) {
console.error('Import failed:', e)
} finally {
importing.value = false
}
}
async function loadScripts() {
scripts.value = await api.get<Script[]>('/scripts')
}
async function selectScript(scriptId: string) {
await gameStore.fetchState()
await gameStore.startGame(scriptId)
}
async function deleteScript(scriptId: string) {
await api.delete(`/scripts/${scriptId}`)
await loadScripts()
}
function resetForm() {
title.value = ''
content.value = ''
}
function formatDate(iso: string): string {
return new Date(iso).toLocaleDateString('zh-CN')
}
onMounted(() => { loadScripts() })
</script>
<style scoped>
.script-import { height: 100%; overflow-y: auto; padding: 2rem; max-width: 1000px; margin: 0 auto; }
.import-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 2rem; }
.import-header h2 { font-size: 1.4rem; }
.back-link { color: var(--accent-primary); text-decoration: none; font-size: 0.9rem; }
.back-link:hover { text-decoration: underline; }
.import-body { display: grid; grid-template-columns: 1fr 1fr; gap: 2rem; margin-bottom: 2rem; }
.form-group { margin-bottom: 1.25rem; }
.form-group label { display: block; font-size: 0.85rem; font-weight: 500; margin-bottom: 0.4rem; color: var(--text-secondary); }
.form-input, .form-textarea { width: 100%; padding: 0.6rem 0.8rem; border: 1px solid var(--border-color); border-radius: var(--border-radius); background: var(--bg-card); color: var(--text-primary); font-size: 0.9rem; font-family: var(--font-sans); transition: border-color 0.2s; }
.form-input:focus, .form-textarea:focus { outline: none; border-color: var(--accent-primary); }
.form-textarea { resize: vertical; font-family: var(--font-mono); font-size: 0.8rem; line-height: 1.6; }
.import-btn, .confirm-btn { padding: 0.7rem 1.5rem; border: none; border-radius: var(--border-radius); font-size: 0.9rem; font-weight: 600; cursor: pointer; transition: opacity 0.2s; }
.import-btn { background: var(--accent-primary); color: white; }
.import-btn:disabled { opacity: 0.5; cursor: not-allowed; }
.confirm-btn { background: var(--accent-success); color: white; }
.confirm-btn:disabled { opacity: 0.5; cursor: not-allowed; }
.import-preview { background: var(--bg-card); border: 1px solid var(--border-color); border-radius: var(--border-radius); padding: 1rem; }
.import-preview h3 { font-size: 0.95rem; margin-bottom: 1rem; }
.preview-section { margin-bottom: 1rem; }
.preview-section h4 { font-size: 0.8rem; color: var(--text-secondary); margin-bottom: 0.4rem; }
.preview-bg { font-size: 0.8rem; color: var(--text-secondary); line-height: 1.5; }
.preview-section ul { list-style: none; padding: 0; }
.preview-section li { font-size: 0.8rem; padding: 0.15rem 0; }
.clue-meta { color: var(--text-muted); font-size: 0.7rem; }
.import-scripts { margin-top: 2rem; }
.import-scripts h3 { font-size: 1rem; margin-bottom: 1rem; }
.script-list { display: flex; flex-direction: column; gap: 0.5rem; }
.script-card { display: flex; justify-content: space-between; align-items: center; padding: 0.75rem 1rem; background: var(--bg-card); border: 1px solid var(--border-color); border-radius: var(--border-radius); }
.script-card.active { border-color: var(--accent-primary); }
.script-title { font-weight: 500; }
.script-meta { font-size: 0.75rem; color: var(--text-secondary); margin-left: 0.75rem; }
.script-actions { display: flex; gap: 0.5rem; }
.action-link { background: none; border: 1px solid var(--border-color); padding: 0.25rem 0.6rem; border-radius: 4px; font-size: 0.75rem; color: var(--text-secondary); cursor: pointer; transition: all 0.2s; }
.action-link:hover { border-color: var(--accent-primary); color: var(--accent-primary); }
.action-link.danger:hover { border-color: var(--accent-danger); color: var(--accent-danger); }
.script-empty { color: var(--text-muted); font-size: 0.9rem; padding: 2rem; text-align: center; }
</style>
+23
View File
@@ -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"]
}
+20
View File
@@ -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,
},
},
},
})
+23
View File
@@ -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."
+27
View File
@@ -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
+5
View File
@@ -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
+5
View File
@@ -0,0 +1,5 @@
#!/bin/bash
set -e
echo "Starting Hermes frontend..."
cd "$(dirname "$0")/../frontend"
npm run dev