feat:剧集创作功能优化开发
This commit is contained in:
parent
f29521d327
commit
c99f66895b
@ -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''\\)\")"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
79
DESIGN_V2.md
Normal file
79
DESIGN_V2.md
Normal file
@ -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 平台。
|
||||
@ -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
|
||||
|
||||
@ -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({
|
||||
@ -156,15 +180,23 @@ async def websocket_project_execution(
|
||||
}
|
||||
})
|
||||
|
||||
# 加载并发送历史消息
|
||||
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)
|
||||
|
||||
|
||||
|
||||
255
backend/app/core/agent_runtime/agent.py
Normal file
255
backend/app/core/agent_runtime/agent.py
Normal file
@ -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)
|
||||
32
backend/app/core/agent_runtime/context.py
Normal file
32
backend/app/core/agent_runtime/context.py
Normal file
@ -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)
|
||||
121
backend/app/core/agent_runtime/director_agent.py
Normal file
121
backend/app/core/agent_runtime/director_agent.py
Normal file
@ -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
|
||||
475
backend/app/core/agent_runtime/director_tools.py
Normal file
475
backend/app/core/agent_runtime/director_tools.py
Normal file
@ -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
|
||||
]
|
||||
153
backend/app/core/agent_runtime/skill_loader.py
Normal file
153
backend/app/core/agent_runtime/skill_loader.py
Normal file
@ -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}"
|
||||
52
backend/app/core/agent_runtime/stream/emitter.py
Normal file
52
backend/app/core/agent_runtime/stream/emitter.py
Normal file
@ -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})
|
||||
103
backend/app/core/agent_runtime/stream/tracker.py
Normal file
103
backend/app/core/agent_runtime/stream/tracker.py
Normal file
@ -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()
|
||||
204
backend/app/core/agent_runtime/stream/utils.py
Normal file
204
backend/app/core/agent_runtime/stream/utils.py
Normal file
@ -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
|
||||
296
backend/app/core/agent_runtime/tools.py
Normal file
296
backend/app/core/agent_runtime/tools.py
Normal file
@ -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
|
||||
]
|
||||
67
backend/app/core/agents/skills_agent_adapter_example.py
Normal file
67
backend/app/core/agents/skills_agent_adapter_example.py
Normal file
@ -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
|
||||
51
backend/app/core/agents/tools/director_tools.py
Normal file
51
backend/app/core/agents/tools/director_tools.py
Normal file
@ -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}"
|
||||
113
backend/app/core/agents/tools/memory_review_tools.py
Normal file
113
backend/app/core/agents/tools/memory_review_tools.py
Normal file
@ -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)"
|
||||
116
backend/app/core/llm/langchain_adapter.py
Normal file
116
backend/app/core/llm/langchain_adapter.py
Normal file
@ -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))
|
||||
@ -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()
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
33
backend/data/projects.json
Normal file
33
backend/data/projects.json
Normal file
@ -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"
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -70,12 +70,13 @@ function App() {
|
||||
<Route path="/projects" element={<ProjectList />} />
|
||||
<Route path="/projects/new" element={<ProjectCreateEnhanced />} />
|
||||
<Route path="/projects/progressive" element={<ProjectCreateProgressive />} />
|
||||
<Route path="/projects/:id" element={<ProjectDetail />} />
|
||||
{/* 更具体的路由要放在前面 */}
|
||||
<Route path="/projects/:id/workspace" element={<ProjectWorkspace />} />
|
||||
<Route path="/projects/:id/execute" element={<ExecutionMonitor />} />
|
||||
<Route path="/projects/:id/memory" element={<MemorySystem />} />
|
||||
<Route path="/projects/:id/review/config" element={<ReviewConfig />} />
|
||||
<Route path="/projects/:id/review/results" element={<ReviewResults />} />
|
||||
<Route path="/projects/:id" element={<ProjectDetail />} />
|
||||
<Route path="/skills" element={<SkillManagement />} />
|
||||
<Route path="/agents" element={<AgentManagement />} />
|
||||
</Routes>
|
||||
|
||||
296
frontend/src/components/Workspace/ContextPanel.tsx
Normal file
296
frontend/src/components/Workspace/ContextPanel.tsx
Normal file
@ -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<ContextPanelProps> = ({
|
||||
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 (
|
||||
<Sider
|
||||
width={350}
|
||||
theme="light"
|
||||
style={{
|
||||
borderRight: '1px solid #f0f0f0',
|
||||
height: '100%',
|
||||
overflowY: 'auto',
|
||||
background: '#fff'
|
||||
}}
|
||||
>
|
||||
<div style={{ padding: '16px' }}>
|
||||
<Title level={4}>
|
||||
<BookOutlined /> 故事上下文
|
||||
</Title>
|
||||
|
||||
{/* 动态状态卡片 */}
|
||||
<Card
|
||||
size="small"
|
||||
title="当前状态 (Active State)"
|
||||
style={{ marginBottom: '16px', background: '#f6ffed', borderColor: '#b7eb8f' }}
|
||||
>
|
||||
<Space direction="vertical" style={{ width: '100%' }}>
|
||||
{displayStates.map((state, idx) => (
|
||||
<div key={idx} style={{ display: 'flex', justifyContent: 'space-between' }}>
|
||||
<Text type="secondary">{state.type === 'time' ? <HistoryOutlined /> : state.type === 'location' ? <EnvironmentOutlined /> : <UserOutlined />}</Text>
|
||||
<Text strong>{state.value}</Text>
|
||||
</div>
|
||||
))}
|
||||
</Space>
|
||||
</Card>
|
||||
|
||||
<Tabs activeKey={activeTab} onChange={setActiveTab}
|
||||
items={[
|
||||
{
|
||||
key: 'world',
|
||||
label: '世界观',
|
||||
children: (
|
||||
<>
|
||||
<Paragraph ellipsis={{ rows: 6, expandable: true, symbol: '展开' }}>
|
||||
{worldSetting}
|
||||
</Paragraph>
|
||||
<Button type="dashed" block icon={<EditOutlined />}>更新设定</Button>
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'characters',
|
||||
label: '人物',
|
||||
children: (
|
||||
<>
|
||||
{/* 如果有文本格式的人物设定,优先显示 */}
|
||||
{charactersText ? (
|
||||
<Paragraph
|
||||
ellipsis={{ rows: 10, expandable: true, symbol: '展开' }}
|
||||
style={{ fontSize: '12px', whiteSpace: 'pre-wrap' }}
|
||||
>
|
||||
{charactersText}
|
||||
</Paragraph>
|
||||
) : Object.keys(characters).length > 0 ? (
|
||||
<List
|
||||
dataSource={Object.entries(characters)}
|
||||
renderItem={([name, profile]: [string, any]) => (
|
||||
<List.Item>
|
||||
<List.Item.Meta
|
||||
avatar={<UserOutlined style={{ fontSize: '24px', color: '#1890ff' }} />}
|
||||
title={name}
|
||||
description={<Text ellipsis>{profile}</Text>}
|
||||
/>
|
||||
</List.Item>
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<Empty description="暂无人物设定" image={Empty.PRESENTED_IMAGE_SIMPLE} />
|
||||
)}
|
||||
<Button type="dashed" block icon={<EditOutlined />} style={{ marginTop: '8px' }}>更新设定</Button>
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'memory',
|
||||
label: '记忆库',
|
||||
children: memoryItems.length > 0 ? (
|
||||
<MemoryLibrary items={memoryItems} />
|
||||
) : (
|
||||
<EmptyMemoryState />
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</Sider>
|
||||
);
|
||||
};
|
||||
|
||||
// 记忆库组件
|
||||
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: (
|
||||
<Timeline
|
||||
mode="left"
|
||||
items={items.map((item: any, idx: number) => ({
|
||||
color: getMemoryColor(item.type),
|
||||
dot: getMemoryIcon(item.type),
|
||||
children: (
|
||||
<div key={idx}>
|
||||
<Text strong style={{ fontSize: '12px' }}>{item.title || item.type}</Text>
|
||||
<br />
|
||||
<Text style={{ fontSize: '12px', color: '#666' }}>{item.description}</Text>
|
||||
{item.timestamp && (
|
||||
<Text type="secondary" style={{ fontSize: '11px', display: 'block', marginTop: '4px' }}>
|
||||
<ClockCircleOutlined /> {new Date(item.timestamp).toLocaleTimeString()}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}))}
|
||||
/>
|
||||
)
|
||||
}
|
||||
];
|
||||
|
||||
if (timelineItems.length > 0) {
|
||||
tabItems.push({
|
||||
key: 'timeline',
|
||||
label: `时间线 (${timelineItems.length})`,
|
||||
children: (
|
||||
<List
|
||||
size="small"
|
||||
dataSource={timelineItems}
|
||||
renderItem={(item: any) => (
|
||||
<List.Item>
|
||||
<List.Item.Meta
|
||||
title={<Text style={{ fontSize: '13px' }}>{item.title}</Text>}
|
||||
description={<Text style={{ fontSize: '12px' }}>{item.description}</Text>}
|
||||
/>
|
||||
</List.Item>
|
||||
)}
|
||||
/>
|
||||
)
|
||||
});
|
||||
}
|
||||
|
||||
if (characterStates.length > 0) {
|
||||
tabItems.push({
|
||||
key: 'character',
|
||||
label: `角色 (${characterStates.length})`,
|
||||
children: (
|
||||
<List
|
||||
size="small"
|
||||
dataSource={characterStates}
|
||||
renderItem={(item: any) => (
|
||||
<List.Item>
|
||||
<List.Item.Meta
|
||||
avatar={<UserOutlined style={{ color: '#1677ff' }} />}
|
||||
title={<Text style={{ fontSize: '13px' }}>{item.character}</Text>}
|
||||
description={<Text style={{ fontSize: '12px' }}>{item.state}</Text>}
|
||||
/>
|
||||
</List.Item>
|
||||
)}
|
||||
/>
|
||||
)
|
||||
});
|
||||
}
|
||||
|
||||
if (pendingThreads.length > 0) {
|
||||
tabItems.push({
|
||||
key: 'pending',
|
||||
label: `待收线 (${pendingThreads.length})`,
|
||||
children: (
|
||||
<List
|
||||
size="small"
|
||||
dataSource={pendingThreads}
|
||||
renderItem={(item: any) => (
|
||||
<List.Item>
|
||||
<Badge status="warning" />
|
||||
<Text style={{ fontSize: '12px', marginLeft: '8px' }}>{item.description}</Text>
|
||||
</List.Item>
|
||||
)}
|
||||
/>
|
||||
)
|
||||
});
|
||||
}
|
||||
|
||||
if (foreshadowing.length > 0) {
|
||||
tabItems.push({
|
||||
key: 'foreshadowing',
|
||||
label: `伏笔 (${foreshadowing.length})`,
|
||||
children: (
|
||||
<List
|
||||
size="small"
|
||||
dataSource={foreshadowing}
|
||||
renderItem={(item: any) => (
|
||||
<List.Item>
|
||||
<List.Item.Meta
|
||||
title={<Text style={{ fontSize: '13px' }}>{item.title}</Text>}
|
||||
description={<Text style={{ fontSize: '12px' }}>{item.description}</Text>}
|
||||
/>
|
||||
</List.Item>
|
||||
)}
|
||||
/>
|
||||
)
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ maxHeight: '400px', overflowY: 'auto' }}>
|
||||
<Tabs defaultActiveKey="all" size="small" items={tabItems} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const EmptyMemoryState = () => (
|
||||
<div style={{ textAlign: 'center', padding: '20px', color: '#999' }}>
|
||||
<BulbOutlined style={{ fontSize: '32px', marginBottom: '8px' }} />
|
||||
<p>随着剧情发展,Agent 会自动记录关键信息</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
// 辅助函数:获取记忆项颜色
|
||||
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 <ClockCircleOutlined />;
|
||||
case 'character_state': return <UserOutlined />;
|
||||
case 'pending_thread': return <AlertOutlined />;
|
||||
case 'foreshadowing': return <BulbOutlined />;
|
||||
default: return <CheckCircleOutlined />;
|
||||
}
|
||||
}
|
||||
189
frontend/src/components/Workspace/DirectorInbox.tsx
Normal file
189
frontend/src/components/Workspace/DirectorInbox.tsx
Normal file
@ -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<DirectorInboxProps> = ({
|
||||
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<HTMLDivElement>(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 (
|
||||
<Sider
|
||||
width={400}
|
||||
theme="light"
|
||||
style={{
|
||||
borderLeft: '1px solid #f0f0f0',
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
background: '#fff'
|
||||
}}
|
||||
>
|
||||
{/* Agent 状态与计划 */}
|
||||
<div style={{ padding: '16px', borderBottom: '1px solid #f0f0f0', background: '#fafafa' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', marginBottom: '8px' }}>
|
||||
<Avatar icon={<RobotOutlined />} style={{ backgroundColor: '#1890ff', marginRight: '8px' }} />
|
||||
<div>
|
||||
<div style={{ fontWeight: 'bold' }}>AI Director Agent</div>
|
||||
<Tag color={getStatusColor(agentStatus)}>
|
||||
{agentStatus !== 'idle' && <LoadingOutlined style={{ marginRight: '4px' }} />}
|
||||
{getStatusText(agentStatus)}
|
||||
</Tag>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{agentPlan.length > 0 && (
|
||||
<Card size="small" title="当前执行计划" style={{ marginTop: '8px' }}>
|
||||
<ul style={{ paddingLeft: '20px', margin: 0 }}>
|
||||
{agentPlan.map((step, idx) => (
|
||||
<li key={idx} style={{ color: idx === 0 ? '#1890ff' : '#666' }}>{step}</li>
|
||||
))}
|
||||
</ul>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 导演信箱 (Inbox) */}
|
||||
<div style={{ flex: 1, overflowY: 'auto', padding: '16px', background: '#f0f2f5' }}>
|
||||
<Divider orientation="left" style={{ margin: '0 0 16px 0', fontSize: '12px' }}>待处理任务 (Inbox)</Divider>
|
||||
|
||||
{inboxItems.map(item => (
|
||||
<Card
|
||||
key={item.id}
|
||||
size="small"
|
||||
style={{ marginBottom: '8px', borderLeft: '3px solid #faad14' }}
|
||||
actions={[
|
||||
<Tooltip title="批准/确认"><Button type="text" size="small" icon={<CheckCircleOutlined style={{ color: '#52c41a' }} />} onClick={() => onInboxAction?.(item.id, 'approve')} /></Tooltip>,
|
||||
<Tooltip title="拒绝/修改"><Button type="text" size="small" icon={<CloseCircleOutlined style={{ color: '#ff4d4f' }} />} onClick={() => onInboxAction?.(item.id, 'reject')} /></Tooltip>
|
||||
]}
|
||||
>
|
||||
<Card.Meta
|
||||
avatar={<ExclamationCircleOutlined style={{ color: '#faad14', fontSize: '20px' }} />}
|
||||
title={<span style={{ fontSize: '14px' }}>{item.title}</span>}
|
||||
description={<span style={{ fontSize: '12px' }}>{item.description}</span>}
|
||||
/>
|
||||
</Card>
|
||||
))}
|
||||
|
||||
<Divider orientation="left" style={{ margin: '16px 0', fontSize: '12px' }}>对话记录</Divider>
|
||||
|
||||
{localMessages.map((msg, idx) => (
|
||||
<div key={idx} style={{
|
||||
display: 'flex',
|
||||
justifyContent: msg.role === 'user' ? 'flex-end' : 'flex-start',
|
||||
marginBottom: '12px'
|
||||
}}>
|
||||
<div style={{
|
||||
maxWidth: '80%',
|
||||
padding: '8px 12px',
|
||||
borderRadius: '8px',
|
||||
background: msg.role === 'user' ? '#1890ff' : '#fff',
|
||||
color: msg.role === 'user' ? '#fff' : '#333',
|
||||
boxShadow: '0 1px 2px rgba(0,0,0,0.1)'
|
||||
}}>
|
||||
{msg.content}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
|
||||
{/* 输入框 */}
|
||||
<div style={{ padding: '16px', borderTop: '1px solid #f0f0f0' }}>
|
||||
<div style={{ display: 'flex' }}>
|
||||
<TextArea
|
||||
rows={2}
|
||||
value={inputValue}
|
||||
onChange={e => setInputValue(e.target.value)}
|
||||
onPressEnter={(e) => {
|
||||
if (!e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSend();
|
||||
}
|
||||
}}
|
||||
placeholder="给 Agent 下达指令..."
|
||||
style={{ resize: 'none', marginRight: '8px' }}
|
||||
/>
|
||||
<Button type="primary" shape="circle" icon={<SendOutlined />} size="large" onClick={handleSend} />
|
||||
</div>
|
||||
</div>
|
||||
</Sider>
|
||||
);
|
||||
};
|
||||
217
frontend/src/components/Workspace/EpisodeSidebar.tsx
Normal file
217
frontend/src/components/Workspace/EpisodeSidebar.tsx
Normal file
@ -0,0 +1,217 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Card, List, Tag, Space, Button, Typography, Progress, Empty, Spin, Badge, Tooltip, message } from 'antd'
|
||||
import { PlayCircleOutlined, CheckCircleOutlined, ClockCircleOutlined, EditOutlined, EyeOutlined, PlusOutlined, ReloadOutlined } from '@ant-design/icons'
|
||||
import { useProjectStore } from '@/stores/projectStore'
|
||||
import { Episode } from '@/services/projectService'
|
||||
|
||||
const { Text } = Typography
|
||||
|
||||
interface EpisodeSidebarProps {
|
||||
projectId: string
|
||||
onEpisodeSelect?: (episode: Episode) => void
|
||||
currentEpisodeId?: string
|
||||
}
|
||||
|
||||
export const EpisodeSidebar: React.FC<EpisodeSidebarProps> = ({
|
||||
projectId,
|
||||
onEpisodeSelect,
|
||||
currentEpisodeId
|
||||
}) => {
|
||||
const { episodes, loading, fetchEpisodes, executeEpisode } = useProjectStore()
|
||||
const [executing, setExecuting] = useState<number | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (projectId) {
|
||||
fetchEpisodes(projectId)
|
||||
}
|
||||
}, [projectId])
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
const colors: Record<string, string> = {
|
||||
'pending': 'default',
|
||||
'writing': 'processing',
|
||||
'completed': 'success',
|
||||
'needs-review': 'warning'
|
||||
}
|
||||
return colors[status] || 'default'
|
||||
}
|
||||
|
||||
const getStatusText = (status: string) => {
|
||||
const texts: Record<string, string> = {
|
||||
'pending': '待开始',
|
||||
'writing': '创作中',
|
||||
'completed': '已完成',
|
||||
'needs-review': '需审核'
|
||||
}
|
||||
return texts[status] || status
|
||||
}
|
||||
|
||||
const getStatusIcon = (status: string) => {
|
||||
const icons: Record<string, React.ReactNode> = {
|
||||
'pending': <ClockCircleOutlined />,
|
||||
'writing': <ReloadOutlined spin />,
|
||||
'completed': <CheckCircleOutlined />,
|
||||
'needs-review': <EditOutlined />
|
||||
}
|
||||
return icons[status] || <ClockCircleOutlined />
|
||||
}
|
||||
|
||||
const handleExecuteEpisode = async (episodeNum: number) => {
|
||||
if (!projectId) return
|
||||
|
||||
setExecuting(episodeNum)
|
||||
try {
|
||||
await executeEpisode(projectId, episodeNum)
|
||||
message.success(`EP${episodeNum} 创作完成!`)
|
||||
await fetchEpisodes(projectId)
|
||||
} catch (error) {
|
||||
message.error(`创作失败: ${(error as Error).message}`)
|
||||
} finally {
|
||||
setExecuting(null)
|
||||
}
|
||||
}
|
||||
|
||||
const totalEpisodes = episodes.length
|
||||
const completedEpisodes = episodes.filter(ep => ep.status === 'completed').length
|
||||
const progress = totalEpisodes > 0 ? Math.round((completedEpisodes / totalEpisodes) * 100) : 0
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
width: '280px',
|
||||
height: '100%',
|
||||
background: '#fafafa',
|
||||
borderRight: '1px solid #f0f0f0',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
overflow: 'hidden'
|
||||
}}
|
||||
>
|
||||
{/* 头部:进度统计 */}
|
||||
<div style={{ padding: '16px', borderBottom: '1px solid #f0f0f0' }}>
|
||||
<Space direction="vertical" style={{ width: '100%' }} size="small">
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<Text strong style={{ fontSize: '14px' }}>剧集管理</Text>
|
||||
<Badge count={completedEpisodes} style={{ backgroundColor: '#52c41a' }} />
|
||||
</div>
|
||||
<Progress
|
||||
percent={progress}
|
||||
size="small"
|
||||
status={progress === 100 ? 'success' : 'active'}
|
||||
strokeColor={{
|
||||
'0%': '#108ee9',
|
||||
'100%': '#87d068',
|
||||
}}
|
||||
/>
|
||||
<Text type="secondary" style={{ fontSize: '12px' }}>
|
||||
已完成 {completedEpisodes} / {totalEpisodes} 集
|
||||
</Text>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
{/* 剧集列表 */}
|
||||
<div style={{ flex: 1, overflowY: 'auto', padding: '12px' }}>
|
||||
{loading ? (
|
||||
<div style={{ textAlign: 'center', padding: '20px' }}>
|
||||
<Spin tip="加载中..." />
|
||||
</div>
|
||||
) : episodes.length === 0 ? (
|
||||
<Empty
|
||||
description="暂无剧集"
|
||||
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||
style={{ marginTop: '20px' }}
|
||||
/>
|
||||
) : (
|
||||
<Space direction="vertical" style={{ width: '100%' }} size="small">
|
||||
{episodes.map((episode) => (
|
||||
<Card
|
||||
key={episode.id}
|
||||
size="small"
|
||||
style={{
|
||||
cursor: 'pointer',
|
||||
border: currentEpisodeId === episode.id ? '2px solid #1677ff' : '1px solid #f0f0f0',
|
||||
background: currentEpisodeId === episode.id ? '#f0f5ff' : '#fff'
|
||||
}}
|
||||
onClick={() => onEpisodeSelect?.(episode)}
|
||||
hoverable
|
||||
>
|
||||
<Space direction="vertical" style={{ width: '100%' }} size="small">
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<Space>
|
||||
<Text strong style={{ fontSize: '13px' }}>
|
||||
EP{String(episode.number).padStart(2, '0')}
|
||||
</Text>
|
||||
{episode.title && (
|
||||
<Text style={{ fontSize: '12px', color: '#666' }} ellipsis>
|
||||
{episode.title}
|
||||
</Text>
|
||||
)}
|
||||
</Space>
|
||||
<Tag
|
||||
color={getStatusColor(episode.status)}
|
||||
icon={getStatusIcon(episode.status)}
|
||||
style={{ margin: 0, fontSize: '11px' }}
|
||||
>
|
||||
{getStatusText(episode.status)}
|
||||
</Tag>
|
||||
</div>
|
||||
|
||||
{episode.qualityScore && (
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: '11px', color: '#999' }}>
|
||||
<span>质量: {episode.qualityScore}</span>
|
||||
{episode.issues && episode.issues.length > 0 && (
|
||||
<span>问题: {episode.issues.length}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{episode.status === 'pending' && (
|
||||
<Button
|
||||
type="primary"
|
||||
size="small"
|
||||
icon={<PlayCircleOutlined />}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleExecuteEpisode(episode.number)
|
||||
}}
|
||||
loading={executing === episode.number}
|
||||
style={{ width: '100%' }}
|
||||
>
|
||||
开始创作
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{episode.status === 'completed' && (
|
||||
<Button
|
||||
size="small"
|
||||
icon={<EyeOutlined />}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onEpisodeSelect?.(episode)
|
||||
}}
|
||||
style={{ width: '100%' }}
|
||||
>
|
||||
查看内容
|
||||
</Button>
|
||||
)}
|
||||
</Space>
|
||||
</Card>
|
||||
))}
|
||||
</Space>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 底部:添加剧集按钮 */}
|
||||
<div style={{ padding: '12px', borderTop: '1px solid #f0f0f0' }}>
|
||||
<Button
|
||||
type="dashed"
|
||||
block
|
||||
icon={<PlusOutlined />}
|
||||
onClick={() => message.info('剧集会根据项目总集数自动创建')}
|
||||
>
|
||||
添加剧集
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
247
frontend/src/components/Workspace/SmartCanvas.tsx
Normal file
247
frontend/src/components/Workspace/SmartCanvas.tsx
Normal file
@ -0,0 +1,247 @@
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import { Layout, Typography, Spin, Empty, Button, Card, Tooltip, message, Modal } from 'antd';
|
||||
import { LoadingOutlined, WarningOutlined, SaveOutlined, EditOutlined, CheckOutlined } from '@ant-design/icons';
|
||||
|
||||
const { Content } = Layout;
|
||||
const { Title, Text } = Typography;
|
||||
|
||||
interface SmartCanvasProps {
|
||||
content: string;
|
||||
streaming: boolean;
|
||||
annotations?: any[];
|
||||
onStartGenerate?: () => void;
|
||||
onContentChange?: (content: string) => void;
|
||||
onContentSave?: (content: string) => void;
|
||||
episodeTitle?: string;
|
||||
episodeNumber?: number;
|
||||
}
|
||||
|
||||
export const SmartCanvas: React.FC<SmartCanvasProps> = ({
|
||||
content,
|
||||
streaming,
|
||||
annotations = [],
|
||||
onStartGenerate,
|
||||
onContentChange,
|
||||
onContentSave,
|
||||
episodeTitle = '未命名草稿',
|
||||
episodeNumber = 5
|
||||
}) => {
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [editContent, setEditContent] = useState(content);
|
||||
const [showSaveModal, setShowSaveModal] = useState(false);
|
||||
const [selectedText, setSelectedText] = useState('');
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
// Update editContent when content changes (e.g., from agent streaming)
|
||||
useEffect(() => {
|
||||
if (!isEditing) {
|
||||
setEditContent(content);
|
||||
}
|
||||
}, [content, isEditing]);
|
||||
|
||||
const handleEditToggle = () => {
|
||||
if (isEditing) {
|
||||
// Save and exit edit mode
|
||||
setIsEditing(false);
|
||||
if (onContentChange) {
|
||||
onContentChange(editContent);
|
||||
}
|
||||
message.success('内容已更新');
|
||||
} else {
|
||||
// Enter edit mode
|
||||
setIsEditing(true);
|
||||
setEditContent(content);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
if (onContentSave) {
|
||||
onContentSave(editContent);
|
||||
message.success('内容已保存');
|
||||
}
|
||||
setIsEditing(false);
|
||||
};
|
||||
|
||||
const handleTextSelection = () => {
|
||||
const selection = window.getSelection();
|
||||
const text = selection?.toString() || '';
|
||||
setSelectedText(text);
|
||||
|
||||
if (text.length > 0) {
|
||||
setShowSaveModal(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleInsertReference = () => {
|
||||
// This will be handled by parent component through callback
|
||||
setShowSaveModal(false);
|
||||
// Notify parent to insert reference into chat
|
||||
if (onContentChange) {
|
||||
onContentChange(`【引用】: ${selectedText}`);
|
||||
}
|
||||
message.info('已复制到剪贴板,可以在对话框中粘贴引用');
|
||||
navigator.clipboard.writeText(selectedText);
|
||||
};
|
||||
|
||||
return (
|
||||
<Content style={{
|
||||
padding: '24px 48px',
|
||||
background: '#fff',
|
||||
overflowY: 'auto',
|
||||
height: '100%',
|
||||
position: 'relative',
|
||||
display: 'flex',
|
||||
gap: '24px'
|
||||
}}>
|
||||
<div style={{ flex: 1, maxWidth: '800px', margin: '0 auto' }}>
|
||||
<Title level={3} style={{ textAlign: 'center', marginBottom: '48px', color: '#333' }}>
|
||||
第 {episodeNumber} 集:{episodeTitle}
|
||||
</Title>
|
||||
|
||||
{/* 操作按钮 */}
|
||||
{!streaming && content && (
|
||||
<div style={{ position: 'absolute', top: '24px', right: '24px', display: 'flex', gap: '8px' }}>
|
||||
<Tooltip title={isEditing ? '保存编辑' : '编辑内容'}>
|
||||
<Button
|
||||
type={isEditing ? 'primary' : 'default'}
|
||||
size="small"
|
||||
icon={isEditing ? <CheckOutlined /> : <EditOutlined />}
|
||||
onClick={handleEditToggle}
|
||||
>
|
||||
{isEditing ? '完成' : '编辑'}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Tooltip title="保存到草稿">
|
||||
<Button
|
||||
type="primary"
|
||||
size="small"
|
||||
icon={<SaveOutlined />}
|
||||
onClick={handleSave}
|
||||
disabled={isEditing}
|
||||
>
|
||||
保存
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{content ? (
|
||||
isEditing ? (
|
||||
// 编辑模式
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={editContent}
|
||||
onChange={(e) => setEditContent(e.target.value)}
|
||||
style={{
|
||||
width: '100%',
|
||||
minHeight: '500px',
|
||||
padding: '16px',
|
||||
fontSize: '16px',
|
||||
lineHeight: '1.8',
|
||||
color: '#262626',
|
||||
fontFamily: "'Merriweather', 'Georgia', serif",
|
||||
border: '1px solid #d9d9d9',
|
||||
borderRadius: '6px',
|
||||
resize: 'vertical',
|
||||
outline: 'none',
|
||||
whiteSpace: 'pre-wrap'
|
||||
}}
|
||||
onMouseUp={handleTextSelection}
|
||||
/>
|
||||
) : (
|
||||
// 查看模式 - 支持选择文本引用
|
||||
<div
|
||||
style={{
|
||||
fontSize: '16px',
|
||||
lineHeight: '1.8',
|
||||
color: '#262626',
|
||||
whiteSpace: 'pre-wrap',
|
||||
fontFamily: "'Merriweather', 'Georgia', serif",
|
||||
userSelect: 'text',
|
||||
cursor: 'text'
|
||||
}}
|
||||
onMouseUp={handleTextSelection}
|
||||
>
|
||||
{editContent}
|
||||
{streaming && <span className="cursor-blink" style={{ borderLeft: '2px solid #1890ff', marginLeft: '2px' }}></span>}
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
<Empty
|
||||
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||
description="画布准备就绪,等待创作..."
|
||||
style={{ marginTop: '100px' }}
|
||||
>
|
||||
<Button type="primary" onClick={onStartGenerate}>开始生成大纲</Button>
|
||||
</Empty>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Annotations Sidebar */}
|
||||
{annotations.length > 0 && (
|
||||
<div style={{ width: '250px', borderLeft: '1px solid #f0f0f0', paddingLeft: '16px' }}>
|
||||
<Title level={5} style={{ fontSize: '14px', marginBottom: '16px' }}>批注 (Annotations)</Title>
|
||||
{annotations.map((note, idx) => (
|
||||
<Card
|
||||
key={idx}
|
||||
size="small"
|
||||
style={{ marginBottom: '8px', borderColor: '#ffccc7', background: '#fff1f0' }}
|
||||
title={<span style={{ color: '#cf1322', fontSize: '12px' }}><WarningOutlined /> {note.type || 'Review Issue'}</span>}
|
||||
>
|
||||
<Text style={{ fontSize: '12px' }}>{note.content || note.description}</Text>
|
||||
{note.suggestion && (
|
||||
<div style={{ marginTop: '8px', fontSize: '12px', color: '#666' }}>
|
||||
建议: {note.suggestion}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 悬浮状态指示 */}
|
||||
{streaming && (
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
top: '20px',
|
||||
right: '20px',
|
||||
background: 'rgba(24, 144, 255, 0.1)',
|
||||
padding: '4px 12px',
|
||||
borderRadius: '14px',
|
||||
color: '#1890ff',
|
||||
display: 'flex',
|
||||
alignItems: 'center'
|
||||
}}>
|
||||
<Spin indicator={<LoadingOutlined style={{ fontSize: 16 }} spin />} style={{ marginRight: '8px' }} />
|
||||
正在实时生成...
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 文本引用模态框 */}
|
||||
<Modal
|
||||
title="引用文本"
|
||||
open={showSaveModal}
|
||||
onOk={handleInsertReference}
|
||||
onCancel={() => setShowSaveModal(false)}
|
||||
okText="复制并引用"
|
||||
cancelText="取消"
|
||||
>
|
||||
<p style={{ marginBottom: '8px', color: '#666' }}>选中的文本:</p>
|
||||
<div style={{
|
||||
padding: '12px',
|
||||
background: '#f5f5f5',
|
||||
borderRadius: '4px',
|
||||
maxHeight: '200px',
|
||||
overflow: 'auto',
|
||||
fontSize: '14px',
|
||||
lineHeight: '1.6'
|
||||
}}>
|
||||
{selectedText}
|
||||
</div>
|
||||
<p style={{ marginTop: '12px', color: '#999', fontSize: '12px' }}>
|
||||
点击"复制并引用"将复制到剪贴板,可以在对话框中粘贴使用
|
||||
</p>
|
||||
</Modal>
|
||||
</Content>
|
||||
);
|
||||
};
|
||||
@ -4,22 +4,27 @@
|
||||
* 标签页结构:
|
||||
* 1. 项目设置 - 编辑基本信息 + 创作方式选择 + 上传剧本/编辑灵感
|
||||
* 2. 全局设定生成 - 根据项目设置进行分析或生成
|
||||
* 3. 剧集创作 - 执行剧集创作任务
|
||||
* 3. 剧集创作 - 创作工作台(直接嵌入,包含剧集管理侧边栏)
|
||||
* 4. 记忆系统 - 故事记忆管理
|
||||
* 5. 审核系统 - 内容质量审核
|
||||
*/
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useState, useEffect, useRef, useCallback } from 'react'
|
||||
import { useParams, useNavigate } from 'react-router-dom'
|
||||
import { Card, Button, Descriptions, List, Tag, Space, Modal, message, Spin, Typography, Tabs, Form, Input, InputNumber, Upload, Alert, Popover, Select } from 'antd'
|
||||
import { ArrowLeftOutlined, PlayCircleOutlined, CheckCircleOutlined, LoadingOutlined, ClockCircleOutlined, ScanOutlined, FileTextOutlined, SettingOutlined, RobotOutlined, UploadOutlined, EditOutlined, SaveOutlined } from '@ant-design/icons'
|
||||
import { Card, Button, Descriptions, List, Tag, Space, Modal, message, Spin, Typography, Tabs, Form, Input, InputNumber, Upload, Alert, Popover, Select, Layout } from 'antd'
|
||||
import { ArrowLeftOutlined, PlayCircleOutlined, CheckCircleOutlined, LoadingOutlined, ClockCircleOutlined, ScanOutlined, FileTextOutlined, SettingOutlined, RobotOutlined, UploadOutlined, EditOutlined, SaveOutlined, RocketOutlined, UnorderedListOutlined } from '@ant-design/icons'
|
||||
import { useProjectStore } from '@/stores/projectStore'
|
||||
import { useSkillStore } from '@/stores/skillStore'
|
||||
import { Episode } from '@/services/projectService'
|
||||
import { taskService } from '@/services/taskService'
|
||||
import { ContextPanel } from '@/components/Workspace/ContextPanel'
|
||||
import { SmartCanvas } from '@/components/Workspace/SmartCanvas'
|
||||
import { DirectorInbox } from '@/components/Workspace/DirectorInbox'
|
||||
import { EpisodeSidebar } from '@/components/Workspace/EpisodeSidebar'
|
||||
|
||||
const { Paragraph } = Typography
|
||||
const { TabPane } = Tabs
|
||||
const { TextArea } = Input
|
||||
const { Header, Content, Sider } = Layout
|
||||
|
||||
// Skill 选择器和自定义提示词组件
|
||||
const SkillSelectorWithPrompt = ({
|
||||
@ -110,6 +115,10 @@ const SkillSelectorWithPrompt = ({
|
||||
export const ProjectDetail = () => {
|
||||
const { id } = useParams<{ id: string }>()
|
||||
const navigate = useNavigate()
|
||||
|
||||
// 调试:确认组件已加载
|
||||
console.log('=== ProjectDetail component loaded ===')
|
||||
console.log('id:', id)
|
||||
const { currentProject, projects, episodes, loading, error, fetchProject, fetchEpisodes, executeEpisode, updateProject } = useProjectStore()
|
||||
const { skills, fetchSkills } = useSkillStore()
|
||||
const [executing, setExecuting] = useState(false)
|
||||
@ -131,6 +140,11 @@ export const ProjectDetail = () => {
|
||||
const [generatingWorld, setGeneratingWorld] = useState(false)
|
||||
const [generatingCharacters, setGeneratingCharacters] = useState(false)
|
||||
const [generatingOutline, setGeneratingOutline] = useState(false)
|
||||
const [generatingAll, setGeneratingAll] = useState(false)
|
||||
|
||||
// 顺序生成状态:跟踪当前完成到哪一步
|
||||
// 步骤:0=未开始, 1=世界观完成, 2=人物完成, 3=大纲完成
|
||||
const [generationStep, setGenerationStep] = useState(0)
|
||||
|
||||
// Skills 配置
|
||||
const [worldSkills, setWorldSkills] = useState<string[]>([])
|
||||
@ -142,6 +156,34 @@ export const ProjectDetail = () => {
|
||||
const [characterPrompt, setCharacterPrompt] = useState('')
|
||||
const [outlinePrompt, setOutlinePrompt] = useState('')
|
||||
|
||||
// 自定义 genre
|
||||
const [customGenre, setCustomGenre] = useState('')
|
||||
|
||||
// 工作台相关状态
|
||||
const [wsConnected, setWsConnected] = useState(false)
|
||||
const [streaming, setStreaming] = useState(false)
|
||||
const [canvasContent, setCanvasContent] = useState<string>('')
|
||||
const [agentStatus, setAgentStatus] = useState<'idle' | 'planning' | 'writing' | 'reviewing'>('idle')
|
||||
const [agentPlan, setAgentPlan] = useState<string[]>([])
|
||||
const [inboxItems, setInboxItems] = useState<any[]>([])
|
||||
const [chatHistory, setChatHistory] = useState<{role: 'user' | 'agent', content: string}[]>([])
|
||||
const [annotations, setAnnotations] = useState<any[]>([])
|
||||
const [activeStates, setActiveStates] = useState<any[]>([
|
||||
{ type: 'time', value: '初始状态' },
|
||||
{ type: 'location', value: '未知地点' }
|
||||
])
|
||||
const [workspaceMemoryItems, setWorkspaceMemoryItems] = useState<any[]>([])
|
||||
const [currentEpisodeInWorkspace, setCurrentEpisodeInWorkspace] = useState<Episode | null>(null)
|
||||
const [showEpisodeSidebar, setShowEpisodeSidebar] = useState(true)
|
||||
|
||||
// 检查全局设定是否完成
|
||||
const globalSettingsCompleted = currentProject?.globalContext?.worldSetting?.trim() &&
|
||||
currentProject?.globalContext?.overallOutline?.trim()
|
||||
|
||||
// WebSocket refs
|
||||
const wsRef = useRef<WebSocket | null>(null)
|
||||
const reconnectTimeoutRef = useRef<number | null>(null)
|
||||
|
||||
// 加载 Skills
|
||||
useEffect(() => {
|
||||
const loadSkills = async () => {
|
||||
@ -158,11 +200,19 @@ export const ProjectDetail = () => {
|
||||
useEffect(() => {
|
||||
if (currentProject) {
|
||||
// 初始化项目设置表单
|
||||
const genre = currentProject.genre || '古风'
|
||||
const isCustomGenre = !['古风', '现代', '科幻', '奇幻', '悬疑', '都市', '历史'].includes(genre)
|
||||
|
||||
settingsForm.setFieldsValue({
|
||||
name: currentProject.name,
|
||||
totalEpisodes: currentProject.totalEpisodes
|
||||
totalEpisodes: currentProject.totalEpisodes,
|
||||
genre: isCustomGenre ? '其他' : genre
|
||||
})
|
||||
|
||||
if (isCustomGenre) {
|
||||
setCustomGenre(genre)
|
||||
}
|
||||
|
||||
// 确定创作方式
|
||||
const hasScript = currentProject.globalContext?.uploadedScript && currentProject.globalContext.uploadedScript.length > 0
|
||||
const hasInspiration = currentProject.globalContext?.inspiration && currentProject.globalContext.inspiration.length > 0
|
||||
@ -176,11 +226,22 @@ export const ProjectDetail = () => {
|
||||
}
|
||||
|
||||
// 初始化全局设定表单
|
||||
const worldSetting = currentProject.globalContext?.worldSetting || ''
|
||||
const characters = currentProject.globalContext?.styleGuide || ''
|
||||
const overallOutline = currentProject.globalContext?.overallOutline || ''
|
||||
|
||||
globalForm.setFieldsValue({
|
||||
worldSetting: currentProject.globalContext?.worldSetting || '',
|
||||
characters: currentProject.globalContext?.styleGuide || '',
|
||||
overallOutline: currentProject.globalContext?.overallOutline || ''
|
||||
worldSetting,
|
||||
characters,
|
||||
overallOutline
|
||||
})
|
||||
|
||||
// 根据已有内容设置初始步骤
|
||||
let initialStep = 0
|
||||
if (worldSetting.trim()) initialStep = 1
|
||||
if (characters.trim()) initialStep = 2
|
||||
if (overallOutline.trim()) initialStep = 3
|
||||
setGenerationStep(initialStep)
|
||||
}
|
||||
}, [currentProject])
|
||||
|
||||
@ -243,9 +304,13 @@ export const ProjectDetail = () => {
|
||||
setUpdatingSettings(true)
|
||||
const values = settingsForm.getFieldsValue()
|
||||
|
||||
// 处理 genre:如果是"其他"且有自定义输入,使用自定义值
|
||||
const genreValue = values.genre === '其他' ? (customGenre || '其他') : (values.genre || '古风')
|
||||
|
||||
await updateProject(id!, {
|
||||
name: values.name,
|
||||
totalEpisodes: values.totalEpisodes,
|
||||
genre: genreValue,
|
||||
globalContext: {
|
||||
...currentProject?.globalContext,
|
||||
uploadedScript: scriptContent,
|
||||
@ -274,6 +339,7 @@ export const ProjectDetail = () => {
|
||||
if (generatingWorld) return // 防止重复点击
|
||||
|
||||
const projectName = currentProject?.name || '未命名项目'
|
||||
const projectGenre = currentProject?.genre || '古风'
|
||||
const selectedSkillsInfo = skills.filter(s => worldSkills.includes(s.id))
|
||||
|
||||
// 根据创作方式决定是分析还是生成(从项目数据中实时获取最新内容)
|
||||
@ -297,7 +363,7 @@ export const ProjectDetail = () => {
|
||||
const response = await taskService.generateWorld({
|
||||
idea,
|
||||
projectName,
|
||||
genre: '古风',
|
||||
genre: projectGenre,
|
||||
skills: selectedSkillsInfo.map(s => ({
|
||||
id: s.id,
|
||||
name: s.name,
|
||||
@ -314,6 +380,9 @@ export const ProjectDetail = () => {
|
||||
const worldText = typeof worldSetting === 'string' ? worldSetting : JSON.stringify(worldSetting, null, 2)
|
||||
globalForm.setFieldsValue({ worldSetting: worldText })
|
||||
|
||||
// 更新步骤状态
|
||||
setGenerationStep(1)
|
||||
|
||||
hideMessage()
|
||||
message.success(isAnalysis ? '世界观分析完成!' : '世界观设定生成完成!')
|
||||
} catch (error: any) {
|
||||
@ -330,6 +399,7 @@ export const ProjectDetail = () => {
|
||||
if (generatingCharacters) return // 防止重复点击
|
||||
|
||||
const projectName = currentProject?.name || '未命名项目'
|
||||
const projectGenre = currentProject?.genre || '古风'
|
||||
const totalEpisodes = currentProject?.totalEpisodes || 30
|
||||
const selectedSkillsInfo = skills.filter(s => characterSkills.includes(s.id))
|
||||
|
||||
@ -350,8 +420,9 @@ export const ProjectDetail = () => {
|
||||
? `分析以下剧本,提取人物设定:\n${baseContent?.substring(0, 2000)}`
|
||||
: `项目名称:${projectName},总集数:${totalEpisodes}集\n创意灵感:\n${baseContent}`
|
||||
|
||||
// 如果已有世界观设定,将其作为上下文
|
||||
if (worldSetting && !isAnalysis) {
|
||||
// 无论分析模式还是创作模式,如果已有世界观设定,都将其作为上下文注入
|
||||
// 这样可以确保生成的世界观能够影响人物设定的生成,保证连贯性
|
||||
if (worldSetting) {
|
||||
idea += `\n\n【世界观设定】\n${worldSetting}`
|
||||
}
|
||||
|
||||
@ -362,6 +433,7 @@ export const ProjectDetail = () => {
|
||||
const response = await taskService.generateCharacters({
|
||||
idea,
|
||||
projectName,
|
||||
genre: projectGenre,
|
||||
totalEpisodes,
|
||||
skills: selectedSkillsInfo.map(s => ({
|
||||
id: s.id,
|
||||
@ -392,6 +464,9 @@ export const ProjectDetail = () => {
|
||||
}
|
||||
globalForm.setFieldsValue({ characters: characterText })
|
||||
|
||||
// 更新步骤状态
|
||||
setGenerationStep(2)
|
||||
|
||||
hideMessage()
|
||||
message.success(isAnalysis ? '人物设定分析完成!' : '人物设定生成完成!')
|
||||
} catch (error: any) {
|
||||
@ -408,6 +483,7 @@ export const ProjectDetail = () => {
|
||||
if (generatingOutline) return // 防止重复点击
|
||||
|
||||
const projectName = currentProject?.name || '未命名项目'
|
||||
const projectGenre = currentProject?.genre || '古风'
|
||||
const totalEpisodes = currentProject?.totalEpisodes || 30
|
||||
const selectedSkillsInfo = skills.filter(s => outlineSkills.includes(s.id))
|
||||
|
||||
@ -429,14 +505,13 @@ export const ProjectDetail = () => {
|
||||
? `分析以下剧本,提取整体大纲:\n${baseContent?.substring(0, 2000)}`
|
||||
: `项目名称:${projectName},总集数:${totalEpisodes}集\n创意灵感:\n${baseContent}`
|
||||
|
||||
// 如果已有世界观设定和人物设定,将其作为上下文
|
||||
if (!isAnalysis) {
|
||||
if (worldSetting) {
|
||||
idea += `\n\n【世界观设定】\n${worldSetting}`
|
||||
}
|
||||
if (characters) {
|
||||
idea += `\n\n【人物设定】\n${characters}`
|
||||
}
|
||||
// 无论分析模式还是创作模式,如果已有世界观设定和人物设定,都将其作为上下文注入
|
||||
// 这样可以确保已生成的内容能够影响大纲的生成,保证连贯性
|
||||
if (worldSetting) {
|
||||
idea += `\n\n【世界观设定】\n${worldSetting}`
|
||||
}
|
||||
if (characters) {
|
||||
idea += `\n\n【人物设定】\n${characters}`
|
||||
}
|
||||
|
||||
const hideMessage = message.loading(isAnalysis ? '正在分析整体大纲...' : '正在生成整体大纲...', 0)
|
||||
@ -446,7 +521,7 @@ export const ProjectDetail = () => {
|
||||
const response = await taskService.generateOutline({
|
||||
idea,
|
||||
totalEpisodes,
|
||||
genre: '古风',
|
||||
genre: projectGenre,
|
||||
projectName,
|
||||
skills: selectedSkillsInfo.map(s => ({
|
||||
id: s.id,
|
||||
@ -464,6 +539,9 @@ export const ProjectDetail = () => {
|
||||
const outlineText = typeof outline === 'string' ? outline : JSON.stringify(outline, null, 2)
|
||||
globalForm.setFieldsValue({ overallOutline: outlineText })
|
||||
|
||||
// 更新步骤状态
|
||||
setGenerationStep(3)
|
||||
|
||||
hideMessage()
|
||||
message.success(isAnalysis ? '整体大纲分析完成!' : '整体大纲生成完成!')
|
||||
} catch (error: any) {
|
||||
@ -474,6 +552,43 @@ export const ProjectDetail = () => {
|
||||
}
|
||||
}
|
||||
|
||||
// 一键生成全部(世界观 → 人物设定 → 大纲)
|
||||
const handleGenerateAll = async () => {
|
||||
if (!id) return
|
||||
if (generatingAll || generatingWorld || generatingCharacters || generatingOutline) return
|
||||
|
||||
setGeneratingAll(true)
|
||||
|
||||
try {
|
||||
// Step 1: 生成世界观
|
||||
message.info('开始生成世界观设定...')
|
||||
await handleGenerateWorld()
|
||||
|
||||
// 等待一下再继续
|
||||
await new Promise(resolve => setTimeout(resolve, 1000))
|
||||
|
||||
// Step 2: 生成人物设定(会使用已生成的世界观)
|
||||
message.info('开始生成人物设定...')
|
||||
await handleGenerateCharacters()
|
||||
|
||||
// 等待一下再继续
|
||||
await new Promise(resolve => setTimeout(resolve, 1000))
|
||||
|
||||
// Step 3: 生成大纲(会使用已生成的世界观和人物设定)
|
||||
message.info('开始生成整体大纲...')
|
||||
await handleGenerateOutline()
|
||||
|
||||
// Step 4: 自动保存全局设定
|
||||
await handleSaveGlobalSettings()
|
||||
|
||||
message.success('全局设定全部生成完成!')
|
||||
} catch (error: any) {
|
||||
message.error(`生成失败: ${error.message || '未知错误'}`)
|
||||
} finally {
|
||||
setGeneratingAll(false)
|
||||
}
|
||||
}
|
||||
|
||||
// 保存全局设定
|
||||
const handleSaveGlobalSettings = async () => {
|
||||
try {
|
||||
@ -486,6 +601,7 @@ export const ProjectDetail = () => {
|
||||
...currentProject?.globalContext,
|
||||
worldSetting: worldSetting || '',
|
||||
overallOutline: overallOutline || '',
|
||||
// 同时保存到两个字段以保持兼容性
|
||||
styleGuide: characters || '',
|
||||
characterProfiles: currentProject?.globalContext?.characterProfiles || {},
|
||||
sceneSettings: currentProject?.globalContext?.sceneSettings || {}
|
||||
@ -513,22 +629,212 @@ export const ProjectDetail = () => {
|
||||
}
|
||||
}
|
||||
|
||||
const handleExecuteBatch = async () => {
|
||||
setExecuting(true)
|
||||
try {
|
||||
for (let i = 1; i <= 3; i++) {
|
||||
await executeEpisode(id!, i)
|
||||
message.success(`EP${i} 创作完成!`)
|
||||
// ==================== 工作台 WebSocket 相关函数 ====================
|
||||
|
||||
// WebSocket 连接逻辑
|
||||
const connectWorkspaceWebSocket = useCallback(() => {
|
||||
if (!id) return
|
||||
|
||||
// 关闭现有连接
|
||||
if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) {
|
||||
wsRef.current.close()
|
||||
}
|
||||
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
|
||||
const host = window.location.host
|
||||
const wsUrl = `${protocol}//${host}/ws/projects/${id}/execute`
|
||||
|
||||
const ws = new WebSocket(wsUrl)
|
||||
|
||||
ws.onopen = () => {
|
||||
console.log('Workspace WebSocket Connected')
|
||||
setWsConnected(true)
|
||||
// 只在剧集创作标签页时显示连接成功消息
|
||||
if (activeTab === 'episodes') {
|
||||
message.success('已连接到 Agent Runtime')
|
||||
}
|
||||
await fetchEpisodes(id!)
|
||||
message.success('批次创作完成!')
|
||||
} catch (error) {
|
||||
message.error(`批量创作失败: ${(error as Error).message}`)
|
||||
} finally {
|
||||
setExecuting(false)
|
||||
}
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
try {
|
||||
const msg = JSON.parse(event.data)
|
||||
handleWorkspaceWebSocketMessage(msg)
|
||||
} catch (e) {
|
||||
console.error('Failed to parse WS message:', e)
|
||||
}
|
||||
}
|
||||
|
||||
ws.onclose = () => {
|
||||
console.log('Workspace WebSocket Disconnected')
|
||||
setWsConnected(false)
|
||||
if (wsRef.current === ws) {
|
||||
wsRef.current = null
|
||||
}
|
||||
}
|
||||
|
||||
ws.onerror = (error) => {
|
||||
console.error('Workspace WebSocket Error:', error)
|
||||
}
|
||||
|
||||
wsRef.current = ws
|
||||
}, [id, activeTab])
|
||||
|
||||
// WebSocket 消息处理
|
||||
const handleWorkspaceWebSocketMessage = (msg: any) => {
|
||||
switch (msg.type) {
|
||||
case 'connected':
|
||||
break
|
||||
case 'history':
|
||||
if (msg.messages) {
|
||||
setChatHistory(msg.messages)
|
||||
}
|
||||
break
|
||||
case 'thinking':
|
||||
break
|
||||
case 'text':
|
||||
setChatHistory(prev => {
|
||||
const lastMsg = prev[prev.length - 1]
|
||||
if (lastMsg && lastMsg.role === 'agent') {
|
||||
const newHistory = [...prev]
|
||||
newHistory[newHistory.length - 1] = {
|
||||
...lastMsg,
|
||||
content: lastMsg.content + msg.content
|
||||
}
|
||||
return newHistory
|
||||
}
|
||||
return [...prev, { role: 'agent', content: msg.content }]
|
||||
})
|
||||
break
|
||||
case 'plan_update':
|
||||
if (msg.plan) {
|
||||
setAgentPlan(msg.plan)
|
||||
}
|
||||
if (msg.status) {
|
||||
setAgentStatus(msg.status)
|
||||
}
|
||||
break
|
||||
case 'review_request':
|
||||
setInboxItems(prev => [...prev, {
|
||||
id: msg.id || Date.now().toString(),
|
||||
type: 'review',
|
||||
title: msg.title || '需要审核',
|
||||
description: msg.description,
|
||||
status: 'pending',
|
||||
timestamp: Date.now()
|
||||
}])
|
||||
break
|
||||
case 'annotation_add':
|
||||
setAnnotations(prev => [...prev, msg.annotation])
|
||||
break
|
||||
case 'context_update':
|
||||
if (msg.states) {
|
||||
setActiveStates(msg.states)
|
||||
}
|
||||
break
|
||||
case 'tool_call':
|
||||
const toolData = msg.data || {}
|
||||
const toolName = toolData.name || msg.name
|
||||
if (toolName === 'update_canvas' || toolName === 'write_file') {
|
||||
setStreaming(true)
|
||||
setAgentStatus('writing')
|
||||
} else {
|
||||
setAgentStatus('planning')
|
||||
}
|
||||
break
|
||||
case 'tool_result':
|
||||
setStreaming(false)
|
||||
break
|
||||
case 'canvas_update':
|
||||
if (msg.content) {
|
||||
setCanvasContent(msg.content)
|
||||
}
|
||||
break
|
||||
case 'done':
|
||||
setAgentStatus('idle')
|
||||
setStreaming(false)
|
||||
break
|
||||
case 'error':
|
||||
message.error(msg.data?.message || 'Unknown error')
|
||||
setAgentStatus('idle')
|
||||
break
|
||||
case 'memory_update':
|
||||
// 处理记忆库更新
|
||||
if (msg.data) {
|
||||
setWorkspaceMemoryItems(prev => [...prev, {
|
||||
type: msg.data.type || msg.data.memory_type || 'timeline',
|
||||
title: msg.data.title || '记忆更新',
|
||||
description: msg.data.description || '',
|
||||
timestamp: msg.data.timestamp || Date.now(),
|
||||
character: msg.data.character,
|
||||
state: msg.data.state
|
||||
}])
|
||||
}
|
||||
break
|
||||
case 'memory_hit':
|
||||
// 添加到记忆库
|
||||
if (msg.data) {
|
||||
setWorkspaceMemoryItems(prev => [...prev, {
|
||||
type: msg.data.memory_type || 'timeline',
|
||||
title: msg.data.title || '记忆更新',
|
||||
description: msg.data.description || JSON.stringify(msg.data.data),
|
||||
timestamp: Date.now()
|
||||
}])
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Inbox 操作处理
|
||||
const handleWorkspaceInboxAction = (itemId: string, action: 'approve' | 'reject') => {
|
||||
setInboxItems(prev => prev.filter(item => item.id !== itemId))
|
||||
if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) {
|
||||
wsRef.current.send(JSON.stringify({
|
||||
type: 'inbox_action',
|
||||
itemId,
|
||||
action
|
||||
}))
|
||||
}
|
||||
message.success(action === 'approve' ? '已批准' : '已拒绝')
|
||||
}
|
||||
|
||||
// Director 消息发送
|
||||
const handleDirectorMessage = (msg: string) => {
|
||||
setChatHistory(prev => [...prev, { role: 'user', content: msg }])
|
||||
|
||||
if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) {
|
||||
const messagePayload = JSON.stringify({
|
||||
type: 'chat_message',
|
||||
content: msg
|
||||
})
|
||||
wsRef.current.send(messagePayload)
|
||||
|
||||
if (msg.includes("开始")) {
|
||||
setAgentStatus('planning')
|
||||
}
|
||||
} else {
|
||||
message.error('Agent 连接未就绪')
|
||||
}
|
||||
}
|
||||
|
||||
// 当切换到剧集创作标签页时连接 WebSocket
|
||||
useEffect(() => {
|
||||
if (activeTab === 'episodes' && globalSettingsCompleted) {
|
||||
connectWorkspaceWebSocket()
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (reconnectTimeoutRef.current) {
|
||||
clearTimeout(reconnectTimeoutRef.current)
|
||||
}
|
||||
if (activeTab !== 'episodes' && wsRef.current) {
|
||||
wsRef.current.close()
|
||||
wsRef.current = null
|
||||
}
|
||||
}
|
||||
}, [activeTab, globalSettingsCompleted, connectWorkspaceWebSocket])
|
||||
|
||||
// ==================== 工作台函数结束 ====================
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
const colors: Record<string, string> = {
|
||||
'pending': 'default',
|
||||
@ -554,10 +860,6 @@ export const ProjectDetail = () => {
|
||||
((creationMode === 'script' && scriptContent.trim()) ||
|
||||
(creationMode === 'inspiration' && inspirationContent.trim()))
|
||||
|
||||
// 检查全局设定是否完成
|
||||
const globalSettingsCompleted = currentProject?.globalContext?.worldSetting?.trim() &&
|
||||
currentProject?.globalContext?.overallOutline?.trim()
|
||||
|
||||
// 错误处理
|
||||
if (error) {
|
||||
return (
|
||||
@ -662,6 +964,46 @@ export const ProjectDetail = () => {
|
||||
<InputNumber min={1} max={500} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="类型/风格" name="genre" rules={[{ required: true, message: '请选择类型' }]}>
|
||||
<Select
|
||||
placeholder="请选择类型"
|
||||
onChange={(value) => {
|
||||
if (value !== '其他') {
|
||||
setCustomGenre('')
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Select.Option value="古风">古风</Select.Option>
|
||||
<Select.Option value="现代">现代</Select.Option>
|
||||
<Select.Option value="科幻">科幻</Select.Option>
|
||||
<Select.Option value="奇幻">奇幻</Select.Option>
|
||||
<Select.Option value="悬疑">悬疑</Select.Option>
|
||||
<Select.Option value="都市">都市</Select.Option>
|
||||
<Select.Option value="历史">历史</Select.Option>
|
||||
<Select.Option value="其他">其他</Select.Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
|
||||
{/* 自定义 genre 输入框 */}
|
||||
<Form.Item noStyle shouldUpdate={(prevValues, currentValues) => prevValues.genre !== currentValues.genre}>
|
||||
{({ getFieldValue }) => {
|
||||
const genre = getFieldValue('genre')
|
||||
return genre === '其他' ? (
|
||||
<Form.Item
|
||||
label="自定义类型"
|
||||
name="customGenre"
|
||||
rules={[{ required: true, message: '请输入自定义类型' }]}
|
||||
>
|
||||
<Input
|
||||
placeholder="例如:武侠、仙侠、校园等"
|
||||
value={customGenre}
|
||||
onChange={(e) => setCustomGenre(e.target.value)}
|
||||
/>
|
||||
</Form.Item>
|
||||
) : null
|
||||
}}
|
||||
</Form.Item>
|
||||
|
||||
{/* 创作方式选择 */}
|
||||
<Form.Item label="选择创作方式" required>
|
||||
<Space direction="vertical" style={{ width: '100%' }} size="middle">
|
||||
@ -845,21 +1187,84 @@ export const ProjectDetail = () => {
|
||||
/>
|
||||
) : (
|
||||
<Space direction="vertical" size="large" style={{ width: '100%' }}>
|
||||
{/* 步骤进度显示 */}
|
||||
<Card size="small">
|
||||
<Space direction="vertical" style={{ width: '100%' }} size="middle">
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<span style={{ fontWeight: 600, fontSize: '14px' }}>生成步骤</span>
|
||||
<span style={{ color: '#666', fontSize: '12px' }}>
|
||||
{generationStep === 0 && '请按顺序依次生成'}
|
||||
{generationStep === 1 && '世界观已完成,继续生成人物设定'}
|
||||
{generationStep === 2 && '人物设定已完成,继续生成整体大纲'}
|
||||
{generationStep === 3 && '全部完成!'}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '8px', alignItems: 'center' }}>
|
||||
<div style={{
|
||||
flex: 1,
|
||||
height: '8px',
|
||||
borderRadius: '4px',
|
||||
background: generationStep >= 1 ? '#52c41a' : '#d9d9d9',
|
||||
transition: 'all 0.3s'
|
||||
}} />
|
||||
<div style={{
|
||||
flex: 1,
|
||||
height: '8px',
|
||||
borderRadius: '4px',
|
||||
background: generationStep >= 2 ? '#52c41a' : '#d9d9d9',
|
||||
transition: 'all 0.3s'
|
||||
}} />
|
||||
<div style={{
|
||||
flex: 1,
|
||||
height: '8px',
|
||||
borderRadius: '4px',
|
||||
background: generationStep >= 3 ? '#52c41a' : '#d9d9d9',
|
||||
transition: 'all 0.3s'
|
||||
}} />
|
||||
</div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: '12px', color: '#666' }}>
|
||||
<span>世界观</span>
|
||||
<span>人物</span>
|
||||
<span>大纲</span>
|
||||
</div>
|
||||
</Space>
|
||||
</Card>
|
||||
|
||||
<Alert
|
||||
message={isAnalysisMode ? "全局设定分析" : "全局设定生成"}
|
||||
description={
|
||||
isAnalysisMode
|
||||
? "AI将分析您上传的剧本,提取世界观、人物设定和整体大纲"
|
||||
: "AI将根据您的创意灵感,生成世界观、人物设定和整体大纲"
|
||||
? "AI将按顺序分析您上传的剧本:世界观 → 人物设定 → 整体大纲"
|
||||
: "AI将按顺序根据您的创意生成:世界观设定 → 人物设定 → 整体大纲"
|
||||
}
|
||||
type="info"
|
||||
showIcon
|
||||
/>
|
||||
|
||||
{/* 生成进度提示 */}
|
||||
{(generatingAll || generatingWorld || generatingCharacters || generatingOutline) && (
|
||||
<Space direction="vertical" style={{ width: '100%' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
|
||||
<Spin size="small" />
|
||||
<span style={{ color: '#666' }}>
|
||||
{generatingAll ? '正在依次生成全部内容...' :
|
||||
generatingWorld ? '正在生成世界观设定...' :
|
||||
generatingCharacters ? '正在生成人物设定...' :
|
||||
generatingOutline ? '正在生成整体大纲...' : ''}
|
||||
</span>
|
||||
</div>
|
||||
</Space>
|
||||
)}
|
||||
|
||||
<Form form={globalForm} layout="vertical">
|
||||
{/* 世界观设定 */}
|
||||
<Card
|
||||
title="世界观设定"
|
||||
title={
|
||||
<Space>
|
||||
<span>世界观设定</span>
|
||||
{generationStep >= 1 && <Tag color="success">已完成</Tag>}
|
||||
</Space>
|
||||
}
|
||||
size="small"
|
||||
extra={
|
||||
<Space>
|
||||
@ -877,13 +1282,13 @@ export const ProjectDetail = () => {
|
||||
size="small"
|
||||
icon={generatingWorld ? <LoadingOutlined /> : <RobotOutlined />}
|
||||
onClick={handleGenerateWorld}
|
||||
disabled={generatingWorld}
|
||||
disabled={generatingWorld || generatingCharacters || generatingOutline || generatingAll}
|
||||
>
|
||||
{isAnalysisMode ? 'AI 分析' : 'AI 生成'}
|
||||
</Button>
|
||||
</Space>
|
||||
}
|
||||
style={{ marginBottom: '16px' }}
|
||||
style={{ marginBottom: '16px', border: generationStep >= 1 ? '1px solid #52c41a' : undefined }}
|
||||
>
|
||||
<Form.Item name="worldSetting">
|
||||
<TextArea
|
||||
@ -899,7 +1304,13 @@ export const ProjectDetail = () => {
|
||||
|
||||
{/* 人物设定 */}
|
||||
<Card
|
||||
title="人物设定"
|
||||
title={
|
||||
<Space>
|
||||
<span>人物设定</span>
|
||||
{generationStep >= 2 && <Tag color="success">已完成</Tag>}
|
||||
{generationStep < 1 && <Tag color="default">需先完成世界观</Tag>}
|
||||
</Space>
|
||||
}
|
||||
size="small"
|
||||
extra={
|
||||
<Space>
|
||||
@ -917,13 +1328,13 @@ export const ProjectDetail = () => {
|
||||
size="small"
|
||||
icon={generatingCharacters ? <LoadingOutlined /> : <RobotOutlined />}
|
||||
onClick={handleGenerateCharacters}
|
||||
disabled={generatingCharacters}
|
||||
disabled={generationStep < 1 || generatingWorld || generatingCharacters || generatingOutline || generatingAll}
|
||||
>
|
||||
{isAnalysisMode ? 'AI 分析' : 'AI 生成'}
|
||||
</Button>
|
||||
</Space>
|
||||
}
|
||||
style={{ marginBottom: '16px' }}
|
||||
style={{ marginBottom: '16px', border: generationStep >= 2 ? '1px solid #52c41a' : undefined, opacity: generationStep < 1 ? 0.6 : 1 }}
|
||||
>
|
||||
<Form.Item name="characters">
|
||||
<TextArea
|
||||
@ -939,7 +1350,13 @@ export const ProjectDetail = () => {
|
||||
|
||||
{/* 整体大纲 */}
|
||||
<Card
|
||||
title="整体大纲"
|
||||
title={
|
||||
<Space>
|
||||
<span>整体大纲</span>
|
||||
{generationStep >= 3 && <Tag color="success">已完成</Tag>}
|
||||
{generationStep < 2 && <Tag color="default">需先完成人物设定</Tag>}
|
||||
</Space>
|
||||
}
|
||||
size="small"
|
||||
extra={
|
||||
<Space>
|
||||
@ -957,13 +1374,13 @@ export const ProjectDetail = () => {
|
||||
size="small"
|
||||
icon={generatingOutline ? <LoadingOutlined /> : <RobotOutlined />}
|
||||
onClick={handleGenerateOutline}
|
||||
disabled={generatingOutline}
|
||||
disabled={generationStep < 2 || generatingWorld || generatingCharacters || generatingOutline || generatingAll}
|
||||
>
|
||||
{isAnalysisMode ? 'AI 分析' : 'AI 生成'}
|
||||
</Button>
|
||||
</Space>
|
||||
}
|
||||
style={{ marginBottom: '16px' }}
|
||||
style={{ marginBottom: '16px', border: generationStep >= 3 ? '1px solid #52c41a' : undefined, opacity: generationStep < 2 ? 0.6 : 1 }}
|
||||
>
|
||||
<Form.Item name="overallOutline">
|
||||
<TextArea
|
||||
@ -987,7 +1404,7 @@ export const ProjectDetail = () => {
|
||||
)}
|
||||
</TabPane>
|
||||
|
||||
{/* 剧集创作标签页 */}
|
||||
{/* 剧集创作标签页 - 直接嵌入创作工作台 */}
|
||||
<TabPane tab="剧集创作" key="episodes">
|
||||
{!globalSettingsCompleted ? (
|
||||
<Alert
|
||||
@ -1002,52 +1419,87 @@ export const ProjectDetail = () => {
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<Space direction="vertical" style={{ width: '100%' }} size="large">
|
||||
<Space style={{ marginBottom: '16px' }}>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={executing ? <LoadingOutlined /> : <PlayCircleOutlined />}
|
||||
onClick={handleExecuteBatch}
|
||||
disabled={executing}
|
||||
>
|
||||
{executing ? '创作中...' : '开始创作 (EP1-EP3)'}
|
||||
</Button>
|
||||
<span style={{ color: '#888' }}>依次创作前3集,使用配置的 Skills</span>
|
||||
</Space>
|
||||
|
||||
<List
|
||||
dataSource={episodes}
|
||||
renderItem={(episode) => (
|
||||
<List.Item
|
||||
actions={[
|
||||
episode.status === 'completed' ? (
|
||||
<Button type="link" onClick={() => setSelectedEpisode(episode)}>
|
||||
查看内容
|
||||
</Button>
|
||||
) : null
|
||||
]}
|
||||
<Layout style={{ height: 'calc(100vh - 200px)', background: '#fff' }}>
|
||||
{/* 工作台头部 */}
|
||||
<div style={{
|
||||
background: '#fff',
|
||||
borderBottom: '1px solid #f0f0f0',
|
||||
padding: '12px 24px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between'
|
||||
}}>
|
||||
<Space>
|
||||
<span style={{ fontWeight: 600, fontSize: '14px' }}>创作工作台</span>
|
||||
<span style={{ color: '#999', fontSize: '12px' }}>{currentProject?.name}</span>
|
||||
<span style={{ color: '#999', fontSize: '12px' }}>ID: {id}</span>
|
||||
<Tag color={wsConnected ? 'success' : 'error'}>
|
||||
{wsConnected ? '已连接' : '未连接'}
|
||||
</Tag>
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
icon={<UnorderedListOutlined />}
|
||||
onClick={() => setShowEpisodeSidebar(!showEpisodeSidebar)}
|
||||
style={{ color: showEpisodeSidebar ? '#1677ff' : '#666' }}
|
||||
>
|
||||
<List.Item.Meta
|
||||
title={
|
||||
<Space>
|
||||
<span>EP{episode.number}</span>
|
||||
{episode.title && <span>- {episode.title}</span>}
|
||||
<Tag color={getStatusColor(episode.status)}>
|
||||
{getStatusText(episode.status)}
|
||||
</Tag>
|
||||
</Space>
|
||||
}
|
||||
description={
|
||||
<Space direction="vertical" size="small">
|
||||
{episode.qualityScore && <span>质量分数: {episode.qualityScore}</span>}
|
||||
{episode.issues && episode.issues.length > 0 && <span>问题数: {episode.issues.length}</span>}
|
||||
</Space>
|
||||
}
|
||||
剧集列表
|
||||
</Button>
|
||||
</Space>
|
||||
<Space>
|
||||
<Button size="small">导出剧本</Button>
|
||||
<Button size="small" type="primary">发布</Button>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
<Layout>
|
||||
{/* 剧集管理侧边栏 */}
|
||||
{showEpisodeSidebar && (
|
||||
<Sider width={280} style={{ background: '#fafafa', borderRight: '1px solid #f0f0f0' }}>
|
||||
<EpisodeSidebar
|
||||
projectId={id!}
|
||||
onEpisodeSelect={(episode) => {
|
||||
setCurrentEpisodeInWorkspace(episode)
|
||||
if (episode.content) {
|
||||
setCanvasContent(episode.content)
|
||||
}
|
||||
}}
|
||||
currentEpisodeId={currentEpisodeInWorkspace?.id}
|
||||
/>
|
||||
</List.Item>
|
||||
</Sider>
|
||||
)}
|
||||
/>
|
||||
</Space>
|
||||
|
||||
{/* 左侧:Context Panel */}
|
||||
<ContextPanel
|
||||
project={currentProject}
|
||||
loading={loading}
|
||||
activeStates={activeStates}
|
||||
memoryItems={workspaceMemoryItems}
|
||||
/>
|
||||
|
||||
{/* 中间:Smart Canvas */}
|
||||
<Content style={{ position: 'relative', background: '#fff' }}>
|
||||
<SmartCanvas
|
||||
content={canvasContent}
|
||||
streaming={streaming}
|
||||
annotations={annotations}
|
||||
onStartGenerate={() => {
|
||||
handleDirectorMessage('开始生成大纲')
|
||||
}}
|
||||
/>
|
||||
</Content>
|
||||
|
||||
{/* 右侧:Director Inbox */}
|
||||
<DirectorInbox
|
||||
onSendMessage={handleDirectorMessage}
|
||||
onInboxAction={handleWorkspaceInboxAction}
|
||||
agentStatus={agentStatus}
|
||||
agentPlan={agentPlan}
|
||||
inboxItems={inboxItems}
|
||||
chatHistory={chatHistory}
|
||||
/>
|
||||
</Layout>
|
||||
</Layout>
|
||||
)}
|
||||
</TabPane>
|
||||
|
||||
|
||||
@ -12,7 +12,7 @@ import { Button, Space, Tag, Card, message, Empty, Row, Col, Progress, Tooltip,
|
||||
import {
|
||||
PlusOutlined, EditOutlined, DeleteOutlined, EyeOutlined,
|
||||
ClockCircleOutlined, CheckCircleOutlined, LoadingOutlined,
|
||||
FileTextOutlined, SettingOutlined
|
||||
FileTextOutlined
|
||||
} from '@ant-design/icons'
|
||||
import { useProjectStore } from '@/stores/projectStore'
|
||||
import { SeriesProject } from '@/services/projectService'
|
||||
@ -56,19 +56,21 @@ const calculateCompletion = (project: SeriesProject): number => {
|
||||
// 检查世界观设定
|
||||
if (project.globalContext?.worldSetting) completed += 1
|
||||
|
||||
// 检查人物设定
|
||||
if (project.globalContext?.characterProfiles &&
|
||||
Object.keys(project.globalContext.characterProfiles).length > 0) {
|
||||
completed += 1
|
||||
}
|
||||
// 检查人物设定 (支持 characterProfiles 或 styleGuide)
|
||||
const hasCharacters = (project.globalContext?.characterProfiles &&
|
||||
Object.keys(project.globalContext.characterProfiles).length > 0) ||
|
||||
(project.globalContext?.styleGuide && project.globalContext.styleGuide.trim().length > 0)
|
||||
if (hasCharacters) completed += 1
|
||||
|
||||
// 检查大纲
|
||||
if (project.globalContext?.overallOutline) completed += 1
|
||||
|
||||
// 检查剧集完成度
|
||||
// 检查剧集完成度 - 只计算状态为 completed 的剧集
|
||||
if (project.totalEpisodes && project.episodes) {
|
||||
total = 3 + project.totalEpisodes
|
||||
completed += project.episodes.length
|
||||
// 只统计状态为 completed 的剧集
|
||||
const completedEpisodes = project.episodes.filter((ep: any) => ep.status === 'completed').length
|
||||
completed += completedEpisodes
|
||||
}
|
||||
|
||||
return Math.min(100, Math.round((completed / total) * 100))
|
||||
@ -87,7 +89,7 @@ const getProjectStatus = (project: SeriesProject): ProjectStatus => {
|
||||
const ProjectCard = ({ project, onEdit, onDelete, onView }: {
|
||||
project: SeriesProject
|
||||
onEdit: (id: string) => void
|
||||
onDelete: (id: string) => void
|
||||
onDelete: (project: SeriesProject) => void
|
||||
onView: (id: string) => void
|
||||
}) => {
|
||||
const status = getProjectStatus(project)
|
||||
@ -205,7 +207,7 @@ const ProjectCard = ({ project, onEdit, onDelete, onView }: {
|
||||
danger
|
||||
size="small"
|
||||
icon={<DeleteOutlined />}
|
||||
onClick={() => onDelete(project.id)}
|
||||
onClick={() => onDelete(project)}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
@ -217,19 +219,35 @@ const ProjectCard = ({ project, onEdit, onDelete, onView }: {
|
||||
export const ProjectList = () => {
|
||||
const navigate = useNavigate()
|
||||
const { projects, loading, fetchProjects, deleteProject } = useProjectStore()
|
||||
const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid')
|
||||
|
||||
useEffect(() => {
|
||||
fetchProjects()
|
||||
}, [])
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
try {
|
||||
await deleteProject(id)
|
||||
message.success('项目已删除')
|
||||
} catch (error) {
|
||||
message.error(`删除失败: ${(error as Error).message}`)
|
||||
}
|
||||
const handleDelete = async (project: SeriesProject) => {
|
||||
// 使用 Modal.confirm 添加确认对话框
|
||||
Modal.confirm({
|
||||
title: '确认删除项目',
|
||||
content: (
|
||||
<div>
|
||||
<p>您确定要删除项目 <strong>{project.name}</strong> 吗?</p>
|
||||
<p style={{ color: '#ff4d4f', fontSize: '12px' }}>
|
||||
此操作不可撤销,项目的所有数据(包括世界观设定、人物设定、已生成的剧集等)将被永久删除。
|
||||
</p>
|
||||
</div>
|
||||
),
|
||||
okText: '确认删除',
|
||||
okType: 'danger',
|
||||
cancelText: '取消',
|
||||
onOk: async () => {
|
||||
try {
|
||||
await deleteProject(project.id)
|
||||
message.success('项目已删除')
|
||||
} catch (error) {
|
||||
message.error(`删除失败: ${(error as Error).message}`)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const handleContinueEdit = (id: string) => {
|
||||
@ -259,12 +277,6 @@ export const ProjectList = () => {
|
||||
</p>
|
||||
</div>
|
||||
<Space>
|
||||
<Button
|
||||
icon={<SettingOutlined />}
|
||||
onClick={() => setViewMode(viewMode === 'grid' ? 'list' : 'grid')}
|
||||
>
|
||||
{viewMode === 'grid' ? '列表视图' : '网格视图'}
|
||||
</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
size="large"
|
||||
|
||||
@ -1,93 +1,58 @@
|
||||
/**
|
||||
* 项目工作台页面
|
||||
*
|
||||
* 完整的项目管理界面:
|
||||
* - 项目概览
|
||||
* - 分集内容列表
|
||||
* - 快速编辑和预览
|
||||
* - 批量导出
|
||||
* - 单集 Skills 配置
|
||||
*/
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useEffect, useState, useRef, useCallback } from 'react'
|
||||
import { useNavigate, useParams } from 'react-router-dom'
|
||||
import {
|
||||
Row,
|
||||
Col,
|
||||
Card,
|
||||
Table,
|
||||
Button,
|
||||
Space,
|
||||
Tag,
|
||||
Progress,
|
||||
Statistic,
|
||||
Timeline,
|
||||
message,
|
||||
Modal,
|
||||
Drawer,
|
||||
Alert,
|
||||
Dropdown,
|
||||
Badge,
|
||||
Empty,
|
||||
Spin,
|
||||
Typography
|
||||
} from 'antd'
|
||||
import {
|
||||
ArrowLeftOutlined,
|
||||
PlayCircleOutlined,
|
||||
EyeOutlined,
|
||||
EditOutlined,
|
||||
DownloadOutlined,
|
||||
CheckCircleOutlined,
|
||||
ClockCircleOutlined,
|
||||
FileTextOutlined,
|
||||
SettingOutlined,
|
||||
RocketOutlined,
|
||||
ToolOutlined
|
||||
} from '@ant-design/icons'
|
||||
import { Layout, Button, Space, message, Spin, Typography, Tag } from 'antd'
|
||||
import { ArrowLeftOutlined, UnorderedListOutlined } from '@ant-design/icons'
|
||||
|
||||
const { Text } = Typography
|
||||
|
||||
import { ContentEditor } from '@/components/ContentEditor'
|
||||
import EpisodeSkillConfig from '@/components/EpisodeSkillConfig'
|
||||
import { episodeContentService, EpisodeContent, ContentStatus, ExportFormat } from '@/services/episodeContentService'
|
||||
import { projectService } from '@/services/projectService'
|
||||
import type { ColumnsType } from 'antd/es/table'
|
||||
import dayjs from 'dayjs'
|
||||
import { ContextPanel } from '@/components/Workspace/ContextPanel'
|
||||
import { SmartCanvas } from '@/components/Workspace/SmartCanvas'
|
||||
import { DirectorInbox } from '@/components/Workspace/DirectorInbox'
|
||||
import { EpisodeSidebar } from '@/components/Workspace/EpisodeSidebar'
|
||||
import { Episode } from '@/services/projectService'
|
||||
|
||||
const { Header, Content, Sider } = Layout;
|
||||
const { Text } = Typography;
|
||||
|
||||
export const ProjectWorkspace: React.FC = () => {
|
||||
const navigate = useNavigate()
|
||||
const { projectId } = useParams<{ projectId: string }>()
|
||||
const { id: projectId } = useParams<{ id: string }>()
|
||||
|
||||
// 调试:确认组件已加载
|
||||
console.log('=== ProjectWorkspace component loaded ===')
|
||||
console.log('projectId:', projectId)
|
||||
|
||||
// 项目数据
|
||||
const [project, setProject] = useState<any>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [streaming, setStreaming] = useState(false)
|
||||
const [canvasContent, setCanvasContent] = useState<string>('')
|
||||
const [agentStatus, setAgentStatus] = useState<'idle' | 'planning' | 'writing' | 'reviewing'>('idle')
|
||||
const [agentPlan, setAgentPlan] = useState<string[]>([])
|
||||
const [inboxItems, setInboxItems] = useState<any[]>([])
|
||||
const [chatHistory, setChatHistory] = useState<{role: 'user' | 'agent', content: string}[]>([])
|
||||
const [annotations, setAnnotations] = useState<any[]>([])
|
||||
const [activeStates, setActiveStates] = useState<any[]>([
|
||||
{ type: 'time', value: '初始状态' },
|
||||
{ type: 'location', value: '未知地点' }
|
||||
])
|
||||
|
||||
// 剧集内容
|
||||
const [contents, setContents] = useState<EpisodeContent[]>([])
|
||||
const [contentsLoading, setContentsLoading] = useState(false)
|
||||
// 记忆库状态
|
||||
const [memoryItems, setMemoryItems] = useState<any[]>([])
|
||||
|
||||
// 内容编辑器
|
||||
const [editorVisible, setEditorVisible] = useState(false)
|
||||
const [selectedEpisode, setSelectedEpisode] = useState<number | null>(null)
|
||||
const [selectedContent, setSelectedContent] = useState<EpisodeContent | null>(null)
|
||||
// 剧集相关状态
|
||||
const [currentEpisode, setCurrentEpisode] = useState<Episode | null>(null)
|
||||
const [showEpisodeSidebar, setShowEpisodeSidebar] = useState(true)
|
||||
|
||||
// 批量操作
|
||||
const [selectedRowKeys, setSelectedRowKeys] = useState<React.Key[]>([])
|
||||
const [wsConnected, setWsConnected] = useState(false);
|
||||
|
||||
// Skills 配置
|
||||
const [skillConfigVisible, setSkillConfigVisible] = useState(false)
|
||||
const [selectedConfigEpisode, setSelectedConfigEpisode] = useState<number | null>(null)
|
||||
const [currentEpisodeConfig, setCurrentEpisodeConfig] = useState<any>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (projectId) {
|
||||
loadProject()
|
||||
loadContents()
|
||||
}
|
||||
}, [projectId])
|
||||
// WebSocket refs
|
||||
const wsRef = useRef<WebSocket | null>(null);
|
||||
const reconnectTimeoutRef = useRef<number | null>(null);
|
||||
|
||||
const loadProject = async () => {
|
||||
if (!projectId) return
|
||||
if (!projectId) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
try {
|
||||
@ -100,426 +65,388 @@ export const ProjectWorkspace: React.FC = () => {
|
||||
}
|
||||
}
|
||||
|
||||
const loadContents = async () => {
|
||||
if (!projectId) return
|
||||
useEffect(() => {
|
||||
loadProject()
|
||||
|
||||
setContentsLoading(true)
|
||||
try {
|
||||
const data = await episodeContentService.listContents(projectId)
|
||||
setContents(data)
|
||||
} catch (error) {
|
||||
console.error('加载内容失败:', error)
|
||||
} finally {
|
||||
setContentsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
// 打开内容编辑器
|
||||
const handleOpenContent = (episodeNumber: number, content?: EpisodeContent) => {
|
||||
setSelectedEpisode(episodeNumber)
|
||||
setSelectedContent(content || null)
|
||||
setEditorVisible(true)
|
||||
}
|
||||
|
||||
// 打开 Skills 配置
|
||||
const handleOpenSkillConfig = async (episodeNumber: number) => {
|
||||
setSelectedConfigEpisode(episodeNumber)
|
||||
|
||||
// 获取当前单集配置
|
||||
if (project?.episodeSkillOverrides?.[episodeNumber]) {
|
||||
setCurrentEpisodeConfig(project.episodeSkillOverrides[episodeNumber])
|
||||
} else {
|
||||
setCurrentEpisodeConfig(null)
|
||||
}
|
||||
|
||||
setSkillConfigVisible(true)
|
||||
}
|
||||
|
||||
const handleSaveSkillConfig = async (config: any) => {
|
||||
// 重新加载项目数据以获取最新配置
|
||||
await loadProject()
|
||||
}
|
||||
|
||||
// 状态标签
|
||||
const getStatusTag = (status: ContentStatus) => {
|
||||
const config = {
|
||||
draft: { color: 'default', text: '草稿' },
|
||||
generating: { color: 'processing', text: '生成中' },
|
||||
pending_review: { color: 'warning', text: '待审核' },
|
||||
approved: { color: 'success', text: '已通过' },
|
||||
rejected: { color: 'error', text: '已拒绝' }
|
||||
}
|
||||
const { color, text } = config[status] || config.draft
|
||||
return <Tag color={color}>{text}</Tag>
|
||||
}
|
||||
|
||||
// 导出菜单
|
||||
const exportMenu = {
|
||||
items: [
|
||||
{ key: 'all', label: '导出全部内容' },
|
||||
{ key: 'selected', label: '导出选中内容' },
|
||||
{ type: 'divider' },
|
||||
{ key: 'markdown', label: 'Markdown 格式' },
|
||||
{ key: 'txt', label: '纯文本格式' },
|
||||
{ key: 'pdf', label: 'PDF 格式' }
|
||||
],
|
||||
onClick: async ({ key }: { key: string }) => {
|
||||
if (!projectId) return
|
||||
|
||||
try {
|
||||
if (key === 'all' || key === 'selected') {
|
||||
const episodeNumbers = key === 'selected' && selectedRowKeys.length > 0
|
||||
? selectedRowKeys.map(k => Number(k))
|
||||
: undefined
|
||||
|
||||
const result = await episodeContentService.exportContents(projectId, {
|
||||
format: 'markdown',
|
||||
episode_numbers: episodeNumbers
|
||||
})
|
||||
|
||||
if (result.content) {
|
||||
const blob = new Blob([result.content], { type: 'text/plain;charset=utf-8' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = result.filename || `project_${projectId}_all.md`
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
URL.revokeObjectURL(url)
|
||||
|
||||
message.success('导出成功')
|
||||
}
|
||||
return () => {
|
||||
if (reconnectTimeoutRef.current) {
|
||||
clearTimeout(reconnectTimeoutRef.current);
|
||||
}
|
||||
} catch (error) {
|
||||
message.error(`导出失败: ${(error as Error).message}`)
|
||||
if (wsRef.current) {
|
||||
wsRef.current.close();
|
||||
}
|
||||
}
|
||||
}, [projectId])
|
||||
|
||||
// WebSocket Connection Logic
|
||||
const connectWebSocket = useCallback(() => {
|
||||
if (!projectId) return;
|
||||
|
||||
// Close existing connection if any
|
||||
if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) {
|
||||
console.log('Closing existing WebSocket connection');
|
||||
wsRef.current.close();
|
||||
}
|
||||
|
||||
// Use relative URL which will be proxied by Vite
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const host = window.location.host; // e.g. localhost:5173
|
||||
const wsUrl = `${protocol}//${host}/ws/projects/${projectId}/execute`;
|
||||
|
||||
console.log('Connecting to WebSocket:', wsUrl);
|
||||
const ws = new WebSocket(wsUrl);
|
||||
|
||||
ws.onopen = () => {
|
||||
console.log('WebSocket Connected');
|
||||
setWsConnected(true);
|
||||
message.success('已连接到 Agent Runtime');
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
try {
|
||||
const msg = JSON.parse(event.data);
|
||||
handleWebSocketMessage(msg);
|
||||
} catch (e) {
|
||||
console.error('Failed to parse WS message:', e);
|
||||
}
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
console.log('WebSocket Disconnected');
|
||||
setWsConnected(false);
|
||||
// Clear ref to allow reconnection
|
||||
if (wsRef.current === ws) {
|
||||
wsRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
ws.onerror = (error) => {
|
||||
console.error('WebSocket Error:', error);
|
||||
// Don't close here, let onclose handle it
|
||||
};
|
||||
|
||||
wsRef.current = ws;
|
||||
}, [projectId]); // Only depend on projectId
|
||||
|
||||
useEffect(() => {
|
||||
if (projectId && !loading) {
|
||||
connectWebSocket();
|
||||
}
|
||||
// Cleanup on unmount or projectId change
|
||||
return () => {
|
||||
if (reconnectTimeoutRef.current) {
|
||||
clearTimeout(reconnectTimeoutRef.current);
|
||||
}
|
||||
if (wsRef.current) {
|
||||
wsRef.current.close();
|
||||
wsRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [projectId, loading, connectWebSocket]);
|
||||
|
||||
const handleInboxAction = (itemId: string, action: 'approve' | 'reject') => {
|
||||
// Optimistic update
|
||||
setInboxItems(prev => prev.filter(item => item.id !== itemId));
|
||||
|
||||
// Send to backend
|
||||
if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) {
|
||||
wsRef.current.send(JSON.stringify({
|
||||
type: 'inbox_action',
|
||||
itemId,
|
||||
action
|
||||
}));
|
||||
}
|
||||
|
||||
message.success(action === 'approve' ? '已批准' : '已拒绝');
|
||||
};
|
||||
|
||||
const handleWebSocketMessage = (msg: any) => {
|
||||
console.log('Received:', msg);
|
||||
|
||||
switch (msg.type) {
|
||||
case 'connected':
|
||||
break;
|
||||
case 'history':
|
||||
if (msg.messages) {
|
||||
setChatHistory(msg.messages);
|
||||
}
|
||||
break;
|
||||
case 'thinking':
|
||||
// Optional: show thinking indicator in chat
|
||||
break;
|
||||
case 'text':
|
||||
setChatHistory(prev => {
|
||||
const lastMsg = prev[prev.length - 1];
|
||||
// 如果上一条消息也是 agent 发送的,则认为是流式输出,追加内容
|
||||
if (lastMsg && lastMsg.role === 'agent') {
|
||||
const newHistory = [...prev];
|
||||
newHistory[newHistory.length - 1] = {
|
||||
...lastMsg,
|
||||
content: lastMsg.content + msg.content
|
||||
};
|
||||
return newHistory;
|
||||
}
|
||||
// 否则添加新消息
|
||||
return [...prev, { role: 'agent', content: msg.content }];
|
||||
});
|
||||
break;
|
||||
case 'plan_update':
|
||||
if (msg.plan) {
|
||||
setAgentPlan(msg.plan);
|
||||
}
|
||||
if (msg.status) {
|
||||
setAgentStatus(msg.status);
|
||||
}
|
||||
break;
|
||||
case 'review_request':
|
||||
setInboxItems(prev => [...prev, {
|
||||
id: msg.id || Date.now().toString(),
|
||||
type: 'review',
|
||||
title: msg.title || '需要审核',
|
||||
description: msg.description,
|
||||
status: 'pending',
|
||||
timestamp: Date.now()
|
||||
}]);
|
||||
break;
|
||||
case 'memory_hit':
|
||||
// 添加到记忆库
|
||||
if (msg.data) {
|
||||
setMemoryItems(prev => [...prev, {
|
||||
type: msg.data.memory_type || 'timeline',
|
||||
title: msg.data.title || '记忆更新',
|
||||
description: msg.data.description || JSON.stringify(msg.data.data),
|
||||
timestamp: Date.now()
|
||||
}]);
|
||||
}
|
||||
break;
|
||||
case 'memory_update':
|
||||
// 从 update_memory 工具触发的记忆更新
|
||||
if (msg.data) {
|
||||
setMemoryItems(prev => [...prev, {
|
||||
type: msg.data.type || msg.data.memory_type || 'timeline',
|
||||
title: msg.data.title || '记忆更新',
|
||||
description: msg.data.description || '',
|
||||
timestamp: msg.data.timestamp || Date.now(),
|
||||
character: msg.data.character,
|
||||
state: msg.data.state
|
||||
}]);
|
||||
}
|
||||
break;
|
||||
case 'annotation_add':
|
||||
setAnnotations(prev => [...prev, msg.annotation]);
|
||||
break;
|
||||
case 'context_update':
|
||||
if (msg.states) {
|
||||
setActiveStates(msg.states);
|
||||
}
|
||||
break;
|
||||
case 'tool_call':
|
||||
const toolData = msg.data || {};
|
||||
const toolName = toolData.name || msg.name;
|
||||
|
||||
if (toolName === 'update_canvas' || toolName === 'write_file') {
|
||||
setStreaming(true);
|
||||
setAgentStatus('writing');
|
||||
} else {
|
||||
setAgentStatus('planning');
|
||||
}
|
||||
break;
|
||||
case 'tool_result':
|
||||
setStreaming(false);
|
||||
break;
|
||||
case 'canvas_update':
|
||||
if (msg.content) {
|
||||
setCanvasContent(msg.content);
|
||||
// 自动保存到当前剧集
|
||||
if (currentEpisode && currentEpisode.id) {
|
||||
// 这里可以调用 API 保存到后端
|
||||
console.log('Auto-saving content to episode:', currentEpisode.id);
|
||||
}
|
||||
}
|
||||
break;
|
||||
case 'episode_saved':
|
||||
// Agent 保存剧集后的确认
|
||||
message.success(`剧集 ${msg.episode_number || ''} 已自动保存`);
|
||||
break;
|
||||
case 'done':
|
||||
setAgentStatus('idle');
|
||||
setStreaming(false);
|
||||
break;
|
||||
case 'error':
|
||||
message.error(msg.data?.message || 'Unknown error');
|
||||
setAgentStatus('idle');
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
const handleDirectorMessage = (msg: string) => {
|
||||
console.log('=== handleDirectorMessage called ===', msg);
|
||||
|
||||
// Optimistically update chat history
|
||||
setChatHistory(prev => [...prev, { role: 'user', content: msg }]);
|
||||
|
||||
// Send message to backend via WebSocket
|
||||
console.log('wsRef.current:', wsRef.current);
|
||||
console.log('wsRef.current?.readyState:', wsRef.current?.readyState);
|
||||
console.log('WebSocket.OPEN:', WebSocket.OPEN);
|
||||
|
||||
if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) {
|
||||
const messagePayload = JSON.stringify({
|
||||
type: 'chat_message',
|
||||
content: msg
|
||||
});
|
||||
console.log('Sending message:', messagePayload);
|
||||
|
||||
wsRef.current.send(messagePayload);
|
||||
console.log('Message sent successfully');
|
||||
|
||||
// Optimistic UI update could happen here
|
||||
if (msg.includes("开始")) {
|
||||
setAgentStatus('planning');
|
||||
}
|
||||
} else {
|
||||
console.error('WebSocket not ready:', {
|
||||
exists: !!wsRef.current,
|
||||
readyState: wsRef.current?.readyState,
|
||||
OPEN: WebSocket.OPEN
|
||||
});
|
||||
message.error('Agent 连接未就绪');
|
||||
}
|
||||
}
|
||||
|
||||
// 表格列定义
|
||||
const columns: ColumnsType<EpisodeContent> = [
|
||||
{
|
||||
title: '集数',
|
||||
dataIndex: 'episode_number',
|
||||
key: 'episode_number',
|
||||
width: 80,
|
||||
render: (num) => <Tag color="blue">#{num}</Tag>
|
||||
},
|
||||
{
|
||||
title: '标题',
|
||||
dataIndex: 'title',
|
||||
key: 'title',
|
||||
render: (title) => title || <span style={{ color: '#999' }}>(无标题)</span>
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
key: 'status',
|
||||
width: 100,
|
||||
render: (status: ContentStatus) => getStatusTag(status)
|
||||
},
|
||||
{
|
||||
title: '质量分数',
|
||||
dataIndex: 'quality_score',
|
||||
key: 'quality_score',
|
||||
width: 120,
|
||||
render: (score) => (
|
||||
score !== null && score !== undefined ? (
|
||||
<Progress
|
||||
percent={score}
|
||||
size="small"
|
||||
status={score >= 80 ? 'success' : score >= 60 ? 'normal' : 'exception'}
|
||||
/>
|
||||
) : null
|
||||
)
|
||||
},
|
||||
{
|
||||
title: '字数',
|
||||
key: 'word_count',
|
||||
width: 100,
|
||||
render: (_, record) => {
|
||||
const count = record.content.length
|
||||
return <span>{count.toLocaleString()} 字</span>
|
||||
}
|
||||
},
|
||||
{
|
||||
title: '更新时间',
|
||||
dataIndex: 'updated_at',
|
||||
key: 'updated_at',
|
||||
width: 160,
|
||||
render: (date) => dayjs(date).format('MM-DD HH:mm')
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'actions',
|
||||
width: 280,
|
||||
fixed: 'right' as const,
|
||||
render: (_, record) => {
|
||||
// 检查是否有自定义配置
|
||||
const hasCustomConfig = project?.episodeSkillOverrides?.[record.episode_number]
|
||||
const useDefault = !hasCustomConfig || hasCustomConfig.use_project_default
|
||||
|
||||
return (
|
||||
<Space size="small">
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
icon={<EyeOutlined />}
|
||||
onClick={() => handleOpenContent(record.episode_number, record)}
|
||||
>
|
||||
预览
|
||||
</Button>
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
icon={<EditOutlined />}
|
||||
onClick={() => handleOpenContent(record.episode_number, record)}
|
||||
>
|
||||
编辑
|
||||
</Button>
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
icon={<ToolOutlined />}
|
||||
onClick={() => handleOpenSkillConfig(record.episode_number)}
|
||||
style={useDefault ? {} : { color: '#1677ff', fontWeight: 500 }}
|
||||
>
|
||||
配置 Skills
|
||||
</Button>
|
||||
</Space>
|
||||
)
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
// 统计数据
|
||||
const getStatistics = () => {
|
||||
const total = contents.length
|
||||
const approved = contents.filter(c => c.status === 'approved').length
|
||||
const pending = contents.filter(c => c.status === 'pending_review').length
|
||||
const avgScore = contents.length > 0
|
||||
? contents.reduce((sum, c) => sum + (c.quality_score || 0), 0) / contents.length
|
||||
: 0
|
||||
|
||||
return { total, approved, pending, avgScore }
|
||||
// 画布内容变更处理
|
||||
const handleContentChange = (content: string) => {
|
||||
setCanvasContent(content);
|
||||
}
|
||||
|
||||
const stats = getStatistics()
|
||||
// 画布内容保存处理
|
||||
const handleContentSave = async (content: string) => {
|
||||
if (!currentEpisode || !currentEpisode.id) {
|
||||
message.warning('请先选择要保存的剧集');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// 调用后端 API 保存剧集内容
|
||||
await projectService.updateEpisode(currentEpisode.id, {
|
||||
content: content,
|
||||
status: 'draft'
|
||||
});
|
||||
message.success('内容已保存');
|
||||
} catch (error) {
|
||||
message.error(`保存失败: ${(error as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div style={{ padding: '24px', textAlign: 'center' }}>
|
||||
<Spin size="large" tip="加载项目中..." />
|
||||
<div style={{ padding: '24px', textAlign: 'center', marginTop: '100px' }}>
|
||||
<Spin size="large" tip="正在初始化数字化片场..." />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!project) {
|
||||
return (
|
||||
<div style={{ padding: '24px', textAlign: 'center', marginTop: '100px' }}>
|
||||
<Text type="danger">项目加载失败或不存在</Text>
|
||||
<br />
|
||||
<Button onClick={() => navigate('/projects')} style={{ marginTop: '16px' }}>
|
||||
返回项目列表
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ padding: '24px' }}>
|
||||
{/* 头部 */}
|
||||
<Card
|
||||
title={
|
||||
<Space>
|
||||
<Button
|
||||
icon={<ArrowLeftOutlined />}
|
||||
onClick={() => navigate('/projects')}
|
||||
>
|
||||
返回
|
||||
</Button>
|
||||
<span>{project?.name || '项目工作台'}</span>
|
||||
<Tag color="blue">ID: {projectId}</Tag>
|
||||
</Space>
|
||||
}
|
||||
extra={
|
||||
<Space>
|
||||
<Button icon={<SettingOutlined />}>设置</Button>
|
||||
<Dropdown menu={exportMenu}>
|
||||
<Button type="primary" icon={<DownloadOutlined />}>
|
||||
导出内容
|
||||
</Button>
|
||||
</Dropdown>
|
||||
</Space>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* 统计概览 */}
|
||||
<Row gutter={16} style={{ marginTop: '16px' }}>
|
||||
<Col span={6}>
|
||||
<Card>
|
||||
<Statistic
|
||||
title="总集数"
|
||||
value={stats.total}
|
||||
prefix="#"
|
||||
suffix="集"
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Card>
|
||||
<Statistic
|
||||
title="已完成"
|
||||
value={stats.approved}
|
||||
valueStyle={{ color: '#3f8600' }}
|
||||
suffix="集"
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Card>
|
||||
<Statistic
|
||||
title="待审核"
|
||||
value={stats.pending}
|
||||
valueStyle={{ color: '#faad14' }}
|
||||
suffix="集"
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Card>
|
||||
<Statistic
|
||||
title="平均质量"
|
||||
value={stats.avgScore}
|
||||
precision={1}
|
||||
suffix="/ 100"
|
||||
valueStyle={{ color: stats.avgScore >= 80 ? '#3f8600' : '#cf1322' }}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
{/* 主内容区域 */}
|
||||
<Row gutter={16} style={{ marginTop: '16px' }}>
|
||||
{/* 左侧:剧集列表 */}
|
||||
<Col span={16}>
|
||||
<Card
|
||||
title="剧集内容列表"
|
||||
extra={
|
||||
<Space>
|
||||
<Badge count={selectedRowKeys.length} offset={[10, 0]}>
|
||||
<Button>批量操作</Button>
|
||||
</Badge>
|
||||
<Button
|
||||
icon={<RocketOutlined />}
|
||||
type="primary"
|
||||
onClick={() => navigate(`/projects/${projectId}/execute`)}
|
||||
>
|
||||
继续生成
|
||||
</Button>
|
||||
</Space>
|
||||
}
|
||||
<Layout style={{ height: '100vh' }}>
|
||||
<Header style={{
|
||||
background: '#fff',
|
||||
borderBottom: '1px solid #f0f0f0',
|
||||
padding: '0 24px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between'
|
||||
}}>
|
||||
<Space>
|
||||
<Button
|
||||
type="text"
|
||||
icon={<ArrowLeftOutlined />}
|
||||
onClick={() => navigate('/projects')}
|
||||
/>
|
||||
<Text strong style={{ fontSize: '16px' }}>{project?.name}</Text>
|
||||
<Text type="secondary" style={{ fontSize: '12px' }}>ID: {projectId}</Text>
|
||||
<Tag color={wsConnected ? 'success' : 'error'}>
|
||||
{wsConnected ? '已连接' : '未连接'}
|
||||
</Tag>
|
||||
<Button
|
||||
type="text"
|
||||
icon={<UnorderedListOutlined />}
|
||||
onClick={() => setShowEpisodeSidebar(!showEpisodeSidebar)}
|
||||
style={{ color: showEpisodeSidebar ? '#1677ff' : '#666' }}
|
||||
>
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={contents}
|
||||
rowKey="id"
|
||||
loading={contentsLoading}
|
||||
rowSelection={{
|
||||
selectedRowKeys,
|
||||
onChange: setSelectedRowKeys
|
||||
剧集列表
|
||||
</Button>
|
||||
</Space>
|
||||
|
||||
<Space>
|
||||
<Button>导出剧本</Button>
|
||||
<Button type="primary">发布</Button>
|
||||
</Space>
|
||||
</Header>
|
||||
|
||||
<Layout>
|
||||
{/* 剧集管理侧边栏 */}
|
||||
{showEpisodeSidebar && (
|
||||
<Sider width={280} style={{ background: '#fafafa', borderRight: '1px solid #f0f0f0' }}>
|
||||
<EpisodeSidebar
|
||||
projectId={projectId!}
|
||||
onEpisodeSelect={(episode) => {
|
||||
setCurrentEpisode(episode)
|
||||
// 可以在这里更新画布内容显示剧集内容
|
||||
if (episode.content) {
|
||||
setCanvasContent(episode.content)
|
||||
}
|
||||
}}
|
||||
pagination={{
|
||||
pageSize: 10,
|
||||
showSizeChanger: true,
|
||||
showTotal: (total) => `共 ${total} 集`
|
||||
}}
|
||||
scroll={{ x: 1000 }}
|
||||
currentEpisodeId={currentEpisode?.id}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
</Sider>
|
||||
)}
|
||||
|
||||
{/* 右侧:项目信息 */}
|
||||
<Col span={8}>
|
||||
<Space direction="vertical" style={{ width: '100%' }} size="middle">
|
||||
{/* 项目信息 */}
|
||||
<Card title="项目信息" size="small">
|
||||
<Row gutter={[8, 8]}>
|
||||
<Col span={8}><span style={{ color: '#999' }}>总集数:</span></Col>
|
||||
<Col span={16}>{project?.totalEpisodes || '-'} 集</Col>
|
||||
|
||||
<Col span={8}><span style={{ color: '#999' }}>进度:</span></Col>
|
||||
<Col span={16}>
|
||||
<Progress
|
||||
percent={Math.round((stats.approved / (project?.totalEpisodes || 1)) * 100)}
|
||||
size="small"
|
||||
/>
|
||||
</Col>
|
||||
|
||||
<Col span={8}><span style={{ color: '#999' }}>创建时间:</span></Col>
|
||||
<Col span={16}>{project?.createdAt ? dayjs(project.createdAt).format('YYYY-MM-DD') : '-'}</Col>
|
||||
|
||||
<Col span={8}><span style={{ color: '#999' }}>Agent:</span></Col>
|
||||
<Col span={16}>{project?.agentId || '-'}</Col>
|
||||
</Row>
|
||||
</Card>
|
||||
|
||||
{/* 使用的 Skills */}
|
||||
<Card title="使用的 Skills" size="small">
|
||||
{project?.skillSettings && Object.keys(project.skillSettings).length > 0 ? (
|
||||
<Space direction="vertical" style={{ width: '100%' }} size="small">
|
||||
{Object.entries(project.skillSettings).map(([skillId, config]: [string, any]) => (
|
||||
<Tag key={skillId} color={config?.enabled ? 'green' : 'default'}>
|
||||
{skillId}
|
||||
</Tag>
|
||||
))}
|
||||
</Space>
|
||||
) : (
|
||||
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="未配置 Skills" />
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* 最近活动 */}
|
||||
<Card title="最近活动" size="small">
|
||||
<Timeline
|
||||
items={contents.slice(-5).reverse().map((c) => ({
|
||||
color: c.status === 'approved' ? 'green' : 'blue',
|
||||
children: (
|
||||
<Space direction="vertical" size={0}>
|
||||
<Text>第 {c.episode_number} 集 {c.status === 'approved' ? '完成' : '更新'}</Text>
|
||||
<span style={{ fontSize: '12px', color: '#999' }}>
|
||||
{dayjs(c.updated_at).format('MM-DD HH:mm')}
|
||||
</span>
|
||||
</Space>
|
||||
)
|
||||
}))}
|
||||
/>
|
||||
{contents.length === 0 && (
|
||||
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="暂无活动" />
|
||||
)}
|
||||
</Card>
|
||||
</Space>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
{/* 内容编辑器 */}
|
||||
<ContentEditor
|
||||
visible={editorVisible}
|
||||
projectId={projectId || ''}
|
||||
episodeNumber={selectedEpisode || undefined}
|
||||
content={selectedContent || undefined}
|
||||
onClose={() => {
|
||||
setEditorVisible(false)
|
||||
setSelectedEpisode(null)
|
||||
setSelectedContent(null)
|
||||
}}
|
||||
onSave={loadContents}
|
||||
/>
|
||||
|
||||
{/* Skills 配置 */}
|
||||
{projectId && selectedConfigEpisode && (
|
||||
<EpisodeSkillConfig
|
||||
visible={skillConfigVisible}
|
||||
projectId={projectId}
|
||||
episodeNumber={selectedConfigEpisode}
|
||||
currentConfig={currentEpisodeConfig}
|
||||
onSave={handleSaveSkillConfig}
|
||||
onClose={() => {
|
||||
setSkillConfigVisible(false)
|
||||
setSelectedConfigEpisode(null)
|
||||
setCurrentEpisodeConfig(null)
|
||||
}}
|
||||
{/* 左侧:Context Panel */}
|
||||
<ContextPanel
|
||||
project={project}
|
||||
loading={loading}
|
||||
activeStates={activeStates}
|
||||
memoryItems={memoryItems}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 中间:Smart Canvas */}
|
||||
<Content style={{ position: 'relative' }}>
|
||||
<SmartCanvas
|
||||
content={canvasContent}
|
||||
streaming={streaming}
|
||||
annotations={annotations}
|
||||
episodeTitle={currentEpisode?.title || '未命名草稿'}
|
||||
episodeNumber={currentEpisode?.number || 5}
|
||||
onStartGenerate={() => {
|
||||
handleDirectorMessage('开始生成大纲');
|
||||
}}
|
||||
onContentChange={handleContentChange}
|
||||
onContentSave={handleContentSave}
|
||||
/>
|
||||
</Content>
|
||||
|
||||
{/* 右侧:Director Inbox */}
|
||||
<DirectorInbox
|
||||
onSendMessage={handleDirectorMessage}
|
||||
onInboxAction={handleInboxAction}
|
||||
agentStatus={agentStatus}
|
||||
agentPlan={agentPlan}
|
||||
inboxItems={inboxItems}
|
||||
chatHistory={chatHistory}
|
||||
/>
|
||||
</Layout>
|
||||
</Layout>
|
||||
)
|
||||
}
|
||||
|
||||
@ -4,7 +4,7 @@
|
||||
import axios from 'axios'
|
||||
|
||||
const api = axios.create({
|
||||
baseURL: import.meta.env.VITE_API_BASE_URL || 'http://localhost:8000/api/v1',
|
||||
baseURL: import.meta.env.VITE_API_BASE_URL || '/api/v1',
|
||||
timeout: 120000, // 2分钟超时(LLM 调用可能需要较长时间)
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
@ -13,17 +13,11 @@ const api = axios.create({
|
||||
maxRedirects: 5,
|
||||
})
|
||||
|
||||
// 请求拦截器 - 自动添加末尾斜杠
|
||||
// 请求拦截器 - 仅对列表类请求添加末尾斜杠
|
||||
api.interceptors.request.use(
|
||||
(config) => {
|
||||
// 确保 URL 路径有末尾斜杠(如果路径不以 / 结尾)
|
||||
if (config.url && !config.url.includes('?') && !config.url.endsWith('/')) {
|
||||
// 只为没有参数的路径添加末尾斜杠
|
||||
const urlParts = config.url.split('/')
|
||||
if (urlParts.length > 0 && !urlParts[urlParts.length - 1].includes('.')) {
|
||||
config.url = config.url + '/'
|
||||
}
|
||||
}
|
||||
// 不自动添加末尾斜杠,FastAPI 后端不需要
|
||||
// 只在某些特定端点需要时手动添加
|
||||
return config
|
||||
},
|
||||
(error) => {
|
||||
|
||||
@ -132,6 +132,11 @@ export const projectService = {
|
||||
return await api.get<Episode>(`/projects/${projectId}/episodes/${episodeNumber}`)
|
||||
},
|
||||
|
||||
// 更新剧集内容
|
||||
updateEpisode: async (episodeId: string, data: Partial<Episode>) => {
|
||||
return await api.put<Episode>(`/episodes/${episodeId}`, data)
|
||||
},
|
||||
|
||||
// 执行单集创作
|
||||
executeEpisode: async (
|
||||
projectId: string,
|
||||
|
||||
@ -17,6 +17,11 @@ export default defineConfig({
|
||||
target: 'http://localhost:8000',
|
||||
changeOrigin: true,
|
||||
},
|
||||
'/ws': {
|
||||
target: 'http://localhost:8000',
|
||||
ws: true,
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
8
test.md
Normal file
8
test.md
Normal file
@ -0,0 +1,8 @@
|
||||
# 我的python 环境是"C:\ProgramData\Anaconda3\envs\creative_studio\python.exe"
|
||||
## 1
|
||||
|
||||
## 2
|
||||
页面上的人物、初始状态这些为什么是无内容,没有从项目设置和全局设定中同步过来,同时这个世界观和人物在剧集创作界面都不能进行修改了,而是只能从前一页内容中进行同步过来。另外,这些设定都有没有正确注入项目agent的前置信息里面?
|
||||
## 3
|
||||
关于这个创建项目,删除按钮有没有确认的流程?如果没有需要添加上,确认后才会删除项目;关于项目完成度应该按照剧集制作完成度来计算显示。
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user