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