This commit is contained in:
2026-06-04 17:59:53 +08:00
parent e6c27ac662
commit 0908495d6f
66 changed files with 4230 additions and 0 deletions
View File
+255
View File
@@ -0,0 +1,255 @@
import asyncio
import logging
import random
from typing import Optional
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.orm import Character, Message
from app.services.hermes_client import hermes_client
logger = logging.getLogger(__name__)
class AgentScheduler:
def __init__(self):
self._running = False
self._agents: dict[str, dict] = {}
self._speaker_queue: list[str] = []
self._current_speaker_index: int = 0
def init_agents(self, characters: list[Character]):
self._agents = {}
self._speaker_queue = []
for char in characters:
if char.hermes_profile:
self._agents[char.id] = {
"name": char.name,
"profile": char.hermes_profile,
"character_id": char.id,
}
self._speaker_queue.append(char.id)
async def ask_agent(
self,
name: str,
message: str,
character_id: Optional[str] = None,
) -> str:
profile_name = name.lower().replace(" ", "_").replace("·", "_")
try:
return await hermes_client.chat(profile_name, message)
except Exception as e:
logger.error(f"ask_agent failed for '{name}': {e}")
return f"({name}暂时无法回应)"
async def dm_speak(self, content: str) -> str:
return f"【主持人】:{content}"
def get_next_speaker(self) -> Optional[str]:
if not self._speaker_queue:
return None
idx = self._current_speaker_index % len(self._speaker_queue)
self._current_speaker_index += 1
return self._speaker_queue[idx]
def reset_speakers(self):
self._current_speaker_index = 0
def set_speaker_order(self, order: list[str]):
self._speaker_queue = [cid for cid in order if cid in self._agents]
self._current_speaker_index = 0
async def ask_current_speaker(
self,
session: AsyncSession,
script_id: str,
context: str,
) -> Optional[dict]:
speaker_id = self.get_next_speaker()
if not speaker_id or speaker_id not in self._agents:
return None
agent = self._agents[speaker_id]
message = f"""当前讨论内容:
{context}
请以{agent['name']}的身份发言。保持在角色中,用中文回复2-4句话。"""
try:
response = await self.ask_agent(agent["name"], message, speaker_id)
msg = Message(
character_id=speaker_id,
game_phase="round1_speak",
msg_type="character",
content=response.strip(),
)
session.add(msg)
await session.commit()
await session.refresh(msg)
return {
"id": msg.id,
"character_id": speaker_id,
"character_name": agent["name"],
"content": response.strip(),
"msg_type": "character",
"game_phase": "round1_speak",
}
except Exception as e:
logger.error(f"Speaker error for {agent['name']}: {e}")
return None
async def run_auto_speaking(
self,
session: AsyncSession,
script_id: str,
interval: float = 8.0,
rounds: int = 1,
socket_emit=None,
):
self._running = True
characters_result = await session.execute(
select(Character).where(Character.script_id == script_id)
)
characters = list(characters_result.scalars().all())
self.init_agents(characters)
for _ in range(rounds):
if not self._running:
break
self.reset_speakers()
for _ in range(len(self._speaker_queue)):
if not self._running:
break
speaker_id = self.get_next_speaker()
agent = self._agents.get(speaker_id)
if not agent:
continue
msgs_result = await session.execute(
select(Message).order_by(Message.created_at.desc()).limit(20)
)
recent = list(msgs_result.scalars().all())
context = "\n".join([
f"{m.character.name if m.character else '系统'}: {m.content}"
for m in reversed(recent)
])
try:
response = await self.ask_agent(agent["name"], context, speaker_id)
msg = Message(
character_id=speaker_id,
game_phase="round1_speak",
msg_type="character",
content=response.strip(),
)
session.add(msg)
await session.commit()
await session.refresh(msg)
if socket_emit:
await socket_emit("new_message", {
"id": msg.id,
"character_id": speaker_id,
"character_name": agent["name"],
"content": response.strip(),
"msg_type": "character",
"game_phase": "round1_speak",
})
await socket_emit("speaker_change", {
"current_speaker_id": self._speaker_queue[
self._current_speaker_index % len(self._speaker_queue)
] if self._speaker_queue else None,
})
except Exception as e:
logger.error(f"Auto speak error for {agent['name']}: {e}")
await asyncio.sleep(interval)
async def trigger_voting(
self,
session: AsyncSession,
script_id: str,
socket_emit=None,
) -> list[dict]:
characters_result = await session.execute(
select(Character).where(Character.script_id == script_id)
)
characters = list(characters_result.scalars().all())
msgs_result = await session.execute(
select(Message).order_by(Message.created_at.desc()).limit(50)
)
messages = list(msgs_result.scalars().all())
summary = "\n".join([
f"{m.character.name if m.character else '系统'}: {m.content[:100]}"
for m in reversed(messages)
])
character_names = [c.name for c in characters]
votes = []
for character in characters:
if not character.hermes_profile:
continue
vote_prompt = f"""当前是投票阶段。以下是讨论摘要:
{summary}
可用角色:{', '.join(n for n in character_names if n != character.name)}
请以{character.name}的身份投票选出你认为的凶手。只回复JSON格式:
{{"target": "角色名", "reason": "投票理由"}}"""
try:
response = await self.ask_agent(character.name, vote_prompt, character.id)
import json
try:
decision = json.loads(response)
except json.JSONDecodeError:
decision = {"target": "", "reason": response}
target_name = decision.get("target", "")
reason = decision.get("reason", "")
target_char = next((c for c in characters if c.name == target_name), None)
if not target_char:
others = [c for c in characters if c.id != character.id]
if others:
target_char = random.choice(others)
reason = "随机投票"
if target_char:
votes.append({
"voter_id": character.id,
"voter_name": character.name,
"target_id": target_char.id,
"target_name": target_char.name,
"reason": reason,
})
if socket_emit:
await socket_emit("vote_cast", {
"voter_id": character.id,
"voter_name": character.name,
"target_id": target_char.id,
"target_name": target_char.name,
"reason": reason,
})
except Exception as e:
logger.error(f"Voting error for {character.name}: {e}")
return votes
def stop(self):
self._running = False
agent_scheduler = AgentScheduler()
+102
View File
@@ -0,0 +1,102 @@
import logging
import random
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.orm import Character
from app.models.schemas import CharacterRole
from app.services.hermes_client import hermes_client
from app.services.soul_generator import soul_generator
logger = logging.getLogger(__name__)
class CharacterGenerator:
async def generate_characters(
self,
session: AsyncSession,
script_id: str,
characters_data: list[dict],
is_json: bool = False,
) -> list[Character]:
characters = []
killer_index = random.randint(0, len(characters_data) - 1) if len(characters_data) > 1 else 0
for i, char_data in enumerate(characters_data):
role = CharacterRole.KILLER if i == killer_index else CharacterRole.SUSPECT
if is_json and "role" in char_data:
role = CharacterRole(char_data["role"]) if char_data["role"] in [r.value for r in CharacterRole] else role
name = char_data.get("name", f"角色{i + 1}")
profile_name = name.lower().replace(" ", "_").replace("·", "_")
character = Character(
script_id=script_id,
name=name,
role=role,
personality=char_data.get("personality", ""),
speaking_style=char_data.get("speaking_style", ""),
background=char_data.get("background", ""),
secret=char_data.get("secret", ""),
motive=char_data.get("motive", ""),
hermes_profile=profile_name,
is_revealed_killer=False,
)
session.add(character)
characters.append(character)
await session.commit()
for character in characters:
await session.refresh(character)
return characters
async def create_profile(self, character: Character) -> bool:
soul_content = soul_generator.generate(
name=character.name,
personality=character.personality,
speaking_style=character.speaking_style,
background=character.background,
secret=character.secret,
motive=character.motive,
knowledge_base=character.knowledge_base,
)
success = await hermes_client.create_profile(character.hermes_profile, soul_content)
if success:
character.soul_md = soul_content
return success
async def create_all_profiles(self, session: AsyncSession, script_id: str) -> int:
result = await session.execute(
select(Character).where(Character.script_id == script_id)
)
characters = list(result.scalars().all())
created = 0
for character in characters:
try:
if await self.create_profile(character):
created += 1
character.soul_md = soul_generator.generate(
name=character.name,
personality=character.personality,
speaking_style=character.speaking_style,
background=character.background,
secret=character.secret,
motive=character.motive,
knowledge_base=character.knowledge_base,
)
except Exception as e:
logger.error(f"Failed to create profile for {character.name}: {e}")
await session.commit()
logger.info(f"Created {created}/{len(characters)} profiles for script {script_id}")
return created
async def delete_profile(self, character: Character) -> bool:
return await hermes_client.delete_profile(character.hermes_profile)
character_generator = CharacterGenerator()
+142
View File
@@ -0,0 +1,142 @@
from datetime import datetime
from typing import Optional
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.orm import GameState
from app.models.schemas import GamePhase, GameStateResponse
class GameStateManager:
PHASE_ORDER = GamePhase.ordered_phases()
PHASE_COUNT = len(PHASE_ORDER)
async def get_state(self, session: AsyncSession) -> Optional[GameState]:
result = await session.execute(
select(GameState).order_by(GameState.created_at.desc()).limit(1)
)
return result.scalars().first()
async def get_or_create_state(self, session: AsyncSession) -> GameState:
state = await self.get_state(session)
if state is None:
state = GameState(
current_phase=GamePhase.INTRO.value,
is_running=False,
is_paused=False,
)
session.add(state)
await session.commit()
await session.refresh(state)
return state
async def next_phase(self, session: AsyncSession) -> GameState:
state = await self.get_or_create_state(session)
current = state.current_phase
try:
idx = self.PHASE_ORDER.index(current)
if idx < self.PHASE_COUNT - 1:
state.current_phase = self.PHASE_ORDER[idx + 1]
except ValueError:
pass
state.phase_started_at = datetime.utcnow()
state.updated_at = datetime.utcnow()
await session.commit()
await session.refresh(state)
return state
async def prev_phase(self, session: AsyncSession) -> GameState:
state = await self.get_or_create_state(session)
current = state.current_phase
try:
idx = self.PHASE_ORDER.index(current)
if idx > 0:
state.current_phase = self.PHASE_ORDER[idx - 1]
except ValueError:
pass
state.phase_started_at = datetime.utcnow()
state.updated_at = datetime.utcnow()
await session.commit()
await session.refresh(state)
return state
def get_current_phase(self, state: GameState) -> str:
return state.current_phase
def get_progress_percent(self, state: GameState) -> float:
try:
idx = self.PHASE_ORDER.index(state.current_phase)
return (idx / max(self.PHASE_COUNT - 1, 1)) * 100
except ValueError:
return 0.0
async def start_game(self, session: AsyncSession, script_id: str) -> GameState:
state = await self.get_or_create_state(session)
state.active_script_id = script_id
state.current_phase = GamePhase.INTRO.value
state.is_running = True
state.is_paused = False
state.phase_started_at = datetime.utcnow()
state.updated_at = datetime.utcnow()
await session.commit()
await session.refresh(state)
return state
async def pause_game(self, session: AsyncSession) -> GameState:
state = await self.get_or_create_state(session)
state.is_paused = True
state.updated_at = datetime.utcnow()
await session.commit()
await session.refresh(state)
return state
async def resume_game(self, session: AsyncSession) -> GameState:
state = await self.get_or_create_state(session)
state.is_paused = False
state.updated_at = datetime.utcnow()
await session.commit()
await session.refresh(state)
return state
async def reset(self, session: AsyncSession) -> GameState:
state = await self.get_or_create_state(session)
state.current_phase = GamePhase.INTRO.value
state.current_speaker_id = None
state.speaker_order = []
state.active_script_id = None
state.is_running = False
state.is_paused = False
state.phase_started_at = None
state.config = {}
state.updated_at = datetime.utcnow()
await session.commit()
await session.refresh(state)
return state
async def set_speaker(self, session: AsyncSession, character_id: str) -> GameState:
state = await self.get_or_create_state(session)
state.current_speaker_id = character_id
state.updated_at = datetime.utcnow()
await session.commit()
await session.refresh(state)
return state
def to_response(self, state: GameState) -> GameStateResponse:
return GameStateResponse(
id=state.id,
current_phase=GamePhase(state.current_phase),
current_speaker_id=state.current_speaker_id,
speaker_order=state.speaker_order or [],
active_script_id=state.active_script_id,
is_running=state.is_running,
is_paused=state.is_paused,
phase_started_at=state.phase_started_at,
config=state.config or {},
progress_percent=self.get_progress_percent(state),
)
game_state_manager = GameStateManager()
+106
View File
@@ -0,0 +1,106 @@
import logging
import os
from typing import Optional
import httpx
from app.main import settings
logger = logging.getLogger(__name__)
HERMES_PROFILES_DIR = os.path.expanduser("~/.hermes/profiles")
HERMES_TIMEOUT = 30
DEGRADED_RESPONSE = "(系统提示:AI引擎暂时不可用,请稍后再试)"
DEGRADED_PROFILE_LIST: list[str] = []
class HermesClient:
def __init__(self):
self.api_url = settings.hermes_api_url.rstrip("/")
self.model = settings.hermes_model
self.profiles_dir = HERMES_PROFILES_DIR
self.timeout = HERMES_TIMEOUT
async def chat(self, profile_name: str, message: str) -> str:
soul = await self.read_soul(profile_name)
messages = []
if soul:
messages.append({"role": "system", "content": soul})
messages.append({"role": "user", "content": message})
try:
async with httpx.AsyncClient(timeout=self.timeout) as client:
response = await client.post(
f"{self.api_url}/chat/completions",
json={
"model": self.model,
"messages": messages,
"temperature": 0.85,
"max_tokens": 1024,
},
)
response.raise_for_status()
data = response.json()
choice = data.get("choices", [{}])[0]
return choice.get("message", {}).get("content", "").strip() or DEGRADED_RESPONSE
except httpx.TimeoutException:
logger.warning(f"Hermes API timeout after {self.timeout}s for profile '{profile_name}'")
return DEGRADED_RESPONSE
except httpx.ConnectError:
logger.error(f"Hermes API unreachable at '{self.api_url}'")
return DEGRADED_RESPONSE
except Exception as e:
logger.error(f"Hermes API error for '{profile_name}': {e}")
return DEGRADED_RESPONSE
async def create_profile(self, name: str, soul_content: str) -> bool:
profile_dir = os.path.join(self.profiles_dir, name)
os.makedirs(profile_dir, exist_ok=True)
soul_path = os.path.join(profile_dir, "SOUL.md")
with open(soul_path, "w", encoding="utf-8") as f:
f.write(soul_content)
logger.info(f"Profile created: {name} -> {profile_dir}")
return True
async def delete_profile(self, name: str) -> bool:
import shutil
profile_dir = os.path.join(self.profiles_dir, name)
if os.path.exists(profile_dir):
shutil.rmtree(profile_dir)
logger.info(f"Profile deleted: {name}")
return True
logger.warning(f"Profile not found for deletion: {name}")
return False
async def get_profile_list(self) -> list[str]:
global DEGRADED_PROFILE_LIST
if not os.path.exists(self.profiles_dir):
return []
try:
dirs = [
d
for d in os.listdir(self.profiles_dir)
if os.path.isdir(os.path.join(self.profiles_dir, d))
and os.path.exists(os.path.join(self.profiles_dir, d, "SOUL.md"))
]
DEGRADED_PROFILE_LIST = dirs
return dirs
except Exception as e:
logger.error(f"Failed to list profiles: {e}")
return DEGRADED_PROFILE_LIST
async def read_soul(self, profile_name: str) -> str:
soul_path = os.path.join(self.profiles_dir, profile_name, "SOUL.md")
if os.path.exists(soul_path):
with open(soul_path, "r", encoding="utf-8") as f:
return f.read()
return ""
hermes_client = HermesClient()
+153
View File
@@ -0,0 +1,153 @@
import json
import logging
from typing import Optional
from app.services.hermes_client import hermes_client
logger = logging.getLogger(__name__)
class ScriptParser:
async def parse(self, content: str, file_type: str = "natural_language") -> dict:
if file_type == "json":
return self._parse_json(content)
return await self._parse_natural_language(content)
def _parse_json(self, content: str) -> dict:
try:
data = json.loads(content)
return {
"title": data.get("title", "未命名剧本"),
"background": data.get("background", ""),
"characters": data.get("characters", []),
"clues": data.get("clues", []),
"phases": data.get("phases", [
{"name": "intro", "order": 0, "duration": 120},
{"name": "round1_speak", "order": 1, "duration": 300},
{"name": "round1_search", "order": 2, "duration": 180},
{"name": "round2_speak", "order": 3, "duration": 300},
{"name": "round2_search", "order": 4, "duration": 180},
{"name": "final_discuss", "order": 5, "duration": 300},
{"name": "voting", "order": 6, "duration": 120},
{"name": "reveal", "order": 7, "duration": 120},
]),
"character_count": len(data.get("characters", [])),
}
except json.JSONDecodeError as e:
logger.error(f"JSON parse error: {e}")
return {"title": "未命名剧本", "background": "", "characters": [], "clues": [], "phases": [], "character_count": 0}
async def _parse_natural_language(self, content: str) -> dict:
prompt = f"""你是一个剧本杀解析器。请分析以下自然语言描述的剧本,提取结构化信息。
输出格式必须是合法的JSON:
{{
"title": "剧本名称(从内容推断)",
"background": "案件背景",
"characters": [
{{
"name": "角色名",
"personality": "性格特征",
"speaking_style": "说话风格",
"secret": "该角色隐藏的秘密",
"motive": "该角色的动机"
}}
],
"clues": [
{{
"id": "线索ID",
"content": "线索内容",
"owner": "线索属于哪个角色",
"phase": "线索在哪个阶段可用(round1_search/round2_search)"
}}
],
"phases": [
{{"name": "intro", "order": 0, "duration": 120}},
{{"name": "round1_speak", "order": 1, "duration": 300}},
{{"name": "round1_search", "order": 2, "duration": 180}},
{{"name": "round2_speak", "order": 3, "duration": 300}},
{{"name": "round2_search", "order": 4, "duration": 180}},
{{"name": "final_discuss", "order": 5, "duration": 300}},
{{"name": "voting", "order": 6, "duration": 120}},
{{"name": "reveal", "order": 7, "duration": 120}}
]
}}
剧本内容:
{content[:8000]}
请只返回JSON,不要包含其他解释文字。"""
try:
response = await hermes_client.chat(profile_name="default", message=prompt)
response = response.strip()
if response.startswith("```"):
lines = response.split("\n")
response = "\n".join(lines[1:-1]) if len(lines) >= 3 else response
data = json.loads(response)
data.setdefault("title", "未命名剧本")
data.setdefault("background", "")
data.setdefault("characters", [])
data.setdefault("clues", [])
data["character_count"] = len(data.get("characters", []))
if not data.get("phases"):
data["phases"] = [
{"name": "intro", "order": 0, "duration": 120},
{"name": "round1_speak", "order": 1, "duration": 300},
{"name": "round1_search", "order": 2, "duration": 180},
{"name": "round2_speak", "order": 3, "duration": 300},
{"name": "round2_search", "order": 4, "duration": 180},
{"name": "final_discuss", "order": 5, "duration": 300},
{"name": "voting", "order": 6, "duration": 120},
{"name": "reveal", "order": 7, "duration": 120},
]
return data
except (json.JSONDecodeError, Exception) as e:
logger.warning(f"LLM parse failed: {e}, using regex fallback")
return self._regex_fallback(content)
def _regex_fallback(self, content: str) -> dict:
import re
title = "未命名剧本"
first_line = content.strip().split("\n")[0].strip()
if len(first_line) <= 50:
title = first_line.lstrip("#").strip()
characters = []
char_pattern = re.compile(r"^[#\-\*]*\s*(.{1,10})(?:[::]\s*(.+))?$", re.MULTILINE)
name_keywords = re.compile(r"(角色|人物|嫌疑人|侦探|凶手|死者|被害人)", re.IGNORECASE)
char_section = False
for line in content.split("\n"):
line = line.strip()
if name_keywords.search(line):
char_section = True
continue
if char_section and line.startswith("#"):
break
if char_section and len(line) <= 30 and line:
name = re.sub(r"[-::\s].*$", "", line).strip()
if name and len(name) <= 10:
characters.append({"name": name, "personality": "", "speaking_style": "", "secret": "", "motive": ""})
return {
"title": title,
"background": content[:200],
"characters": characters,
"clues": [],
"phases": [
{"name": "intro", "order": 0, "duration": 120},
{"name": "round1_speak", "order": 1, "duration": 300},
{"name": "round1_search", "order": 2, "duration": 180},
{"name": "round2_speak", "order": 3, "duration": 300},
{"name": "round2_search", "order": 4, "duration": 180},
{"name": "final_discuss", "order": 5, "duration": 300},
{"name": "voting", "order": 6, "duration": 120},
{"name": "reveal", "order": 7, "duration": 120},
],
"character_count": len(characters),
}
script_parser = ScriptParser()
+55
View File
@@ -0,0 +1,55 @@
import os
SOUL_MD_TEMPLATE = """# 角色定位
你是{name},{personality}
## 行为风格
- 语气:{speaking_style}
- 性格特征:{personality}
## 人物背景
{background}
## 沟通边界
- 绝不说:现代用语、脏话、直接承认有罪
- 必回应:被质疑时、被点名时
## 剧本杀专属规则
- 秘密:{secret}
- 动机:{motive}
- 披露策略:只有在证据确凿时才承认关键信息
## 知识库
{knowledge_base}
"""
class SoulGenerator:
def generate(
self,
name: str,
personality: str = "",
speaking_style: str = "",
background: str = "",
secret: str = "",
motive: str = "",
knowledge_base: str = "",
) -> str:
return SOUL_MD_TEMPLATE.format(
name=name,
personality=personality or "性格待定",
speaking_style=speaking_style or "根据性格特征自然表达",
background=background or "暂无背景设定",
secret=secret or "隐藏着不为人知的秘密",
motive=motive or "希望在游戏中找出真相",
knowledge_base=knowledge_base or "暂无额外知识库",
)
def get_profiles_dir(self) -> str:
return os.path.expanduser("~/.hermes/profiles")
def get_soul_path(self, name: str) -> str:
return os.path.join(self.get_profiles_dir(), name, "SOUL.md")
soul_generator = SoulGenerator()