feat:剧集创作功能优化开发

This commit is contained in:
hjjjj 2026-01-27 18:31:04 +08:00
parent f29521d327
commit c99f66895b
33 changed files with 4586 additions and 731 deletions

View File

@ -14,7 +14,18 @@
"Bash(netstat:*)", "Bash(netstat:*)",
"Bash(tail:*)", "Bash(tail:*)",
"Bash(tasklist:*)", "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
View 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 平台。

View File

@ -66,6 +66,8 @@ async def execute_generate_characters(
extra_info += f"\n项目名称:{params['projectName']}" extra_info += f"\n项目名称:{params['projectName']}"
if params.get("totalEpisodes"): if params.get("totalEpisodes"):
extra_info += f"\n总集数:{params['totalEpisodes']}" extra_info += f"\n总集数:{params['totalEpisodes']}"
if params.get("genre"):
extra_info += f"\n类型:{params['genre']}"
custom_requirements = "" custom_requirements = ""
if params.get("customPrompt"): if params.get("customPrompt"):
@ -303,6 +305,7 @@ class GenerateCharactersRequest(BaseModel):
idea: str idea: str
projectName: Optional[str] = None projectName: Optional[str] = None
totalEpisodes: Optional[int] = None totalEpisodes: Optional[int] = None
genre: Optional[str] = "古风"
skills: Optional[List[SkillInfo]] = None skills: Optional[List[SkillInfo]] = None
customPrompt: Optional[str] = None customPrompt: Optional[str] = None
projectId: Optional[str] = None # 关联项目ID projectId: Optional[str] = None # 关联项目ID

View File

@ -8,8 +8,12 @@ from typing import Dict, Set, Optional, Any
import json import json
import asyncio import asyncio
from datetime import datetime from datetime import datetime
from pathlib import Path
from app.config import settings
from app.utils.logger import get_logger 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__) logger = get_logger(__name__)
@ -28,6 +32,8 @@ class ConnectionManager:
self.project_connections: Dict[str, Set[WebSocket]] = {} self.project_connections: Dict[str, Set[WebSocket]] = {}
# 批次ID -> WebSocket连接集合 # 批次ID -> WebSocket连接集合
self.batch_connections: Dict[str, Set[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): async def connect_to_project(self, websocket: WebSocket, project_id: str):
"""连接到项目执行流""" """连接到项目执行流"""
@ -54,6 +60,9 @@ class ConnectionManager:
logger.info(f"WebSocket 已从项目断开: {project_id}") logger.info(f"WebSocket 已从项目断开: {project_id}")
if not connections: if not connections:
del self.project_connections[project_id] 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(): for batch_id, connections in self.batch_connections.items():
@ -105,6 +114,30 @@ class ConnectionManager:
for connection in disconnected: for connection in disconnected:
self.disconnect(connection) 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: def get_project_connections_count(self, project_id: str) -> int:
"""获取项目的连接数""" """获取项目的连接数"""
return len(self.project_connections.get(project_id, set())) return len(self.project_connections.get(project_id, set()))
@ -129,22 +162,13 @@ async def websocket_project_execution(
): ):
""" """
项目执行 WebSocket 端点 项目执行 WebSocket 端点
实时接收项目执行进度更新包括
- 执行开始/完成事件
- 各阶段进度结构分析大纲生成对话创作等
- 质量检查结果
- 错误信息
消息格式:
{
"type": "stage_start|stage_progress|stage_complete|error|complete",
"data": {...},
"timestamp": "ISO 8601"
}
""" """
await manager.connect_to_project(websocket, project_id) await manager.connect_to_project(websocket, project_id)
# 准备工作目录 (假设在 projects/{id})
# 注意:这里需要根据实际配置调整路径
project_dir = Path(f"d:/platform/creative_studio/workspace/projects/{project_id}")
try: try:
# 发送连接确认 # 发送连接确认
await websocket.send_json({ 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: while True:
try: try:
# 接收客户端消息(可用于心跳、控制命令等) # 接收客户端消息
data = await websocket.receive_text() data = await websocket.receive_text()
message = json.loads(data) message = json.loads(data)
# 处理客户端消息 # 处理客户端消息
await _handle_client_message(websocket, project_id, message) await _handle_client_message(websocket, project_id, message, project_dir)
except WebSocketDisconnect: except WebSocketDisconnect:
logger.info(f"WebSocket 客户端主动断开: {project_id}") logger.info(f"WebSocket 客户端主动断开: {project_id}")
@ -199,20 +231,6 @@ async def websocket_batch_execution(
): ):
""" """
批量执行 WebSocket 端点 批量执行 WebSocket 端点
实时接收批量执行进度更新包括
- 批次开始/完成事件
- 各剧集执行进度
- 整体进度百分比
- 质量统计信息
- 错误信息
消息格式:
{
"type": "batch_start|episode_start|episode_complete|progress|batch_complete|error",
"data": {...},
"timestamp": "ISO 8601"
}
""" """
await manager.connect_to_batch(websocket, batch_id) await manager.connect_to_batch(websocket, batch_id)
@ -231,17 +249,18 @@ async def websocket_batch_execution(
while True: while True:
try: try:
data = await websocket.receive_text() data = await websocket.receive_text()
# 批量执行目前不接受客户端控制消息,仅广播
# 但为了保持连接活性,可以处理 ping
message = json.loads(data) 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: except WebSocketDisconnect:
logger.info(f"WebSocket 客户端主动断开: {batch_id}") logger.info(f"WebSocket 客户端主动断开: {batch_id}")
break break
except json.JSONDecodeError:
await websocket.send_json({
"type": "error",
"data": {"message": "无效的 JSON 格式"}
})
except Exception as e: except Exception as e:
logger.error(f"处理 WebSocket 消息错误: {str(e)}") logger.error(f"处理 WebSocket 消息错误: {str(e)}")
@ -255,18 +274,15 @@ async def websocket_batch_execution(
async def _handle_client_message( async def _handle_client_message(
websocket: WebSocket, websocket: WebSocket,
id: str, project_id: str,
message: Dict[str, Any] message: Dict[str, Any],
project_dir: Path
): ):
""" """
处理客户端发送的消息 处理客户端发送的消息
Args:
websocket: WebSocket 连接
id: 项目ID或批次ID
message: 客户端消息
""" """
message_type = message.get("type") message_type = message.get("type")
logger.info(f"收到消息: type={message_type}, full_message={message}")
if message_type == "ping": 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": elif message_type == "get_status":
# 请求状态 # 请求状态
from app.core.execution.batch_executor import get_batch_executor # 这里可以返回 Agent 的状态,或者之前的 executor 状态
executor = get_batch_executor() pass
status = executor.get_batch_status(id)
await websocket.send_json({
"type": "status",
"data": status or {"message": "未找到执行状态"}
})
else: else:
await websocket.send_json({ 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, stage: str,
data: Dict[str, Any] data: Dict[str, Any]
): ):
"""
广播阶段更新消息
Args:
project_id: 项目ID
episode_number: 集数
stage: 阶段名称
data: 阶段数据
"""
message = { message = {
"type": "stage_update", "type": "stage_update",
"data": { "data": {
@ -326,7 +571,6 @@ async def broadcast_stage_update(
}, },
"timestamp": datetime.now().isoformat() "timestamp": datetime.now().isoformat()
} }
await manager.send_to_project(project_id, message) await manager.send_to_project(project_id, message)
@ -337,16 +581,6 @@ async def broadcast_episode_complete(
quality_score: float, quality_score: float,
data: Dict[str, Any] data: Dict[str, Any]
): ):
"""
广播剧集完成消息
Args:
project_id: 项目ID
episode_number: 集数
success: 是否成功
quality_score: 质量分数
data: 额外数据
"""
message = { message = {
"type": "episode_complete", "type": "episode_complete",
"data": { "data": {
@ -358,7 +592,6 @@ async def broadcast_episode_complete(
}, },
"timestamp": datetime.now().isoformat() "timestamp": datetime.now().isoformat()
} }
await manager.send_to_project(project_id, message) await manager.send_to_project(project_id, message)
@ -370,17 +603,6 @@ async def broadcast_batch_progress(
failed: int, failed: int,
data: Dict[str, Any] data: Dict[str, Any]
): ):
"""
广播批量执行进度
Args:
batch_id: 批次ID
current_episode: 当前集数
total_episodes: 总集数
completed: 已完成数
failed: 失败数
data: 额外数据
"""
message = { message = {
"type": "batch_progress", "type": "batch_progress",
"data": { "data": {
@ -394,7 +616,6 @@ async def broadcast_batch_progress(
}, },
"timestamp": datetime.now().isoformat() "timestamp": datetime.now().isoformat()
} }
await manager.send_to_batch(batch_id, message) await manager.send_to_batch(batch_id, message)
@ -404,15 +625,6 @@ async def broadcast_error(
error: str, error: str,
error_type: str = "execution_error" error_type: str = "execution_error"
): ):
"""
广播错误消息
Args:
project_id: 项目ID
episode_number: 集数可选
error: 错误信息
error_type: 错误类型
"""
message = { message = {
"type": "error", "type": "error",
"data": { "data": {
@ -423,7 +635,6 @@ async def broadcast_error(
}, },
"timestamp": datetime.now().isoformat() "timestamp": datetime.now().isoformat()
} }
await manager.send_to_project(project_id, message) await manager.send_to_project(project_id, message)
@ -431,13 +642,6 @@ async def broadcast_batch_complete(
batch_id: str, batch_id: str,
summary: Dict[str, Any] summary: Dict[str, Any]
): ):
"""
广播批量执行完成
Args:
batch_id: 批次ID
summary: 执行摘要
"""
message = { message = {
"type": "batch_complete", "type": "batch_complete",
"data": { "data": {
@ -446,7 +650,6 @@ async def broadcast_batch_complete(
}, },
"timestamp": datetime.now().isoformat() "timestamp": datetime.now().isoformat()
} }
await manager.send_to_batch(batch_id, message) await manager.send_to_batch(batch_id, message)

View 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)

View 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)

View 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

View 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
]

View 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}"

