From c99f66895bbc1a7607309985c329281a21ecf2db Mon Sep 17 00:00:00 2001 From: hjjjj <1311711287@qq.com> Date: Tue, 27 Jan 2026 18:31:04 +0800 Subject: [PATCH] =?UTF-8?q?feat:=E5=89=A7=E9=9B=86=E5=88=9B=E4=BD=9C?= =?UTF-8?q?=E5=8A=9F=E8=83=BD=E4=BC=98=E5=8C=96=E5=BC=80=E5=8F=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .claude/settings.local.json | 13 +- DESIGN_V2.md | 79 ++ backend/app/api/v1/ai_async.py | 3 + backend/app/api/v1/websocket.py | 405 ++++++-- backend/app/core/agent_runtime/agent.py | 255 +++++ backend/app/core/agent_runtime/context.py | 32 + .../app/core/agent_runtime/director_agent.py | 121 +++ .../app/core/agent_runtime/director_tools.py | 475 ++++++++++ .../app/core/agent_runtime/skill_loader.py | 153 +++ .../app/core/agent_runtime/stream/emitter.py | 52 ++ .../app/core/agent_runtime/stream/tracker.py | 103 ++ .../app/core/agent_runtime/stream/utils.py | 204 ++++ backend/app/core/agent_runtime/tools.py | 296 ++++++ .../agents/skills_agent_adapter_example.py | 67 ++ .../app/core/agents/tools/director_tools.py | 51 + .../core/agents/tools/memory_review_tools.py | 113 +++ backend/app/core/llm/langchain_adapter.py | 116 +++ backend/app/db/repositories.py | 178 +++- backend/app/models/project.py | 4 + backend/data/projects.json | 33 + backend/requirements.txt | 5 + frontend/src/App.tsx | 3 +- .../src/components/Workspace/ContextPanel.tsx | 296 ++++++ .../components/Workspace/DirectorInbox.tsx | 189 ++++ .../components/Workspace/EpisodeSidebar.tsx | 217 +++++ .../src/components/Workspace/SmartCanvas.tsx | 247 +++++ frontend/src/pages/ProjectDetail.tsx | 634 +++++++++++-- frontend/src/pages/ProjectList.tsx | 60 +- frontend/src/pages/ProjectWorkspace.tsx | 881 ++++++++---------- frontend/src/services/api.ts | 14 +- frontend/src/services/projectService.ts | 5 + frontend/vite.config.ts | 5 + test.md | 8 + 33 files changed, 4586 insertions(+), 731 deletions(-) create mode 100644 DESIGN_V2.md create mode 100644 backend/app/core/agent_runtime/agent.py create mode 100644 backend/app/core/agent_runtime/context.py create mode 100644 backend/app/core/agent_runtime/director_agent.py create mode 100644 backend/app/core/agent_runtime/director_tools.py create mode 100644 backend/app/core/agent_runtime/skill_loader.py create mode 100644 backend/app/core/agent_runtime/stream/emitter.py create mode 100644 backend/app/core/agent_runtime/stream/tracker.py create mode 100644 backend/app/core/agent_runtime/stream/utils.py create mode 100644 backend/app/core/agent_runtime/tools.py create mode 100644 backend/app/core/agents/skills_agent_adapter_example.py create mode 100644 backend/app/core/agents/tools/director_tools.py create mode 100644 backend/app/core/agents/tools/memory_review_tools.py create mode 100644 backend/app/core/llm/langchain_adapter.py create mode 100644 backend/data/projects.json create mode 100644 frontend/src/components/Workspace/ContextPanel.tsx create mode 100644 frontend/src/components/Workspace/DirectorInbox.tsx create mode 100644 frontend/src/components/Workspace/EpisodeSidebar.tsx create mode 100644 frontend/src/components/Workspace/SmartCanvas.tsx create mode 100644 test.md diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 11ac2dc..ede070f 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -14,7 +14,18 @@ "Bash(netstat:*)", "Bash(tail:*)", "Bash(tasklist:*)", - "Bash(taskkill:*)" + "Bash(taskkill:*)", + "Bash(where:*)", + "Bash(\"C:/ProgramData/Anaconda3/envs/creative_studio/python.exe\" -m uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload)", + "Bash(\"C:/ProgramData/Anaconda3/envs/creative_studio/python.exe\" -m pip install langchain langchain-core langgraph)", + "Bash(npm run dev:*)", + "Bash(\"C:\\\\ProgramData\\\\Anaconda3\\\\envs\\\\creative_studio\\\\python.exe\" --version)", + "Bash(\"C:\\\\ProgramData\\\\Anaconda3\\\\envs\\\\creative_studio\\\\python.exe\":*)", + "Bash(\"C:\\\\ProgramData\\\\Anaconda3\\\\envs\\\\creative_studio\\\\python.exe\" -c \"import langchain; print\\(''langchain version:'', langchain.__version__\\)\")", + "Bash(\"C:\\\\ProgramData\\\\Anaconda3\\\\envs\\\\creative_studio\\\\python.exe\" -c \"from langchain.agents import create_agent; print\\(''create_agent OK''\\)\")", + "Bash(\"C:\\\\ProgramData\\\\Anaconda3\\\\envs\\\\creative_studio\\\\python.exe\" -c \"from app.core.agent_runtime.agent import LangChainSkillsAgent; print\\(''Import OK''\\)\")", + "Bash(powershell -Command \"Get-Process | Where-Object {$_ProcessName -like ''*python*'' -or $_ProcessName -like ''*uvicorn*''}\")", + "Bash(\"C:\\\\ProgramData\\\\Anaconda3\\\\envs\\\\creative_studio\\\\python.exe\" -c \"import sys; sys.path.insert\\(0, r''d:\\\\platform\\\\creative_studio\\\\backend''\\); from app.api.v1.websocket import app; print\\(''WebSocket module import successful''\\)\")" ] } } diff --git a/DESIGN_V2.md b/DESIGN_V2.md new file mode 100644 index 0000000..8fa8b68 --- /dev/null +++ b/DESIGN_V2.md @@ -0,0 +1,79 @@ +# Agent-Native 创作平台:交互与架构重构 + +基于你的需求,我们将平台从“工具流”升级为 **"Agent-Native" 协作平台**。核心理念是:**用户是导演,Agent 是制片人兼编剧团队,系统是数字化片场。** + +## 1. 核心交互范式转变 + +| 传统模式 (Terminal/Form) | Agent-Native 模式 (Interactive Canvas) | +| :--- | :--- | +| **即时响应** | **异步长流程 (Long-Running Process)** | +| 用户等待 spinner 转圈 | Agent 在后台工作,实时推送进度卡片 | +| 审核是“通过/拒绝”按钮 | 审核是 **"待办任务 (Inbox)"** 和 **"批注 (Annotation)"** | +| 记忆是后台数据库 | 记忆是 **"可视化知识图谱"**,Agent 主动引用并展示 | + +## 2. 关键流程设计 + +### 2.1 项目启动 (Onboarding) +* **保持不变**: 支持上传剧本/灵感/文字。 +* **新增**: Agent 立即介入,进行 **"初始设定构建"**。 + * Agent: "收到你的灵感。我正在生成初步的世界观和人物小传... 完成。请确认或修改。" (推送一个可交互的设定卡片) + +### 2.2 剧集创作 (Execution & Planning) +* **用户动作**: 点击 "开始创作第 X 集"。 +* **Agent 行为**: + 1. **规划 (Planning)**: Agent 生成一个 "创作计划书" (ToDo List)。 + * *示例*: "1. 回顾上一集伏笔; 2. 构思本集大纲; 3. 撰写初稿; 4. 自查一致性。" + 2. **透明化执行**: 用户在界面右侧看到 Agent 正在打钩完成这些步骤。 + 3. **流式输出**: 左侧编辑器实时显示 Agent 正在写的内容 (Ghost-writing)。 + +### 2.3 记忆系统 (Active Memory) - "Agent 记得什么" +* **不是后台黑盒**,而是 **前台高亮**。 +* **交互**: 当 Agent 写到 "主角拔出了那把生锈的剑" 时,界面侧边栏自动弹出记忆卡片: + * *记忆引用*: "关联记忆: EP01 - 主角在废墟中捡到了生锈的剑 (Confidence: 98%)" +* **作用**: 让用户知道 Agent 是基于记忆在写作,而不是瞎编。 + +### 2.4 审核系统 (Human-in-the-Loop) - "导演请过目" +* **拒绝 Terminal 形式**: 不要让用户在聊天框里打 "通过"。 +* **任务流形式**: + * Agent 发现潜在问题 (如: "这句台词可能有点OOC"),但它不确定。 + * Agent **不中断流程**,而是生成一个 **"审核任务 (Review Task)"** 推送到用户的 **"导演信箱"**。 + * 用户可以在方便时处理这些任务:点击 "忽略" (Agent 继续) 或 "修正" (Agent 根据修正重写)。 +* **最终交付**: Agent 完成初稿后,提交 "验收申请"。系统自动跑一遍 `ReviewManager`,把高风险问题标记在文本上 (类似 Word 的批注)。 + +## 3. 页面架构重构 + +建议将 `ProjectWorkspace.tsx` 改造为 **三栏式布局**: + +* **左栏 (Navigation & Context)**: + * 剧集列表 + * **动态上下文**: 当前生效的世界观、活跃的人物状态 (随剧情进度变化)。 +* **中栏 (Canvas - 创作区)**: + * **剧本编辑器**: 多人(人+AI) 协作编辑器。 + * **流式内容**: Agent 的输出实时上屏。 + * **批注层**: 审核系统发现的问题直接高亮显示。 +* **右栏 (Agent Command Center - 导演控制台)**: + * **Chat**: 与 Agent 对话 ("把这段改得悲伤一点")。 + * **Plan**: Agent 的当前执行计划 (Step 1/2/3)。 + * **Inbox**: **需要用户决策的任务** (审核请求、分支选择、设定确认)。 + +## 4. 技术实现要点 + +### 4.1 异步任务与 WebSocket +* Agent 的运行是长流程 (可能持续几分钟)。 +* 后端使用 Celery/TaskQueue 执行 Agent 逻辑。 +* 前端通过 WebSocket 接收: + * `token`: 文本生成流。 + * `plan_update`: 步骤状态变更。 + * `memory_hit`: 记忆引用通知。 + * `review_request`: 审核任务推送。 + +### 4.2 记忆与审核的 Tool 封装 +* **Memory Tool**: 不仅返回文本,还返回 `metadata` (引用来源 ID),前端据此渲染引用卡片。 +* **Review Tool**: 生成结构化的 `ReviewIssue` 对象,前端将其渲染为编辑器中的 **Annotation (批注)**。 + +--- + +### 总结 +你的直觉非常敏锐。**Terminal 是给程序员用的,Dashboard 是给创作者用的。** + +通过 **"任务流 (Inbox)"** 和 **"可视化批注"** 替代简单的对话交互,将记忆和审核无缝融入创作流,这才是真正的 Enterprise-grade Agent 平台。 diff --git a/backend/app/api/v1/ai_async.py b/backend/app/api/v1/ai_async.py index afae2a4..c378593 100644 --- a/backend/app/api/v1/ai_async.py +++ b/backend/app/api/v1/ai_async.py @@ -66,6 +66,8 @@ async def execute_generate_characters( extra_info += f"\n项目名称:{params['projectName']}" if params.get("totalEpisodes"): extra_info += f"\n总集数:{params['totalEpisodes']}" + if params.get("genre"): + extra_info += f"\n类型:{params['genre']}" custom_requirements = "" if params.get("customPrompt"): @@ -303,6 +305,7 @@ class GenerateCharactersRequest(BaseModel): idea: str projectName: Optional[str] = None totalEpisodes: Optional[int] = None + genre: Optional[str] = "古风" skills: Optional[List[SkillInfo]] = None customPrompt: Optional[str] = None projectId: Optional[str] = None # 关联项目ID diff --git a/backend/app/api/v1/websocket.py b/backend/app/api/v1/websocket.py index 4fc7683..0db053f 100644 --- a/backend/app/api/v1/websocket.py +++ b/backend/app/api/v1/websocket.py @@ -8,8 +8,12 @@ from typing import Dict, Set, Optional, Any 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__) @@ -28,6 +32,8 @@ class ConnectionManager: 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): """连接到项目执行流""" @@ -54,6 +60,9 @@ class ConnectionManager: 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(): @@ -105,6 +114,30 @@ class ConnectionManager: 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())) @@ -129,22 +162,13 @@ async def websocket_project_execution( ): """ 项目执行 WebSocket 端点 - - 实时接收项目执行进度更新,包括: - - 执行开始/完成事件 - - 各阶段进度(结构分析、大纲生成、对话创作等) - - 质量检查结果 - - 错误信息 - - 消息格式: - { - "type": "stage_start|stage_progress|stage_complete|error|complete", - "data": {...}, - "timestamp": "ISO 8601" - } """ 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({ @@ -155,16 +179,24 @@ async def websocket_project_execution( "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) + await _handle_client_message(websocket, project_id, message, project_dir) except WebSocketDisconnect: logger.info(f"WebSocket 客户端主动断开: {project_id}") @@ -199,20 +231,6 @@ async def websocket_batch_execution( ): """ 批量执行 WebSocket 端点 - - 实时接收批量执行进度更新,包括: - - 批次开始/完成事件 - - 各剧集执行进度 - - 整体进度百分比 - - 质量统计信息 - - 错误信息 - - 消息格式: - { - "type": "batch_start|episode_start|episode_complete|progress|batch_complete|error", - "data": {...}, - "timestamp": "ISO 8601" - } """ await manager.connect_to_batch(websocket, batch_id) @@ -231,17 +249,18 @@ async def websocket_batch_execution( while True: try: data = await websocket.receive_text() + # 批量执行目前不接受客户端控制消息,仅广播 + # 但为了保持连接活性,可以处理 ping message = json.loads(data) - await _handle_client_message(websocket, batch_id, message) + 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 json.JSONDecodeError: - await websocket.send_json({ - "type": "error", - "data": {"message": "无效的 JSON 格式"} - }) except Exception as e: logger.error(f"处理 WebSocket 消息错误: {str(e)}") @@ -255,18 +274,15 @@ async def websocket_batch_execution( async def _handle_client_message( websocket: WebSocket, - id: str, - message: Dict[str, Any] + project_id: str, + message: Dict[str, Any], + project_dir: Path ): """ 处理客户端发送的消息 - - Args: - websocket: WebSocket 连接 - id: 项目ID或批次ID - message: 客户端消息 """ message_type = message.get("type") + logger.info(f"收到消息: type={message_type}, full_message={message}") if message_type == "ping": # 心跳响应 @@ -277,16 +293,118 @@ async def _handle_client_message( } }) + 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) + 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 == "chat_message": + # 用户发送聊天消息 -> 触发 Agent 执行 + content = message.get("content", "") + if not content: + return + + # 保存用户消息 + await message_repo.add_message(project_id, "user", content) + + # 获取 Agent + agent = manager.get_agent(project_id, project_dir) + + # 加载项目上下文并更新 Agent(如果尚未加载) + if agent.context and not agent.context.project_id: + try: + from app.db.repositories import project_repo + from app.core.agent_runtime.context import SkillAgentContext + from app.core.agent_runtime.skill_loader import SkillLoader + from app.core.skills.skill_manager import skill_manager + + project = await project_repo.get(project_id) + if project: + # 将项目的 defaultTaskSkills 转换为 user_skills 格式 + user_skills = [] + if hasattr(project, 'defaultTaskSkills') and project.defaultTaskSkills: + for task_config in project.defaultTaskSkills: + for skill_config in task_config.skills: + try: + # 通过 skill_manager 获取技能详细信息 + skill = skill_manager.get_skill_by_id(skill_config.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 {skill_config.skill_id}: {e}") + + # 创建项目上下文 + 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=project.globalContext.styleGuide if project.globalContext else 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 + ) + # 更新 Agent 的上下文 + agent.context = project_context + # 重新构建 system prompt + agent.system_prompt = agent._build_system_prompt() + logger.info(f"Loaded project context for {project_id}: {project.name}") + except Exception as e: + logger.warning(f"Failed to load project context for {project_id}: {e}") + + # 异步运行 Agent 并将事件流推送到前端 + full_response = "" + 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": + full_response += event.get("content", "") + + await manager.send_to_project(project_id, event) + + # 保存 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)} + }) + elif message_type == "get_status": # 请求状态 - from app.core.execution.batch_executor import get_batch_executor - executor = get_batch_executor() - status = executor.get_batch_status(id) - - await websocket.send_json({ - "type": "status", - "data": status or {"message": "未找到执行状态"} - }) + # 这里可以返回 Agent 的状态,或者之前的 executor 状态 + pass else: await websocket.send_json({ @@ -297,6 +415,142 @@ async def _handle_client_message( }) +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", "") + }) + + await manager.send_to_project(project_id, { + "type": "memory_update", + "data": memory_data + }) + + elif name == "save_episode": + # 处理剧集保存 + episode_number = args.get("episode_number", 0) + title = args.get("title", "") + + await manager.send_to_project(project_id, { + "type": "episode_saved", + "episode_number": episode_number, + "title": title + }) + # ============================================ # 辅助函数 - 用于从其他模块发送消息 # ============================================ @@ -307,15 +561,6 @@ async def broadcast_stage_update( stage: str, data: Dict[str, Any] ): - """ - 广播阶段更新消息 - - Args: - project_id: 项目ID - episode_number: 集数 - stage: 阶段名称 - data: 阶段数据 - """ message = { "type": "stage_update", "data": { @@ -326,7 +571,6 @@ async def broadcast_stage_update( }, "timestamp": datetime.now().isoformat() } - await manager.send_to_project(project_id, message) @@ -337,16 +581,6 @@ async def broadcast_episode_complete( quality_score: float, data: Dict[str, Any] ): - """ - 广播剧集完成消息 - - Args: - project_id: 项目ID - episode_number: 集数 - success: 是否成功 - quality_score: 质量分数 - data: 额外数据 - """ message = { "type": "episode_complete", "data": { @@ -358,7 +592,6 @@ async def broadcast_episode_complete( }, "timestamp": datetime.now().isoformat() } - await manager.send_to_project(project_id, message) @@ -370,17 +603,6 @@ async def broadcast_batch_progress( failed: int, data: Dict[str, Any] ): - """ - 广播批量执行进度 - - Args: - batch_id: 批次ID - current_episode: 当前集数 - total_episodes: 总集数 - completed: 已完成数 - failed: 失败数 - data: 额外数据 - """ message = { "type": "batch_progress", "data": { @@ -394,7 +616,6 @@ async def broadcast_batch_progress( }, "timestamp": datetime.now().isoformat() } - await manager.send_to_batch(batch_id, message) @@ -404,15 +625,6 @@ async def broadcast_error( error: str, error_type: str = "execution_error" ): - """ - 广播错误消息 - - Args: - project_id: 项目ID - episode_number: 集数(可选) - error: 错误信息 - error_type: 错误类型 - """ message = { "type": "error", "data": { @@ -423,7 +635,6 @@ async def broadcast_error( }, "timestamp": datetime.now().isoformat() } - await manager.send_to_project(project_id, message) @@ -431,13 +642,6 @@ async def broadcast_batch_complete( batch_id: str, summary: Dict[str, Any] ): - """ - 广播批量执行完成 - - Args: - batch_id: 批次ID - summary: 执行摘要 - """ message = { "type": "batch_complete", "data": { @@ -446,7 +650,6 @@ async def broadcast_batch_complete( }, "timestamp": datetime.now().isoformat() } - await manager.send_to_batch(batch_id, message) diff --git a/backend/app/core/agent_runtime/agent.py b/backend/app/core/agent_runtime/agent.py new file mode 100644 index 0000000..43d78c4 --- /dev/null +++ b/backend/app/core/agent_runtime/agent.py @@ -0,0 +1,255 @@ +""" +LangChain Skills Agent 主体 +""" + +import os +from pathlib import Path +from typing import Optional, Iterator + +from dotenv import load_dotenv +from langchain.agents import create_agent +from langchain.chat_models import init_chat_model +from langchain_community.chat_models import ChatZhipuAI +from langchain_core.messages import AIMessage, AIMessageChunk +from langgraph.checkpoint.memory import InMemorySaver + +from app.config import settings +from .skill_loader import SkillLoader +from .context import SkillAgentContext +from .tools import ALL_TOOLS +from .stream.emitter import StreamEventEmitter +from .stream.tracker import ToolCallTracker +from .stream.utils import is_success, DisplayLimits + +load_dotenv(override=True) + +DEFAULT_MODEL = "claude-sonnet-4-5-20250929" +DEFAULT_MAX_TOKENS = 16000 +DEFAULT_TEMPERATURE = 1.0 +DEFAULT_THINKING_BUDGET = 10000 + + +def get_anthropic_credentials() -> tuple[str | None, str | None]: + api_key = os.getenv("ANTHROPIC_API_KEY") or os.getenv("ANTHROPIC_AUTH_TOKEN") + base_url = os.getenv("ANTHROPIC_BASE_URL") + return api_key, base_url + + +class LangChainSkillsAgent: + """基于 LangChain 1.0 的 Skills Agent""" + + def __init__( + self, + model: Optional[str] = None, + skill_paths: Optional[list[Path]] = None, + working_directory: Optional[Path] = None, + max_tokens: Optional[int] = None, + temperature: Optional[float] = None, + enable_thinking: bool = True, + thinking_budget: int = DEFAULT_THINKING_BUDGET, + ): + self.enable_thinking = enable_thinking + self.thinking_budget = thinking_budget + + self.model_name = model or os.getenv("CLAUDE_MODEL", DEFAULT_MODEL) + self.max_tokens = max_tokens or int(os.getenv("MAX_TOKENS", str(DEFAULT_MAX_TOKENS))) + if enable_thinking: + self.temperature = 1.0 + else: + self.temperature = temperature or float(os.getenv("MODEL_TEMPERATURE", str(DEFAULT_TEMPERATURE))) + self.working_directory = working_directory or Path.cwd() + + self.skill_loader = SkillLoader(skill_paths) + self.system_prompt = self._build_system_prompt() + self.context = SkillAgentContext( + skill_loader=self.skill_loader, + working_directory=self.working_directory, + ) + self.agent = self._create_agent() + + def _build_system_prompt(self) -> str: + base_prompt = """You are a helpful coding assistant with access to specialized skills. + +Your capabilities include: +- Loading and using specialized skills for specific tasks +- Executing bash commands and scripts +- Reading and writing files +- Following skill instructions to complete complex tasks + +When a user request matches a skill's description, use the load_skill tool to get detailed instructions before proceeding.""" + return self.skill_loader.build_system_prompt(base_prompt) + + def _create_agent(self): + # GLM Support + if "glm" in self.model_name.lower(): + model = ChatZhipuAI( + model=self.model_name, + api_key=settings.zai_api_key, + temperature=self.temperature, + ) + + agent = create_agent( + model=model, + tools=ALL_TOOLS, + system_prompt=self.system_prompt, + context_schema=SkillAgentContext, + checkpointer=InMemorySaver(), + ) + return agent + + api_key, base_url = get_anthropic_credentials() + init_kwargs = { + "temperature": self.temperature, + "max_tokens": self.max_tokens, + } + if api_key: + init_kwargs["api_key"] = api_key + if base_url: + init_kwargs["base_url"] = base_url + + if self.enable_thinking: + init_kwargs["thinking"] = { + "type": "enabled", + "budget_tokens": self.thinking_budget, + } + + model = init_chat_model(self.model_name, **init_kwargs) + + agent = create_agent( + model=model, + tools=ALL_TOOLS, + system_prompt=self.system_prompt, + context_schema=SkillAgentContext, + checkpointer=InMemorySaver(), + ) + return agent + + def stream_events(self, message: str, thread_id: str = "default") -> Iterator[dict]: + config = {"configurable": {"thread_id": thread_id}} + emitter = StreamEventEmitter() + tracker = ToolCallTracker() + full_response = "" + + try: + for event in self.agent.stream( + {"messages": [{"role": "user", "content": message}]}, + config=config, + context=self.context, + stream_mode="messages", + ): + if isinstance(event, tuple) and len(event) >= 2: + chunk = event[0] + else: + chunk = event + + if isinstance(chunk, (AIMessageChunk, AIMessage)): + for ev in self._process_chunk_content(chunk, emitter, tracker): + if ev.type == "text": + full_response += ev.data.get("content", "") + yield ev.data + + if hasattr(chunk, "tool_calls") and chunk.tool_calls: + for ev in self._process_tool_calls(chunk.tool_calls, emitter, tracker): + yield ev.data + + elif hasattr(chunk, "type") and chunk.type == "tool": + for ev in self._process_tool_result(chunk, emitter, tracker): + yield ev.data + + except Exception as e: + yield emitter.error(str(e)).data + raise + + yield emitter.done(full_response).data + + def _process_chunk_content(self, chunk, emitter, tracker): + content = chunk.content + if isinstance(content, str): + if content: + yield emitter.text(content) + return + + blocks = None + if hasattr(chunk, "content_blocks"): + blocks = chunk.content_blocks + + if blocks is None: + if isinstance(content, dict): + blocks = [content] + elif isinstance(content, list): + blocks = content + else: + return + + for block in blocks: + if not isinstance(block, dict): + if hasattr(block, "model_dump"): + block = block.model_dump() + elif hasattr(block, "dict"): + block = block.dict() + else: + continue + + block_type = block.get("type") + + if block_type in ("thinking", "reasoning"): + thinking = block.get("thinking") or block.get("reasoning") or "" + if thinking: + yield emitter.thinking(thinking) + + elif block_type == "text": + text = block.get("text") or block.get("content") or "" + if text: + yield emitter.text(text) + + elif block_type in ("tool_use", "tool_call"): + tool_id = block.get("id", "") + name = block.get("name", "") + args = block.get("input") if block_type == "tool_use" else block.get("args") + args_payload = args if isinstance(args, dict) else {} + + if tool_id: + tracker.update(tool_id, name=name, args=args_payload) + if tracker.is_ready(tool_id): + tracker.mark_emitted(tool_id) + yield emitter.tool_call(name, args_payload, tool_id) + + elif block_type == "input_json_delta": + partial_json = block.get("partial_json", "") + if partial_json: + tracker.append_json_delta(partial_json) + + elif block_type == "tool_call_chunk": + tool_id = block.get("id", "") + name = block.get("name", "") + if tool_id: + tracker.update(tool_id, name=name) + partial_args = block.get("args", "") + if isinstance(partial_args, str) and partial_args: + tracker.append_json_delta(partial_args) + + def _process_tool_calls(self, tool_calls, emitter, tracker): + for tc in tool_calls: + tool_id = tc.get("id", "") + if tool_id: + name = tc.get("name", "") + args = tc.get("args", {}) + args_payload = args if isinstance(args, dict) else {} + tracker.update(tool_id, name=name, args=args_payload) + if tracker.is_ready(tool_id): + tracker.mark_emitted(tool_id) + yield emitter.tool_call(name, args_payload, tool_id) + + def _process_tool_result(self, chunk, emitter, tracker): + tracker.finalize_all() + for info in tracker.get_all(): + yield emitter.tool_call(info.name, info.args, info.id) + + name = getattr(chunk, "name", "unknown") + raw_content = str(getattr(chunk, "content", "")) + content = raw_content[:DisplayLimits.TOOL_RESULT_MAX] + if len(raw_content) > DisplayLimits.TOOL_RESULT_MAX: + content += "\n... (truncated)" + + success = is_success(content) + yield emitter.tool_result(name, content, success) diff --git a/backend/app/core/agent_runtime/context.py b/backend/app/core/agent_runtime/context.py new file mode 100644 index 0000000..1be6bf4 --- /dev/null +++ b/backend/app/core/agent_runtime/context.py @@ -0,0 +1,32 @@ +""" +Agent Runtime Context +""" +from pathlib import Path +from dataclasses import dataclass, field +from typing import Optional, Dict, Any, List +from .skill_loader import SkillLoader + + +@dataclass +class SkillAgentContext: + """Agent 运行时上下文""" + skill_loader: SkillLoader + working_directory: Path = field(default_factory=Path.cwd) + + # 项目上下文(可选) + project_id: Optional[str] = None + project_name: Optional[str] = None + project_genre: Optional[str] = None + total_episodes: Optional[int] = None + + # 全局设定 + world_setting: Optional[str] = None + characters: Optional[str] = None + overall_outline: Optional[str] = None + + # 创作方式 + creation_mode: Optional[str] = None # 'script' or 'inspiration' + source_content: Optional[str] = None # 剧本或灵感内容 + + # 用户配置的 Skills + user_skills: List[Dict[str, Any]] = field(default_factory=list) diff --git a/backend/app/core/agent_runtime/director_agent.py b/backend/app/core/agent_runtime/director_agent.py new file mode 100644 index 0000000..8191c0b --- /dev/null +++ b/backend/app/core/agent_runtime/director_agent.py @@ -0,0 +1,121 @@ +""" +Director Agent +专用于 Creative Studio 的制片人 Agent +""" +from pathlib import Path +from typing import Optional +from .agent import LangChainSkillsAgent +from .context import SkillAgentContext + +class DirectorAgent(LangChainSkillsAgent): + """ + Director Agent 继承自 LangChainSkillsAgent + 使用专门的 System Prompt 和工具集来支持 Agent-Native 协作模式 + """ + + def __init__( + self, + working_directory: Optional[Path] = None, + enable_thinking: bool = True, + model: Optional[str] = None, + project_context: Optional[SkillAgentContext] = None, + ): + """ + 初始化 Director Agent + + Args: + working_directory: 工作目录 + enable_thinking: 是否启用思考模式 + model: 模型名称 + project_context: 项目上下文(可选) + """ + # 保存项目上下文引用 + self._project_context = project_context + + # 调用父类初始化 + super().__init__( + working_directory=working_directory, + enable_thinking=enable_thinking, + model=model, + ) + + # 如果提供了项目上下文,更新 context + if project_context: + self.context = project_context + # 重新构建 system prompt + self.system_prompt = self._build_system_prompt() + + def _build_system_prompt(self) -> str: + # 基础 prompt + base_prompt = """You are the AI Producer & Head Writer for a creative studio. +The User is the Director. Your goal is to help the Director create high-quality series content. + +## Your Role +- **Proactive Partner**: Don't just wait for orders. Propose plans, spot issues, and suggest improvements. +- **Structured Executor**: For any complex task (like "Write Episode 1"), you MUST first create a Plan using `update_plan`. +- **Transparent**: Always keep the Director informed of your status via the plan and inbox. + +## Workflow Protocols + +1. **Planning (Mandatory for new tasks)** + - When receiving a high-level goal (e.g., "Write Scene 1"), break it down into steps. + - Use `update_plan(steps=[...], current_step=0, status='planning')`. + +2. **Execution & Writing** + - Use `write_file` to generate content. + - Use `update_canvas` (or write to the active file) to show progress. + - Update your plan status as you progress: `update_plan(..., status='writing')`. + +3. **Review & Approval** + - NEVER mark a major deliverable as "Final" without Director approval. + - Use `add_inbox_task` to submit drafts or questions to the Director. + - Example: `add_inbox_task(title="Review Episode 1 Outline", type="review", ...)` + +4. **Context & Memory** + - If the story state changes (e.g., location change, character injury), use `update_context`. + - If you spot consistency issues, use `add_annotation` on the canvas. + +## Interaction Style +- Be professional, creative, and concise. +- Use the Director's language (Chinese/English) matching their input. +- When you are working, keep the plan updated. +""" + + # 添加项目上下文(如果有) + if self.context and self.context.project_name: + base_prompt += "\n\n## Project Context\n\n" + base_prompt += f"**Project**: {self.context.project_name}\n" + + if self.context.project_genre: + base_prompt += f"**Genre**: {self.context.project_genre}\n" + + if self.context.total_episodes: + base_prompt += f"**Total Episodes**: {self.context.total_episodes}\n" + + base_prompt += "\n### Global Settings\n\n" + + if self.context.world_setting: + base_prompt += f"**World Setting**:\n{self.context.world_setting}\n\n" + + if self.context.characters: + base_prompt += f"**Characters**:\n{self.context.characters}\n\n" + + if self.context.overall_outline: + base_prompt += f"**Overall Outline**:\n{self.context.overall_outline}\n\n" + + if self.context.source_content: + mode = "剧本改编" if self.context.creation_mode == "script" else "创意灵感" + base_prompt += f"**Source** ({mode}):\n{self.context.source_content[:1000]}...\n\n" + + # 构建 skills prompt + skills_prompt = self.skill_loader.build_system_prompt("") + + # 添加用户配置的 skills + if self.context and self.context.user_skills: + skills_prompt += "\n\n### User Configured Skills\n\n" + skills_prompt += "The Director has configured these specific skills for this project:\n\n" + for skill in self.context.user_skills: + skills_prompt += f"- **{skill.get('name', 'Unknown')}**: {skill.get('behavior', 'No description')}\n" + skills_prompt += "\nUse `load_skill` to load detailed instructions for these skills when relevant.\n" + + return base_prompt + "\n\n" + skills_prompt diff --git a/backend/app/core/agent_runtime/director_tools.py b/backend/app/core/agent_runtime/director_tools.py new file mode 100644 index 0000000..a38b318 --- /dev/null +++ b/backend/app/core/agent_runtime/director_tools.py @@ -0,0 +1,475 @@ +""" +Director Agent Tools +用于 Agent-Native 协作模式的工具集 + +结合 skills-agent-proto 的设计模式,使用 ToolRuntime 访问运行时状态 +""" +from langchain.tools import tool, ToolRuntime +from .context import SkillAgentContext +from typing import List, Optional, Dict, Any +import json + +# Director 专用的状态键 +PLAN_STATE_KEY = "director_plan" +INBOX_STATE_KEY = "director_inbox" +ANNOTATION_STATE_KEY = "director_annotations" +CONTEXT_STATE_KEY = "director_context" + + +@tool +def update_plan( + steps: List[str], + current_step_index: int = 0, + status: str = 'planning', + runtime: ToolRuntime[SkillAgentContext] = None +) -> str: + """ + Update the current execution plan displayed to the user. + + This tool updates the director's plan which will be sent to the frontend + via WebSocket events. The plan shows the user what steps the agent is + working on and the current progress. + + Args: + steps: List of plan steps (e.g. ["Analyze context", "Create outline", "Write draft"]) + current_step_index: Index of the current step (0-based) + status: Current status ('planning', 'writing', 'reviewing', 'idle') + + Returns: + Confirmation message with plan details + + Example: + update_plan(steps=["Research", "Outline", "Write"], current_step_index=0, status="planning") + """ + if runtime is None: + return "Error: runtime context not available" + + # 存储计划状态到 runtime.state + plan_data = { + "steps": steps, + "current_step_index": current_step_index, + "status": status + } + runtime.state[PLAN_STATE_KEY] = plan_data + + step_info = "" + if steps and current_step_index < len(steps): + current_step = steps[current_step_index] + step_info = f"Current: [{current_step_index + 1}/{len(steps)}] {current_step}" + + return f"""✓ Plan updated with {len(steps)} steps +Status: {status} +{step_info} + +Steps: +{chr(10).join(f'{i+1}. {step}' for i, step in enumerate(steps))}""" + + +@tool +def add_inbox_task( + title: str, + description: str, + task_type: str = 'review', + options: Optional[List[str]] = None, + runtime: ToolRuntime[SkillAgentContext] = None +) -> str: + """ + Add a task to the Director's Inbox for human review/decision. + + This tool creates a task that will appear in the user's inbox, allowing + for human-in-the-loop collaboration. The user can approve, reject, or + provide feedback on the task. + + Args: + title: Short title of the task (e.g. "Review Episode 1 Outline") + description: Detailed description or question for the user + task_type: Type of task ('review', 'decision', 'notification') + options: List of available options for decision (e.g. ["Approve", "Reject"]) + + Returns: + Confirmation message with task details + + Example: + add_inbox_task( + title="Review Character Arc", + description="Does this character development make sense?", + task_type="review", + options=["Approve", "Request Changes"] + ) + """ + if runtime is None: + return "Error: runtime context not available" + + # 确保 inbox 列表存在 + if INBOX_STATE_KEY not in runtime.state: + runtime.state[INBOX_STATE_KEY] = [] + + # 创建任务 + task = { + "id": f"task_{len(runtime.state[INBOX_STATE_KEY])}_{hash(title) % 10000}", + "title": title, + "description": description, + "type": task_type, + "options": options or ["Approve", "Reject"], + "timestamp": None # Will be set by WebSocket handler + } + + # 添加到 inbox + runtime.state[INBOX_STATE_KEY].append(task) + + return f"""✓ Task added to Director's Inbox + +Title: {title} +Type: {task_type} +Description: {description[:100]}{'...' if len(description) > 100 else ''} + +Waiting for director's response...""" + + +@tool +def add_annotation( + content: str, + annotation_type: str = 'review', + suggestion: str = '', + runtime: ToolRuntime[SkillAgentContext] = None +) -> str: + """ + Add an annotation/comment to the canvas for the user to see. + + This tool creates annotations that appear on the content canvas, + highlighting issues or providing feedback without interrupting + the agent's workflow. + + Args: + content: The text content being annotated or description of issue + annotation_type: Type of annotation ('consistency', 'grammar', 'style', 'plot', 'review') + suggestion: Suggested fix or improvement + + Returns: + Confirmation message with annotation details + + Example: + add_annotation( + content="Character name changed from 'John' to 'Jon'", + annotation_type="consistency", + suggestion="Use consistent spelling throughout" + ) + """ + if runtime is None: + return "Error: runtime context not available" + + # 确保 annotations 列表存在 + if ANNOTATION_STATE_KEY not in runtime.state: + runtime.state[ANNOTATION_STATE_KEY] = [] + + # 创建批注 + annotation = { + "content": content, + "type": annotation_type, + "suggestion": suggestion, + "timestamp": None # Will be set by WebSocket handler + } + + # 添加到列表 + runtime.state[ANNOTATION_STATE_KEY].append(annotation) + + return f"""✓ Annotation added to canvas + +Type: {annotation_type} +Content: {content[:100]}{'...' if len(content) > 100 else ''} +{suggestion and f"Suggestion: {suggestion}" or ""}""" + + +@tool +def update_context( + context_type: str, + data: Any = None, + runtime: ToolRuntime[SkillAgentContext] = None +) -> str: + """ + Update the dynamic context panel (e.g. world state, character status). + + This tool updates the context panel shown in the left sidebar, keeping + the user informed of the current story state, character conditions, + and other dynamic information. + + Args: + context_type: Type of context to update ('world', 'character', 'state', 'all') + data: Data to update (can be string, dict, or list of state objects) + + Returns: + Confirmation message with context details + + Example: + update_context( + context_type="state", + data=[{"type": "location", "value": "Throne Room"}, {"type": "time", "value": "Evening"}] + ) + """ + if runtime is None: + return "Error: runtime context not available" + + # 处理不同类型的数据输入 + if isinstance(data, str): + try: + data = json.loads(data) + except json.JSONDecodeError: + # 如果不是 JSON,当作简单的键值对 + data = [{"type": context_type, "value": data}] + + # 确保 context 状态存在 + if CONTEXT_STATE_KEY not in runtime.state: + runtime.state[CONTEXT_STATE_KEY] = {} + + # 更新上下文 + if context_type == 'all' and isinstance(data, list): + # 更新所有状态 + runtime.state[CONTEXT_STATE_KEY] = {item['type']: item['value'] for item in data} + elif isinstance(data, list): + # 更新特定类型的状态 + for item in data: + if isinstance(item, dict) and 'type' in item and 'value' in item: + runtime.state[CONTEXT_STATE_KEY][item['type']] = item['value'] + elif isinstance(data, dict): + # 直接更新字典 + runtime.state[CONTEXT_STATE_KEY].update(data) + + # 生成状态摘要 + states = runtime.state[CONTEXT_STATE_KEY] + state_summary = '\n'.join(f' • {k}: {v}' for k, v in states.items()) + + return f"""✓ Context updated + +Type: {context_type} +Current states: +{state_summary}""" + + +@tool +def write_to_canvas( + content: str, + append: bool = True, + runtime: ToolRuntime[SkillAgentContext] = None +) -> str: + """ + Write content directly to the canvas (main content area). + + This tool updates the main content canvas with new text. It can either + append to existing content or replace it entirely. + + Args: + content: The text content to write to the canvas + append: If True, append to existing content; if False, replace entirely + + Returns: + Confirmation with content length info + + Example: + write_to_canvas(content="# Episode 1\\n\\nOnce upon a time...", append=False) + """ + if runtime is None: + return "Error: runtime context not available" + + # 获取当前内容 + current_content = runtime.state.get("canvas_content", "") + + if append: + new_content = current_content + content + else: + new_content = content + + # 更新状态 + runtime.state["canvas_content"] = new_content + + lines = len(content.split('\n')) + action = "appended to" if append else "written to" + return f"✓ Content {action} canvas ({lines} lines, {len(content)} characters)" + + +@tool +def save_episode( + episode_number: int, + title: str, + content: str, + outline: Optional[str] = None, + runtime: ToolRuntime[SkillAgentContext] = None +) -> str: + """ + Save episode content to the database. + + This tool saves the episode content to the project database, + storing the outline, full content, and metadata. + + Args: + episode_number: Episode number (e.g., 1, 2, 3...) + title: Episode title + content: Full episode script/content + outline: Episode outline (optional) + + Returns: + Confirmation message with episode details + + Example: + save_episode( + episode_number=1, + title="The Beginning", + content="Once upon a time...", + outline="Introduction to the main characters" + ) + """ + if runtime is None: + return "Error: runtime context not available" + + # 获取项目 ID + project_id = runtime.context.project_id if runtime.context else None + if not project_id: + return "Error: No project ID in context" + + # 存储到运行时状态(由 WebSocket 处理器保存到数据库) + if "episodes_to_save" not in runtime.state: + runtime.state["episodes_to_save"] = [] + + episode_data = { + "number": episode_number, + "title": title, + "content": content, + "outline": outline, + "status": "completed" + } + runtime.state["episodes_to_save"].append(episode_data) + + return f"""✓ Episode {episode_number} saved + +Title: {title} +Content length: {len(content)} characters +{f"Outline: {outline[:50]}..." if outline else ""} + +The episode has been queued for saving to the database.""" + + +@tool +def update_memory( + memory_type: str, + data: Any, + runtime: ToolRuntime[SkillAgentContext] = None +) -> str: + """ + Update the story memory system. + + This tool updates various aspects of the story memory including + timeline events, character states, pending threads, and foreshadowing. + + Args: + memory_type: Type of memory to update ('timeline', 'character_state', 'pending_thread', 'foreshadowing') + data: Data to update (format depends on memory_type) + + Returns: + Confirmation message with memory details + + Example: + update_memory( + memory_type="character_state", + data={"character": "Alice", "state": "injured", "description": "Leg injury from fall"} + ) + """ + if runtime is None: + return "Error: runtime context not available" + + # 存储到运行时状态(由 WebSocket 处理器保存到数据库) + if "memory_updates" not in runtime.state: + runtime.state["memory_updates"] = [] + + memory_update = { + "type": memory_type, + "data": data + } + runtime.state["memory_updates"].append(memory_update) + + # 格式化输出 + if memory_type == "timeline": + return f"✓ Timeline event added: {data.get('event', 'Unknown event')}" + elif memory_type == "character_state": + return f"✓ Character state updated: {data.get('character', 'Unknown')} - {data.get('state', 'Unknown state')}" + elif memory_type == "pending_thread": + return f"✓ Pending thread added: {data.get('description', 'Unknown thread')}" + elif memory_type == "foreshadowing": + return f"✓ Foreshadowing added: {data.get('description', 'Unknown foreshadowing')}" + else: + return f"✓ Memory updated: {memory_type}" + + +@tool +def request_review( + content: str, + review_type: str = 'quality', + criteria: Optional[List[str]] = None, + runtime: ToolRuntime[SkillAgentContext] = None +) -> str: + """ + Request a content review from the Director. + + This tool creates a review request that will be sent to the Director + for approval. It can be used for quality checks, consistency reviews, + or other types of content validation. + + Args: + content: The content to review (or description of what to review) + review_type: Type of review ('quality', 'consistency', 'grammar', 'plot') + criteria: Specific criteria to check (optional) + + Returns: + Confirmation message with review request details + + Example: + request_review( + content="Episode 1 dialogue between Alice and Bob", + review_type="quality", + criteria=["Character voice consistency", "Dialogue naturalness"] + ) + """ + if runtime is None: + return "Error: runtime context not available" + + # 确保 inbox 列表存在 + if INBOX_STATE_KEY not in runtime.state: + runtime.state[INBOX_STATE_KEY] = [] + + # 创建审核任务 + task = { + "id": f"review_{len(runtime.state[INBOX_STATE_KEY])}_{hash(content) % 10000}", + "title": f"{review_type.capitalize()} Review Required", + "description": f"Please review the following content:\n\n{content[:500]}{'...' if len(content) > 500 else ''}", + "type": "review", + "review_type": review_type, + "criteria": criteria or [], + "options": ["Approve", "Request Changes", "Skip"], + "timestamp": None + } + + # 添加到 inbox + runtime.state[INBOX_STATE_KEY].append(task) + + criteria_text = "\n".join(f" • {c}" for c in (criteria or [])) + criteria_section = "" + if criteria_text: + criteria_section = f"Criteria:\n{criteria_text}\n" + + return f"""✓ Review request created + +Type: {review_type} +Content: {content[:100]}{'...' if len(content) > 100 else ''} +{criteria_section} +Waiting for director's review...""" + + +# 导出工具列表 +DIRECTOR_TOOLS = [ + update_plan, + add_inbox_task, + add_annotation, + update_context, + write_to_canvas, + save_episode, + update_memory, + request_review +] diff --git a/backend/app/core/agent_runtime/skill_loader.py b/backend/app/core/agent_runtime/skill_loader.py new file mode 100644 index 0000000..5b1198a --- /dev/null +++ b/backend/app/core/agent_runtime/skill_loader.py @@ -0,0 +1,153 @@ +""" +Skills 发现和加载器 +""" + +import re +from pathlib import Path +from typing import Optional +from dataclasses import dataclass + +import yaml + + +# 默认 Skills 搜索路径(项目级优先,用户级兜底) +DEFAULT_SKILL_PATHS = [ + Path.cwd() / ".claude" / "skills", + Path.home() / ".claude" / "skills", +] + + +@dataclass +class SkillMetadata: + """Skill 元数据(Level 1)""" + name: str + description: str + skill_path: Path + + def to_prompt_line(self) -> str: + return f"- **{self.name}**: {self.description}" + + +@dataclass +class SkillContent: + """Skill 完整内容(Level 2)""" + metadata: SkillMetadata + instructions: str + + +class SkillLoader: + """Skills 加载器""" + + def __init__(self, skill_paths: list[Path] | None = None): + self.skill_paths = skill_paths or DEFAULT_SKILL_PATHS + self._metadata_cache: dict[str, SkillMetadata] = {} + + def scan_skills(self) -> list[SkillMetadata]: + """Level 1: 扫描所有 Skills 元数据""" + skills = [] + seen_names = set() + + for base_path in self.skill_paths: + if not base_path.exists(): + continue + + for skill_dir in base_path.iterdir(): + if not skill_dir.is_dir(): + continue + + skill_md = skill_dir / "SKILL.md" + if not skill_md.exists(): + continue + + metadata = self._parse_skill_metadata(skill_md) + if metadata and metadata.name not in seen_names: + skills.append(metadata) + seen_names.add(metadata.name) + self._metadata_cache[metadata.name] = metadata + + return skills + + def _parse_skill_metadata(self, skill_md_path: Path) -> Optional[SkillMetadata]: + try: + content = skill_md_path.read_text(encoding="utf-8") + except Exception: + return None + + frontmatter_match = re.match( + r'^---\s*\n(.*?)\n---\s*\n', + content, + re.DOTALL + ) + + if not frontmatter_match: + return None + + try: + frontmatter = yaml.safe_load(frontmatter_match.group(1)) + name = frontmatter.get("name", "") + description = frontmatter.get("description", "") + + if not name: + return None + + return SkillMetadata( + name=name, + description=description, + skill_path=skill_md_path.parent, + ) + except yaml.YAMLError: + return None + + def load_skill(self, skill_name: str) -> Optional[SkillContent]: + """Level 2: 加载 Skill 完整内容""" + metadata = self._metadata_cache.get(skill_name) + if not metadata: + self.scan_skills() + metadata = self._metadata_cache.get(skill_name) + + if not metadata: + return None + + skill_md = metadata.skill_path / "SKILL.md" + try: + content = skill_md.read_text(encoding="utf-8") + except Exception: + return None + + body_match = re.match( + r'^---\s*\n.*?\n---\s*\n(.*)$', + content, + re.DOTALL + ) + instructions = body_match.group(1).strip() if body_match else content + + return SkillContent( + metadata=metadata, + instructions=instructions, + ) + + def build_system_prompt(self, base_prompt: str = "") -> str: + """构建包含 Skills 列表的 system prompt""" + skills = self.scan_skills() + + if skills: + skills_section = "## Available Skills\n\n" + skills_section += "You have access to the following specialized skills:\n\n" + for skill in skills: + skills_section += skill.to_prompt_line() + "\n" + skills_section += "\n" + skills_section += "### How to Use Skills\n\n" + skills_section += "1. **Discover**: Review the skills list above\n" + skills_section += "2. **Load**: When a user request matches a skill's description, " + skills_section += "use `load_skill(skill_name)` to get detailed instructions\n" + skills_section += "3. **Execute**: Follow the skill's instructions, which may include " + skills_section += "running scripts via `bash`\n\n" + skills_section += "**Important**: Only load a skill when it's relevant to the user's request. " + skills_section += "Script code never enters the context - only their output does.\n" + else: + skills_section = "## Skills\n\nNo skills currently available.\n" + + if base_prompt: + return f"{base_prompt}\n\n{skills_section}" + else: + return f"You are a helpful coding assistant.\n\n{skills_section}" diff --git a/backend/app/core/agent_runtime/stream/emitter.py b/backend/app/core/agent_runtime/stream/emitter.py new file mode 100644 index 0000000..608a2fc --- /dev/null +++ b/backend/app/core/agent_runtime/stream/emitter.py @@ -0,0 +1,52 @@ +""" +StreamEventEmitter - 统一事件格式 +""" + +from dataclasses import dataclass +from typing import Any, Dict + + +@dataclass +class StreamEvent: + """统一的流式事件""" + type: str + data: Dict[str, Any] + + +class StreamEventEmitter: + """流式事件发射器""" + + @staticmethod + def thinking(content: str, thinking_id: int = 0) -> StreamEvent: + """思考内容事件""" + return StreamEvent("thinking", {"type": "thinking", "content": content, "id": thinking_id}) + + @staticmethod + def text(content: str) -> StreamEvent: + """文本内容事件""" + return StreamEvent("text", {"type": "text", "content": content}) + + @staticmethod + def tool_call(name: str, args: Dict[str, Any], tool_id: str = "") -> StreamEvent: + """工具调用事件""" + return StreamEvent("tool_call", {"type": "tool_call", "name": name, "args": args, "id": tool_id}) + + @staticmethod + def tool_result(name: str, content: str, success: bool = True) -> StreamEvent: + """工具结果事件""" + return StreamEvent("tool_result", { + "type": "tool_result", + "name": name, + "content": content, + "success": success, + }) + + @staticmethod + def done(response: str = "") -> StreamEvent: + """完成事件""" + return StreamEvent("done", {"type": "done", "response": response}) + + @staticmethod + def error(message: str) -> StreamEvent: + """错误事件""" + return StreamEvent("error", {"type": "error", "message": message}) diff --git a/backend/app/core/agent_runtime/stream/tracker.py b/backend/app/core/agent_runtime/stream/tracker.py new file mode 100644 index 0000000..cbf8f40 --- /dev/null +++ b/backend/app/core/agent_runtime/stream/tracker.py @@ -0,0 +1,103 @@ +""" +ToolCallTracker - 工具调用追踪器 +""" + +import json +from dataclasses import dataclass, field +from typing import Dict, Optional + + +@dataclass +class ToolCallInfo: + """工具调用信息""" + id: str + name: str + args: Dict = field(default_factory=dict) + emitted: bool = False + args_complete: bool = False + _json_buffer: str = "" + + +class ToolCallTracker: + """工具调用追踪器""" + + def __init__(self): + self._calls: Dict[str, ToolCallInfo] = {} + self._last_tool_id: Optional[str] = None + + def update( + self, + tool_id: str, + name: Optional[str] = None, + args: Optional[Dict] = None, + args_complete: bool = False, + ) -> None: + """更新工具调用信息""" + if tool_id not in self._calls: + self._calls[tool_id] = ToolCallInfo( + id=tool_id, + name=name or "", + args=args or {}, + args_complete=args_complete, + ) + self._last_tool_id = tool_id + else: + info = self._calls[tool_id] + if name: + info.name = name + if args: + info.args = args + if args_complete: + info.args_complete = True + + def append_json_delta(self, partial_json: str, index: int = 0) -> None: + """累积 input_json_delta 片段""" + tool_id = self._last_tool_id + if tool_id and tool_id in self._calls: + self._calls[tool_id]._json_buffer += partial_json + + def finalize_all(self) -> None: + """最终化所有工具调用""" + for info in self._calls.values(): + if info._json_buffer: + try: + info.args = json.loads(info._json_buffer) + except json.JSONDecodeError: + pass + info._json_buffer = "" + info.args_complete = True + + def is_ready(self, tool_id: str) -> bool: + """检查工具调用是否准备好发送""" + if tool_id not in self._calls: + return False + info = self._calls[tool_id] + return bool(info.name) and not info.emitted + + def get_all(self) -> list[ToolCallInfo]: + """获取所有工具调用""" + return list(self._calls.values()) + + def mark_emitted(self, tool_id: str) -> None: + """标记已发送""" + if tool_id in self._calls: + self._calls[tool_id].emitted = True + + def get(self, tool_id: str) -> Optional[ToolCallInfo]: + """获取工具调用信息""" + return self._calls.get(tool_id) + + def get_pending(self) -> list[ToolCallInfo]: + """获取所有未发送的工具调用""" + return [info for info in self._calls.values() if not info.emitted] + + def emit_all_pending(self) -> list[ToolCallInfo]: + """发送所有待处理的工具调用并标记""" + pending = self.get_pending() + for info in pending: + info.emitted = True + return pending + + def clear(self) -> None: + """清空追踪器""" + self._calls.clear() diff --git a/backend/app/core/agent_runtime/stream/utils.py b/backend/app/core/agent_runtime/stream/utils.py new file mode 100644 index 0000000..8461d72 --- /dev/null +++ b/backend/app/core/agent_runtime/stream/utils.py @@ -0,0 +1,204 @@ +""" +Stream 工具函数和常量 +""" + +import sys +from pathlib import Path, PurePath +from enum import Enum + + +# === 状态标记常量 === +SUCCESS_PREFIX = "[OK]" +FAILURE_PREFIX = "[FAILED]" + + +# === 工具状态指示器 === +class ToolStatus(str, Enum): + """工具执行状态指示器""" + RUNNING = "●" # 执行中 - 黄色 + SUCCESS = "●" # 成功 - 绿色 + ERROR = "●" # 失败 - 红色 + PENDING = "○" # 等待 - 灰色 + + +def get_status_symbol(status: ToolStatus) -> str: + """获取状态符号""" + try: + supports_unicode = ( + sys.stdout.encoding + and 'utf' in sys.stdout.encoding.lower() + ) + except Exception: + supports_unicode = False + + if supports_unicode: + return status.value + + fallback = { + ToolStatus.RUNNING: "*", + ToolStatus.SUCCESS: "+", + ToolStatus.ERROR: "x", + ToolStatus.PENDING: "-", + } + return fallback.get(status, "?") + + +# === 显示限制常量 === +class DisplayLimits: + """显示相关的长度限制""" + THINKING_STREAM = 1000 # 流式显示时的 thinking 长度 + THINKING_FINAL = 2000 # 最终显示时的 thinking 长度 + ARGS_INLINE = 100 # 内联显示的参数长度 + ARGS_FORMATTED = 300 # 格式化显示的参数长度 + TOOL_RESULT_STREAM = 500 # 流式显示时的工具结果长度 + TOOL_RESULT_FINAL = 800 # 最终显示时的工具结果长度 + TOOL_RESULT_MAX = 2000 # 工具结果最大长度 + + +def has_args(args) -> bool: + """检查 args 是否有内容""" + return args is not None and args != {} + + +def is_success(content: str) -> bool: + """判断工具输出是否表示成功执行""" + content = content.strip() + if content.startswith(SUCCESS_PREFIX): + return True + if content.startswith(FAILURE_PREFIX): + return False + error_patterns = [ + 'Traceback (most recent call last)', + 'Exception:', + 'Error:', + ] + return not any(pattern in content for pattern in error_patterns) + + +def resolve_path(file_path: str, working_directory: Path) -> Path: + """解析文件路径""" + path = Path(file_path).expanduser() + if not path.is_absolute(): + path = working_directory / path + return path + + +def truncate(content: str, max_length: int, suffix: str = "\n... (truncated)") -> str: + """截断内容""" + if len(content) > max_length: + return content[:max_length] + suffix + return content + + +def format_tool_compact(name: str, args: dict | None) -> str: + """格式化为紧凑格式""" + if not args: + return f"{name}()" + + name_lower = name.lower() + + if name_lower == "bash": + cmd = args.get("command", "") + if len(cmd) > 50: + cmd = cmd[:47] + "..." + return f"Bash({cmd})" + + elif name_lower == "read": + path = args.get("file_path", "") + if len(path) > 40: + path_obj = PurePath(path) + parts = path_obj.parts + if len(parts) > 2: + path = ".../" + "/".join(parts[-2:]) + return f"Read({path})" + + elif name_lower == "write": + path = args.get("file_path", "") + if len(path) > 40: + path_obj = PurePath(path) + parts = path_obj.parts + if len(parts) > 2: + path = ".../" + "/".join(parts[-2:]) + return f"Write({path})" + + elif name_lower == "edit": + path = args.get("file_path", "") + if len(path) > 40: + path_obj = PurePath(path) + parts = path_obj.parts + if len(parts) > 2: + path = ".../" + "/".join(parts[-2:]) + return f"Edit({path})" + + elif name_lower == "glob": + pattern = args.get("pattern", "") + if len(pattern) > 40: + pattern = pattern[:37] + "..." + return f"Glob({pattern})" + + elif name_lower == "grep": + pattern = args.get("pattern", "") + path = args.get("path", ".") + if len(pattern) > 30: + pattern = pattern[:27] + "..." + return f"Grep({pattern}, {path})" + + elif name_lower == "list_dir": + path = args.get("path", ".") + return f"ListDir({path})" + + elif name_lower == "load_skill": + skill_name = args.get("skill_name", "") + return f"load_skill({skill_name})" + + params = [] + for k, v in list(args.items())[:2]: + v_str = str(v) + if len(v_str) > 20: + v_str = v_str[:17] + "..." + params.append(f"{k}={v_str}") + + params_str = ", ".join(params) + if len(params_str) > 50: + params_str = params_str[:47] + "..." + + return f"{name}({params_str})" + + +def format_tree_output(lines: list[str], max_lines: int = 5, indent: str = " ") -> str: + """将输出格式化为树形结构""" + if not lines: + return "" + + result = [] + display_lines = lines[:max_lines] + + for i, line in enumerate(display_lines): + prefix = "└" if i == 0 else " " + result.append(f"{indent}{prefix} {line}") + + remaining = len(lines) - max_lines + if remaining > 0: + result.append(f"{indent} ... +{remaining} lines") + + return "\n".join(result) + + +def count_lines(content: str) -> int: + """统计内容行数""" + if not content: + return 0 + return len(content.strip().split("\n")) + + +def truncate_with_line_hint(content: str, max_lines: int = 5) -> tuple[str, int]: + """按行数截断内容""" + lines = content.strip().split("\n") + total = len(lines) + + if total <= max_lines: + return content.strip(), 0 + + truncated = "\n".join(lines[:max_lines]) + remaining = total - max_lines + return truncated, remaining diff --git a/backend/app/core/agent_runtime/tools.py b/backend/app/core/agent_runtime/tools.py new file mode 100644 index 0000000..beb3d24 --- /dev/null +++ b/backend/app/core/agent_runtime/tools.py @@ -0,0 +1,296 @@ +""" +LangChain Tools 定义 +""" + +import subprocess +import re +from pathlib import Path + +from langchain.tools import tool, ToolRuntime + +from .skill_loader import SkillLoader +from .stream.utils import resolve_path +from .context import SkillAgentContext +from .director_tools import DIRECTOR_TOOLS + + +@tool +def load_skill(skill_name: str, runtime: ToolRuntime[SkillAgentContext]) -> str: + """Load a skill's detailed instructions.""" + loader = runtime.context.skill_loader + skill_content = loader.load_skill(skill_name) + + if not skill_content: + skills = loader.scan_skills() + if skills: + available = [s.name for s in skills] + return f"Skill '{skill_name}' not found. Available skills: {', '.join(available)}" + else: + return f"Skill '{skill_name}' not found. No skills are currently available." + + skill_path = skill_content.metadata.skill_path + scripts_dir = skill_path / "scripts" + + path_info = f""" +## Skill Path Info + +- **Skill Directory**: `{skill_path}` +- **Scripts Directory**: `{scripts_dir}` + +**Important**: When running scripts, use absolute paths like: +```bash +uv run {scripts_dir}/script_name.py [args] +``` +""" + + return f"""# Skill: {skill_name} + +## Instructions + +{skill_content.instructions} +{path_info} +""" + + +@tool +def bash(command: str, runtime: ToolRuntime[SkillAgentContext]) -> str: + """Execute a shell command (bash on Unix/macOS, cmd.exe on Windows).""" + cwd = str(runtime.context.working_directory) + + try: + result = subprocess.run( + command, + shell=True, + cwd=cwd, + capture_output=True, + text=True, + timeout=300, + ) + + parts = [] + if result.returncode == 0: + parts.append("[OK]") + else: + parts.append(f"[FAILED] Exit code: {result.returncode}") + + parts.append("") + + if result.stdout: + parts.append(result.stdout.rstrip()) + + if result.stderr: + if result.stdout: + parts.append("") + parts.append("--- stderr ---") + parts.append(result.stderr.rstrip()) + + if not result.stdout and not result.stderr: + parts.append("(no output)") + + return "\n".join(parts) + + except subprocess.TimeoutExpired: + return "[FAILED] Command timed out after 300 seconds." + except Exception as e: + return f"[FAILED] {str(e)}" + + +@tool +def read_file(file_path: str, runtime: ToolRuntime[SkillAgentContext]) -> str: + """Read the contents of a file.""" + path = resolve_path(file_path, runtime.context.working_directory) + + if not path.exists(): + return f"[Error] File not found: {file_path}" + + if not path.is_file(): + return f"[Error] Not a file: {file_path}" + + try: + content = path.read_text(encoding="utf-8") + lines = content.split("\n") + numbered_lines = [] + for i, line in enumerate(lines[:2000], 1): + numbered_lines.append(f"{i:4d}| {line}") + + if len(lines) > 2000: + numbered_lines.append(f"... ({len(lines) - 2000} more lines)") + + return "\n".join(numbered_lines) + + except Exception as e: + return f"[Error] Failed to read file: {str(e)}" + + +@tool +def write_file(file_path: str, content: str, runtime: ToolRuntime[SkillAgentContext]) -> str: + """Write content to a file.""" + path = resolve_path(file_path, runtime.context.working_directory) + + try: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content, encoding="utf-8") + return f"[Success] File written: {path}" + + except Exception as e: + return f"[Error] Failed to write file: {str(e)}" + + +@tool +def glob(pattern: str, runtime: ToolRuntime[SkillAgentContext]) -> str: + """Find files matching a glob pattern.""" + cwd = runtime.context.working_directory + try: + matches = sorted(cwd.glob(pattern)) + if not matches: + return f"No files matching pattern: {pattern}" + + max_results = 100 + result_lines = [] + for path in matches[:max_results]: + try: + rel_path = path.relative_to(cwd) + result_lines.append(str(rel_path)) + except ValueError: + result_lines.append(str(path)) + + result = "\n".join(result_lines) + if len(matches) > max_results: + result += f"\n... and {len(matches) - max_results} more files" + return f"[OK]\n\n{result}" + except Exception as e: + return f"[FAILED] {str(e)}" + + +@tool +def grep(pattern: str, path: str, runtime: ToolRuntime[SkillAgentContext]) -> str: + """Search for a pattern in files.""" + cwd = runtime.context.working_directory + search_path = resolve_path(path, cwd) + + try: + regex = re.compile(pattern) + except re.error as e: + return f"[FAILED] Invalid regex pattern: {e}" + + results = [] + max_results = 50 + files_searched = 0 + + try: + if search_path.is_file(): + files = [search_path] + else: + files = [] + for p in search_path.rglob("*"): + if p.is_file(): + parts = p.parts + if any(part.startswith(".") or part in ("node_modules", "__pycache__", ".git", "venv", ".venv") for part in parts): + continue + files.append(p) + + for file_path in files: + if len(results) >= max_results: + break + try: + content = file_path.read_text(encoding="utf-8", errors="ignore") + lines = content.split("\n") + files_searched += 1 + + for line_num, line in enumerate(lines, 1): + if regex.search(line): + try: + rel_path = file_path.relative_to(cwd) + except ValueError: + rel_path = file_path + results.append(f"{rel_path}:{line_num}: {line.strip()[:100]}") + if len(results) >= max_results: + break + except Exception: + continue + + if not results: + return f"No matches found for pattern: {pattern} (searched {files_searched} files)" + + output = "\n".join(results) + if len(results) >= max_results: + output += f"\n... (truncated, showing first {max_results} matches)" + return f"[OK]\n\n{output}" + + except Exception as e: + return f"[FAILED] {str(e)}" + + +@tool +def edit( + file_path: str, + old_string: str, + new_string: str, + runtime: ToolRuntime[SkillAgentContext] +) -> str: + """Edit a file by replacing text.""" + path = resolve_path(file_path, runtime.context.working_directory) + + if not path.exists(): + return f"[FAILED] File not found: {file_path}" + if not path.is_file(): + return f"[FAILED] Not a file: {file_path}" + + try: + content = path.read_text(encoding="utf-8") + count = content.count(old_string) + if count == 0: + return f"[FAILED] String not found in file." + if count > 1: + return f"[FAILED] String appears {count} times in file." + + new_content = content.replace(old_string, new_string, 1) + path.write_text(new_content, encoding="utf-8") + old_lines = len(old_string.split("\n")) + new_lines = len(new_string.split("\n")) + return f"[OK]\n\nEdited {path.name}: replaced {old_lines} lines with {new_lines} lines" + + except Exception as e: + return f"[FAILED] {str(e)}" + + +@tool +def list_dir(path: str, runtime: ToolRuntime[SkillAgentContext]) -> str: + """List contents of a directory.""" + dir_path = resolve_path(path, runtime.context.working_directory) + + if not dir_path.exists(): + return f"[FAILED] Directory not found: {path}" + if not dir_path.is_dir(): + return f"[FAILED] Not a directory: {path}" + + try: + entries = sorted(dir_path.iterdir(), key=lambda p: (not p.is_dir(), p.name.lower())) + result_lines = [] + for entry in entries[:100]: + if entry.is_dir(): + result_lines.append(f"📁 {entry.name}/") + else: + size = entry.stat().st_size + if size < 1024: + size_str = f"{size}B" + elif size < 1024 * 1024: + size_str = f"{size // 1024}KB" + else: + size_str = f"{size // (1024 * 1024)}MB" + result_lines.append(f" {entry.name} ({size_str})") + + if len(entries) > 100: + result_lines.append(f"... and {len(entries) - 100} more entries") + + return f"[OK]\n\n{chr(10).join(result_lines)}" + + except Exception as e: + return f"[FAILED] {str(e)}" + + +ALL_TOOLS = [ + load_skill, bash, read_file, write_file, glob, grep, edit, list_dir, + # Director Tools (includes update_plan, add_inbox_task, add_annotation, update_context, write_to_canvas) + *DIRECTOR_TOOLS +] diff --git a/backend/app/core/agents/skills_agent_adapter_example.py b/backend/app/core/agents/skills_agent_adapter_example.py new file mode 100644 index 0000000..c11a292 --- /dev/null +++ b/backend/app/core/agents/skills_agent_adapter_example.py @@ -0,0 +1,67 @@ +from typing import AsyncIterator, Optional, List +from pathlib import Path +from langchain_core.messages import BaseMessage + +from app.core.skills.skill_manager import skill_manager +from app.core.agent_runtime.agent import LangChainSkillsAgent # 假设移植后的路径 + +class CreativeStudioAgent: + """ + Creative Studio 的 Agent 适配器 + 将 LangChainSkillsAgent 集成到现有的后端架构中 + """ + + def __init__(self, working_dir: str): + self.working_dir = Path(working_dir) + # 初始化底层的 LangChainSkillsAgent + self.agent = LangChainSkillsAgent( + working_directory=self.working_dir, + enable_thinking=True + ) + + # 注入 SkillManager 的能力 + # 注意:这里我们覆盖默认的 skill_loader,改用 skill_manager + self._inject_skills_from_manager() + + def _inject_skills_from_manager(self): + """ + 从 SkillManager 获取 Skills 并注入到 Agent 的 System Prompt + """ + # 获取所有 Skills (内置 + 用户) + # 这里需要 sync 包装或者改造 agent 支持 async init + # 简化演示:假设我们构建了一个类似的 prompt + pass + + async def chat_stream(self, message: str, thread_id: str) -> AsyncIterator[dict]: + """ + 流式对话接口 + 适配前端所需的 WebSocket 消息格式 + """ + async for event in self.agent.stream_events(message, thread_id): + # 转换为前端协议 + if event["type"] == "thinking": + yield {"type": "thinking", "content": event["content"]} + elif event["type"] == "text": + yield {"type": "content", "content": event["content"]} + elif event["type"] == "tool_call": + yield { + "type": "tool_start", + "tool": event["name"], + "input": event["args"] + } + elif event["type"] == "tool_result": + yield { + "type": "tool_end", + "tool": event["name"], + "output": event["content"] + } + elif event["type"] == "done": + yield {"type": "done"} + + async def run_task(self, task_description: str): + """ + 执行特定任务(非对话模式) + 替代原本硬编码的 Service 逻辑 + """ + result = await self.agent.invoke(task_description) + return result diff --git a/backend/app/core/agents/tools/director_tools.py b/backend/app/core/agents/tools/director_tools.py new file mode 100644 index 0000000..51e6386 --- /dev/null +++ b/backend/app/core/agents/tools/director_tools.py @@ -0,0 +1,51 @@ +from typing import List, Dict, Any, Optional +from langchain_core.tools import tool +import json + +# We can use a global or context-based store for session-based items if needed, +# but for now we'll rely on the tool call payload being sent to the frontend. + +@tool +async def add_inbox_task(type: str, title: str, description: str, options: List[str] = None) -> str: + """ + Add a task to the Director's Inbox (Human-in-the-Loop). + Use this when you need the user (Director) to make a decision, review something, or confirm a setting. + + Args: + type: Task type. One of: 'decision' (for choices), 'review' (for checking content), 'notification' (for info). + title: Short title of the task. + description: Detailed description of what needs to be done. + options: For 'decision' type, a list of choices (e.g. ["Fight", "Flight"]). + + Returns: + Confirmation message. + """ + # In a real system, this would save to a DB. + # Here, the tool call itself serves as the signal to the frontend. + return f"[Inbox Task Added] {title} ({type})" + +@tool +async def update_plan(steps: List[str], current_step_index: int) -> str: + """ + Update the execution plan visible to the user. + Call this at the beginning of a complex task (like writing a chapter) and update it as you progress. + + Args: + steps: List of strings describing the steps (e.g. ["Analyze Context", "Draft Outline", "Write Scene"]). + current_step_index: The 0-based index of the current step being executed. + + Returns: + Confirmation message. + """ + return f"[Plan Updated] Step {current_step_index + 1}/{len(steps)}: {steps[current_step_index] if steps and 0 <= current_step_index < len(steps) else 'Unknown'}" + +@tool +async def ask_director(question: str) -> str: + """ + Directly ask the Director (User) a question in the chat interface. + Use this for conversational clarifications, not for structured tasks. + + Args: + question: The question to ask. + """ + return f"[Question Asked] {question}" diff --git a/backend/app/core/agents/tools/memory_review_tools.py b/backend/app/core/agents/tools/memory_review_tools.py new file mode 100644 index 0000000..fafcc5a --- /dev/null +++ b/backend/app/core/agents/tools/memory_review_tools.py @@ -0,0 +1,113 @@ +from typing import List, Dict, Any, Optional +from langchain_core.tools import tool +from app.core.memory.memory_manager import get_memory_manager +from app.core.review.review_manager import get_review_manager +from app.models.project import SeriesProject, Episode, Memory +from app.models.review import ReviewConfig + +# ============================================================================ +# Memory Tools - 赋予 Agent 记忆能力 +# ============================================================================ + +@tool +async def query_project_memory(project_id: str, query: str) -> str: + """ + 查询项目记忆库。 + 当你需要了解过去发生的事件、角色当前状态、未解决的伏笔或任何背景信息时使用此工具。 + + Args: + project_id: 项目 ID + query: 查询内容,例如 "主角现在的心理状态" 或 "第三集的关键转折" + + Returns: + 相关的记忆信息摘要 + """ + memory_manager = get_memory_manager() + # 注意:这里假设 MemoryManager 需要实现一个 semantic_search 或类似的查询方法 + # 目前可以用简单的规则匹配或 LLM 总结来模拟 + # 示例实现: + # results = await memory_manager.search(project_id, query) + # return format_results(results) + return f"Memory query results for '{query}' (Not implemented yet)" + +@tool +async def update_episode_memory(project_id: str, episode_number: int, content: str) -> Dict[str, Any]: + """ + 更新剧集记忆。 + 在一集创作完成后调用此工具,它会自动分析内容,提取关键事件、伏笔、角色状态变化, + 并更新到项目的长期记忆库中。 + + Args: + project_id: 项目 ID + episode_number: 集数 + content: 剧集完整内容 + + Returns: + 提取结果摘要(包含提取的事件数、伏笔数等) + """ + memory_manager = get_memory_manager() + + # 需要获取 Project 对象,这里简化处理,实际需要 ProjectService + # project = await project_service.get_project(project_id) + + # 模拟 Episode 对象 + episode = Episode( + projectId=project_id, + number=episode_number, + content=content, + status="completed" + ) + + # result = await memory_manager.update_memory_from_episode(project, episode) + # return result.dict() + return {"status": "success", "message": "Memory updated (Simulation)"} + +# ============================================================================ +# Review Tools - 赋予 Agent 自我审查能力 +# ============================================================================ + +@tool +async def review_content_consistency(project_id: str, episode_number: int, content: str) -> Dict[str, Any]: + """ + 审查内容一致性。 + 在完成创作后调用,检查内容是否与设定、历史剧情、人物性格一致。 + + Args: + project_id: 项目 ID + episode_number: 集数 + content: 待审查的内容 + + Returns: + 审查结果,包含分数和发现的问题列表 + """ + review_manager = get_review_manager() + + # 同样需要获取 Project 和 Config + # config = await review_service.get_config(project_id) + + # 模拟调用 + # result = await review_manager.review_episode(project, episode, config, dimensions=[DimensionType.consistency]) + + return { + "score": 85, + "passed": True, + "issues": [ + {"severity": "low", "description": "Simulation: Character tone slight mismatch"} + ] + } + +# ============================================================================ +# Context Tools - 赋予 Agent 设定管理能力 +# ============================================================================ + +@tool +async def get_world_setting(project_id: str) -> str: + """获取项目的世界观设定""" + # return project.globalContext.worldSetting + return "Cyberpunk future city (Simulation)" + +@tool +async def get_character_profile(project_id: str, character_name: str) -> str: + """获取特定角色的详细设定""" + # return project.globalContext.characterProfiles.get(character_name) + return f"Profile for {character_name} (Simulation)" diff --git a/backend/app/core/llm/langchain_adapter.py b/backend/app/core/llm/langchain_adapter.py new file mode 100644 index 0000000..50c9046 --- /dev/null +++ b/backend/app/core/llm/langchain_adapter.py @@ -0,0 +1,116 @@ +from typing import Any, List, Optional, Dict, Iterator +from langchain_core.language_models.chat_models import BaseChatModel +from langchain_core.messages import BaseMessage, AIMessage, HumanMessage, SystemMessage, ToolMessage, AIMessageChunk +from langchain_core.outputs import ChatResult, ChatGeneration, ChatGenerationChunk +from app.core.llm.glm_client import glm_client +from app.config import settings + +class ChatGLM(BaseChatModel): + """ + LangChain adapter for ZhipuAI GLM models. + Wraps the project's existing GLMClient. + """ + model_name: str = "glm-4.7" + temperature: float = 0.7 + + def __init__(self, model: str = None, temperature: float = 0.7, **kwargs): + super().__init__(**kwargs) + self.model_name = model or settings.zai_model + self.temperature = temperature + + @property + def _llm_type(self) -> str: + return "chat-glm" + + def _convert_messages(self, messages: List[BaseMessage]) -> List[Dict[str, Any]]: + glm_messages = [] + for msg in messages: + role = "user" + if isinstance(msg, SystemMessage): + role = "system" + elif isinstance(msg, AIMessage): + role = "assistant" + if msg.tool_calls: + # Handle tool calls if necessary, but basic text is priority + pass + elif isinstance(msg, ToolMessage): + role = "tool" + + content = msg.content + if isinstance(content, str): + glm_messages.append({"role": role, "content": content}) + + # Note: Tool calling support might need more complex conversion + # but for basic chat and text generation this should suffice. + + return glm_messages + + def _generate( + self, + messages: List[BaseMessage], + stop: Optional[List[str]] = None, + run_manager: Any = None, + **kwargs: Any, + ) -> ChatResult: + glm_messages = self._convert_messages(messages) + + # Use sync call or async call? BaseChatModel._generate is sync. + # But GLMClient is async-first. + # We might need to use _agenerate instead or run async in sync. + # For simplicity, let's use the synchronous client from zai-sdk if possible, + # but GLMClient wraps it. + # Let's check GLMClient again. It has async chat. + + # If we are in an async environment (FastAPI), we should implement _agenerate. + raise NotImplementedError("Use ainvoke or astream for this model") + + async def _agenerate( + self, + messages: List[BaseMessage], + stop: Optional[List[str]] = None, + run_manager: Any = None, + **kwargs: Any, + ) -> ChatResult: + glm_messages = self._convert_messages(messages) + + response = await glm_client.chat( + messages=glm_messages, + temperature=self.temperature, + stream=False + ) + + # Parse response + content = response["choices"][0]["message"]["content"] + + return ChatResult(generations=[ChatGeneration(message=AIMessage(content=content))]) + + def _stream( + self, + messages: List[BaseMessage], + stop: Optional[List[str]] = None, + run_manager: Any = None, + **kwargs: Any, + ) -> Iterator[ChatGenerationChunk]: + # Sync stream not supported + raise NotImplementedError("Use astream") + + async def _astream( + self, + messages: List[BaseMessage], + stop: Optional[List[str]] = None, + run_manager: Any = None, + **kwargs: Any, + ) -> Iterator[ChatGenerationChunk]: + glm_messages = self._convert_messages(messages) + + response_data = await glm_client.chat( + messages=glm_messages, + temperature=self.temperature, + stream=True + ) + + async_gen = response_data["stream"] + + async for chunk in async_gen: + if chunk: + yield ChatGenerationChunk(message=AIMessageChunk(content=chunk)) diff --git a/backend/app/db/repositories.py b/backend/app/db/repositories.py index ed43f70..9ec2112 100644 --- a/backend/app/db/repositories.py +++ b/backend/app/db/repositories.py @@ -1,29 +1,95 @@ from datetime import datetime -from typing import List, Optional +from typing import List, Optional, Dict import uuid +import json +import os +from pathlib import Path from app.models.project import ( SeriesProject, SeriesProjectCreate, - Episode, - EpisodeExecuteRequest, - EpisodeExecuteResponse + Episode ) -from app.core.agents.series_creation_agent import get_series_agent from app.utils.logger import get_logger logger = get_logger(__name__) +# 数据存储路径 +# 使用绝对路径,确保在不同工作目录下都能正确找到 +# BASE_DIR = Path(__file__).resolve().parent.parent.parent +# DATA_DIR = BASE_DIR / "data" -# ============================================ -# 内存存储 (MVP 阶段使用文件存储) -# ============================================ -_projects: dict = {} -_episodes: dict = {} +# 临时使用硬编码绝对路径进行调试 +DATA_DIR = Path("d:/platform/creative_studio/backend/data") +PROJECTS_FILE = DATA_DIR / "projects.json" +EPISODES_FILE = DATA_DIR / "episodes.json" +MESSAGES_FILE = DATA_DIR / "messages.json" +# 确保数据目录存在 +if not DATA_DIR.exists(): + try: + DATA_DIR.mkdir(parents=True, exist_ok=True) + logger.info(f"Created data directory: {DATA_DIR}") + except Exception as e: + logger.error(f"Failed to create data directory {DATA_DIR}: {e}") -class ProjectRepository: - """项目仓储(MVP 简化版)""" +logger.info(f"Data directory: {DATA_DIR}") +logger.info(f"Projects file: {PROJECTS_FILE}") + +class JsonRepository: + """JSON 文件持久化基类""" + + def __init__(self, file_path: Path): + self.file_path = file_path + self._data = {} + self._load() + + def _load(self): + """从文件加载数据""" + if self.file_path.exists(): + try: + content = self.file_path.read_text(encoding="utf-8") + self._data = json.loads(content) + except Exception as e: + logger.error(f"Failed to load data from {self.file_path}: {e}") + self._data = {} + else: + self._data = {} + + def _save(self): + """保存数据到文件""" + try: + # 转换对象为可序列化的字典 + serialized_data = {} + for k, v in self._data.items(): + if hasattr(v, "dict"): + serialized_data[k] = json.loads(v.json()) + elif isinstance(v, dict): + serialized_data[k] = v + else: + serialized_data[k] = str(v) + + self.file_path.write_text( + json.dumps(serialized_data, ensure_ascii=False, indent=2), + encoding="utf-8" + ) + except Exception as e: + logger.error(f"Failed to save data to {self.file_path}: {e}") + # Re-raise exception to make it visible + raise e + +class ProjectRepository(JsonRepository): + """项目仓储(持久化版)""" + + def __init__(self): + super().__init__(PROJECTS_FILE) + # 将加载的字典转换为对象 + self._objects: Dict[str, SeriesProject] = {} + for k, v in self._data.items(): + try: + self._objects[k] = SeriesProject.parse_obj(v) + except Exception as e: + logger.error(f"Failed to parse project {k}: {e}") async def create(self, project_data: SeriesProjectCreate) -> SeriesProject: """创建新项目""" @@ -39,17 +105,26 @@ class ProjectRepository: createdAt=datetime.now(), updatedAt=datetime.now() ) - _projects[project_id] = project + self._objects[project_id] = project + self._data[project_id] = json.loads(project.json()) + self._save() + logger.info(f"创建项目: {project_id} - {project.name}") return project async def get(self, project_id: str) -> Optional[SeriesProject]: """获取项目""" - return _projects.get(project_id) + return self._objects.get(project_id) async def list(self, skip: int = 0, limit: int = 100) -> List[SeriesProject]: """列出所有项目""" - return list(_projects.values())[skip:skip + limit] + # 按创建时间倒序 + projects = sorted( + self._objects.values(), + key=lambda p: p.createdAt or datetime.min, + reverse=True + ) + return projects[skip:skip + limit] async def update( self, @@ -57,7 +132,7 @@ class ProjectRepository: project_data: dict ) -> Optional[SeriesProject]: """更新项目""" - project = _projects.get(project_id) + project = self._objects.get(project_id) if not project: return None @@ -66,30 +141,51 @@ class ProjectRepository: setattr(project, key, value) project.updatedAt = datetime.now() + + # 更新存储 + self._data[project_id] = json.loads(project.json()) + self._save() + return project async def delete(self, project_id: str) -> bool: """删除项目""" - if project_id in _projects: - del _projects[project_id] + if project_id in self._objects: + del self._objects[project_id] + if project_id in self._data: + del self._data[project_id] + self._save() return True return False -class EpisodeRepository: - """剧集仓储(MVP 简化版)""" +class EpisodeRepository(JsonRepository): + """剧集仓储(持久化版)""" + + def __init__(self): + super().__init__(EPISODES_FILE) + self._objects: Dict[str, Episode] = {} + for k, v in self._data.items(): + try: + self._objects[k] = Episode.parse_obj(v) + except Exception as e: + logger.error(f"Failed to parse episode {k}: {e}") async def create(self, episode: Episode) -> Episode: """创建剧集""" if not episode.id: episode.id = str(uuid.uuid4()) - _episodes[episode.id] = episode + + self._objects[episode.id] = episode + self._data[episode.id] = json.loads(episode.json()) + self._save() + logger.info(f"创建剧集: {episode.id} - EP{episode.number}") return episode async def get(self, episode_id: str) -> Optional[Episode]: """获取剧集""" - return _episodes.get(episode_id) + return self._objects.get(episode_id) async def list_by_project( self, @@ -98,14 +194,18 @@ class EpisodeRepository: limit: int = 100 ) -> List[Episode]: """列出项目的所有剧集""" - return [ - ep for ep in _episodes.values() + episodes = [ + ep for ep in self._objects.values() if ep.projectId == project_id - ][skip:skip + limit] + ] + episodes.sort(key=lambda x: x.number) + return episodes[skip:skip + limit] async def update(self, episode: Episode) -> Episode: """更新剧集""" - _episodes[episode.id] = episode + self._objects[episode.id] = episode + self._data[episode.id] = json.loads(episode.json()) + self._save() return episode @@ -114,3 +214,29 @@ class EpisodeRepository: # ============================================ project_repo = ProjectRepository() episode_repo = EpisodeRepository() + +class MessageRepository(JsonRepository): + """消息记录仓储""" + + def __init__(self): + super().__init__(MESSAGES_FILE) + # 结构: {project_id: [{role, content, timestamp}, ...]} + + async def add_message(self, project_id: str, role: str, content: str): + """添加消息""" + if project_id not in self._data: + self._data[project_id] = [] + + message = { + "role": role, + "content": content, + "timestamp": datetime.now().isoformat() + } + self._data[project_id].append(message) + self._save() + + async def get_history(self, project_id: str) -> List[Dict]: + """获取项目聊天历史""" + return self._data.get(project_id, []) + +message_repo = MessageRepository() diff --git a/backend/app/models/project.py b/backend/app/models/project.py index def70bb..2c0f88b 100644 --- a/backend/app/models/project.py +++ b/backend/app/models/project.py @@ -115,6 +115,9 @@ class SeriesProject(BaseModel): agentId: str = "series-creation" mode: str = "batch" # auto, batch, step + # 项目类型/风格(如:古风、现代、科幻等) + genre: str = "古风" + # 全局上下文 globalContext: GlobalContext = Field(default_factory=GlobalContext) @@ -177,6 +180,7 @@ class SeriesProjectCreate(BaseModel): totalEpisodes: int = 30 agentId: str = "series-creation" mode: str = "batch" + genre: str = "古风" globalContext: GlobalContext = Field(default_factory=GlobalContext) skillSettings: Dict[str, SkillSetting] = Field(default_factory=dict) diff --git a/backend/data/projects.json b/backend/data/projects.json new file mode 100644 index 0000000..3a71ab7 --- /dev/null +++ b/backend/data/projects.json @@ -0,0 +1,33 @@ +{ + "8f969272-4ece-49e7-8ca1-4877cc62c57c": { + "id": "8f969272-4ece-49e7-8ca1-4877cc62c57c", + "name": "test", + "type": "series", + "agentId": "series-creation", + "mode": "batch", + "genre": "古风", + "globalContext": { + "worldSetting": "哈哈哈", + "characterProfiles": {}, + "sceneSettings": {}, + "overallOutline": "11", + "styleGuide": "【人物1】\n姓名:苏瑾月\n身份:江南富商之女,表面柔弱实则聪慧 - 性格:机智过人,临危不乱,略带顽皮幽默 - 说话风格:言语犀利却不失温婉,常出其不意 - 背景故事:苏家商户之女,自幼习武读书,精通棋艺与谋略,因不愿遵循寻常女子命运而常出奇招。\n\n【人物2】\n姓名:楚云飞\n身份:江湖车夫,实为前朝将领之后 - 性格:外表粗犷,内心细腻,行事果断 - 说话风格:直爽直接,偶尔带点江湖气息 - 背景故事:因家族遭变,隐姓埋名做车夫为生,心中却藏有复国之志,为人义气但行事不拘小节。\n\n【人物3】\n姓名:林墨轩\n身份:朝廷官员,苏瑾月的追求者 - 性格:表面温文尔雅,实则心机深沉 - 说话风格:彬彬有礼,言辞华丽,常引经据典 - 背景故事:出身书香门第,一心想攀附权贵,对苏瑾月既爱慕又嫉妒她的才智。\n\n【人物4】\n姓名:苏老爷\n身份:江南富商,苏瑾月之父 - 性格:精明能干,重视门第,传统守旧 - 说话风格:威严沉稳,商人思维,看重利益 - 背景故事:白手起家建立商业帝国,希望女儿能嫁入豪门,巩固家族地位。\n\n【人物5】\n姓名:燕无痕\n身份:江湖游侠,楚云飞旧友 - 性格:豪爽不羁,重情重义,武功高强 - 说话风格:豪迈直接,不拘礼节,常带玩笑 - 背景故事:与楚云飞曾是战友,因故失散,如今在江湖游荡,寻找线索。", + "uploadedScript": "One day I was walking to the store, all of the sudden this huge truck comes roaring around the corner and flies right into me. Luckily, it slowed down a little when he saw my legs sticking out from under his wheels. He stopped his truck in front of where I was lying and got out. I wasn't hurt at all, but I couldn't move because I didn't want him to drive off while I was still underneath the car. I think he must have thought I was dead because he just stood there for awhile looking down at me. Then he bent over and picked up my purse that had fallen from my arm. While he was rummaging through it, I get an idea. \"Oh no,\" I say as I sit up. \"Don't you dare take my shoes.\" He looks down at me with a puzzled expression on his face. \"My shoes are ruined anyway,\" I say. \"You can keep 'em if you want.\" The guy picks them up with one hand and says, \"Sure thing,\" then gets back in his truck and drives away. My mind is spinning with excitement, so I decide to go after him and see what happens next. After about two blocks I catch up with him and jump onto the hood of his pickup truck. It's not long before he notices me and pulls over to the side of the road. He turns around and looks down at me like he's trying to figure out who I am. His eyes fall to my feet and he starts laughing. \"Nice try, lady,\" he says. \"But your shoes aren't mine.\" I hop off the hood and walk back home. I'm pretty sure I scared the crap out of him and now he'll be careful if he ever sees me again.", + "inspiration": "" + }, + "memory": { + "eventTimeline": [], + "pendingThreads": [], + "foreshadowing": [], + "characterStates": {} + }, + "totalEpisodes": 30, + "defaultTaskSkills": [], + "episodeSkillOverrides": {}, + "skillSettings": {}, + "autoRetryConfig": null, + "reviewConfig": null, + "createdAt": "2026-01-27T16:22:58.755260", + "updatedAt": "2026-01-27T18:11:56.500700" + } +} \ No newline at end of file diff --git a/backend/requirements.txt b/backend/requirements.txt index f3517a6..7fb57a9 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -39,6 +39,11 @@ python-dotenv==1.0.0 # Vector Database chromadb==0.4.18 +# LangChain +langchain>=0.3.0 +langchain-core>=0.3.0 +langgraph>=0.2.0 + # Development pytest==7.4.3 pytest-asyncio==0.21.1 diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index f179820..1195d81 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -70,12 +70,13 @@ function App() { } /> } /> } /> - } /> + {/* 更具体的路由要放在前面 */} } /> } /> } /> } /> } /> + } /> } /> } /> diff --git a/frontend/src/components/Workspace/ContextPanel.tsx b/frontend/src/components/Workspace/ContextPanel.tsx new file mode 100644 index 0000000..a87d9de --- /dev/null +++ b/frontend/src/components/Workspace/ContextPanel.tsx @@ -0,0 +1,296 @@ +import React, { useState } from 'react'; +import { Layout, Card, Typography, Space, Tag, Tabs, List, Button, Input, Empty, Timeline, Badge, Popconfirm } from 'antd'; +import { + BookOutlined, + UserOutlined, + EnvironmentOutlined, + HistoryOutlined, + BulbOutlined, + EditOutlined, + ClockCircleOutlined, + AlertOutlined, + CheckCircleOutlined +} from '@ant-design/icons'; + +const { Sider } = Layout; +const { Title, Text, Paragraph } = Typography; + +interface ContextPanelProps { + project: any; + loading: boolean; + activeStates?: any[]; + memoryItems?: any[]; + onUpdateContext?: (type: string, data: any) => void; +} + +export const ContextPanel: React.FC = ({ + project, + loading, + activeStates = [], + memoryItems = [] +}) => { + const [activeTab, setActiveTab] = useState('world'); + + // 模拟数据 - 实际应从 project.globalContext 获取 + const worldSetting = project?.globalContext?.worldSetting || "暂无世界观设定"; + const rawCharacters = project?.globalContext?.characterProfiles; + // 人物设定可能存储在 characterProfiles (对象) 或 styleGuide (文本字符串) + const characters = (rawCharacters && typeof rawCharacters === 'object') ? rawCharacters : {}; + const charactersText = project?.globalContext?.styleGuide || ""; + + // Use passed activeStates or default if empty (and not loading) + const displayStates = activeStates.length > 0 ? activeStates : [ + { type: 'time', value: '未初始化' }, + { type: 'location', value: '未初始化' } + ]; + + return ( + +
+ + <BookOutlined /> 故事上下文 + + + {/* 动态状态卡片 */} + + + {displayStates.map((state, idx) => ( +
+ {state.type === 'time' ? : state.type === 'location' ? : } + {state.value} +
+ ))} +
+
+ + + + {worldSetting} + + + + ), + }, + { + key: 'characters', + label: '人物', + children: ( + <> + {/* 如果有文本格式的人物设定,优先显示 */} + {charactersText ? ( + + {charactersText} + + ) : Object.keys(characters).length > 0 ? ( + ( + + } + title={name} + description={{profile}} + /> + + )} + /> + ) : ( + + )} + + + ), + }, + { + key: 'memory', + label: '记忆库', + children: memoryItems.length > 0 ? ( + + ) : ( + + ), + }, + ]} + /> +
+
+ ); +}; + +// 记忆库组件 +const MemoryLibrary: React.FC<{ items: any[] }> = ({ items }) => { + // 按类型分组记忆项 + const timelineItems = items.filter((item: any) => item.type === 'timeline'); + const characterStates = items.filter((item: any) => item.type === 'character_state'); + const pendingThreads = items.filter((item: any) => item.type === 'pending_thread'); + const foreshadowing = items.filter((item: any) => item.type === 'foreshadowing'); + + // 构建 tabs items + const tabItems = [ + { + key: 'all', + label: `全部 (${items.length})`, + children: ( + ({ + color: getMemoryColor(item.type), + dot: getMemoryIcon(item.type), + children: ( +
+ {item.title || item.type} +
+ {item.description} + {item.timestamp && ( + + {new Date(item.timestamp).toLocaleTimeString()} + + )} +
+ ) + }))} + /> + ) + } + ]; + + if (timelineItems.length > 0) { + tabItems.push({ + key: 'timeline', + label: `时间线 (${timelineItems.length})`, + children: ( + ( + + {item.title}} + description={{item.description}} + /> + + )} + /> + ) + }); + } + + if (characterStates.length > 0) { + tabItems.push({ + key: 'character', + label: `角色 (${characterStates.length})`, + children: ( + ( + + } + title={{item.character}} + description={{item.state}} + /> + + )} + /> + ) + }); + } + + if (pendingThreads.length > 0) { + tabItems.push({ + key: 'pending', + label: `待收线 (${pendingThreads.length})`, + children: ( + ( + + + {item.description} + + )} + /> + ) + }); + } + + if (foreshadowing.length > 0) { + tabItems.push({ + key: 'foreshadowing', + label: `伏笔 (${foreshadowing.length})`, + children: ( + ( + + {item.title}} + description={{item.description}} + /> + + )} + /> + ) + }); + } + + return ( +
+ +
+ ); +}; + +const EmptyMemoryState = () => ( +
+ +

随着剧情发展,Agent 会自动记录关键信息

+
+); + +// 辅助函数:获取记忆项颜色 +function getMemoryColor(type: string): string { + switch (type) { + case 'timeline': return 'blue'; + case 'character_state': return 'green'; + case 'pending_thread': return 'orange'; + case 'foreshadowing': return 'purple'; + default: return 'gray'; + } +} + +// 辅助函数:获取记忆项图标 +function getMemoryIcon(type: string): React.ReactNode { + switch (type) { + case 'timeline': return ; + case 'character_state': return ; + case 'pending_thread': return ; + case 'foreshadowing': return ; + default: return ; + } +} diff --git a/frontend/src/components/Workspace/DirectorInbox.tsx b/frontend/src/components/Workspace/DirectorInbox.tsx new file mode 100644 index 0000000..85e6181 --- /dev/null +++ b/frontend/src/components/Workspace/DirectorInbox.tsx @@ -0,0 +1,189 @@ +import React, { useState, useEffect, useRef } from 'react'; +import { Layout, Input, List, Avatar, Button, Card, Tag, Badge, Tooltip, Divider } from 'antd'; +import { + SendOutlined, + RobotOutlined, + UserOutlined, + CheckCircleOutlined, + CloseCircleOutlined, + ExclamationCircleOutlined, + LoadingOutlined +} from '@ant-design/icons'; + +const { Sider } = Layout; +const { TextArea } = Input; + +export interface InboxItem { + id: string; + type: 'review' | 'decision' | 'notification'; + title: string; + description: string; + status: 'pending' | 'approved' | 'rejected' | 'ignored'; + timestamp: number; + options?: string[]; +} + +interface DirectorInboxProps { + onSendMessage: (message: string) => void; + onInboxAction?: (itemId: string, action: 'approve' | 'reject') => void; + agentStatus: 'idle' | 'planning' | 'writing' | 'reviewing'; + agentPlan?: string[]; + inboxItems?: InboxItem[]; + chatHistory?: {role: 'user' | 'agent', content: string}[]; +} + +export const DirectorInbox: React.FC = ({ + onSendMessage, + onInboxAction, + agentStatus, + agentPlan = [], + inboxItems = [], + chatHistory = [] +}) => { + const [inputValue, setInputValue] = useState(''); + // Use local state for immediate feedback, but sync with props if provided + const [localMessages, setLocalMessages] = useState<{role: 'user' | 'agent', content: string}[]>([]); + + useEffect(() => { + if (chatHistory.length > 0) { + setLocalMessages(chatHistory); + } else if (localMessages.length === 0) { + setLocalMessages([{ role: 'agent', content: '导演你好,我是你的 AI 助手。' }]); + } + }, [chatHistory]); + + const messagesEndRef = useRef(null); + + const handleSend = () => { + if (!inputValue.trim()) return; + const newMsg = { role: 'user' as const, content: inputValue }; + setLocalMessages(prev => [...prev, newMsg]); + onSendMessage(inputValue); + setInputValue(''); + }; + + useEffect(() => { + messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); + }, [localMessages]); + + const getStatusColor = (status: string) => { + switch (status) { + case 'planning': return 'blue'; + case 'writing': return 'green'; + case 'reviewing': return 'orange'; + default: return 'default'; + } + }; + + const getStatusText = (status: string) => { + switch (status) { + case 'planning': return '规划中...'; + case 'writing': return '撰写中...'; + case 'reviewing': return '自查中...'; + default: return '待命'; + } + }; + + return ( + + {/* Agent 状态与计划 */} +
+
+ } style={{ backgroundColor: '#1890ff', marginRight: '8px' }} /> +
+
AI Director Agent
+ + {agentStatus !== 'idle' && } + {getStatusText(agentStatus)} + +
+
+ + {agentPlan.length > 0 && ( + +
    + {agentPlan.map((step, idx) => ( +
  • {step}
  • + ))} +
+
+ )} +
+ + {/* 导演信箱 (Inbox) */} +
+ 待处理任务 (Inbox) + + {inboxItems.map(item => ( +