hjjjj 5487450f34 feat: 实现审核系统核心功能与UI优化
- 新增审核卡片和确认卡片模型,支持Agent推送审核任务和用户确认
- 实现审核卡片API服务,支持创建、更新、批准、驳回等操作
- 扩展审核维度配置,新增角色一致性、剧情连贯性等维度
- 优化前端审核配置页面,修复API路径错误和状态枚举问题
- 改进剧集创作平台布局,新增左侧边栏用于剧集管理和上下文查看
- 增强Skill管理,支持从审核系统跳转创建/编辑Skill
- 修复episodes.json数据问题,清理聊天历史记录
- 更新Agent提示词,明确Skill引用加载流程
- 统一前端主题配置,优化整体UI体验
2026-01-30 18:32:48 +08:00

2159 lines
83 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
WebSocket Streaming API
提供实时执行进度更新的 WebSocket 端点
"""
from fastapi import APIRouter, WebSocket, WebSocketDisconnect, Depends, Query
from typing import Dict, Set, Optional, Any, List
import json
import asyncio
from datetime import datetime
from pathlib import Path
from app.config import settings
from app.utils.logger import get_logger
from app.core.agent_runtime.director_agent import DirectorAgent
from app.db.repositories import message_repo
logger = get_logger(__name__)
router = APIRouter()
# ============================================
# 动态Skill选择辅助函数
# ============================================
async def _auto_select_skills_for_context(
user_message: str,
project_id: str,
agent
) -> List[str]:
"""
根据用户消息自动选择相关Skills
Args:
user_message: 用户消息
project_id: 项目ID
agent: Agent实例
Returns:
选中的Skill ID列表
"""
try:
# 获取可用的用户技能
user_skills = []
if agent.context and agent.context.user_skills:
user_skills = agent.context.user_skills
# 如果有用户配置的Skills使用智能选择
if user_skills:
# 从上下文中提取信息进行智能匹配
context_info = {
"episode_number": agent.context.active_episode_number if agent.context else None,
"project_genre": agent.context.project_genre if agent.context else None,
"creation_mode": agent.context.creation_mode if agent.context else None
}
selected_skills = await agent.skill_loader.select_skills_for_task(
user_message,
available_skills=user_skills,
context=context_info
)
if selected_skills:
logger.info(f"自动选择的Skills: {selected_skills}")
return selected_skills
return []
except Exception as e:
logger.warning(f"自动Skill选择失败: {e}")
return []
# ============================================
# WebSocket 连接管理
# ============================================
class ConnectionManager:
"""WebSocket 连接管理器"""
def __init__(self):
# 项目ID -> WebSocket连接集合
self.project_connections: Dict[str, Set[WebSocket]] = {}
# 批次ID -> WebSocket连接集合
self.batch_connections: Dict[str, Set[WebSocket]] = {}
# 项目ID -> Agent实例
self.project_agents: Dict[str, DirectorAgent] = {}
async def connect_to_project(self, websocket: WebSocket, project_id: str):
"""连接到项目执行流"""
await websocket.accept()
if project_id not in self.project_connections:
self.project_connections[project_id] = set()
self.project_connections[project_id].add(websocket)
logger.info(f"WebSocket 已连接到项目: {project_id}")
async def connect_to_batch(self, websocket: WebSocket, batch_id: str):
"""连接到批次执行流"""
await websocket.accept()
if batch_id not in self.batch_connections:
self.batch_connections[batch_id] = set()
self.batch_connections[batch_id].add(websocket)
logger.info(f"WebSocket 已连接到批次: {batch_id}")
def disconnect(self, websocket: WebSocket):
"""断开连接"""
# 从所有项目连接中移除
for project_id, connections in self.project_connections.items():
if websocket in connections:
connections.remove(websocket)
logger.info(f"WebSocket 已从项目断开: {project_id}")
if not connections:
del self.project_connections[project_id]
# 清理 Agent 实例
if project_id in self.project_agents:
del self.project_agents[project_id]
# 从所有批次连接中移除
for batch_id, connections in self.batch_connections.items():
if websocket in connections:
connections.remove(websocket)
logger.info(f"WebSocket 已从批次断开: {batch_id}")
if not connections:
del self.batch_connections[batch_id]
async def send_to_project(
self,
project_id: str,
message: Dict[str, Any],
exclude: Optional[WebSocket] = None
):
"""向项目的所有连接发送消息"""
if project_id in self.project_connections:
disconnected = set()
for connection in self.project_connections[project_id]:
if connection != exclude:
try:
await connection.send_json(message)
except Exception as e:
logger.error(f"发送消息失败: {str(e)}")
disconnected.add(connection)
# 清理断开的连接
for connection in disconnected:
self.disconnect(connection)
async def send_to_batch(
self,
batch_id: str,
message: Dict[str, Any],
exclude: Optional[WebSocket] = None
):
"""向批次的所有连接发送消息"""
if batch_id in self.batch_connections:
disconnected = set()
for connection in self.batch_connections[batch_id]:
if connection != exclude:
try:
await connection.send_json(message)
except Exception as e:
logger.error(f"发送消息失败: {str(e)}")
disconnected.add(connection)
# 清理断开的连接
for connection in disconnected:
self.disconnect(connection)
def get_agent(self, project_id: str, working_dir: Path) -> DirectorAgent:
"""获取或创建 Agent 实例"""
if project_id not in self.project_agents:
# 确保工作目录存在
working_dir.mkdir(parents=True, exist_ok=True)
# 检查配置的模型类型
model_name = settings.zai_model
enable_thinking = True
# 如果是 GLM 模型,禁用 thinking 模式(不支持)
if "glm" in model_name.lower():
enable_thinking = False
# 不在这里加载项目上下文,而是在 WebSocket 消息处理时加载
# 因为 get_agent 是同步方法,而 project_repo.get 是异步的
self.project_agents[project_id] = DirectorAgent(
working_directory=working_dir,
enable_thinking=enable_thinking,
model=model_name,
project_context=None # 稍后在消息处理中更新
)
return self.project_agents[project_id]
def get_project_connections_count(self, project_id: str) -> int:
"""获取项目的连接数"""
return len(self.project_connections.get(project_id, set()))
def get_batch_connections_count(self, batch_id: str) -> int:
"""获取批次的连接数"""
return len(self.batch_connections.get(batch_id, set()))
# 全局连接管理器
manager = ConnectionManager()
# ============================================
# WebSocket 端点
# ============================================
@router.websocket("/ws/projects/{project_id}/execute")
async def websocket_project_execution(
websocket: WebSocket,
project_id: str
):
"""
项目执行 WebSocket 端点
"""
await manager.connect_to_project(websocket, project_id)
# 准备工作目录 (假设在 projects/{id})
# 注意:这里需要根据实际配置调整路径
project_dir = Path(f"d:/platform/creative_studio/workspace/projects/{project_id}")
try:
# 发送连接确认
await websocket.send_json({
"type": "connected",
"data": {
"project_id": project_id,
"message": "已连接到项目执行流",
"timestamp": datetime.now().isoformat()
}
})
# 加载并发送历史消息
history = await message_repo.get_history(project_id)
if history:
await websocket.send_json({
"type": "history",
"messages": history
})
# 保持连接并接收客户端消息
while True:
try:
# 接收客户端消息
data = await websocket.receive_text()
message = json.loads(data)
# 处理客户端消息
await _handle_client_message(websocket, project_id, message, project_dir)
except WebSocketDisconnect:
logger.info(f"WebSocket 客户端主动断开: {project_id}")
break
except json.JSONDecodeError:
await websocket.send_json({
"type": "error",
"data": {
"message": "无效的 JSON 格式"
}
})
except Exception as e:
logger.error(f"处理 WebSocket 消息错误: {str(e)}")
await websocket.send_json({
"type": "error",
"data": {
"message": f"处理消息错误: {str(e)}"
}
})
except Exception as e:
logger.error(f"WebSocket 连接错误: {str(e)}")
finally:
manager.disconnect(websocket)
@router.websocket("/ws/batches/{batch_id}/execute")
async def websocket_batch_execution(
websocket: WebSocket,
batch_id: str
):
"""
批量执行 WebSocket 端点
"""
await manager.connect_to_batch(websocket, batch_id)
try:
# 发送连接确认
await websocket.send_json({
"type": "connected",
"data": {
"batch_id": batch_id,
"message": "已连接到批量执行流",
"timestamp": datetime.now().isoformat()
}
})
# 保持连接
while True:
try:
data = await websocket.receive_text()
# 批量执行目前不接受客户端控制消息,仅广播
# 但为了保持连接活性,可以处理 ping
message = json.loads(data)
if message.get("type") == "ping":
await websocket.send_json({
"type": "pong",
"data": {"timestamp": datetime.now().isoformat()}
})
except WebSocketDisconnect:
logger.info(f"WebSocket 客户端主动断开: {batch_id}")
break
except Exception as e:
logger.error(f"处理 WebSocket 消息错误: {str(e)}")
finally:
manager.disconnect(websocket)
# ============================================
# 消息处理
# ============================================
async def _ensure_agent_context(
agent: DirectorAgent,
project_id: str,
active_episode_number: Optional[int] = None,
active_episode_title: Optional[str] = None
):
"""确保 Agent 加载了正确的项目上下文"""
# 如果已经加载了该项目的上下文,且剧集信息一致,则跳过
if (agent.context and
agent.context.project_id == project_id and
agent.context.active_episode_number == active_episode_number):
return
try:
from app.db.repositories import project_repo
from app.core.agent_runtime.context import SkillAgentContext
from app.core.skills.skill_manager import skill_manager
project = await project_repo.get(project_id)
if not project:
logger.warning(f"Project {project_id} not found in repository")
return
# 1. 加载用户技能
user_skills = []
default_task_skills = getattr(project, 'defaultTaskSkills', [])
if isinstance(default_task_skills, list):
for task_config in default_task_skills:
task_skills = getattr(task_config, 'skills', [])
if isinstance(task_skills, list):
for skill_config in task_skills:
try:
skill_id = getattr(skill_config, 'skill_id', None) or (skill_config.get('skill_id') if isinstance(skill_config, dict) else None)
if skill_id:
skill = skill_manager.get_skill_by_id(skill_id)
if skill:
user_skills.append({
'id': skill.id,
'name': skill.name,
'behavior': skill.behavior_guide or skill.description or ''
})
except Exception as e:
logger.warning(f"Failed to load skill: {e}")
# 2. 整合角色信息
characters_text = ""
if project.globalContext:
if project.globalContext.styleGuide:
characters_text += project.globalContext.styleGuide + "\n\n"
if project.globalContext.characterProfiles:
# 确保 characterProfiles 是字典类型
profiles = project.globalContext.characterProfiles
if isinstance(profiles, dict):
characters_text += "### Detailed Character Profiles:\n"
for name, profile in profiles.items():
characters_text += f"- **{name}**: {getattr(profile, 'description', '')}\n"
if hasattr(profile, 'personality') and profile.personality:
characters_text += f" Personality: {profile.personality}\n"
else:
# characterProfiles 是字符串或其他类型,直接添加
characters_text += str(profiles) + "\n\n"
# 3. 获取所有剧集
from app.db.repositories import episode_repo
episodes_data = await episode_repo.list_by_project(project_id)
episodes = []
for ep in episodes_data:
episodes.append({
"number": ep.number,
"title": ep.title,
"status": ep.status
})
# 4. 创建项目上下文
project_context = SkillAgentContext(
skill_loader=agent.context.skill_loader,
working_directory=agent.context.working_directory,
project_id=project.id,
project_name=project.name,
project_genre=getattr(project, 'genre', '古风'),
total_episodes=project.totalEpisodes,
world_setting=project.globalContext.worldSetting if project.globalContext else None,
characters=characters_text.strip() or None,
overall_outline=project.globalContext.overallOutline if project.globalContext else None,
creation_mode='script' if (project.globalContext and project.globalContext.uploadedScript) else 'inspiration',
source_content=(project.globalContext.uploadedScript if project.globalContext and project.globalContext.uploadedScript
else project.globalContext.inspiration if project.globalContext else None),
user_skills=user_skills,
active_episode_number=active_episode_number,
active_episode_title=active_episode_title,
episodes=episodes
)
# 5. 更新 Agent
agent.context = project_context
agent.system_prompt = agent._build_system_prompt()
if hasattr(agent, 'refresh_agent'):
agent.refresh_agent()
msg = f"Successfully injected project context for {project_id}: {project.name}"
if active_episode_number:
msg += f" (Focusing on Episode {active_episode_number})"
logger.info(msg)
except Exception as e:
logger.error(f"Failed to load project context for {project_id}: {e}", exc_info=True)
async def _handle_client_message(
websocket: WebSocket,
project_id: str,
message: Dict[str, Any],
project_dir: Path
):
"""
处理客户端发送的消息
"""
message_type = message.get("type")
logger.info(f"收到消息: type={message_type}, full_message={message}")
if message_type == "ping":
# 心跳响应
await websocket.send_json({
"type": "pong",
"data": {
"timestamp": datetime.now().isoformat()
}
})
elif message_type == "focus_episode":
# 客户端请求切换关注的剧集
episode_number = message.get("episodeNumber")
episode_title = message.get("episodeTitle")
agent = manager.get_agent(project_id, project_dir)
await _ensure_agent_context(agent, project_id, episode_number, episode_title)
await websocket.send_json({
"type": "focus_confirmed",
"data": {
"episodeNumber": episode_number,
"episodeTitle": episode_title
}
})
elif message_type == "update_episode_title":
# 用户手动编辑画布标题
episode_number = message.get("episodeNumber")
new_title = message.get("title")
if episode_number and new_title:
try:
from app.db.repositories import episode_repo
episodes = await episode_repo.list_by_project(project_id)
episode = next((ep for ep in episodes if ep.number == episode_number), None)
if episode:
episode.title = new_title
await episode_repo.update(episode)
logger.info(f"Manual title update for EP{episode_number}: {new_title}")
# 广播更新给所有连接
await manager.send_to_project(project_id, {
"type": "episode_updated",
"data": {
"number": episode_number,
"title": new_title,
"status": episode.status
}
})
except Exception as e:
logger.error(f"Failed to update episode title: {e}")
elif message_type == "inbox_action":
# 用户在 Inbox 中点击批准或拒绝
action = message.get("action")
item_id = message.get("itemId")
# 将操作转换为自然语言反馈给 Agent
feedback = f"User {action}ed inbox item {item_id}."
agent = manager.get_agent(project_id, project_dir)
# 确保上下文已加载
await _ensure_agent_context(agent, project_id)
try:
for event in agent.stream_events(feedback, thread_id=project_id):
# 同样的事件处理逻辑
if event.get("type") == "tool_call":
await _handle_tool_call(project_id, event)
await manager.send_to_project(project_id, event)
except Exception as e:
await manager.send_to_project(project_id, {
"type": "error",
"data": {"message": str(e)}
})
elif message_type == "reset_conversation":
# 用户请求重建对话(清除历史但保留上下文)
try:
# 清除历史消息
await message_repo.clear_history(project_id)
# 重新注入上下文到agent
agent = manager.get_agent(project_id, project_dir)
await _ensure_agent_context(agent, project_id)
await websocket.send_json({
"type": "conversation_reset",
"data": {
"message": "对话已重建,上下文已保留",
"timestamp": datetime.now().isoformat()
}
})
logger.info(f"Conversation reset for project {project_id}")
except Exception as e:
await websocket.send_json({
"type": "error",
"data": {
"message": f"重建对话失败: {str(e)}"
}
})
elif message_type == "clear_conversation":
# 用户请求完全清除对话(包括重置上下文)
try:
# 清除历史消息
await message_repo.clear_history(project_id)
# 重置 Agent 上下文(完全重置)
if project_id in manager.project_agents:
del manager.project_agents[project_id]
# 创建新的 Agent 实例
agent = manager.get_agent(project_id, project_dir)
await _ensure_agent_context(agent, project_id)
await websocket.send_json({
"type": "conversation_cleared",
"data": {
"message": "对话已完全清除,上下文已重置",
"timestamp": datetime.now().isoformat()
}
})
logger.info(f"Conversation fully cleared for project {project_id}")
except Exception as e:
await websocket.send_json({
"type": "error",
"data": {
"message": f"清除对话失败: {str(e)}"
}
})
elif message_type == "chat_message":
# 用户发送聊天消息 -> 触发 Agent 执行
content = message.get("content", "")
episode_number = message.get("episodeNumber")
episode_title = message.get("episodeTitle")
view_context = message.get("viewContext", {}) # 新增:接收用户视图上下文
if not content:
return
# 保存用户消息
await message_repo.add_message(project_id, "user", content)
# 获取 Agent 并存储视图上下文
agent = manager.get_agent(project_id, project_dir)
if view_context:
# 存储视图上下文到 Agent 状态中
agent.runtime.state["current_view_context"] = view_context
logger.info(f"Received view context: {view_context}")
# 获取 Agent
agent = manager.get_agent(project_id, project_dir)
# 确保上下文已加载(包含当前剧集信息)
await _ensure_agent_context(agent, project_id, episode_number, episode_title)
# ========== 动态Skill选择 ==========
# 在执行前自动选择相关的Skills
selected_skill_ids = await _auto_select_skills_for_context(content, project_id, agent)
# 如果选中了Skills将其注入到系统提示中
if selected_skill_ids:
# 动态更新系统提示以包含选中的Skills
# 这将在后续的agent.stream_events中生效
agent.runtime.state["selected_skills"] = selected_skill_ids
# 通知用户自动选择的Skills
skill_names = []
for skill_info in agent.context.user_skills or []:
if skill_info.get('id') in selected_skill_ids:
skill_names.append(skill_info.get('name', skill_info.get('id')))
if skill_names:
await manager.send_to_project(project_id, {
"type": "text",
"content": f"\n🔧 自动启用技能: {', '.join(skill_names)}\n"
})
# 检查是否是生成大纲的请求
is_outline_generation = "大纲" in content and "生成" in content
if is_outline_generation:
await manager.send_to_project(project_id, {
"type": "outline_streaming_start"
})
# 检查是否是开始创作的请求,如果是,尝试注入大纲内容
# 改进匹配逻辑,支持更多创作相关的关键词
is_content_creation = any(keyword in content for keyword in ["创作", "开始创作", "写内容", "生成内容"])
if is_content_creation and episode_number:
try:
from app.db.repositories import episode_repo
episodes = await episode_repo.list_by_project(project_id)
episode = next((ep for ep in episodes if ep.number == episode_number), None)
if episode and episode.outline and episode.outline.strip():
# 在用户消息前注入大纲内容
outline_instruction = f"""
【当前剧集大纲】
{episode.outline}
请严格按照上述大纲进行创作。大纲中的场景顺序、情节要点、人物对话都必须完整体现在最终的剧本内容中。
创作要求:
1. 完整实现大纲中的所有场景
2. 保持大纲中的情节要点
3. 人物对话要符合角色设定
4. 场景转换要自然流畅
现在请开始创作完整的剧集内容。
"""
content = outline_instruction + content
logger.info(f"已注入大纲内容到EP{episode_number}的创作请求,大纲长度: {len(episode.outline)}")
# 通知用户大纲已注入
await manager.send_to_project(project_id, {
"type": "text",
"content": f"\n✅ 已加载 EP{episode_number} 的大纲({len(episode.outline)} 字),将按照大纲进行创作。\n"
})
else:
logger.info(f"EP{episode_number} 没有大纲内容,将自由创作")
await manager.send_to_project(project_id, {
"type": "text",
"content": f"\n⚠️ EP{episode_number} 暂无大纲,将进行自由创作。建议先生成大纲后再开始创作。\n"
})
except Exception as e:
logger.warning(f"注入大纲内容失败: {e}")
# 检查是否是模糊请求(需要先获取当前内容)
# 支持的关键词:改、修改、调整、优化、改进、这里不对、把这个...
is_vague_request = any(keyword in content for keyword in [
"", "修改", "调整", "优化", "改进", "这里不对", "把这个"
])
# 如果是模糊请求且有视图上下文,自动注入草稿内容
if is_vague_request and view_context and episode_number:
try:
from app.db.repositories import episode_repo
episodes = await episode_repo.list_by_project(project_id)
episode = next((ep for ep in episodes if ep.number == episode_number), None)
if episode:
view_type = view_context.get("viewType", "")
content_to_inject = ""
# 根据视图类型决定注入什么内容
if view_type == "outline" and episode.outline:
content_to_inject = f"""
【用户正在查看:第 {episode_number} 集大纲】
【当前大纲内容】
{episode.outline}
用户请求:{content}
请基于以上大纲进行用户请求的修改/调整/优化。修改后请使用 update_episode() 保存更新后的大纲,并调用 request_review() 进行质量检查。
"""
elif view_type == "content" and episode.content:
content_preview = episode.content[:3000]
if len(episode.content) > 3000:
content_preview += "\n... (内容过长已截取前3000字)"
content_to_inject = f"""
【用户正在查看:第 {episode_number} 集内容】
【当前内容】
{content_preview}
用户请求:{content}
请基于以上内容进行用户请求的修改/调整/优化。修改后请:
1. 使用 write_to_canvas(content="修改后的内容", append=false) 更新画布
2. 使用 update_episode() 保存更新的内容
3. 调用 request_review() 进行质量检查
4. 调用 update_memory() 更新记忆系统(如果有重要变化)
"""
elif view_type in ["both", "full"]:
# 同时注入大纲和内容
outline_part = f"\n【大纲】\n{episode.outline}\n" if episode.outline else ""
content_preview = episode.content[:1500] if episode.content else ""
if episode.content and len(episode.content) > 1500:
content_preview += "\n... (内容过长已截取前1500字)"
content_part = f"\n【内容】\n{content_preview}\n" if episode.content else ""
content_to_inject = f"""
【用户正在查看:第 {episode_number} 集(完整视图)】
{outline_part}{content_part}
用户请求:{content}
请基于以上内容进行用户请求的修改/调整/优化。根据用户意图修改大纲或内容。
修改后请:
1. 使用 write_to_canvas() 和 update_episode() 更新相应内容
2. 调用 request_review() 进行质量检查
3. 调用 update_memory() 更新记忆系统(如果有重要变化)
"""
if content_to_inject:
content = content_to_inject
logger.info(f"已为模糊请求注入草稿内容,视图类型: {view_type}, 集数: {episode_number}")
# 通知用户
view_desc = {"outline": "大纲", "content": "内容", "both": "大纲+内容", "full": "大纲+内容"}.get(view_type, "未知")
await manager.send_to_project(project_id, {
"type": "text",
"content": f"\n📝 已加载第 {episode_number} 集的{view_desc},将根据您的要求进行调整。\n"
})
else:
logger.info(f"EP{episode_number} 没有找到剧集数据")
except Exception as e:
logger.warning(f"自动注入草稿内容失败: {e}")
# 异步运行 Agent 并将事件流推送到前端
full_response = ""
outline_content = ""
try:
for event in agent.stream_events(content, thread_id=project_id):
# 检查特殊工具调用并转换格式
if event.get("type") == "tool_call":
await _handle_tool_call(project_id, event)
# 收集 Agent 回复内容
if event.get("type") == "text":
text_content = event.get("content", "")
full_response += text_content
# 如果是生成大纲,收集大纲内容
if is_outline_generation:
outline_content += text_content
await manager.send_to_project(project_id, event)
# 如果是生成大纲,发送大纲更新消息
if is_outline_generation and outline_content:
await manager.send_to_project(project_id, {
"type": "outline_update",
"content": outline_content
})
await manager.send_to_project(project_id, {
"type": "outline_streaming_end"
})
# 保存 Agent 回复
if full_response:
await message_repo.add_message(project_id, "agent", full_response)
except Exception as e:
await manager.send_to_project(project_id, {
"type": "error",
"data": {"message": str(e)}
})
if is_outline_generation:
await manager.send_to_project(project_id, {
"type": "outline_streaming_end"
})
elif message_type == "get_status":
# 请求状态
# 这里可以返回 Agent 的状态,或者之前的 executor 状态
pass
elif message_type == "review_task_created":
# 审核任务已创建 - 来自Agent推送
# 前端显示审核卡片
await manager.send_to_project(project_id, {
"type": "text",
"content": f"\n📋 [审核任务] {message.get('data', {}).get('issue_type', '未知')}: {message.get('data', {}).get('issue_description', '')}\n"
})
elif message_type == "review_task_completed":
# 审核任务已完成 - 用户完成审核任务
task_data = message.get('data', {})
await manager.send_to_project(project_id, {
"type": "text",
"content": f"\n✅ 审核任务已完成: {task_data.get('status', '未知')}\n"
})
elif message_type == "post_creation_review_completed":
# 生成后审核完成
review_data = message.get('data', {})
task_count = review_data.get('task_count', 0)
passed = review_data.get('passed', False)
await manager.send_to_project(project_id, {
"type": "text",
"content": f"\n📊 生成后审核完成: 发现 {task_count} 个问题,{'通过' if passed else '未通过'}\n"
})
elif message_type == "review_card_action":
# 用户对审核卡片的操作(通过/驳回/修改)
card_id = message.get("cardId")
action = message.get("action")
user_comment = message.get("userComment")
modified_content = message.get("modifiedContent")
try:
from app.db.review_card_repository import get_review_card_repo
from app.models.review_card import ReviewCardUpdate, ReviewCardStatus
repo = get_review_card_repo()
card = await repo.get(card_id)
if not card:
await manager.send_to_project(project_id, {
"type": "error",
"data": {"message": f"审核卡片不存在: {card_id}"}
})
return
# 根据操作类型更新卡片
if action == "approve":
update_data = ReviewCardUpdate(
status=ReviewCardStatus.APPROVED,
user_comment=user_comment
)
elif action == "reject":
update_data = ReviewCardUpdate(
status=ReviewCardStatus.REJECTED,
user_comment=user_comment,
modified_content=modified_content
)
elif action == "modify":
update_data = ReviewCardUpdate(
status=ReviewCardStatus.MODIFIED,
user_comment=user_comment,
modified_content=modified_content
)
elif action == "ignore":
update_data = ReviewCardUpdate(
status=ReviewCardStatus.IGNORED,
user_comment=user_comment
)
else:
await manager.send_to_project(project_id, {
"type": "error",
"data": {"message": f"未知操作: {action}"}
})
return
# 更新卡片
updated_card = await repo.update(card_id, update_data)
# 通知前端操作完成
await manager.send_to_project(project_id, {
"type": "review_card_action_completed",
"data": {
"card_id": card_id,
"action": action,
"status": updated_card.status.value if updated_card else "",
"updated_at": updated_card.updated_at.isoformat() if updated_card else None
}
})
logger.info(f"用户对审核卡片执行操作: {card_id}, 操作: {action}")
except Exception as e:
logger.error(f"处理审核卡片操作失败: {e}")
await manager.send_to_project(project_id, {
"type": "error",
"data": {"message": f"操作失败: {str(e)}"}
})
elif message_type == "confirm_card_response":
# 用户对确认卡片的响应
card_id = message.get("cardId")
selected_option_id = message.get("selectedOptionId")
custom_response = message.get("customResponse")
user_notes = message.get("userNotes")
action = message.get("action", "confirm")
try:
from app.db.confirm_card_repository import get_confirm_card_repo
from app.models.confirm_card import ConfirmCardStatus
repo = get_confirm_card_repo()
if action == "confirm":
# 确认卡片
updated_card = await repo.update_status(
card_id,
ConfirmCardStatus.APPROVED,
selected_option_id,
custom_response,
user_notes
)
if updated_card:
# 通知前端确认完成
await manager.send_to_project(project_id, {
"type": "confirm_card_processed",
"data": {
"card_id": card_id,
"status": "approved",
"selected_option": selected_option_id,
"custom_response": custom_response
}
})
# 如果有Agent将用户选择反馈给Agent
if project_id in manager.project_agents:
feedback = f"用户已确认卡片 {card_id},选择了选项 {selected_option_id or '自定义回复'}"
if custom_response:
feedback += f": {custom_response}"
# 可以在这里将feedback传递给agent继续处理
logger.info(f"用户确认卡片反馈: {feedback}")
elif action == "reject":
# 拒绝卡片
updated_card = await repo.update_status(
card_id,
ConfirmCardStatus.REJECTED,
user_notes=user_notes
)
await manager.send_to_project(project_id, {
"type": "confirm_card_processed",
"data": {
"card_id": card_id,
"status": "rejected",
"user_notes": user_notes
}
})
logger.info(f"用户对确认卡片执行操作: {card_id}, 操作: {action}")
except Exception as e:
logger.error(f"处理确认卡片响应失败: {e}")
await manager.send_to_project(project_id, {
"type": "error",
"data": {"message": f"操作失败: {str(e)}"}
})
else:
await websocket.send_json({
"type": "error",
"data": {
"message": f"未知消息类型: {message_type}"
}
})
async def _sync_context_states(
project_id: str,
episode_number: int,
memory: Any
):
"""同步上下文状态到前端"""
try:
# 提取角色状态作为上下文状态
context_states = []
# 添加时间状态
context_states.append({
"type": "time",
"value": f"EP{episode_number} 完成后"
})
# 添加角色状态
character_states = getattr(memory, 'characterStates', {})
if isinstance(character_states, dict):
for char_name, states in character_states.items():
if states and isinstance(states, list) and len(states) > 0:
latest_state = states[-1]
if isinstance(latest_state, dict):
state_value = latest_state.get('state', f"{char_name}状态")
else:
state_value = getattr(latest_state, 'state', f"{char_name}状态")
context_states.append({
"type": "character",
"value": f"{char_name}: {state_value}",
"character": char_name,
"state": state_value
})
# 添加待收线数量
pending_threads = getattr(memory, 'pendingThreads', [])
if pending_threads:
context_states.append({
"type": "pending_threads",
"value": f"待收线: {len(pending_threads)}"
})
# 广播上下文更新
await manager.send_to_project(project_id, {
"type": "context_update",
"states": context_states,
"episode_number": episode_number
})
logger.info(f"已同步上下文状态到项目 {project_id}, {len(context_states)} 个状态")
except Exception as e:
logger.error(f"同步上下文状态失败: {str(e)}")
async def _handle_tool_call(project_id: str, event: Dict[str, Any]):
"""
处理工具调用,转换为特定的 WebSocket 消息
这个函数在 agent.stream_events() 中被调用,当检测到 director 工具调用时,
会将其转换为前端可以理解的 WebSocket 事件格式。
"""
name = event.get("name")
args = event.get("args", {})
# Director 工具处理
if name == "update_plan":
await manager.send_to_project(project_id, {
"type": "plan_update",
"plan": args.get("steps", []),
"status": args.get("status", "planning"),
"current_step_index": args.get("current_step_index", 0)
})
elif name == "add_inbox_task":
await manager.send_to_project(project_id, {
"type": "review_request",
"id": f"task_{args.get('title', 'unknown')}_{int(datetime.now().timestamp())}",
"title": args.get("title"),
"description": args.get("description"),
"options": args.get("options", ["Approve", "Reject"]),
"timestamp": int(datetime.now().timestamp() * 1000)
})
elif name == "add_annotation":
await manager.send_to_project(project_id, {
"type": "annotation_add",
"annotation": {
"content": args.get("content"),
"type": args.get("annotation_type", "review"),
"suggestion": args.get("suggestion", ""),
"timestamp": int(datetime.now().timestamp() * 1000)
}
})
elif name == "update_context":
# 解析 data (可能是 JSON string 或 dict)
data = args.get("data")
context_type = args.get("context_type", "state")
try:
if isinstance(data, str):
data = json.loads(data)
# 转换为前端期望的 activeStates 格式
if isinstance(data, dict):
# 将字典转换为 [{type, value}, ...] 格式
states = [{"type": k, "value": v} for k, v in data.items()]
elif isinstance(data, list):
# 已经是列表格式
states = data
else:
states = [{"type": context_type, "value": str(data)}]
await manager.send_to_project(project_id, {
"type": "context_update",
"states": states
})
except Exception as e:
logger.warning(f"Failed to process context update: {e}")
elif name == "write_to_canvas":
# 新的 write_to_canvas 工具
content = args.get("content", "")
if content:
await manager.send_to_project(project_id, {
"type": "canvas_update",
"content": content
})
elif name == "write_file":
# 如果写入的是当前画布文件,也更新画布
# 这里简化:只要有内容就更新画布
content = args.get("content")
if content:
await manager.send_to_project(project_id, {
"type": "canvas_update",
"content": content
})
elif name == "update_memory":
# 处理记忆库更新并持久化
memory_type = args.get("memory_type", "timeline")
data = args.get("data", {})
# 根据记忆类型格式化数据
memory_data = {
"type": memory_type,
"memory_type": memory_type,
"timestamp": int(datetime.now().timestamp() * 1000)
}
# 添加具体信息
if memory_type == "timeline":
memory_data.update({
"title": data.get("event", "时间线事件"),
"description": data.get("description", "")
})
elif memory_type == "character_state":
memory_data.update({
"title": f"{data.get('character', '角色')}状态变化",
"description": data.get("state", ""),
"character": data.get("character", "")
})
elif memory_type == "pending_thread":
memory_data.update({
"title": "待收线问题",
"description": data.get("description", "")
})
elif memory_type == "foreshadowing":
memory_data.update({
"title": "伏笔",
"description": data.get("description", "")
})
# 持久化到项目记忆库
try:
from app.db.repositories import project_repo
project = await project_repo.get(project_id)
if project and project.memory:
import copy
memory_dict = project.memory.dict()
if memory_type == "timeline":
# 添加到时间线
timeline_event = {
"episode": data.get("episode", 0),
"event": data.get("event", ""),
"description": data.get("description", ""),
"timestamp": datetime.now().isoformat()
}
memory_dict["eventTimeline"].append(timeline_event)
logger.info(f"Added timeline event: {data.get('event', 'Unknown')}")
elif memory_type == "character_state":
# 更新角色状态
character = data.get("character", "")
if character:
if character not in memory_dict["characterStates"]:
memory_dict["characterStates"][character] = []
character_state = {
"state": data.get("state", ""),
"description": data.get("description", ""),
"timestamp": datetime.now().isoformat()
}
memory_dict["characterStates"][character].append(character_state)
logger.info(f"Updated character state: {character} - {data.get('state', '')}")
elif memory_type == "pending_thread":
# 添加待收线
thread = {
"description": data.get("description", ""),
"status": "pending",
"timestamp": datetime.now().isoformat()
}
memory_dict["pendingThreads"].append(thread)
logger.info(f"Added pending thread: {data.get('description', 'Unknown')}")
elif memory_type == "foreshadowing":
# 添加伏笔
foreshadow = {
"description": data.get("description", ""),
"status": "planted",
"timestamp": datetime.now().isoformat()
}
memory_dict["foreshadowing"].append(foreshadow)
logger.info(f"Added foreshadowing: {data.get('description', 'Unknown')}")
# 保存更新后的记忆库
await project_repo.update(project_id, {"memory": memory_dict})
# 同步上下文状态到前端
if memory_type in ["character_state", "timeline"]:
# 获取当前剧集号(如果可能)
episode_number = data.get("episode", 0)
await _sync_context_states(project_id, episode_number, project.memory)
except Exception as e:
logger.warning(f"Failed to persist memory update: {e}")
await manager.send_to_project(project_id, {
"type": "memory_update",
"data": memory_data
})
elif name == "save_episode":
# 处理剧集保存并持久化
episode_number = args.get("episode_number")
title = args.get("title")
content = args.get("content")
outline = args.get("outline")
if episode_number:
try:
from app.db.repositories import episode_repo, project_repo
from app.core.memory.memory_manager import get_memory_manager
episodes = await episode_repo.list_by_project(project_id)
episode = next((ep for ep in episodes if ep.number == episode_number), None)
if episode:
if title: episode.title = title
if content: episode.content = content
if outline: episode.outline = outline
episode.status = "completed"
await episode_repo.update(episode)
logger.info(f"Persisted episode {episode_number} via save_episode")
# 自动更新记忆库
try:
project = await project_repo.get(project_id)
if project and episode.content:
memory_manager = get_memory_manager()
await memory_manager.update_memory_from_episode(project, episode)
logger.info(f"Updated memory after saving episode {episode_number}")
# 同步上下文状态到前端
await _sync_context_states(project_id, episode_number, project.memory)
except Exception as memory_error:
logger.warning(f"Failed to update memory for episode {episode_number}: {memory_error}")
# 广播更新
await manager.send_to_project(project_id, {
"type": "episode_updated",
"data": {
"number": episode_number,
"title": episode.title,
"status": episode.status
}
})
except Exception as e:
logger.error(f"Failed to persist episode via save_episode: {e}")
await manager.send_to_project(project_id, {
"type": "episode_saved",
"episode_number": episode_number,
"title": title
})
elif name == "get_episode_data":
# 处理剧集数据查询 - 直接返回给 Agent 使用
episode_number = args.get("episode_number")
include_outline = args.get("include_outline", True)
include_content = args.get("include_content", True)
# 如果没有指定剧集号,使用当前焦点剧集
if episode_number is None:
agent = manager.get_agent(project_id, project_dir)
episode_number = getattr(agent.context, 'active_episode_number', None) if agent and agent.context else None
if episode_number:
try:
from app.db.repositories import episode_repo
episodes = await episode_repo.list_by_project(project_id)
episode = next((ep for ep in episodes if ep.number == episode_number), None)
if episode:
# 将数据格式化为 Agent 可读的格式
formatted_response = f"""
【剧集数据 - EP{episode_number}
标题: {episode.title}
状态: {episode.status}
"""
if include_outline and episode.outline:
formatted_response += f"\n【大纲】\n{episode.outline}\n"
if include_content and episode.content:
content_preview = episode.content[:2000]
if len(episode.content) > 2000:
content_preview += "\n... (内容过长已截取前2000字)"
formatted_response += f"\n【内容】\n{content_preview}\n"
# 发送给前端(用于调试/UI显示
await manager.send_to_project(project_id, {
"type": "episode_data_loaded",
"data": {
"episode_number": episode_number,
"title": episode.title,
"status": episode.status,
"outline": episode.outline if include_outline else None,
"content": episode.content if include_content else None
}
})
# 将数据发送给 Agent通过直接返回由 stream_events 处理)
return formatted_response
else:
return f"Error: Episode {episode_number} not found in project."
except Exception as e:
logger.error(f"Failed to get episode data: {e}")
return f"Error: Failed to get episode data: {str(e)}"
else:
return "Error: No episode number specified and no episode is currently focused."
elif name == "update_episode":
# 处理剧集更新并持久化
episode_number = args.get("episode_number")
title = args.get("title")
content = args.get("content")
status = args.get("status")
outline = args.get("outline")
if episode_number:
try:
from app.db.repositories import episode_repo, project_repo
from app.core.memory.memory_manager import get_memory_manager
episodes = await episode_repo.list_by_project(project_id)
episode = next((ep for ep in episodes if ep.number == episode_number), None)
if episode:
if title is not None: episode.title = title
if content is not None: episode.content = content
if status is not None: episode.status = status
if outline is not None: episode.outline = outline
await episode_repo.update(episode)
logger.info(f"Updated episode {episode_number} via update_episode")
# 如果有新内容,自动更新记忆库
if content and content.strip():
try:
project = await project_repo.get(project_id)
if project:
memory_manager = get_memory_manager()
await memory_manager.update_memory_from_episode(project, episode)
logger.info(f"Updated memory after updating episode {episode_number}")
# 同步上下文状态到前端
await _sync_context_states(project_id, episode_number, project.memory)
except Exception as memory_error:
logger.warning(f"Failed to update memory for episode {episode_number}: {memory_error}")
# 广播更新
await manager.send_to_project(project_id, {
"type": "episode_updated",
"data": {
"number": episode_number,
"title": episode.title,
"status": episode.status
}
})
except Exception as e:
logger.error(f"Failed to update episode: {e}")
elif name == "focus_episode":
# 处理剧集焦点切换
episode_number = args.get("episode_number")
title = args.get("title")
await manager.send_to_project(project_id, {
"type": "focus_update",
"episodeNumber": episode_number,
"episodeTitle": title
})
elif name == "request_review":
# 处理审核请求并调用审核管理器
content = args.get("content", "")
review_type = args.get("review_type", "quality")
criteria = args.get("criteria", [])
if content:
try:
from app.db.repositories import project_repo, episode_repo
from app.core.review.review_manager import get_review_manager
from app.models.project import ReviewConfig
# 获取项目
project = await project_repo.get(project_id)
if not project:
logger.warning(f"Project {project_id} not found for review")
else:
# 尝试找到当前剧集(如果存在)
episodes = await episode_repo.list_by_project(project_id)
episode = None
# 如果项目有当前剧集焦点,使用它
from app.core.agent_runtime.director_agent import DirectorAgent
agent = manager.get_agent(project_id, project_dir)
if agent and agent.context and agent.context.active_episode_number:
episode = next((ep for ep in episodes if ep.number == agent.context.active_episode_number), None)
# 如果没有找到剧集,创建临时剧集对象用于审核
if not episode:
from app.models.project import Episode
episode = Episode(
projectId=project_id,
number=agent.context.active_episode_number if agent and agent.context else 1,
title="Content Review",
content=content,
status="draft"
)
# 获取审核配置(使用项目配置或默认配置)
review_config = ReviewConfig(
enabled=True,
dimensions=["quality", "consistency", "plot"] if not criteria else criteria,
strictness=0.7,
autoApprove=False,
requireHumanReview=True
)
# 调用审核管理器
review_manager = get_review_manager()
review_result = await review_manager.review_episode(
project=project,
episode=episode,
config=review_config
)
# 发送审核结果到前端
await manager.send_to_project(project_id, {
"type": "review_completed",
"data": {
"score": review_result.score,
"passed": review_result.passed,
"issue_count": review_result.issue_count,
"issues": [
{
"dimension": issue.dimension,
"severity": issue.severity,
"description": issue.description,
"location": issue.location,
"suggestion": issue.suggestion
}
for issue in review_result.issues
]
}
})
logger.info(f"Review completed for project {project_id}: score={review_result.score}, passed={review_result.passed}")
except Exception as e:
logger.error(f"Failed to execute review: {e}")
# 发送错误消息
await manager.send_to_project(project_id, {
"type": "error",
"data": {"message": f"审核执行失败: {str(e)}"}
})
# 同时发送原始的 review_request 消息(用于 UI 显示)
await manager.send_to_project(project_id, {
"type": "review_request",
"id": f"review_{int(datetime.now().timestamp())}",
"title": f"{review_type.capitalize()} Review",
"description": content[:500] + "..." if len(content) > 500 else content,
"criteria": criteria,
"options": ["Approve", "Request Changes"],
"timestamp": int(datetime.now().timestamp() * 1000)
})
elif name == "create_episode":
# 处理剧集创作请求
episode_number = args.get("episode_number")
analyze_previous_memory = args.get("analyze_previous_memory", True)
if episode_number:
# 使用 ensure_future 确保任务在后台执行,即使 WebSocket 断开也能继续
asyncio.ensure_future(_execute_episode_creation(
project_id, episode_number, analyze_previous_memory
))
logger.info(f"已启动后台创作任务: EP{episode_number}")
elif name == "plan_episode_review":
# 处理剧集审查规划请求
episode_selector = args.get("episode_selector")
review_criteria = args.get("review_criteria")
review_dimensions = args.get("review_dimensions")
if episode_selector:
# 启动后台审查任务
asyncio.ensure_future(_execute_episode_review(
project_id, episode_selector, review_criteria, review_dimensions
))
logger.info(f"已启动后台审查任务: {episode_selector}")
# ============================================
# 辅助函数 - 用于从其他模块发送消息
# ============================================
# 全局后台任务跟踪
_background_tasks: Dict[str, asyncio.Task] = {}
async def _execute_episode_creation(
project_id: str,
episode_number: int,
analyze_previous_memory: bool
):
"""
异步执行剧集创作
这个函数在后台执行,不会阻塞 WebSocket 连接。
即使 WebSocket 断开,任务也会继续执行并保存到数据库。
它会:
1. 分析上一集的记忆(如果需要)
2. 执行剧集创作
3. 通过 WebSocket 发送进度更新(如果连接存在)
4. 将创作内容发送到画布(如果连接存在)
5. 无论如何都保存到数据库
"""
task_key = f"{project_id}_{episode_number}"
try:
from app.db.repositories import project_repo, episode_repo
from app.core.agents.series_creation_agent import get_series_agent
from app.core.memory.memory_manager import get_memory_manager
# 注册到后台任务字典
current_task = asyncio.current_task()
if current_task:
_background_tasks[task_key] = current_task
logger.info(f"开始后台创作任务: {task_key}")
# 辅助函数:安全发送 WebSocket 消息(忽略连接错误)
async def safe_send(message_type: str, data: dict = None):
try:
if data is None:
data = {}
await manager.send_to_project(project_id, {
"type": message_type,
"data": data
})
except Exception as e:
# WebSocket 可能已断开,忽略错误继续执行
logger.debug(f"WebSocket 发送失败(可能已断开): {e}")
# 获取项目
project = await project_repo.get(project_id)
if not project:
await safe_send("error", {"message": f"项目不存在: {project_id}"})
return
# 更新计划状态 - 开始
await safe_send("plan_update", {
"plan": [
f"分析 EP{episode_number - 1 if episode_number > 1 else 'N/A'} 的记忆系统" if analyze_previous_memory and episode_number > 1 else "跳过记忆分析(首集)",
f"生成 EP{episode_number} 大纲",
f"创作 EP{episode_number} 对话内容",
f"执行质量审核",
f"更新记忆系统"
],
"status": "planning",
"current_step_index": 0
})
# 步骤 1: 分析上一集记忆(如果需要)
if analyze_previous_memory and episode_number > 1:
await safe_send("plan_update", {
"plan": [
f"分析 EP{episode_number - 1} 的记忆系统",
f"生成 EP{episode_number} 大纲",
f"创作 EP{episode_number} 对话内容",
f"执行质量审核",
f"更新记忆系统"
],
"status": "planning",
"current_step_index": 0
})
# 获取上一集内容
prev_episodes = await episode_repo.list_by_project(project_id)
prev_episode = next((ep for ep in prev_episodes if ep.number == episode_number - 1), None)
if prev_episode and prev_episode.content:
await safe_send("text", {"content": f"\n\n--- 正在分析 EP{episode_number - 1} 的记忆系统 ---\n"})
# 使用 MemoryManager 更新记忆
try:
memory_manager = get_memory_manager()
await memory_manager.update_memory_from_episode(project, prev_episode)
logger.info(f"EP{episode_number - 1} 记忆已分析并注入到 EP{episode_number}")
except Exception as e:
logger.warning(f"分析 EP{episode_number - 1} 记忆失败: {e}")
# 步骤 2-5: 执行剧集创作
agent = get_series_agent()
await safe_send("plan_update", {
"plan": [
f"分析 EP{episode_number - 1} 的记忆系统" if analyze_previous_memory and episode_number > 1 else "跳过记忆分析",
f"生成 EP{episode_number} 大纲",
f"创作 EP{episode_number} 对话内容",
f"执行质量审核",
f"更新记忆系统"
],
"status": "writing",
"current_step_index": 1
})
await safe_send("text", {"content": f"\n\n--- 开始创作 EP{episode_number} ---\n"})
# 执行创作
episode = await agent.execute_episode(
project=project,
episode_number=episode_number,
title=f"{episode_number}"
)
# 检查是否创作成功(错误处理)
if episode.status == "needs-review" and not episode.content:
# 创作失败,没有内容
await safe_send("error", {
"message": f"EP{episode_number} 创作失败",
"episode_number": episode_number
})
await safe_send("text", {"content": f"\n\n❌ EP{episode_number} 创作失败。请检查错误日志并重试。\n"})
logger.error(f"EP{episode_number} 创作失败,无内容生成")
return
# 创作成功,保存到数据库(无论 WebSocket 是否连接)
existing_episodes = await episode_repo.list_by_project(project_id)
episode_record = next((ep for ep in existing_episodes if ep.number == episode_number), None)
if episode_record:
episode.id = episode_record.id
episode.projectId = project_id
await episode_repo.update(episode)
logger.info(f"更新现有剧集记录: {episode.id}")
else:
episode.projectId = project_id
await episode_repo.create(episode)
logger.info(f"创建新剧集记录: {episode.id}")
# 发送内容到画布
if episode.content:
await safe_send("canvas_update", {"content": episode.content})
# 更新记忆
await project_repo.update(project_id, {
"memory": project.memory.dict()
})
# 完成消息
await safe_send("plan_update", {
"plan": [
f"分析 EP{episode_number - 1} 的记忆系统" if analyze_previous_memory and episode_number > 1 else "跳过记忆分析",
f"生成 EP{episode_number} 大纲",
f"创作 EP{episode_number} 对话内容",
f"执行质量审核",
f"更新记忆系统"
],
"status": "idle",
"current_step_index": 4
})
await safe_send("text", {"content": f"\n\n✅ EP{episode_number} 创作完成!质量分数: {episode.qualityScore or 0}\n"})
# 推送审核卡片到对话框
if episode.issues and len(episode.issues) > 0:
high_severity_count = sum(1 for issue in episode.issues if getattr(issue, 'severity', 'low') == 'high')
await safe_send("review_complete", {
"data": {
"episode_number": episode_number,
"overall_score": episode.qualityScore or 0,
"passed": episode.status != "needs-review",
"high_severity_count": high_severity_count,
"issues": episode.issues
}
})
# 广播更新
await safe_send("episode_updated", {
"number": episode_number,
"title": episode.title,
"status": episode.status
})
logger.info(f"EP{episode_number} 后台创作完成,已保存到数据库")
except Exception as e:
logger.error(f"执行剧集创作失败: {str(e)}", exc_info=True)
try:
await manager.send_to_project(project_id, {
"type": "error",
"data": {
"message": f"EP{episode_number} 创作失败: {str(e)}",
"episode_number": episode_number
}
})
await manager.send_to_project(project_id, {
"type": "text",
"data": {"content": f"\n\n❌ EP{episode_number} 创作失败: {str(e)}\n"}
})
except Exception:
# WebSocket 可能已断开,忽略错误
pass
finally:
# 清理任务跟踪
if task_key in _background_tasks:
del _background_tasks[task_key]
logger.info(f"后台创作任务结束: {task_key}")
async def _execute_episode_review(
project_id: str,
episode_selector: str,
review_criteria: Optional[List[str]] = None,
review_dimensions: Optional[List[str]] = None
):
"""
异步执行剧集审查
这个函数在后台执行剧集审查任务:
1. 解析剧集选择器
2. 获取剧集数据
3. 执行多维审查
4. 生成审查报告
5. 推送到审核系统
"""
task_key = f"{project_id}_review_{episode_selector}"
try:
from app.db.repositories import project_repo, episode_repo
from app.core.review.review_manager import get_review_manager
from app.core.review.review_task_manager import get_review_task_manager
from app.models.project import ReviewConfig
# 注册到后台任务字典
current_task = asyncio.current_task()
if current_task:
_background_tasks[task_key] = current_task
logger.info(f"开始后台审查任务: {task_key}")
# 辅助函数:安全发送 WebSocket 消息
async def safe_send(message_type: str, data: dict = None):
try:
if data is None:
data = {}
await manager.send_to_project(project_id, {
"type": message_type,
"data": data
})
except Exception as e:
logger.debug(f"WebSocket 发送失败(可能已断开): {e}")
# 获取项目
project = await project_repo.get(project_id)
if not project:
await safe_send("error", {"message": f"项目不存在: {project_id}"})
return
# 解析剧集选择器
agent = manager.get_agent(project_id, Path(f"d:/platform/creative_studio/workspace/projects/{project_id}"))
episodes_to_review = _parse_episode_selector_for_review(episode_selector, agent)
if not episodes_to_review:
await safe_send("error", {"message": f"无效的剧集选择器: {episode_selector}"})
return
# 更新计划状态 - 开始
await safe_send("plan_update", {
"plan": [
f"获取剧集数据: {len(episodes_to_review)}",
f"执行审查: {', '.join(review_dimensions or ['quality', 'consistency', 'plot'])}",
"生成审查报告",
"推送到审核系统"
],
"status": "reviewing",
"current_step_index": 0
})
await safe_send("text", {"content": f"\n\n--- 开始审查 {len(episodes_to_review)} 集内容 ---\n"})
# 获取所有剧集数据
all_episodes = await episode_repo.list_by_project(project_id)
review_results = []
for idx, episode_number in enumerate(episodes_to_review):
episode = next((ep for ep in all_episodes if ep.number == episode_number), None)
if not episode:
logger.warning(f"EP{episode_number} 不存在,跳过")
continue
await safe_send("text", {"content": f"\n正在审查 EP{episode_number}: {episode.title}...\n"})
# 执行审查
try:
review_config = ReviewConfig(
enabled=True,
dimensions=review_dimensions or ["quality", "consistency", "plot"],
strictness=0.7,
autoApprove=False,
requireHumanReview=True
)
review_manager = get_review_manager()
review_result = await review_manager.review_episode(
project=project,
episode=episode,
config=review_config
)
review_results.append({
"episode_number": episode_number,
"episode_title": episode.title,
"score": review_result.score,
"passed": review_result.passed,
"issue_count": review_result.issue_count,
"issues": [
{
"dimension": issue.dimension,
"severity": issue.severity,
"description": issue.description,
"location": issue.location,
"suggestion": issue.suggestion
}
for issue in review_result.issues
]
})
# 发送单个剧集的审查结果
await safe_send("episode_review_completed", {
"episode_number": episode_number,
"score": review_result.score,
"passed": review_result.passed,
"issue_count": review_result.issue_count
})
except Exception as e:
logger.error(f"审查 EP{episode_number} 失败: {e}")
await safe_send("error", {"message": f"审查 EP{episode_number} 失败: {str(e)}"})
# 更新计划状态 - 完成
await safe_send("plan_update", {
"plan": [
f"获取剧集数据: {len(episodes_to_review)}",
f"执行审查: {', '.join(review_dimensions or ['quality', 'consistency', 'plot'])}",
"生成审查报告",
"推送到审核系统"
],
"status": "idle",
"current_step_index": 3
})
# 推送到审核系统
review_task_manager = get_review_task_manager()
try:
for result in review_results:
if result["issue_count"] > 0:
# 创建审核任务
await review_task_manager.create_task_from_review(
project_id=project_id,
episode_number=result["episode_number"],
review_data=result
)
logger.info(f"已将 EP{result['episode_number']} 的审查结果推送到审核系统")
await safe_send("text", {"content": f"\n\n✅ 审查完成!共审查 {len(review_results)} 集,发现问题的剧集已推送到审核系统。\n"})
await safe_send("review_tasks_created", {
"total_episodes": len(review_results),
"episodes_with_issues": sum(1 for r in review_results if r["issue_count"] > 0),
"review_results": review_results
})
except Exception as e:
logger.error(f"推送审核系统失败: {e}")
await safe_send("error", {"message": f"推送审核系统失败: {str(e)}"})
logger.info(f"EP{episode_selector} 后台审查完成")
except Exception as e:
logger.error(f"执行剧集审查失败: {str(e)}", exc_info=True)
try:
await manager.send_to_project(project_id, {
"type": "error",
"data": {
"message": f"审查失败: {str(e)}",
"episode_selector": episode_selector
}
})
except Exception:
pass
finally:
# 清理任务跟踪
if task_key in _background_tasks:
del _background_tasks[task_key]
logger.info(f"后台审查任务结束: {task_key}")
def _parse_episode_selector_for_review(selector: str, agent) -> List[int]:
"""解析剧集选择器,用于审查任务"""
selector_lower = selector.lower().strip()
# 获取所有剧集号
episodes = getattr(agent.context, 'episodes', [])
all_episode_numbers = [ep.get('number') for ep in episodes if ep.get('number')]
# 处理 "all" 或 "全部剧集"
if selector_lower in ['all', '全部', '全部剧集', 'all episodes']:
return sorted(all_episode_numbers)
# 处理范围 "1-5" 或 "episodes 1-5"
if '-' in selector_lower:
try:
parts = selector_lower.split('-')
start_part = parts[0].strip()
end_part = parts[1].strip()
start = int(''.join(filter(str.isdigit, start_part)))
end = int(''.join(filter(str.isdigit, end_part)))
return list(range(start, end + 1))
except (ValueError, IndexError):
pass
# 处理逗号分隔 "1,2,3"
if ',' in selector_lower:
try:
numbers = selector_lower.split(',')
result = []
for num in numbers:
extracted = int(''.join(filter(str.isdigit, num)))
result.append(extracted)
return sorted(set(result))
except ValueError:
pass
# 处理单个剧集 "1" 或 "episode 1"
try:
number = int(''.join(filter(str.isdigit, selector_lower)))
return [number]
except ValueError:
pass
return []
# ============================================
# 辅助函数 - 用于从其他模块发送消息
# ============================================
async def broadcast_stage_update(
project_id: str,
episode_number: int,
stage: str,
data: Dict[str, Any]
):
message = {
"type": "stage_update",
"data": {
"project_id": project_id,
"episode_number": episode_number,
"stage": stage,
**data
},
"timestamp": datetime.now().isoformat()
}
await manager.send_to_project(project_id, message)
async def broadcast_episode_complete(
project_id: str,
episode_number: int,
success: bool,
quality_score: float,
data: Dict[str, Any]
):
message = {
"type": "episode_complete",
"data": {
"project_id": project_id,
"episode_number": episode_number,
"success": success,
"quality_score": quality_score,
**data
},
"timestamp": datetime.now().isoformat()
}
await manager.send_to_project(project_id, message)
async def broadcast_batch_progress(
batch_id: str,
current_episode: int,
total_episodes: int,
completed: int,
failed: int,
data: Dict[str, Any]
):
message = {
"type": "batch_progress",
"data": {
"batch_id": batch_id,
"current_episode": current_episode,
"total_episodes": total_episodes,
"completed_episodes": completed,
"failed_episodes": failed,
"progress_percentage": (completed / total_episodes * 100) if total_episodes > 0 else 0,
**data
},
"timestamp": datetime.now().isoformat()
}
await manager.send_to_batch(batch_id, message)
async def broadcast_error(
project_id: str,
episode_number: Optional[int],
error: str,
error_type: str = "execution_error"
):
message = {
"type": "error",
"data": {
"project_id": project_id,
"episode_number": episode_number,
"error": error,
"error_type": error_type
},
"timestamp": datetime.now().isoformat()
}
await manager.send_to_project(project_id, message)
async def broadcast_batch_complete(
batch_id: str,
summary: Dict[str, Any]
):
message = {
"type": "batch_complete",
"data": {
"batch_id": batch_id,
**summary
},
"timestamp": datetime.now().isoformat()
}
await manager.send_to_batch(batch_id, message)
async def broadcast_to_project(
project_id: str,
message_type: str,
data: Dict[str, Any]
):
"""向项目的所有连接广播消息"""
message = {
"type": message_type,
"data": data,
"timestamp": datetime.now().isoformat()
}
await manager.send_to_project(project_id, message)
async def broadcast_to_project(
project_id: str,
message_type: str,
data: Dict[str, Any]
):
"""向项目的所有连接广播消息"""
message = {
"type": message_type,
"data": data,
"timestamp": datetime.now().isoformat()
}
await manager.send_to_project(project_id, message)
async def broadcast_review_card_created(
project_id: str,
card_id: str,
card_data: Dict[str, Any]
):
"""广播审核卡片创建通知"""
await manager.send_to_project(project_id, {
"type": "review_card_created",
"data": {
"card_id": card_id,
"card_type": card_data.get("card_type"),
"episode_numbers": card_data.get("episode_numbers", []),
"severity": card_data.get("severity"),
"review_reason": card_data.get("review_reason"),
"timestamp": datetime.now().isoformat()
}
})
async def broadcast_confirm_card_created(
project_id: str,
card_id: str,
card_data: Dict[str, Any]
):
"""广播确认卡片创建通知"""
await manager.send_to_project(project_id, {
"type": "confirm_card_created",
"data": {
"card_id": card_id,
"card_type": card_data.get("card_type"),
"title": card_data.get("title"),
"description": card_data.get("description", "")[:200],
"options": card_data.get("options", []),
"timestamp": datetime.now().isoformat()
}
})
# 导出连接管理器和辅助函数
__all__ = [
"manager",
"broadcast_stage_update",
"broadcast_episode_complete",
"broadcast_batch_progress",
"broadcast_error",
"broadcast_batch_complete",
"broadcast_to_project",
"broadcast_review_card_created",
"broadcast_confirm_card_created"
]