View 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})

View 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()

View 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

View 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
]

View 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

View 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}"

View 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)"

View 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))

View File

@ -1,29 +1,95 @@
from datetime import datetime from datetime import datetime
from typing import List, Optional from typing import List, Optional, Dict
import uuid import uuid
import json
import os
from pathlib import Path
from app.models.project import ( from app.models.project import (
SeriesProject, SeriesProject,
SeriesProjectCreate, SeriesProjectCreate,
Episode, Episode
EpisodeExecuteRequest,
EpisodeExecuteResponse
) )
from app.core.agents.series_creation_agent import get_series_agent
from app.utils.logger import get_logger from app.utils.logger import get_logger
logger = get_logger(__name__) logger = get_logger(__name__)
# 数据存储路径
# 使用绝对路径,确保在不同工作目录下都能正确找到
# BASE_DIR = Path(__file__).resolve().parent.parent.parent
# DATA_DIR = BASE_DIR / "data"
# ============================================ # 临时使用硬编码绝对路径进行调试
# 内存存储 (MVP 阶段使用文件存储) DATA_DIR = Path("d:/platform/creative_studio/backend/data")
# ============================================ PROJECTS_FILE = DATA_DIR / "projects.json"
_projects: dict = {} EPISODES_FILE = DATA_DIR / "episodes.json"
_episodes: dict = {} 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: logger.info(f"Data directory: {DATA_DIR}")
"""项目仓储MVP 简化版)""" 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: async def create(self, project_data: SeriesProjectCreate) -> SeriesProject:
"""创建新项目""" """创建新项目"""
@ -39,17 +105,26 @@ class ProjectRepository:
createdAt=datetime.now(), createdAt=datetime.now(),
updatedAt=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}") logger.info(f"创建项目: {project_id} - {project.name}")
return project return project
async def get(self, project_id: str) -> Optional[SeriesProject]: 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]: 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( async def update(
self, self,
@ -57,7 +132,7 @@ class ProjectRepository:
project_data: dict project_data: dict
) -> Optional[SeriesProject]: ) -> Optional[SeriesProject]:
"""更新项目""" """更新项目"""
project = _projects.get(project_id) project = self._objects.get(project_id)
if not project: if not project:
return None return None
@ -66,30 +141,51 @@ class ProjectRepository:
setattr(project, key, value) setattr(project, key, value)
project.updatedAt = datetime.now() project.updatedAt = datetime.now()
# 更新存储
self._data[project_id] = json.loads(project.json())
self._save()
return project return project
async def delete(self, project_id: str) -> bool: async def delete(self, project_id: str) -> bool:
"""删除项目""" """删除项目"""
if project_id in _projects: if project_id in self._objects:
del _projects[project_id] del self._objects[project_id]
if project_id in self._data:
del self._data[project_id]
self._save()
return True return True
return False return False
class EpisodeRepository: class EpisodeRepository(JsonRepository):
"""剧集仓储MVP 简化版)""" """剧集仓储(持久化版)"""
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: async def create(self, episode: Episode) -> Episode:
"""创建剧集""" """创建剧集"""
if not episode.id: if not episode.id:
episode.id = str(uuid.uuid4()) 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}") logger.info(f"创建剧集: {episode.id} - EP{episode.number}")
return episode return episode
async def get(self, episode_id: str) -> Optional[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( async def list_by_project(
self, self,
@ -98,14 +194,18 @@ class EpisodeRepository:
limit: int = 100 limit: int = 100
) -> List[Episode]: ) -> List[Episode]:
"""列出项目的所有剧集""" """列出项目的所有剧集"""
return [ episodes = [
ep for ep in _episodes.values() ep for ep in self._objects.values()
if ep.projectId == project_id 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: 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 return episode
@ -114,3 +214,29 @@ class EpisodeRepository:
# ============================================ # ============================================
project_repo = ProjectRepository() project_repo = ProjectRepository()
episode_repo = EpisodeRepository() 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()

View File

@ -115,6 +115,9 @@ class SeriesProject(BaseModel):
agentId: str = "series-creation" agentId: str = "series-creation"
mode: str = "batch" # auto, batch, step mode: str = "batch" # auto, batch, step
# 项目类型/风格(如:古风、现代、科幻等)
genre: str = "古风"
# 全局上下文 # 全局上下文
globalContext: GlobalContext = Field(default_factory=GlobalContext) globalContext: GlobalContext = Field(default_factory=GlobalContext)
@ -177,6 +180,7 @@ class SeriesProjectCreate(BaseModel):
totalEpisodes: int = 30 totalEpisodes: int = 30
agentId: str = "series-creation" agentId: str = "series-creation"
mode: str = "batch" mode: str = "batch"
genre: str = "古风"
globalContext: GlobalContext = Field(default_factory=GlobalContext) globalContext: GlobalContext = Field(default_factory=GlobalContext)
skillSettings: Dict[str, SkillSetting] = Field(default_factory=dict) skillSettings: Dict[str, SkillSetting] = Field(default_factory=dict)

View 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"
}
}

View File

@ -39,6 +39,11 @@ python-dotenv==1.0.0
# Vector Database # Vector Database
chromadb==0.4.18 chromadb==0.4.18
# LangChain
langchain>=0.3.0
langchain-core>=0.3.0
langgraph>=0.2.0
# Development # Development
pytest==7.4.3 pytest==7.4.3
pytest-asyncio==0.21.1 pytest-asyncio==0.21.1

View File

@ -70,12 +70,13 @@ function App() {
<Route path="/projects" element={<ProjectList />} /> <Route path="/projects" element={<ProjectList />} />
<Route path="/projects/new" element={<ProjectCreateEnhanced />} /> <Route path="/projects/new" element={<ProjectCreateEnhanced />} />
<Route path="/projects/progressive" element={<ProjectCreateProgressive />} /> <Route path="/projects/progressive" element={<ProjectCreateProgressive />} />
<Route path="/projects/:id" element={<ProjectDetail />} /> {/* 更具体的路由要放在前面 */}
<Route path="/projects/:id/workspace" element={<ProjectWorkspace />} /> <Route path="/projects/:id/workspace" element={<ProjectWorkspace />} />
<Route path="/projects/:id/execute" element={<ExecutionMonitor />} /> <Route path="/projects/:id/execute" element={<ExecutionMonitor />} />
<Route path="/projects/:id/memory" element={<MemorySystem />} /> <Route path="/projects/:id/memory" element={<MemorySystem />} />
<Route path="/projects/:id/review/config" element={<ReviewConfig />} /> <Route path="/projects/:id/review/config" element={<ReviewConfig />} />
<Route path="/projects/:id/review/results" element={<ReviewResults />} /> <Route path="/projects/:id/review/results" element={<ReviewResults />} />
<Route path="/projects/:id" element={<ProjectDetail />} />
<Route path="/skills" element={<SkillManagement />} /> <Route path="/skills" element={<SkillManagement />} />
<Route path="/agents" element={<AgentManagement />} /> <Route path="/agents" element={<AgentManagement />} />
</Routes> </Routes>

View 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 />;
}
}

