init
This commit is contained in:
@@ -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())
|
||||
Reference in New Issue
Block a user