View 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>
);
};

View 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>
)
}

View 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>
);
};

View File

@ -4,22 +4,27 @@
* *
* 1. - + + / * 1. - + + /
* 2. - * 2. -
* 3. - * 3. -
* 4. - * 4. -
* 5. - * 5. -
*/ */
import { useState, useEffect } from 'react' import { useState, useEffect, useRef, useCallback } from 'react'
import { useParams, useNavigate } from 'react-router-dom' 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 { 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 } from '@ant-design/icons' 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 { useProjectStore } from '@/stores/projectStore'
import { useSkillStore } from '@/stores/skillStore' import { useSkillStore } from '@/stores/skillStore'
import { Episode } from '@/services/projectService' import { Episode } from '@/services/projectService'
import { taskService } from '@/services/taskService' 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 { Paragraph } = Typography
const { TabPane } = Tabs const { TabPane } = Tabs
const { TextArea } = Input const { TextArea } = Input
const { Header, Content, Sider } = Layout
// Skill 选择器和自定义提示词组件 // Skill 选择器和自定义提示词组件
const SkillSelectorWithPrompt = ({ const SkillSelectorWithPrompt = ({
@ -110,6 +115,10 @@ const SkillSelectorWithPrompt = ({
export const ProjectDetail = () => { export const ProjectDetail = () => {
const { id } = useParams<{ id: string }>() const { id } = useParams<{ id: string }>()
const navigate = useNavigate() const navigate = useNavigate()
// 调试:确认组件已加载
console.log('=== ProjectDetail component loaded ===')
console.log('id:', id)
const { currentProject, projects, episodes, loading, error, fetchProject, fetchEpisodes, executeEpisode, updateProject } = useProjectStore() const { currentProject, projects, episodes, loading, error, fetchProject, fetchEpisodes, executeEpisode, updateProject } = useProjectStore()
const { skills, fetchSkills } = useSkillStore() const { skills, fetchSkills } = useSkillStore()
const [executing, setExecuting] = useState(false) const [executing, setExecuting] = useState(false)
@ -131,6 +140,11 @@ export const ProjectDetail = () => {
const [generatingWorld, setGeneratingWorld] = useState(false) const [generatingWorld, setGeneratingWorld] = useState(false)
const [generatingCharacters, setGeneratingCharacters] = useState(false) const [generatingCharacters, setGeneratingCharacters] = useState(false)
const [generatingOutline, setGeneratingOutline] = useState(false) const [generatingOutline, setGeneratingOutline] = useState(false)
const [generatingAll, setGeneratingAll] = useState(false)
// 顺序生成状态:跟踪当前完成到哪一步
// 步骤0=未开始, 1=世界观完成, 2=人物完成, 3=大纲完成
const [generationStep, setGenerationStep] = useState(0)
// Skills 配置 // Skills 配置
const [worldSkills, setWorldSkills] = useState<string[]>([]) const [worldSkills, setWorldSkills] = useState<string[]>([])
@ -142,6 +156,34 @@ export const ProjectDetail = () => {
const [characterPrompt, setCharacterPrompt] = useState('') const [characterPrompt, setCharacterPrompt] = useState('')
const [outlinePrompt, setOutlinePrompt] = 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 // 加载 Skills
useEffect(() => { useEffect(() => {
const loadSkills = async () => { const loadSkills = async () => {
@ -158,11 +200,19 @@ export const ProjectDetail = () => {
useEffect(() => { useEffect(() => {
if (currentProject) { if (currentProject) {
// 初始化项目设置表单 // 初始化项目设置表单
const genre = currentProject.genre || '古风'
const isCustomGenre = !['古风', '现代', '科幻', '奇幻', '悬疑', '都市', '历史'].includes(genre)
settingsForm.setFieldsValue({ settingsForm.setFieldsValue({
name: currentProject.name, 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 hasScript = currentProject.globalContext?.uploadedScript && currentProject.globalContext.uploadedScript.length > 0
const hasInspiration = currentProject.globalContext?.inspiration && currentProject.globalContext.inspiration.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({ globalForm.setFieldsValue({
worldSetting: currentProject.globalContext?.worldSetting || '', worldSetting,
characters: currentProject.globalContext?.styleGuide || '', characters,
overallOutline: currentProject.globalContext?.overallOutline || '' overallOutline
}) })
// 根据已有内容设置初始步骤
let initialStep = 0
if (worldSetting.trim()) initialStep = 1
if (characters.trim()) initialStep = 2
if (overallOutline.trim()) initialStep = 3
setGenerationStep(initialStep)
} }
}, [currentProject]) }, [currentProject])
@ -243,9 +304,13 @@ export const ProjectDetail = () => {
setUpdatingSettings(true) setUpdatingSettings(true)
const values = settingsForm.getFieldsValue() const values = settingsForm.getFieldsValue()
// 处理 genre如果是"其他"且有自定义输入,使用自定义值
const genreValue = values.genre === '其他' ? (customGenre || '其他') : (values.genre || '古风')
await updateProject(id!, { await updateProject(id!, {
name: values.name, name: values.name,
totalEpisodes: values.totalEpisodes, totalEpisodes: values.totalEpisodes,
genre: genreValue,
globalContext: { globalContext: {
...currentProject?.globalContext, ...currentProject?.globalContext,
uploadedScript: scriptContent, uploadedScript: scriptContent,
@ -274,6 +339,7 @@ export const ProjectDetail = () => {
if (generatingWorld) return // 防止重复点击 if (generatingWorld) return // 防止重复点击
const projectName = currentProject?.name || '未命名项目' const projectName = currentProject?.name || '未命名项目'
const projectGenre = currentProject?.genre || '古风'
const selectedSkillsInfo = skills.filter(s => worldSkills.includes(s.id)) const selectedSkillsInfo = skills.filter(s => worldSkills.includes(s.id))
// 根据创作方式决定是分析还是生成(从项目数据中实时获取最新内容) // 根据创作方式决定是分析还是生成(从项目数据中实时获取最新内容)
@ -297,7 +363,7 @@ export const ProjectDetail = () => {
const response = await taskService.generateWorld({ const response = await taskService.generateWorld({
idea, idea,
projectName, projectName,
genre: '古风', genre: projectGenre,
skills: selectedSkillsInfo.map(s => ({ skills: selectedSkillsInfo.map(s => ({
id: s.id, id: s.id,
name: s.name, name: s.name,
@ -314,6 +380,9 @@ export const ProjectDetail = () => {
const worldText = typeof worldSetting === 'string' ? worldSetting : JSON.stringify(worldSetting, null, 2) const worldText = typeof worldSetting === 'string' ? worldSetting : JSON.stringify(worldSetting, null, 2)
globalForm.setFieldsValue({ worldSetting: worldText }) globalForm.setFieldsValue({ worldSetting: worldText })
// 更新步骤状态
setGenerationStep(1)
hideMessage() hideMessage()
message.success(isAnalysis ? '世界观分析完成!' : '世界观设定生成完成!') message.success(isAnalysis ? '世界观分析完成!' : '世界观设定生成完成!')
} catch (error: any) { } catch (error: any) {
@ -330,6 +399,7 @@ export const ProjectDetail = () => {
if (generatingCharacters) return // 防止重复点击 if (generatingCharacters) return // 防止重复点击
const projectName = currentProject?.name || '未命名项目' const projectName = currentProject?.name || '未命名项目'
const projectGenre = currentProject?.genre || '古风'
const totalEpisodes = currentProject?.totalEpisodes || 30 const totalEpisodes = currentProject?.totalEpisodes || 30
const selectedSkillsInfo = skills.filter(s => characterSkills.includes(s.id)) const selectedSkillsInfo = skills.filter(s => characterSkills.includes(s.id))
@ -350,8 +420,9 @@ export const ProjectDetail = () => {
? `分析以下剧本,提取人物设定:\n${baseContent?.substring(0, 2000)}` ? `分析以下剧本,提取人物设定:\n${baseContent?.substring(0, 2000)}`
: `项目名称:${projectName},总集数:${totalEpisodes}\n创意灵感\n${baseContent}` : `项目名称:${projectName},总集数:${totalEpisodes}\n创意灵感\n${baseContent}`
// 如果已有世界观设定,将其作为上下文 // 无论分析模式还是创作模式,如果已有世界观设定,都将其作为上下文注入
if (worldSetting && !isAnalysis) { // 这样可以确保生成的世界观能够影响人物设定的生成,保证连贯性
if (worldSetting) {
idea += `\n\n【世界观设定】\n${worldSetting}` idea += `\n\n【世界观设定】\n${worldSetting}`
} }
@ -362,6 +433,7 @@ export const ProjectDetail = () => {
const response = await taskService.generateCharacters({ const response = await taskService.generateCharacters({
idea, idea,
projectName, projectName,
genre: projectGenre,
totalEpisodes, totalEpisodes,
skills: selectedSkillsInfo.map(s => ({ skills: selectedSkillsInfo.map(s => ({
id: s.id, id: s.id,
@ -392,6 +464,9 @@ export const ProjectDetail = () => {
} }
globalForm.setFieldsValue({ characters: characterText }) globalForm.setFieldsValue({ characters: characterText })
// 更新步骤状态
setGenerationStep(2)
hideMessage() hideMessage()
message.success(isAnalysis ? '人物设定分析完成!' : '人物设定生成完成!') message.success(isAnalysis ? '人物设定分析完成!' : '人物设定生成完成!')
} catch (error: any) { } catch (error: any) {
@ -408,6 +483,7 @@ export const ProjectDetail = () => {
if (generatingOutline) return // 防止重复点击 if (generatingOutline) return // 防止重复点击
const projectName = currentProject?.name || '未命名项目' const projectName = currentProject?.name || '未命名项目'
const projectGenre = currentProject?.genre || '古风'
const totalEpisodes = currentProject?.totalEpisodes || 30 const totalEpisodes = currentProject?.totalEpisodes || 30
const selectedSkillsInfo = skills.filter(s => outlineSkills.includes(s.id)) const selectedSkillsInfo = skills.filter(s => outlineSkills.includes(s.id))
@ -429,14 +505,13 @@ export const ProjectDetail = () => {
? `分析以下剧本,提取整体大纲:\n${baseContent?.substring(0, 2000)}` ? `分析以下剧本,提取整体大纲:\n${baseContent?.substring(0, 2000)}`
: `项目名称:${projectName},总集数:${totalEpisodes}\n创意灵感\n${baseContent}` : `项目名称:${projectName},总集数:${totalEpisodes}\n创意灵感\n${baseContent}`
// 如果已有世界观设定和人物设定,将其作为上下文 // 无论分析模式还是创作模式,如果已有世界观设定和人物设定,都将其作为上下文注入
if (!isAnalysis) { // 这样可以确保已生成的内容能够影响大纲的生成,保证连贯性
if (worldSetting) { if (worldSetting) {
idea += `\n\n【世界观设定】\n${worldSetting}` idea += `\n\n【世界观设定】\n${worldSetting}`
} }
if (characters) { if (characters) {
idea += `\n\n【人物设定】\n${characters}` idea += `\n\n【人物设定】\n${characters}`
}
} }
const hideMessage = message.loading(isAnalysis ? '正在分析整体大纲...' : '正在生成整体大纲...', 0) const hideMessage = message.loading(isAnalysis ? '正在分析整体大纲...' : '正在生成整体大纲...', 0)
@ -446,7 +521,7 @@ export const ProjectDetail = () => {
const response = await taskService.generateOutline({ const response = await taskService.generateOutline({
idea, idea,
totalEpisodes, totalEpisodes,
genre: '古风', genre: projectGenre,
projectName, projectName,
skills: selectedSkillsInfo.map(s => ({ skills: selectedSkillsInfo.map(s => ({
id: s.id, id: s.id,
@ -464,6 +539,9 @@ export const ProjectDetail = () => {
const outlineText = typeof outline === 'string' ? outline : JSON.stringify(outline, null, 2) const outlineText = typeof outline === 'string' ? outline : JSON.stringify(outline, null, 2)
globalForm.setFieldsValue({ overallOutline: outlineText }) globalForm.setFieldsValue({ overallOutline: outlineText })
// 更新步骤状态
setGenerationStep(3)
hideMessage() hideMessage()
message.success(isAnalysis ? '整体大纲分析完成!' : '整体大纲生成完成!') message.success(isAnalysis ? '整体大纲分析完成!' : '整体大纲生成完成!')
} catch (error: any) { } 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 () => { const handleSaveGlobalSettings = async () => {
try { try {
@ -486,6 +601,7 @@ export const ProjectDetail = () => {
...currentProject?.globalContext, ...currentProject?.globalContext,
worldSetting: worldSetting || '', worldSetting: worldSetting || '',
overallOutline: overallOutline || '', overallOutline: overallOutline || '',
// 同时保存到两个字段以保持兼容性
styleGuide: characters || '', styleGuide: characters || '',
characterProfiles: currentProject?.globalContext?.characterProfiles || {}, characterProfiles: currentProject?.globalContext?.characterProfiles || {},
sceneSettings: currentProject?.globalContext?.sceneSettings || {} sceneSettings: currentProject?.globalContext?.sceneSettings || {}
@ -513,22 +629,212 @@ export const ProjectDetail = () => {
} }
} }
const handleExecuteBatch = async () => { // ==================== 工作台 WebSocket 相关函数 ====================
setExecuting(true)
try { // WebSocket 连接逻辑
for (let i = 1; i <= 3; i++) { const connectWorkspaceWebSocket = useCallback(() => {
await executeEpisode(id!, i) if (!id) return
message.success(`EP${i} 创作完成!`)
// 关闭现有连接
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) { ws.onmessage = (event) => {
message.error(`批量创作失败: ${(error as Error).message}`) try {
} finally { const msg = JSON.parse(event.data)
setExecuting(false) 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 getStatusColor = (status: string) => {
const colors: Record<string, string> = { const colors: Record<string, string> = {
'pending': 'default', 'pending': 'default',
@ -554,10 +860,6 @@ export const ProjectDetail = () => {
((creationMode === 'script' && scriptContent.trim()) || ((creationMode === 'script' && scriptContent.trim()) ||
(creationMode === 'inspiration' && inspirationContent.trim())) (creationMode === 'inspiration' && inspirationContent.trim()))
// 检查全局设定是否完成
const globalSettingsCompleted = currentProject?.globalContext?.worldSetting?.trim() &&
currentProject?.globalContext?.overallOutline?.trim()
// 错误处理 // 错误处理
if (error) { if (error) {
return ( return (
@ -662,6 +964,46 @@ export const ProjectDetail = () => {
<InputNumber min={1} max={500} style={{ width: '100%' }} /> <InputNumber min={1} max={500} style={{ width: '100%' }} />
</Form.Item> </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> <Form.Item label="选择创作方式" required>
<Space direction="vertical" style={{ width: '100%' }} size="middle"> <Space direction="vertical" style={{ width: '100%' }} size="middle">
@ -845,21 +1187,84 @@ export const ProjectDetail = () => {
/> />
) : ( ) : (
<Space direction="vertical" size="large" style={{ width: '100%' }}> <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 <Alert
message={isAnalysisMode ? "全局设定分析" : "全局设定生成"} message={isAnalysisMode ? "全局设定分析" : "全局设定生成"}
description={ description={
isAnalysisMode isAnalysisMode
? "AI将分析您上传的剧本提取世界观、人物设定和整体大纲" ? "AI将按顺序分析您上传的剧本:世界观 → 人物设定 → 整体大纲"
: "AI将根据您的创意灵感生成世界观、人物设定和整体大纲" : "AI将按顺序根据您的创意生成:世界观设定 → 人物设定 → 整体大纲"
} }
type="info" type="info"
showIcon 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"> <Form form={globalForm} layout="vertical">
{/* 世界观设定 */} {/* 世界观设定 */}
<Card <Card
title="世界观设定" title={
<Space>
<span></span>
{generationStep >= 1 && <Tag color="success"></Tag>}
</Space>
}
size="small" size="small"
extra={ extra={
<Space> <Space>
@ -877,13 +1282,13 @@ export const ProjectDetail = () => {
size="small" size="small"
icon={generatingWorld ? <LoadingOutlined /> : <RobotOutlined />} icon={generatingWorld ? <LoadingOutlined /> : <RobotOutlined />}
onClick={handleGenerateWorld} onClick={handleGenerateWorld}
disabled={generatingWorld} disabled={generatingWorld || generatingCharacters || generatingOutline || generatingAll}
> >
{isAnalysisMode ? 'AI 分析' : 'AI 生成'} {isAnalysisMode ? 'AI 分析' : 'AI 生成'}
</Button> </Button>
</Space> </Space>
} }
style={{ marginBottom: '16px' }} style={{ marginBottom: '16px', border: generationStep >= 1 ? '1px solid #52c41a' : undefined }}
> >
<Form.Item name="worldSetting"> <Form.Item name="worldSetting">
<TextArea <TextArea
@ -899,7 +1304,13 @@ export const ProjectDetail = () => {
{/* 人物设定 */} {/* 人物设定 */}
<Card <Card
title="人物设定" title={
<Space>
<span></span>
{generationStep >= 2 && <Tag color="success"></Tag>}
{generationStep < 1 && <Tag color="default"></Tag>}
</Space>
}
size="small" size="small"
extra={ extra={
<Space> <Space>
@ -917,13 +1328,13 @@ export const ProjectDetail = () => {
size="small" size="small"
icon={generatingCharacters ? <LoadingOutlined /> : <RobotOutlined />} icon={generatingCharacters ? <LoadingOutlined /> : <RobotOutlined />}
onClick={handleGenerateCharacters} onClick={handleGenerateCharacters}
disabled={generatingCharacters} disabled={generationStep < 1 || generatingWorld || generatingCharacters || generatingOutline || generatingAll}
> >
{isAnalysisMode ? 'AI 分析' : 'AI 生成'} {isAnalysisMode ? 'AI 分析' : 'AI 生成'}
</Button> </Button>
</Space> </Space>
} }
style={{ marginBottom: '16px' }} style={{ marginBottom: '16px', border: generationStep >= 2 ? '1px solid #52c41a' : undefined, opacity: generationStep < 1 ? 0.6 : 1 }}
> >
<Form.Item name="characters"> <Form.Item name="characters">
<TextArea <TextArea
@ -939,7 +1350,13 @@ export const ProjectDetail = () => {
{/* 整体大纲 */} {/* 整体大纲 */}
<Card <Card
title="整体大纲" title={
<Space>
<span></span>
{generationStep >= 3 && <Tag color="success"></Tag>}
{generationStep < 2 && <Tag color="default"></Tag>}
</Space>
}
size="small" size="small"
extra={ extra={
<Space> <Space>
@ -957,13 +1374,13 @@ export const ProjectDetail = () => {
size="small" size="small"
icon={generatingOutline ? <LoadingOutlined /> : <RobotOutlined />} icon={generatingOutline ? <LoadingOutlined /> : <RobotOutlined />}
onClick={handleGenerateOutline} onClick={handleGenerateOutline}
disabled={generatingOutline} disabled={generationStep < 2 || generatingWorld || generatingCharacters || generatingOutline || generatingAll}
> >
{isAnalysisMode ? 'AI 分析' : 'AI 生成'} {isAnalysisMode ? 'AI 分析' : 'AI 生成'}
</Button> </Button>
</Space> </Space>
} }
style={{ marginBottom: '16px' }} style={{ marginBottom: '16px', border: generationStep >= 3 ? '1px solid #52c41a' : undefined, opacity: generationStep < 2 ? 0.6 : 1 }}
> >
<Form.Item name="overallOutline"> <Form.Item name="overallOutline">
<TextArea <TextArea
@ -987,7 +1404,7 @@ export const ProjectDetail = () => {
)} )}
</TabPane> </TabPane>
{/* 剧集创作标签页 */} {/* 剧集创作标签页 - 直接嵌入创作工作台 */}
<TabPane tab="剧集创作" key="episodes"> <TabPane tab="剧集创作" key="episodes">
{!globalSettingsCompleted ? ( {!globalSettingsCompleted ? (
<Alert <Alert
@ -1002,52 +1419,87 @@ export const ProjectDetail = () => {
} }
/> />
) : ( ) : (
<Space direction="vertical" style={{ width: '100%' }} size="large"> <Layout style={{ height: 'calc(100vh - 200px)', background: '#fff' }}>
<Space style={{ marginBottom: '16px' }}> {/* 工作台头部 */}
<Button <div style={{
type="primary" background: '#fff',
icon={executing ? <LoadingOutlined /> : <PlayCircleOutlined />} borderBottom: '1px solid #f0f0f0',
onClick={handleExecuteBatch} padding: '12px 24px',
disabled={executing} display: 'flex',
> alignItems: 'center',
{executing ? '创作中...' : '开始创作 (EP1-EP3)'} justifyContent: 'space-between'
</Button> }}>
<span style={{ color: '#888' }}>3使 Skills</span> <Space>
</Space> <span style={{ fontWeight: 600, fontSize: '14px' }}></span>
<span style={{ color: '#999', fontSize: '12px' }}>{currentProject?.name}</span>
<List <span style={{ color: '#999', fontSize: '12px' }}>ID: {id}</span>
dataSource={episodes} <Tag color={wsConnected ? 'success' : 'error'}>
renderItem={(episode) => ( {wsConnected ? '已连接' : '未连接'}
<List.Item </Tag>
actions={[ <Button
episode.status === 'completed' ? ( type="text"
<Button type="link" onClick={() => setSelectedEpisode(episode)}> size="small"
icon={<UnorderedListOutlined />}
</Button> onClick={() => setShowEpisodeSidebar(!showEpisodeSidebar)}
) : null style={{ color: showEpisodeSidebar ? '#1677ff' : '#666' }}
]}
> >
<List.Item.Meta
title={ </Button>
<Space> </Space>
<span>EP{episode.number}</span> <Space>
{episode.title && <span>- {episode.title}</span>} <Button size="small"></Button>
<Tag color={getStatusColor(episode.status)}> <Button size="small" type="primary"></Button>
{getStatusText(episode.status)} </Space>
</Tag> </div>
</Space>
} <Layout>
description={ {/* 剧集管理侧边栏 */}
<Space direction="vertical" size="small"> {showEpisodeSidebar && (
{episode.qualityScore && <span>: {episode.qualityScore}</span>} <Sider width={280} style={{ background: '#fafafa', borderRight: '1px solid #f0f0f0' }}>
{episode.issues && episode.issues.length > 0 && <span>: {episode.issues.length}</span>} <EpisodeSidebar
</Space> 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> </TabPane>

View File

@ -12,7 +12,7 @@ import { Button, Space, Tag, Card, message, Empty, Row, Col, Progress, Tooltip,
import { import {
PlusOutlined, EditOutlined, DeleteOutlined, EyeOutlined, PlusOutlined, EditOutlined, DeleteOutlined, EyeOutlined,
ClockCircleOutlined, CheckCircleOutlined, LoadingOutlined, ClockCircleOutlined, CheckCircleOutlined, LoadingOutlined,
FileTextOutlined, SettingOutlined FileTextOutlined
} from '@ant-design/icons' } from '@ant-design/icons'
import { useProjectStore } from '@/stores/projectStore' import { useProjectStore } from '@/stores/projectStore'
import { SeriesProject } from '@/services/projectService' import { SeriesProject } from '@/services/projectService'
@ -56,19 +56,21 @@ const calculateCompletion = (project: SeriesProject): number => {
// 检查世界观设定 // 检查世界观设定
if (project.globalContext?.worldSetting) completed += 1 if (project.globalContext?.worldSetting) completed += 1
// 检查人物设定 // 检查人物设定 (支持 characterProfiles 或 styleGuide)
if (project.globalContext?.characterProfiles && const hasCharacters = (project.globalContext?.characterProfiles &&
Object.keys(project.globalContext.characterProfiles).length > 0) { Object.keys(project.globalContext.characterProfiles).length > 0) ||
completed += 1 (project.globalContext?.styleGuide && project.globalContext.styleGuide.trim().length > 0)
} if (hasCharacters) completed += 1
// 检查大纲 // 检查大纲
if (project.globalContext?.overallOutline) completed += 1 if (project.globalContext?.overallOutline) completed += 1
// 检查剧集完成度 // 检查剧集完成度 - 只计算状态为 completed 的剧集
if (project.totalEpisodes && project.episodes) { if (project.totalEpisodes && project.episodes) {
total = 3 + project.totalEpisodes 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)) return Math.min(100, Math.round((completed / total) * 100))
@ -87,7 +89,7 @@ const getProjectStatus = (project: SeriesProject): ProjectStatus => {
const ProjectCard = ({ project, onEdit, onDelete, onView }: { const ProjectCard = ({ project, onEdit, onDelete, onView }: {
project: SeriesProject project: SeriesProject
onEdit: (id: string) => void onEdit: (id: string) => void
onDelete: (id: string) => void onDelete: (project: SeriesProject) => void
onView: (id: string) => void onView: (id: string) => void
}) => { }) => {
const status = getProjectStatus(project) const status = getProjectStatus(project)
@ -205,7 +207,7 @@ const ProjectCard = ({ project, onEdit, onDelete, onView }: {
danger danger
size="small" size="small"
icon={<DeleteOutlined />} icon={<DeleteOutlined />}
onClick={() => onDelete(project.id)} onClick={() => onDelete(project)}
/> />
</Tooltip> </Tooltip>
</div> </div>
@ -217,19 +219,35 @@ const ProjectCard = ({ project, onEdit, onDelete, onView }: {
export const ProjectList = () => { export const ProjectList = () => {
const navigate = useNavigate() const navigate = useNavigate()
const { projects, loading, fetchProjects, deleteProject } = useProjectStore() const { projects, loading, fetchProjects, deleteProject } = useProjectStore()
const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid')
useEffect(() => { useEffect(() => {
fetchProjects() fetchProjects()
}, []) }, [])
const handleDelete = async (id: string) => { const handleDelete = async (project: SeriesProject) => {
try { // 使用 Modal.confirm 添加确认对话框
await deleteProject(id) Modal.confirm({
message.success('项目已删除') title: '确认删除项目',
} catch (error) { content: (
message.error(`删除失败: ${(error as Error).message}`) <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) => { const handleContinueEdit = (id: string) => {
@ -259,12 +277,6 @@ export const ProjectList = () => {
</p> </p>
</div> </div>
<Space> <Space>
<Button
icon={<SettingOutlined />}
onClick={() => setViewMode(viewMode === 'grid' ? 'list' : 'grid')}
>
{viewMode === 'grid' ? '列表视图' : '网格视图'}
</Button>
<Button <Button
type="primary" type="primary"
size="large" size="large"

View File

@ -1,93 +1,58 @@
/** import { useEffect, useState, useRef, useCallback } from 'react'
*
*
*
* -
* -
* -
* -
* - Skills
*/
import { useEffect, useState } from 'react'
import { useNavigate, useParams } from 'react-router-dom' import { useNavigate, useParams } from 'react-router-dom'
import { import { Layout, Button, Space, message, Spin, Typography, Tag } from 'antd'
Row, import { ArrowLeftOutlined, UnorderedListOutlined } from '@ant-design/icons'
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'
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 { projectService } from '@/services/projectService'
import type { ColumnsType } from 'antd/es/table' import { ContextPanel } from '@/components/Workspace/ContextPanel'
import dayjs from 'dayjs' 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 = () => { export const ProjectWorkspace: React.FC = () => {
const navigate = useNavigate() 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 [project, setProject] = useState<any>(null)
const [loading, setLoading] = useState(true) 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 [memoryItems, setMemoryItems] = useState<any[]>([])
const [contentsLoading, setContentsLoading] = useState(false)
// 内容编辑器 // 剧集相关状态
const [editorVisible, setEditorVisible] = useState(false) const [currentEpisode, setCurrentEpisode] = useState<Episode | null>(null)
const [selectedEpisode, setSelectedEpisode] = useState<number | null>(null) const [showEpisodeSidebar, setShowEpisodeSidebar] = useState(true)
const [selectedContent, setSelectedContent] = useState<EpisodeContent | null>(null)
// 批量操作 const [wsConnected, setWsConnected] = useState(false);
const [selectedRowKeys, setSelectedRowKeys] = useState<React.Key[]>([])
// Skills 配置 // WebSocket refs
const [skillConfigVisible, setSkillConfigVisible] = useState(false) const wsRef = useRef<WebSocket | null>(null);
const [selectedConfigEpisode, setSelectedConfigEpisode] = useState<number | null>(null) const reconnectTimeoutRef = useRef<number | null>(null);
const [currentEpisodeConfig, setCurrentEpisodeConfig] = useState<any>(null)
useEffect(() => {
if (projectId) {
loadProject()
loadContents()
}
}, [projectId])
const loadProject = async () => { const loadProject = async () => {
if (!projectId) return if (!projectId) {
setLoading(false);
return;
}
setLoading(true) setLoading(true)
try { try {
@ -100,426 +65,388 @@ export const ProjectWorkspace: React.FC = () => {
} }
} }
const loadContents = async () => { useEffect(() => {
if (!projectId) return loadProject()
setContentsLoading(true) return () => {
try { if (reconnectTimeoutRef.current) {
const data = await episodeContentService.listContents(projectId) clearTimeout(reconnectTimeoutRef.current);
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('导出成功')
}
} }
} catch (error) { if (wsRef.current) {
message.error(`导出失败: ${(error as Error).message}`) 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> = [ const handleContentChange = (content: string) => {
{ setCanvasContent(content);
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 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) { if (loading) {
return ( return (
<div style={{ padding: '24px', textAlign: 'center' }}> <div style={{ padding: '24px', textAlign: 'center', marginTop: '100px' }}>
<Spin size="large" tip="加载项目中..." /> <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> </div>
) )
} }
return ( return (
<div style={{ padding: '24px' }}> <Layout style={{ height: '100vh' }}>
{/* 头部 */} <Header style={{
<Card background: '#fff',
title={ borderBottom: '1px solid #f0f0f0',
<Space> padding: '0 24px',
<Button display: 'flex',
icon={<ArrowLeftOutlined />} alignItems: 'center',
onClick={() => navigate('/projects')} justifyContent: 'space-between'
> }}>
<Space>
</Button> <Button
<span>{project?.name || '项目工作台'}</span> type="text"
<Tag color="blue">ID: {projectId}</Tag> icon={<ArrowLeftOutlined />}
</Space> onClick={() => navigate('/projects')}
} />
extra={ <Text strong style={{ fontSize: '16px' }}>{project?.name}</Text>
<Space> <Text type="secondary" style={{ fontSize: '12px' }}>ID: {projectId}</Text>
<Button icon={<SettingOutlined />}></Button> <Tag color={wsConnected ? 'success' : 'error'}>
<Dropdown menu={exportMenu}> {wsConnected ? '已连接' : '未连接'}
<Button type="primary" icon={<DownloadOutlined />}> </Tag>
<Button
</Button> type="text"
</Dropdown> icon={<UnorderedListOutlined />}
</Space> onClick={() => setShowEpisodeSidebar(!showEpisodeSidebar)}
} style={{ color: showEpisodeSidebar ? '#1677ff' : '#666' }}
/>
{/* 统计概览 */}
<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>
}
> >
<Table
columns={columns} </Button>
dataSource={contents} </Space>
rowKey="id"
loading={contentsLoading} <Space>
rowSelection={{ <Button></Button>
selectedRowKeys, <Button type="primary"></Button>
onChange: setSelectedRowKeys </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={{ currentEpisodeId={currentEpisode?.id}
pageSize: 10,
showSizeChanger: true,
showTotal: (total) => `${total}`
}}
scroll={{ x: 1000 }}
/> />
</Card> </Sider>
</Col> )}
{/* 右侧:项目信息 */} {/* 左侧Context Panel */}
<Col span={8}> <ContextPanel
<Space direction="vertical" style={{ width: '100%' }} size="middle"> project={project}
{/* 项目信息 */} loading={loading}
<Card title="项目信息" size="small"> activeStates={activeStates}
<Row gutter={[8, 8]}> memoryItems={memoryItems}
<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)
}}
/> />
)}
</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>
) )
} }

View File

@ -4,7 +4,7 @@
import axios from 'axios' import axios from 'axios'
const api = axios.create({ 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 调用可能需要较长时间) timeout: 120000, // 2分钟超时LLM 调用可能需要较长时间)
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
@ -13,17 +13,11 @@ const api = axios.create({
maxRedirects: 5, maxRedirects: 5,
}) })
// 请求拦截器 - 自动添加末尾斜杠 // 请求拦截器 - 仅对列表类请求添加末尾斜杠
api.interceptors.request.use( api.interceptors.request.use(
(config) => { (config) => {
// 确保 URL 路径有末尾斜杠(如果路径不以 / 结尾) // 不自动添加末尾斜杠FastAPI 后端不需要
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 + '/'
}
}
return config return config
}, },
(error) => { (error) => {

View File

@ -132,6 +132,11 @@ export const projectService = {
return await api.get<Episode>(`/projects/${projectId}/episodes/${episodeNumber}`) 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 ( executeEpisode: async (
projectId: string, projectId: string,

View File

@ -17,6 +17,11 @@ export default defineConfig({
target: 'http://localhost:8000', target: 'http://localhost:8000',
changeOrigin: true, changeOrigin: true,
}, },
'/ws': {
target: 'http://localhost:8000',
ws: true,
changeOrigin: true,
},
}, },
}, },
}) })

8
test.md Normal file
View File

@ -0,0 +1,8 @@
# 我的python 环境是"C:\ProgramData\Anaconda3\envs\creative_studio\python.exe"
## 1
## 2
页面上的人物、初始状态这些为什么是无内容没有从项目设置和全局设定中同步过来同时这个世界观和人物在剧集创作界面都不能进行修改了而是只能从前一页内容中进行同步过来。另外这些设定都有没有正确注入项目agent的前置信息里面
## 3
关于这个创建项目,删除按钮有没有确认的流程?如果没有需要添加上,确认后才会删除项目;关于项目完成度应该按照剧集制作完成度来计算显示。