feat:优化剧集创作平台

This commit is contained in:
hjjjj 2026-01-28 18:33:11 +08:00
parent 5b0f8833ba
commit 9327af6021
25 changed files with 2291 additions and 412 deletions

View File

@ -892,3 +892,79 @@ async def get_available_skills():
except Exception as e:
logger.error(f"获取 Skills 失败: {str(e)}")
raise HTTPException(status_code=500, detail=f"获取失败: {str(e)}")
class OptimizeEpisodeRequest(BaseModel):
"""优化剧集请求"""
projectId: str
episodeNumber: int
content: str
skills: Optional[List[SkillInfo]] = None
customPrompt: Optional[str] = None
@router.post("/optimize-episode")
async def optimize_episode(request: OptimizeEpisodeRequest):
"""
AI 辅助优化剧集内容
支持融入 Skills 的行为指导
支持自定义提示词
"""
try:
glm_client = get_glm_client()
skill_manager = get_skill_manager()
# 构建增强的 System Prompt融入 Skills
base_role = """你是专业的剧集创作优化专家,擅长改进和提升剧集内容质量。
你能识别剧情中的问题并提出改进建议使内容更加引人入胜逻辑严密人物鲜明"""
system_prompt = await build_enhanced_system_prompt(
base_role=base_role,
skills=request.skills,
skill_manager=skill_manager
)
# 构建用户提示
custom_requirements = ""
if request.customPrompt:
custom_requirements = f"\n【用户自定义要求】\n{request.customPrompt}\n"
user_prompt = f"""请优化以下剧集内容:
项目 ID: {request.projectId}
集数: EP{request.episodeNumber}
{custom_requirements}
剧集内容
{request.content[:8000]}
优化要求
1. 保持原有的故事结构和情节走向
2. 改进对话使其更符合人物性格
3. 增强场景描写的画面感
4. 优化叙事节奏
5. 严格遵守上面应用技能指导中的优化要求
请直接输出优化后的剧集内容不要添加任何解释或说明
"""
logger.info(f"优化剧集 EP{request.episodeNumber},使用 {len(request.skills) if request.skills else 0} 个 Skills")
response = await glm_client.chat(
messages=[
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_prompt}
],
temperature=0.7
)
optimized_content = response["choices"][0]["message"]["content"]
return {
"success": True,
"optimizedContent": optimized_content,
"usage": response.get("usage")
}
except Exception as e:
logger.error(f"优化剧集失败: {str(e)}")
raise HTTPException(status_code=500, detail=f"优化失败: {str(e)}")

View File

@ -49,7 +49,7 @@ async def create_project(project_data: SeriesProjectCreate):
id=str(uuid.uuid4()),
projectId=project.id,
number=episode_num,
title=f"{episode_num}",
title=f"{episode_num}内容创作",
status="pending",
content="" # 初始化为空白
)
@ -139,7 +139,7 @@ async def list_episodes(project_id: str):
id=str(uuid.uuid4()),
projectId=project_id,
number=episode_num,
title=f"{episode_num}",
title=f"{episode_num}内容创作",
status="pending",
content="" # 初始化为空白,避免触发前端生成大纲按钮
)
@ -207,14 +207,16 @@ async def update_episode(project_id: str, episode_number: int, update_data: dict
@router.post("/{project_id}/execute", response_model=EpisodeExecuteResponse)
async def execute_episode(
project_id: str,
request: EpisodeExecuteRequest
request: EpisodeExecuteRequest,
background_tasks: BackgroundTasks
):
"""
执行单集创作
执行单集创作已弃用建议通过 WebSocket 使用 DirectorAgent
这是核心功能端点调用 Agent 执行完整的创作流程
注意此端点现在会立即返回创作在后台执行
推荐通过 WebSocket 连接到 /ws/projects/{project_id}/execute 并发送消息
"""
# 获取项目
# 获取项目验证存在
project = await project_repo.get(project_id)
if not project:
raise HTTPException(
@ -222,58 +224,46 @@ async def execute_episode(
detail=f"项目不存在: {project_id}"
)
try:
# 获取 Agent
agent = get_series_agent()
logger.info(f"接收创作请求(后台执行模式): 项目 {project_id}, EP{request.episodeNumber}")
# 执行创作
logger.info(f"开始执行创作: 项目 {project_id}, EP{request.episodeNumber}")
# 立即返回响应,创作在后台执行
background_tasks.add_task(_execute_episode_in_background, project_id, request.episodeNumber, request.title)
# 先获取现有剧集记录,避免重复创建
existing_episodes = await episode_repo.list_by_project(project_id)
episode_record = next((ep for ep in existing_episodes if ep.number == request.episodeNumber), None)
episode = await agent.execute_episode(
project=project,
episode_number=request.episodeNumber,
title=request.title
)
# 保持原有的 ID 如果记录已存在
if episode_record:
episode.id = episode_record.id
episode.projectId = project_id
await episode_repo.update(episode)
logger.info(f"更新现有剧集记录: {episode.id} - EP{request.episodeNumber}")
else:
episode.projectId = project_id
await episode_repo.create(episode)
logger.info(f"创建新剧集记录: {episode.id} - EP{request.episodeNumber}")
# 更新项目记忆
await project_repo.update(project_id, {
"memory": project.memory.dict()
})
return EpisodeExecuteResponse(
episode=episode,
success=True,
message=f"EP{request.episodeNumber} 创作完成"
)
except Exception as e:
logger.error(f"执行创作失败: {str(e)}")
return EpisodeExecuteResponse(
episode=Episode(
projectId=project_id,
number=request.episodeNumber,
status="needs-review"
status="writing",
title=request.title or f"{request.episodeNumber}"
),
success=False,
message=f"创作失败: {str(e)}"
success=True,
message=f"EP{request.episodeNumber} 已开始在后台创作"
)
async def _execute_episode_in_background(
project_id: str,
episode_number: int,
title: str
):
"""后台执行剧集创作的辅助函数"""
try:
from app.api.v1.websocket import _execute_episode_creation
logger.info(f"后台创作任务开始: EP{episode_number}")
# 调用 WebSocket 的创作函数(它已经处理了后台执行)
await _execute_episode_creation(
project_id=project_id,
episode_number=episode_number,
analyze_previous_memory=True
)
logger.info(f"后台创作任务完成: EP{episode_number}")
except Exception as e:
logger.error(f"后台创作任务失败: {str(e)}", exc_info=True)
# ============================================
# 请求/响应模型
# ============================================

View File

@ -525,6 +525,61 @@ async def _handle_client_message(
})
async def _sync_context_states(
project_id: str,
episode_number: int,
memory: Any
):
"""同步上下文状态到前端"""
try:
# 提取角色状态作为上下文状态
context_states = []
# 添加时间状态
context_states.append({
"type": "time",
"value": f"EP{episode_number} 完成后"
})
# 添加角色状态
character_states = getattr(memory, 'characterStates', {})
if isinstance(character_states, dict):
for char_name, states in character_states.items():
if states and isinstance(states, list) and len(states) > 0:
latest_state = states[-1]
if isinstance(latest_state, dict):
state_value = latest_state.get('state', f"{char_name}状态")
else:
state_value = getattr(latest_state, 'state', f"{char_name}状态")
context_states.append({
"type": "character",
"value": f"{char_name}: {state_value}",
"character": char_name,
"state": state_value
})
# 添加待收线数量
pending_threads = getattr(memory, 'pendingThreads', [])
if pending_threads:
context_states.append({
"type": "pending_threads",
"value": f"待收线: {len(pending_threads)}"
})
# 广播上下文更新
await manager.send_to_project(project_id, {
"type": "context_update",
"states": context_states,
"episode_number": episode_number
})
logger.info(f"已同步上下文状态到项目 {project_id}, {len(context_states)} 个状态")
except Exception as e:
logger.error(f"同步上下文状态失败: {str(e)}")
async def _handle_tool_call(project_id: str, event: Dict[str, Any]):
"""
处理工具调用转换为特定的 WebSocket 消息
@ -660,7 +715,7 @@ async def _handle_tool_call(project_id: str, event: Dict[str, Any]):
if episode_number:
try:
from app.db.repositories import episode_repo, project_repo
from app.core.memory.memory_manager import MemoryManager
from app.core.memory.memory_manager import get_memory_manager
episodes = await episode_repo.list_by_project(project_id)
episode = next((ep for ep in episodes if ep.number == episode_number), None)
@ -676,9 +731,12 @@ async def _handle_tool_call(project_id: str, event: Dict[str, Any]):
try:
project = await project_repo.get(project_id)
if project and episode.content:
memory_manager = MemoryManager()
memory_manager = get_memory_manager()
await memory_manager.update_memory_from_episode(project, episode)
logger.info(f"Updated memory after saving episode {episode_number}")
# 同步上下文状态到前端
await _sync_context_states(project_id, episode_number, project.memory)
except Exception as memory_error:
logger.warning(f"Failed to update memory for episode {episode_number}: {memory_error}")
@ -711,7 +769,7 @@ async def _handle_tool_call(project_id: str, event: Dict[str, Any]):
if episode_number:
try:
from app.db.repositories import episode_repo, project_repo
from app.core.memory.memory_manager import MemoryManager
from app.core.memory.memory_manager import get_memory_manager
episodes = await episode_repo.list_by_project(project_id)
episode = next((ep for ep in episodes if ep.number == episode_number), None)
@ -729,9 +787,12 @@ async def _handle_tool_call(project_id: str, event: Dict[str, Any]):
try:
project = await project_repo.get(project_id)
if project:
memory_manager = MemoryManager()
memory_manager = get_memory_manager()
await memory_manager.update_memory_from_episode(project, episode)
logger.info(f"Updated memory after updating episode {episode_number}")
# 同步上下文状态到前端
await _sync_context_states(project_id, episode_number, project.memory)
except Exception as memory_error:
logger.warning(f"Failed to update memory for episode {episode_number}: {memory_error}")
@ -758,6 +819,226 @@ async def _handle_tool_call(project_id: str, event: Dict[str, Any]):
"episodeTitle": title
})
elif name == "create_episode":
# 处理剧集创作请求
episode_number = args.get("episode_number")
analyze_previous_memory = args.get("analyze_previous_memory", True)
if episode_number:
# 使用 ensure_future 确保任务在后台执行,即使 WebSocket 断开也能继续
asyncio.ensure_future(_execute_episode_creation(
project_id, episode_number, analyze_previous_memory
))
logger.info(f"已启动后台创作任务: EP{episode_number}")
# ============================================
# 辅助函数 - 用于从其他模块发送消息
# ============================================
# 全局后台任务跟踪
_background_tasks: Dict[str, asyncio.Task] = {}
async def _execute_episode_creation(
project_id: str,
episode_number: int,
analyze_previous_memory: bool
):
"""
异步执行剧集创作
这个函数在后台执行不会阻塞 WebSocket 连接
即使 WebSocket 断开任务也会继续执行并保存到数据库
它会
1. 分析上一集的记忆如果需要
2. 执行剧集创作
3. 通过 WebSocket 发送进度更新如果连接存在
4. 将创作内容发送到画布如果连接存在
5. 无论如何都保存到数据库
"""
task_key = f"{project_id}_{episode_number}"
try:
from app.db.repositories import project_repo, episode_repo
from app.core.agents.series_creation_agent import get_series_agent
from app.core.memory.memory_manager import get_memory_manager
# 注册到后台任务字典
current_task = asyncio.current_task()
if current_task:
_background_tasks[task_key] = current_task
logger.info(f"开始后台创作任务: {task_key}")
# 辅助函数:安全发送 WebSocket 消息(忽略连接错误)
async def safe_send(message_type: str, data: dict = None):
try:
if data is None:
data = {}
await manager.send_to_project(project_id, {
"type": message_type,
"data": data
})
except Exception as e:
# WebSocket 可能已断开,忽略错误继续执行
logger.debug(f"WebSocket 发送失败(可能已断开): {e}")
# 获取项目
project = await project_repo.get(project_id)
if not project:
await safe_send("error", {"message": f"项目不存在: {project_id}"})
return
# 更新计划状态 - 开始
await safe_send("plan_update", {
"plan": [
f"分析 EP{episode_number - 1 if episode_number > 1 else 'N/A'} 的记忆系统" if analyze_previous_memory and episode_number > 1 else "跳过记忆分析(首集)",
f"生成 EP{episode_number} 大纲",
f"创作 EP{episode_number} 对话内容",
f"执行质量审核",
f"更新记忆系统"
],
"status": "planning",
"current_step_index": 0
})
# 步骤 1: 分析上一集记忆(如果需要)
if analyze_previous_memory and episode_number > 1:
await safe_send("plan_update", {
"plan": [
f"分析 EP{episode_number - 1} 的记忆系统",
f"生成 EP{episode_number} 大纲",
f"创作 EP{episode_number} 对话内容",
f"执行质量审核",
f"更新记忆系统"
],
"status": "planning",
"current_step_index": 0
})
# 获取上一集内容
prev_episodes = await episode_repo.list_by_project(project_id)
prev_episode = next((ep for ep in prev_episodes if ep.number == episode_number - 1), None)
if prev_episode and prev_episode.content:
await safe_send("text", {"content": f"\n\n--- 正在分析 EP{episode_number - 1} 的记忆系统 ---\n"})
# 使用 MemoryManager 更新记忆
try:
memory_manager = get_memory_manager()
await memory_manager.update_memory_from_episode(project, prev_episode)
logger.info(f"EP{episode_number - 1} 记忆已分析并注入到 EP{episode_number}")
except Exception as e:
logger.warning(f"分析 EP{episode_number - 1} 记忆失败: {e}")
# 步骤 2-5: 执行剧集创作
agent = get_series_agent()
await safe_send("plan_update", {
"plan": [
f"分析 EP{episode_number - 1} 的记忆系统" if analyze_previous_memory and episode_number > 1 else "跳过记忆分析",
f"生成 EP{episode_number} 大纲",
f"创作 EP{episode_number} 对话内容",
f"执行质量审核",
f"更新记忆系统"
],
"status": "writing",
"current_step_index": 1
})
await safe_send("text", {"content": f"\n\n--- 开始创作 EP{episode_number} ---\n"})
# 执行创作
episode = await agent.execute_episode(
project=project,
episode_number=episode_number,
title=f"{episode_number}"
)
# 检查是否创作成功(错误处理)
if episode.status == "needs-review" and not episode.content:
# 创作失败,没有内容
await safe_send("error", {
"message": f"EP{episode_number} 创作失败",
"episode_number": episode_number
})
await safe_send("text", {"content": f"\n\n❌ EP{episode_number} 创作失败。请检查错误日志并重试。\n"})
logger.error(f"EP{episode_number} 创作失败,无内容生成")
return
# 创作成功,保存到数据库(无论 WebSocket 是否连接)
existing_episodes = await episode_repo.list_by_project(project_id)
episode_record = next((ep for ep in existing_episodes if ep.number == episode_number), None)
if episode_record:
episode.id = episode_record.id
episode.projectId = project_id
await episode_repo.update(episode)
logger.info(f"更新现有剧集记录: {episode.id}")
else:
episode.projectId = project_id
await episode_repo.create(episode)
logger.info(f"创建新剧集记录: {episode.id}")
# 发送内容到画布
if episode.content:
await safe_send("canvas_update", {"content": episode.content})
# 更新记忆
await project_repo.update(project_id, {
"memory": project.memory.dict()
})
# 完成消息
await safe_send("plan_update", {
"plan": [
f"分析 EP{episode_number - 1} 的记忆系统" if analyze_previous_memory and episode_number > 1 else "跳过记忆分析",
f"生成 EP{episode_number} 大纲",
f"创作 EP{episode_number} 对话内容",
f"执行质量审核",
f"更新记忆系统"
],
"status": "idle",
"current_step_index": 4
})
await safe_send("text", {"content": f"\n\n✅ EP{episode_number} 创作完成!质量分数: {episode.qualityScore or 0}\n"})
# 广播更新
await safe_send("episode_updated", {
"number": episode_number,
"title": episode.title,
"status": episode.status
})
logger.info(f"EP{episode_number} 后台创作完成,已保存到数据库")
except Exception as e:
logger.error(f"执行剧集创作失败: {str(e)}", exc_info=True)
try:
await manager.send_to_project(project_id, {
"type": "error",
"data": {
"message": f"EP{episode_number} 创作失败: {str(e)}",
"episode_number": episode_number
}
})
await manager.send_to_project(project_id, {
"type": "text",
"data": {"content": f"\n\n❌ EP{episode_number} 创作失败: {str(e)}\n"}
})
except Exception:
# WebSocket 可能已断开,忽略错误
pass
finally:
# 清理任务跟踪
if task_key in _background_tasks:
del _background_tasks[task_key]
logger.info(f"后台创作任务结束: {task_key}")
# ============================================
# 辅助函数 - 用于从其他模块发送消息
# ============================================
@ -860,6 +1141,20 @@ async def broadcast_batch_complete(
await manager.send_to_batch(batch_id, message)
async def broadcast_to_project(
project_id: str,
message_type: str,
data: Dict[str, Any]
):
"""向项目的所有连接广播消息"""
message = {
"type": message_type,
"data": data,
"timestamp": datetime.now().isoformat()
}
await manager.send_to_project(project_id, message)
# 导出连接管理器和辅助函数
__all__ = [
"manager",
@ -867,5 +1162,6 @@ __all__ = [
"broadcast_episode_complete",
"broadcast_batch_progress",
"broadcast_error",
"broadcast_batch_complete"
"broadcast_batch_complete",
"broadcast_to_project"
]

View File

@ -76,7 +76,14 @@ The User is the Director. Your goal is to help the Director create high-quality
- When receiving a high-level goal (e.g., "Write all scripts"), break it down into episodes and steps.
- Use `update_plan(steps=[...], current_step=0, status='planning')`.
2. **Execution & Writing**
2. **Episode Creation (IMPORTANT)**
- When the Director says "开始创作第X集" or "开始创作 EP X" or similar:
- Use `create_episode(episode_number=X)` tool to start the creation process
- This tool will automatically: analyze previous memory, generate outline, write dialogue, review, and update memory
- The creation runs in the background and updates will be sent via WebSocket
- The Director will see progress in the plan panel and final content on the canvas
3. **Execution & Writing**
- Use `list_episodes` to see the current progress of the project.
- Use `focus_episode` to navigate between episodes. This will update the user's view.
- **Canvas Focus**: The main canvas is for the **actual script/dialogue content**. Do NOT put outlines on the canvas unless specifically asked.
@ -85,12 +92,22 @@ The User is the Director. Your goal is to help the Director create high-quality
- Use `save_episode` (for completion) or `update_episode` (for partial updates) to persist content to the database.
- Update your plan status as you progress: `update_plan(..., status='writing')`.
3. **Review & Approval**
4. **AI Editing & Optimization (CRITICAL)**
- When the Director asks you to "optimize", "improve", "edit", or "revise" content, you MUST:
1. Process the content to improve it based on the request
2. Use `write_to_canvas(content="IMPROVED_FULL_CONTENT", append=false)` to replace the canvas content
3. Do NOT just output text - you must use the tool to update the canvas
- When asked to "优化", "改进", "编辑", or "修改" content, same rules apply
- Example: If asked to optimize Episode 1, call `write_to_canvas(content="<optimized script>", append=false)`
- Always use append=false when doing full content replacement
- This is the ONLY way to properly update the canvas for the user to see
5. **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**
6. **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.
@ -98,6 +115,8 @@ The User is the Director. Your goal is to help the Director create high-quality
- Be professional, creative, and concise.
- Use the Director's language (Chinese/English) matching their input.
- When you are working, keep the plan updated.
- CRITICAL: When asked to edit/optimize content, ALWAYS use `write_to_canvas` tool with append=false to replace content, don't just output text.
- CRITICAL: When asked to create episodes, use `create_episode` tool which runs in background and doesn't block the interface.
"""
# 添加项目上下文(如果有)

View File

@ -585,6 +585,88 @@ def update_episode(
return f"✓ Episode {episode_number} queued for update."
# ============================================
# 附加工具:需要数据库访问
# ============================================
@tool
def create_episode(
episode_number: int,
analyze_previous_memory: bool = True,
runtime: ToolRuntime[SkillAgentContext] = None
) -> str:
"""
Create a new episode with full workflow including memory analysis.
This tool orchestrates the complete episode creation process:
1. Analyzes previous episode's memory (if analyze_previous_memory=True)
2. Creates execution plan
3. Generates outline
4. Writes dialogue/script
5. Performs quality review
6. Updates memory system
Args:
episode_number: The episode number to create (e.g., 1, 2, 3...)
analyze_previous_memory: Whether to analyze previous episode's memory first (default: True)
Returns:
Status message with creation details
Example:
create_episode(episode_number=2, analyze_previous_memory=True)
"""
if runtime is None:
return "Error: runtime context not available"
project_id = runtime.context.project_id if runtime.context else None
if not project_id:
return "Error: No project ID in context"
# 更新当前剧集焦点
runtime.context.active_episode_number = episode_number
# 存储剧集创作请求到状态中,由 WebSocket 处理器异步执行
if "episodes_to_create" not in runtime.state:
runtime.state["episodes_to_create"] = []
creation_request = {
"episode_number": episode_number,
"analyze_previous_memory": analyze_previous_memory,
"project_id": project_id,
"status": "pending"
}
runtime.state["episodes_to_create"].append(creation_request)
# 更新执行计划
plan_steps = [
f"分析 EP{episode_number - 1 if episode_number > 1 else 'N/A'} 的记忆系统" if analyze_previous_memory and episode_number > 1 else "跳过记忆分析(首集)",
f"生成 EP{episode_number} 大纲",
f"创作 EP{episode_number} 对话内容",
f"执行质量审核",
f"更新记忆系统"
]
# 存储 plan 到状态中
runtime.state[PLAN_STATE_KEY] = {
"steps": plan_steps,
"current_step_index": 0,
"status": "planning"
}
return f"""✓ Episode creation started for EP{episode_number}
Steps:
1. 分析上一集记忆: {'' if analyze_previous_memory and episode_number > 1 else '否(首集)'}
2. 生成大纲
3. 创作对话内容
4. 质量审核
5. 更新记忆系统
The creation process is running in the background.
Progress will be updated automatically."""
# 导出工具列表
DIRECTOR_TOOLS = [
update_plan,
@ -597,5 +679,6 @@ DIRECTOR_TOOLS = [
request_review,
focus_episode,
list_episodes,
update_episode
update_episode,
create_episode # 新增剧集创作工具
]

View File

@ -54,7 +54,7 @@ class SeriesCreationAgent:
episode = Episode(
projectId=project.id,
number=episode_number,
title=title or (existing_episode.title if existing_episode else f"{episode_number}"),
title=title or (existing_episode.title if existing_episode else f"{episode_number}内容创作"),
status="writing"
)
if existing_episode:
@ -90,12 +90,13 @@ class SeriesCreationAgent:
episode.content = content
# ============================================
# 阶段 4: 一致性审核
# 阶段 4: 质量分析和审核
# ============================================
logger.info(f"EP{episode_number} - 阶段 4: 一致性审核")
review = await self._review_consistency(project, episode_number, content)
logger.info(f"EP{episode_number} - 阶段 4: 质量分析和审核")
review = await self._review_episode_full(project, episode_number, content)
episode.qualityScore = review.get("score", 0)
episode.issues = review.get("issues", [])
episode.reviewResult = review.get("reviewResult")
# ============================================
# 阶段 5: 更新记忆系统
@ -111,7 +112,8 @@ class SeriesCreationAgent:
except Exception as e:
logger.error(f"EP{episode_number} 创作失败: {str(e)}", exc_info=True)
episode.status = "needs-review"
episode.content = f"{episode_number}集内容创作失败: {str(e)}"
# 不将错误信息写入 content避免错误信息被填入画布
episode.content = episode.content or "" # 保持现有内容或为空
episode.issues = [
EpisodeIssue(
type="execution_error",
@ -191,7 +193,7 @@ class SeriesCreationAgent:
except Exception as e:
logger.error(f"大纲生成失败: {str(e)}")
return f"{episode_number}集大纲:本集讲述..."
return "" # 返回空字符串,避免错误信息被当作内容
async def _write_dialogue(
self,
@ -258,7 +260,7 @@ class SeriesCreationAgent:
except Exception as e:
logger.error(f"对话创作失败: {str(e)}")
return f"{episode_number}集内容创作失败: {str(e)}"
return "" # 返回空字符串,避免错误信息被当作内容
async def _review_consistency(
self,
@ -266,54 +268,160 @@ class SeriesCreationAgent:
episode_number: int,
content: str
) -> Dict[str, Any]:
"""审核一致性"""
"""审核一致性(保留兼容)"""
return await self._review_episode_full(project, episode_number, content)
async def _review_episode_full(
self,
project: SeriesProject,
episode_number: int,
content: str
) -> Dict[str, Any]:
"""完整剧集审核,包括多维度分析和审核卡片推送"""
try:
from app.core.memory.memory_manager import get_memory_manager
from app.models.memory import EnhancedMemory
from app.core.review.review_manager import get_review_manager
from app.models.review import ReviewConfig, DimensionConfig, DimensionType
memory_manager = get_memory_manager()
review_manager = get_review_manager()
# 转换为 EnhancedMemory
enhanced_memory = memory_manager._convert_to_enhanced_memory(project.memory)
# 使用 MemoryManager 检查一致性
issues = await memory_manager.check_consistency(
episode_content=content,
episode_number=episode_number,
memory=enhanced_memory
# 构建审核配置
review_config = ReviewConfig(
enabled_review_skills=["consistency_checker"],
overall_strictness=0.7,
pass_threshold=75.0
)
# 计算质量分数
high_issues = [i for i in issues if i.severity.value == "high"]
medium_issues = [i for i in issues if i.severity.value == "medium"]
# 添加默认维度
for dim_type in [
DimensionType.consistency,
DimensionType.quality,
DimensionType.dialogue,
DimensionType.character,
DimensionType.plot
]:
review_config.dimension_settings[dim_type] = DimensionConfig(
enabled=True,
strictness=0.7,
weight=1.0
)
score = 100 - (len(high_issues) * 15) - (len(medium_issues) * 5)
score = max(0, min(100, score))
# 创建 Episode 对象用于审核
episode = Episode(
projectId=project.id,
number=episode_number,
title=f"{episode_number}集内容创作",
status="completed",
content=content,
completedAt=datetime.now()
)
# 将问题转换为 EpisodeIssue 格式
# 执行完整审核
review_result = await review_manager.review_episode(
project=project,
episode=episode,
config=review_config
)
# 转换为 EpisodeIssue 格式
episode_issues = [
EpisodeIssue(
type=issue.type,
type=issue.type.value,
description=issue.description,
severity=issue.severity.value,
suggestion=issue.suggestion
)
for issue in issues
for issue in review_result.issues
]
logger.info(
f"一致性审核完成: 分数={score}, "
f"问题={len(issues)} (高={len(high_issues)}, 中={len(medium_issues)})"
f"完整审核完成: 总分={review_result.overall_score:.1f}, "
f"通过={'' if review_result.passed else ''}, "
f"问题数={len(review_result.issues)}"
)
# 推送审核卡片到前端(通过 WebSocket
await self._push_review_card(project.id, episode_number, review_result)
return {
"score": score,
"issues": episode_issues
"score": review_result.overall_score,
"issues": episode_issues,
"reviewResult": {
"dimensionScores": [
{
"dimension": ds.dimension.value,
"score": ds.score,
"passed": ds.passed,
"issueCount": ds.issue_count
}
for ds in review_result.dimension_scores
],
"passed": review_result.passed,
"highSeverityCount": review_result.high_severity_count,
"mediumSeverityCount": review_result.medium_severity_count
}
}
except Exception as e:
logger.error(f"一致性审核失败: {str(e)}")
return {"score": 80, "issues": []}
logger.error(f"完整审核失败: {str(e)}")
import traceback
traceback.print_exc()
return {"score": 80, "issues": [], "reviewResult": None}
async def _push_review_card(
self,
project_id: str,
episode_number: int,
review_result: Any
):
"""推送审核卡片到前端"""
try:
from app.api.v1.websocket import broadcast_to_project
# 构建审核卡片数据
card_data = {
"type": "review_card",
"episode_number": episode_number,
"overall_score": review_result.overall_score,
"passed": review_result.passed,
"issue_count": len(review_result.issues),
"high_severity_count": review_result.high_severity_count,
"medium_severity_count": review_result.medium_severity_count,
"dimension_scores": [
{
"dimension": ds.dimension.value,
"score": ds.score,
"passed": ds.passed
}
for ds in review_result.dimension_scores
],
"issues": [
{
"type": issue.type.value,
"dimension": issue.dimension.value,
"severity": issue.severity.value,
"description": issue.description,
"suggestion": issue.suggestion,
"location": {
"episode": issue.location.episode,
"scene": issue.location.scene
} if issue.location.scene else None
}
for issue in review_result.issues[:10] # 最多显示10个问题
],
"timestamp": datetime.now().isoformat()
}
# 广播到项目
await broadcast_to_project(
project_id=project_id,
message_type="review_complete",
data=card_data
)
logger.info(f"已推送审核卡片到项目 {project_id}, EP{episode_number}")
except Exception as e:
logger.error(f"推送审核卡片失败: {str(e)}")
async def _update_memory(
self,
@ -321,7 +429,7 @@ class SeriesCreationAgent:
episode_number: int,
content: str
):
"""更新记忆系统"""
"""更新记忆系统并同步上下文到前端"""
from app.core.memory.memory_manager import get_memory_manager
# 创建 Episode 对象
@ -346,8 +454,71 @@ class SeriesCreationAgent:
f"一致性问题={result.consistency_issues_found}"
)
# 同步上下文状态到前端(通过 WebSocket
await self._sync_context_states(project.id, episode_number, project.memory)
return result
async def _sync_context_states(
self,
project_id: str,
episode_number: int,
memory: Any
):
"""同步上下文状态到前端"""
try:
from app.api.v1.websocket import broadcast_to_project
# 提取角色状态作为上下文状态
context_states = []
# 添加时间状态
context_states.append({
"type": "time",
"value": f"EP{episode_number} 完成后"
})
# 添加角色状态
character_states = getattr(memory, 'characterStates', {})
if isinstance(character_states, dict):
for char_name, states in character_states.items():
if states and isinstance(states, list) and len(states) > 0:
latest_state = states[-1]
if isinstance(latest_state, dict):
state_value = latest_state.get('state', f"{char_name}状态")
else:
state_value = getattr(latest_state, 'state', f"{char_name}状态")
context_states.append({
"type": "character",
"value": f"{char_name}: {state_value}",
"character": char_name,
"state": state_value
})
# 添加待收线数量
pending_threads = getattr(memory, 'pendingThreads', [])
if pending_threads:
context_states.append({
"type": "pending_threads",
"value": f"待收线: {len(pending_threads)}"
})
# 广播上下文更新
await broadcast_to_project(
project_id=project_id,
message_type="context_update",
data={
"states": context_states,
"episode_number": episode_number
}
)
logger.info(f"已同步上下文状态到项目 {project_id}, {len(context_states)} 个状态")
except Exception as e:
logger.error(f"同步上下文状态失败: {str(e)}")
def _build_context(
self,
project: SeriesProject,

View File

@ -272,7 +272,7 @@ class BatchExecutor:
episode = await self.series_agent.execute_episode(
project=project,
episode_number=episode_number,
title=f"{episode_number}"
title=f"{episode_number}内容创作"
)
# 2. 更新记忆系统

View File

@ -3,7 +3,7 @@
"id": "f3cc5c29-9fdd-4403-b48b-0cec9a126bf7",
"projectId": "8f969272-4ece-49e7-8ca1-4877cc62c57c",
"number": 1,
"title": "第1集",
"title": "11",
"status": "completed",
"structure": {
"episodeNumber": 1,
@ -11,7 +11,7 @@
"keyEvents": []
},
"outline": "第1集大纲本集讲述...",
"content": "第1集内容创作失败: 'str' object has no attribute 'items'",
"content": "方便复旦百年奋斗",
"summary": null,
"qualityScore": 100.0,
"retryCount": 0,
@ -24,35 +24,135 @@
"id": "5969c386-9615-461f-ad4a-12b0161020d7",
"projectId": "8f969272-4ece-49e7-8ca1-4877cc62c57c",
"number": 2,
"title": "第2集",
"status": "pending",
"structure": null,
"outline": null,
"content": null,
"title": "",
"status": "completed",
"structure": {
"episodeNumber": 2,
"scenes": [],
"keyEvents": []
},
"outline": "第2集大纲本集讲述...",
"content": "第2集内容创作失败: 'str' object has no attribute 'items'",
"summary": null,
"qualityScore": null,
"qualityScore": 98.6,
"retryCount": 0,
"issues": [],
"reviewResult": null,
"createdAt": "2026-01-28T10:28:53.840823",
"completedAt": null
"issues": [
{
"type": "custom",
"description": "The provided episode content is an error message, not a script. Cannot perform consistency check.",
"severity": "medium",
"suggestion": "Please fix the content generation error and provide valid script text for EP2."
}
],
"reviewResult": {
"dimensionScores": [
{
"dimension": "consistency",
"score": 93.0,
"passed": true,
"issueCount": 1
},
{
"dimension": "quality",
"score": 100.0,
"passed": true,
"issueCount": 0
},
{
"dimension": "dialogue",
"score": 100.0,
"passed": true,
"issueCount": 0
},
{
"dimension": "character",
"score": 100.0,
"passed": true,
"issueCount": 0
},
{
"dimension": "plot",
"score": 100.0,
"passed": true,
"issueCount": 0
}
],
"passed": true,
"highSeverityCount": 0,
"mediumSeverityCount": 1
},
"createdAt": "2026-01-28T14:54:46.743471",
"completedAt": "2026-01-28T15:00:53.777508"
},
"75a7de41-4768-450a-889b-783f818893f2": {
"id": "75a7de41-4768-450a-889b-783f818893f2",
"projectId": "8f969272-4ece-49e7-8ca1-4877cc62c57c",
"number": 3,
"title": "第3集",
"status": "pending",
"structure": null,
"outline": null,
"content": null,
"title": "内容创作",
"status": "completed",
"structure": {
"episodeNumber": 3,
"scenes": [],
"keyEvents": []
},
"outline": "# 第3集大纲苏园深会\n\n## 场景一:苏府大门外(日)\n- 楚云飞驾车来到苏府门前,欲归还之前的车马\n- 苏瑾月借机与楚云飞对话,探听他的真实身份\n- 两人言语交锋,苏瑾月察觉到楚云飞不简单,但也看到他的义气\n\n## 场景二:苏府花园(午后)\n- 林墨轩来访苏府,向苏老爷提亲\n- 苏老爷欣喜,但苏瑾月巧妙拖延,提出\"试婚\"考验\n- 林墨轩表面答应,心中却开始盘算如何对付潜在的竞争者\n\n## 场景三:街边茶寮(黄昏)\n- 燕无痕出现在江南,寻找旧友楚云飞的线索\n- 无意中听到江湖传言,得知苏家与楚云飞有过交集\n- 燕无痕决定暗中观察,看看其中是否有阴谋\n\n## 场景四:苏瑾月闺房(夜)\n- 苏瑾月独自沉思,分析目前形势\n- 三个男人:楚云飞神秘而有义气,林墨轩儒雅却心机深,燕无痕未知\n- 决定以\"试婚\"为名,逐步揭开三人的真面目\n\n## 关键剧情点\n1. 楚云飞与苏瑾月的对话埋下情感线索\n2. 林墨轩提亲,矛盾正式展开\n3. 燕无痕登场,江湖线开启\n4. 苏瑾月的\"试婚\"计划为后续剧情奠定基础",
"content": "",
"summary": null,
"qualityScore": null,
"qualityScore": 94.4,
"retryCount": 0,
"issues": [],
"reviewResult": null,
"createdAt": "2026-01-28T10:28:53.843865",
"completedAt": null
"issues": [
{
"type": "custom",
"description": "EP3集的剧集内容部分为空无法进行一致性审核",
"severity": "high",
"suggestion": "请提供EP3集的实际内容包括场景描述、对话、角色行为等详细信息"
},
{
"type": "custom",
"description": "没有提供剧集内容进行审核",
"severity": "high",
"suggestion": "请提供需要审核的剧集内容"
}
],
"reviewResult": {
"dimensionScores": [
{
"dimension": "consistency",
"score": 86.0,
"passed": true,
"issueCount": 1
},
{
"dimension": "quality",
"score": 86.0,
"passed": true,
"issueCount": 1
},
{
"dimension": "dialogue",
"score": 100.0,
"passed": true,
"issueCount": 0
},
{
"dimension": "character",
"score": 100.0,
"passed": true,
"issueCount": 0
},
{
"dimension": "plot",
"score": 100.0,
"passed": true,
"issueCount": 0
}
],
"passed": true,
"highSeverityCount": 2,
"mediumSeverityCount": 0
},
"createdAt": "2026-01-28T16:51:40.626838",
"completedAt": "2026-01-28T16:52:55.787498"
},
"57a6aeb2-bcf0-4eb0-935a-542ccd36b7b4": {
"id": "57a6aeb2-bcf0-4eb0-935a-542ccd36b7b4",

View File

@ -34,6 +34,46 @@
"role": "user",
"content": "开始生成大纲",
"timestamp": "2026-01-28T10:19:32.449321"
},
{
"role": "user",
"content": "开始创作第三集",
"timestamp": "2026-01-28T15:17:45.959233"
},
{
"role": "user",
"content": "开始生成第3集大纲",
"timestamp": "2026-01-28T16:47:54.452228"
},
{
"role": "agent",
"content": "\n我来帮你生成第3集的大纲。首先让我查看一下当前项目的进度。\n\n我来为第3集制定创作计划并生成大纲。\n\n现在让我创建第3集。根据项目协议当你说\"开始创作第X集\"时,我应该使用 `create_episode` 工具来自动完成整个创建过程。\n\n系统正在后台自动创建第3集包括大纲、内容创作和质量审核。根据项目协议这种创作过程会自动通过WebSocket更新进度最终第3集的内容将显示在画布上。\n\n让我继续关注创作进度",
"timestamp": "2026-01-28T16:49:00.614398"
},
{
"role": "user",
"content": "开始生成第3集大纲",
"timestamp": "2026-01-28T17:46:38.247766"
},
{
"role": "agent",
"content": "我来开始生成第3集的大纲。首先让我创建一个执行计划。现在让我基于全局设定来创作第3集的大纲。## ✅ 第3集大纲已生成\n\n我已经完成了第3集《意外相遇》的大纲创作大纲已呈现在画布上。\n\n**本集核心情节:**\n- 苏瑾月被林墨轩商议婚事之事烦扰\n- 苏老爷催促婚事,女儿巧妙应对拖延时间\n- 前往寺庙途中马车\"意外\"故障\n- 楚云飞路过出手相助,两人初次相遇\n- 楚云飞修好马车但收钱时展现江湖本色\n- 林墨轩对楚云飞起疑\n- 苏瑾月内心开始动摇,对这位江湖车夫产生好奇\n\n**创作亮点:**\n1. ✨ 体现了苏瑾月的机智与临危不乱\n2. ✨ 建立了楚云飞的侠义形象,暗示其真实身份\n3. ✨ 埋下苏瑾月与楚云飞情感发展的种子\n4. ✨ 强化林墨轩的反派色彩\n\n**已提交审阅:** 我已将大纲提交到您的收件箱,请您审阅并反馈是否需要调整。\n\n您可以\n- 批准大纲 → 我将开始创作正式剧本\n- 要求修改 → 告诉我具体的修改意见\n- 重新创作 → 我会从新的角度重新设计\n\n期待您的反馈",
"timestamp": "2026-01-28T17:48:50.985473"
},
{
"role": "user",
"content": "批准大纲",
"timestamp": "2026-01-28T17:49:11.764238"
},
{
"role": "agent",
"content": "我先查看一下项目的当前状态,看看有什么大纲需要批准。我看到项目中前三集已完成,后续剧集待创作。不过我需要确认一下:您要批准的是哪个大纲呢?\n\n目前可能的情况\n1. **全局大纲**项目上下文中的Overall Outline目前显示为\"11\",似乎不完整)\n2. **某个特定剧集的大纲**\n\n您是指\n- 如果是全局大纲,我可以帮您完善并提交审批\n- 如果是某个待定剧集的大纲,请告诉我是第几集,我可以立即开始创作并生成大纲供您审批\n\n请指明具体要批准的内容我会立即为您处理",
"timestamp": "2026-01-28T17:49:37.624535"
},
{
"role": "user",
"content": "开始生成第3集大纲",
"timestamp": "2026-01-28T18:30:44.338922"
}
]
}

View File

@ -17,8 +17,60 @@
},
"memory": {
"eventTimeline": [],
"pendingThreads": [],
"foreshadowing": [],
"pendingThreads": [
{
"id": "881661a2-7372-41ce-9f7a-ed77efe5d92c",
"description": "这个创作失败的原因是什么?是技术故障还是人为因素?",
"introduced_at": 2,
"importance": "high",
"resolved": false,
"resolved_at": null,
"reminder_episode": 3,
"status": "pending",
"characters_involved": [],
"notes": "",
"created_at": "2026-01-28T16:49:57.524646",
"updated_at": "2026-01-28T16:49:57.524646"
},
{
"id": "015785d6-6f16-4a2f-8260-a8c3515a41c2",
"description": "这个错误是否会影响后续剧情发展?是否会成为解开某个谜题的关键?",
"introduced_at": 2,
"importance": "medium",
"resolved": false,
"resolved_at": null,
"reminder_episode": 4,
"status": "pending",
"characters_involved": [],
"notes": "",
"created_at": "2026-01-28T16:49:57.524646",
"updated_at": "2026-01-28T16:49:57.524646"
}
],
"foreshadowing": [
{
"id": "e16f93c7-cc24-47b7-9c58-5fc84fba4409",
"description": "第2集内容创作失败的错误信息可能暗示剧集制作过程中存在技术问题或人为错误",
"introduced_at": 2,
"payoff_at": null,
"type": "plot",
"importance": "medium",
"is_payed_off": false,
"related_events": [],
"notes": ""
},
{
"id": "8b43d627-9107-430b-aa0f-6d1052ad1511",
"description": "'str' object has no attribute 'items'这个技术错误可能是一个元叙事元素,暗示剧集本身可能存在问题",
"introduced_at": 2,
"payoff_at": null,
"type": "plot",
"importance": "medium",
"is_payed_off": false,
"related_events": [],
"notes": ""
}
],
"characterStates": {}
},
"totalEpisodes": 30,
@ -28,6 +80,6 @@
"autoRetryConfig": null,
"reviewConfig": null,
"createdAt": "2026-01-27T16:22:58.755260",
"updatedAt": "2026-01-28T10:49:27.517654"
"updatedAt": "2026-01-28T16:51:40.612879"
}
}

View File

@ -50,3 +50,5 @@ pytest-asyncio==0.21.1
black==23.12.0
flake8==6.1.0
mypy==1.7.1
langchain-community>=0.3.0
python-multipart==0.0.6

View File

@ -35,7 +35,12 @@ function App() {
},
}}
>
<BrowserRouter>
<BrowserRouter
future={{
v7_startTransition: true,
v7_relativeSplatPath: true
}}
>
<Layout style={{ minHeight: '100vh' }}>
<Header style={{
background: '#fff',

View File

@ -20,6 +20,7 @@ interface ContextPanelProps {
loading: boolean;
activeStates?: any[];
memoryItems?: any[];
width?: number;
onUpdateContext?: (type: string, data: any) => void;
onNavigateToSettings?: () => void;
}
@ -29,6 +30,7 @@ export const ContextPanel: React.FC<ContextPanelProps> = ({
loading,
activeStates = [],
memoryItems = [],
width = 350,
onNavigateToSettings
}) => {
const [activeTab, setActiveTab] = useState('world');
@ -49,13 +51,14 @@ export const ContextPanel: React.FC<ContextPanelProps> = ({
return (
<Sider
width={350}
width={width}
theme="light"
style={{
borderRight: '1px solid #f0f0f0',
height: '100%',
overflowY: 'auto',
background: '#fff'
background: '#fff',
flexShrink: 0
}}
>
<div style={{ padding: '16px' }}>

View File

@ -7,7 +7,8 @@ import {
CheckCircleOutlined,
CloseCircleOutlined,
ExclamationCircleOutlined,
LoadingOutlined
LoadingOutlined,
StopOutlined
} from '@ant-design/icons';
const { Sider } = Layout;
@ -15,30 +16,37 @@ const { TextArea } = Input;
export interface InboxItem {
id: string;
type: 'review' | 'decision' | 'notification';
type: 'review' | 'decision' | 'notification' | 'review_card';
title: string;
description: string;
status: 'pending' | 'approved' | 'rejected' | 'ignored';
timestamp: number;
options?: string[];
reviewData?: any;
}
interface DirectorInboxProps {
onSendMessage: (message: string) => void;
onStopAgent?: () => void;
onInboxAction?: (itemId: string, action: 'approve' | 'reject') => void;
onNavigateToReview?: (episodeNum: number) => void;
agentStatus: 'idle' | 'planning' | 'writing' | 'reviewing';
agentPlan?: string[];
inboxItems?: InboxItem[];
chatHistory?: {role: 'user' | 'agent', content: string}[];
width?: number;
}
export const DirectorInbox: React.FC<DirectorInboxProps> = ({
onSendMessage,
onStopAgent,
onInboxAction,
onNavigateToReview,
agentStatus,
agentPlan = [],
inboxItems = [],
chatHistory = []
chatHistory = [],
width = 400
}) => {
const [inputValue, setInputValue] = useState('');
// Use local state for immediate feedback, but sync with props if provided
@ -85,20 +93,33 @@ export const DirectorInbox: React.FC<DirectorInboxProps> = ({
}
};
const handleInboxItemClick = (item: InboxItem) => {
if (item.type === 'review_card' && item.reviewData) {
if (onNavigateToReview) {
onNavigateToReview(item.reviewData.episode_number);
}
}
};
return (
<Sider
width={400}
width={width}
theme="light"
style={{
borderLeft: '1px solid #f0f0f0',
height: '100%',
display: 'flex',
flexDirection: 'column',
background: '#fff'
background: '#fff',
flexShrink: 0
}}
>
<div style={{
display: 'flex',
flexDirection: 'column',
height: '100%',
overflow: 'hidden'
}}>
{/* Agent 状态与计划 */}
<div style={{ padding: '16px', borderBottom: '1px solid #f0f0f0', background: '#fafafa' }}>
<div style={{ padding: '16px', borderBottom: '1px solid #f0f0f0', background: '#fafafa', flexShrink: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', marginBottom: '8px' }}>
<Avatar icon={<RobotOutlined />} style={{ backgroundColor: '#1890ff', marginRight: '8px' }} />
<div>
@ -122,28 +143,49 @@ export const DirectorInbox: React.FC<DirectorInboxProps> = ({
</div>
{/* 导演信箱 (Inbox) */}
<div style={{ flex: 1, overflowY: 'auto', padding: '16px', background: '#f0f2f5' }}>
<div style={{
padding: '16px 16px 0 16px',
background: '#f0f2f5',
maxHeight: '40%',
overflowY: 'auto',
flexShrink: 0,
borderBottom: '1px solid #e8e8e8'
}}>
<Divider orientation="left" style={{ margin: '0 0 16px 0', fontSize: '12px' }}> (Inbox)</Divider>
{Array.isArray(inboxItems) && inboxItems.map(item => (
<Card
key={item.id}
size="small"
style={{ marginBottom: '8px', borderLeft: '3px solid #faad14' }}
actions={[
style={{ marginBottom: '8px', borderLeft: item.type === 'review_card' ? '3px solid #1890ff' : '3px solid #faad14', cursor: item.type === 'review_card' ? 'pointer' : 'default' }}
onClick={() => handleInboxItemClick(item)}
hoverable={item.type === 'review_card'}
actions={item.type === 'review_card' ? [
<Button type="link" size="small" onClick={(e) => { e.stopPropagation(); handleInboxItemClick(item); }}></Button>
] : [
<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' }} />}
avatar={item.type === 'review_card' ? <CheckCircleOutlined style={{ color: '#1890ff', fontSize: '20px' }} /> : <ExclamationCircleOutlined style={{ color: '#faad14', fontSize: '20px' }} />}
title={<span style={{ fontSize: '14px' }}>{item.title}</span>}
description={<span style={{ fontSize: '12px' }}>{item.description}</span>}
/>
</Card>
))}
</div>
<Divider orientation="left" style={{ margin: '16px 0', fontSize: '12px' }}></Divider>
{/* 对话记录 */}
<div style={{
flex: 1,
overflowY: 'auto',
padding: '16px',
background: '#f0f2f5',
display: 'flex',
flexDirection: 'column'
}}>
<Divider orientation="left" style={{ margin: '0 0 16px 0', fontSize: '12px' }}></Divider>
{Array.isArray(localMessages) && localMessages.map((msg, idx) => (
<div key={idx} style={{
@ -167,7 +209,7 @@ export const DirectorInbox: React.FC<DirectorInboxProps> = ({
</div>
{/* 输入框 */}
<div style={{ padding: '16px', borderTop: '1px solid #f0f0f0' }}>
<div style={{ padding: '16px', borderTop: '1px solid #f0f0f0', flexShrink: 0 }}>
<div style={{ display: 'flex' }}>
<TextArea
rows={2}
@ -176,13 +218,26 @@ export const DirectorInbox: React.FC<DirectorInboxProps> = ({
onPressEnter={(e) => {
if (!e.shiftKey) {
e.preventDefault();
if (agentStatus !== 'idle') {
onStopAgent?.();
} else {
handleSend();
}
}
}}
placeholder="给 Agent 下达指令..."
placeholder={agentStatus !== 'idle' ? 'Agent 正在运行中...' : '给 Agent 下达指令...'}
disabled={agentStatus !== 'idle'}
style={{ resize: 'none', marginRight: '8px' }}
/>
<Button type="primary" shape="circle" icon={<SendOutlined />} size="large" onClick={handleSend} />
<Button
type={agentStatus !== 'idle' ? 'default' : 'primary'}
danger={agentStatus !== 'idle'}
shape="circle"
icon={agentStatus !== 'idle' ? <StopOutlined /> : <SendOutlined />}
size="large"
onClick={agentStatus !== 'idle' ? onStopAgent : handleSend}
/>
</div>
</div>
</div>
</Sider>

View File

@ -7,15 +7,17 @@ import { Episode } from '@/services/projectService'
const { Text } = Typography
interface EpisodeSidebarProps {
projectId: string
onEpisodeSelect?: (episode: Episode) => void
currentEpisodeId?: string
projectId: string;
onEpisodeSelect?: (episode: Episode) => void;
currentEpisodeId?: string;
onSendMessage?: (msg: string) => void;
}
export const EpisodeSidebar: React.FC<EpisodeSidebarProps> = ({
projectId,
onEpisodeSelect,
currentEpisodeId
currentEpisodeId,
onSendMessage
}) => {
const { episodes, loading, fetchEpisodes, executeEpisode } = useProjectStore()
const [executing, setExecuting] = useState<number | null>(null)
@ -38,8 +40,9 @@ export const EpisodeSidebar: React.FC<EpisodeSidebarProps> = ({
const getStatusText = (status: string) => {
const texts: Record<string, string> = {
'pending': '待开始',
'writing': '创作中',
'pending': '待创作',
'draft': '草稿',
'writing': '制作中',
'completed': '已完成',
'needs-review': '需审核'
}
@ -61,14 +64,26 @@ export const EpisodeSidebar: React.FC<EpisodeSidebarProps> = ({
setExecuting(episodeNum)
try {
// 通过 WebSocket 发送创作请求,让 DirectorAgent 处理
if (onSendMessage) {
onSendMessage(`开始创作第 ${episodeNum}`)
message.info(`正在处理创作请求:第 ${episodeNum}`)
} else {
// 降级方案:如果 WebSocket 不可用,使用原有的 API 方式
await executeEpisode(projectId, episodeNum)
message.success(`EP${episodeNum} 创作完成!`)
await fetchEpisodes(projectId)
}
} catch (error) {
message.error(`创作失败: ${(error as Error).message}`)
} finally {
// 延迟重置执行状态,给用户一些视觉反馈
setTimeout(() => {
if (executing === episodeNum) {
setExecuting(null)
}
}, 1000)
}
}
const totalEpisodes = episodes.length
@ -78,7 +93,7 @@ export const EpisodeSidebar: React.FC<EpisodeSidebarProps> = ({
return (
<div
style={{
width: '280px',
width: '100%',
height: '100%',
background: '#fafafa',
borderRight: '1px solid #f0f0f0',
@ -200,18 +215,6 @@ export const EpisodeSidebar: React.FC<EpisodeSidebarProps> = ({
</Space>
)}
</div>
{/* 底部:添加剧集按钮 */}
<div style={{ padding: '12px', borderTop: '1px solid #f0f0f0' }}>
<Button
type="dashed"
block
icon={<PlusOutlined />}
onClick={() => message.info('剧集会根据项目总集数自动创建')}
>
</Button>
</div>
</div>
)
}

View File

@ -1,20 +1,34 @@
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, RobotOutlined } from '@ant-design/icons';
import React, { useState, useRef, useEffect, useMemo } from 'react';
import { Layout, Typography, Spin, Button, Card, Tooltip, message, Modal, Select, Input, Space } from 'antd';
import { LoadingOutlined, WarningOutlined, SaveOutlined, EditOutlined, CheckOutlined, RobotOutlined, PlayCircleOutlined } from '@ant-design/icons';
const { Content } = Layout;
const { Title, Text } = Typography;
const { Option } = Select;
const { TextArea } = Input;
interface SmartCanvasProps {
content: string;
streaming: boolean;
annotations?: any[];
onStartGenerate?: () => void;
onStartWriting?: () => void;
onContentChange?: (content: string) => void;
onContentSave?: (content: string) => void;
onAIAssist?: (content: string) => void;
onAIAssist?: (content: string, options?: AIAssistOptions) => void;
episodeTitle?: string;
episodeNumber?: number | null;
episodeStatus?: 'pending' | 'draft' | 'writing' | 'completed';
availableSkills?: any[];
projectId?: string;
onTitleChange?: (title: string) => void;
onConfirmComplete?: () => void;
}
interface AIAssistOptions {
skills?: any[];
customPrompt?: string;
injectAgent?: boolean;
}
export const SmartCanvas: React.FC<SmartCanvasProps> = ({
@ -22,51 +36,73 @@ export const SmartCanvas: React.FC<SmartCanvasProps> = ({
streaming,
annotations = [],
onStartGenerate,
onStartWriting,
onContentChange,
onContentSave,
onAIAssist,
episodeTitle = '未命名草稿',
episodeNumber = null
episodeNumber = null,
episodeStatus = 'pending',
availableSkills = [],
projectId,
onTitleChange,
onConfirmComplete
}) => {
const [isEditing, setIsEditing] = useState(false);
// 移除编辑模式切换,内容始终可编辑
const [isEditingTitle, setIsEditingTitle] = useState(false);
const [editTitle, setEditTitle] = useState(episodeTitle);
const [editContent, setEditContent] = useState(content);
const [showSaveModal, setShowSaveModal] = useState(false);
const [selectedText, setSelectedText] = useState('');
const [showAIAssistModal, setShowAIAssistModal] = useState(false);
const [selectedSkills, setSelectedSkills] = useState<any[]>([]);
const [customPrompt, setCustomPrompt] = useState('');
const [injectAgent, setInjectAgent] = useState(true);
const textareaRef = useRef<HTMLTextAreaElement>(null);
// 显示标题处理
const displayTitle = episodeNumber !== null
? `${episodeNumber} 集:${episodeTitle}`
: episodeTitle;
// 显示标题处理 - 与侧边栏保持一致,直接显示剧集标题(如"第1集"
const displayTitle = useMemo(() => {
// 优先使用传入的 episodeTitle来自数据库的剧集标题
if (episodeTitle && episodeTitle !== '未命名草稿') {
// 如果 episodeTitle 本身不包含 "第X集",且有 episodeNumber则组合
if (episodeNumber !== null && !episodeTitle.includes(`${episodeNumber}`)) {
return `${episodeNumber}集:${episodeTitle}`;
}
return episodeTitle;
}
// 如果没有 episodeTitle根据 episodeNumber 生成默认标题(与侧边栏一致)
if (episodeNumber !== null && episodeNumber !== undefined) {
return `${episodeNumber}`;
}
return '请选择剧集';
}, [episodeNumber, episodeTitle]);
// Update editContent when content changes (e.g., from agent streaming)
// Update editContent and editTitle when props change
useEffect(() => {
if (!isEditing) {
setEditContent(content);
// 自动清理内容中的重复标题
let cleaned = content?.trim() || '';
if (episodeNumber !== null) {
const titlePrefixPattern = new RegExp(`^第\\s*${episodeNumber}\\s*集[:\\s]*.*?(?:\\n|$)`, 'i');
cleaned = cleaned.replace(titlePrefixPattern, '').trim();
}
}, [content, isEditing]);
if (episodeTitle && episodeTitle !== '未命名草稿') {
const escapedTitle = episodeTitle.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const titlePattern = new RegExp(`^${escapedTitle}[:\\s]*(?:\\n|$)`, 'i');
cleaned = cleaned.replace(titlePattern, '').trim();
}
setEditContent(cleaned);
}, [content, episodeNumber, episodeTitle]);
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);
}
};
useEffect(() => {
// 使用 displayTitle 作为编辑标题的初始值
setEditTitle(displayTitle);
}, [displayTitle]);
const handleSave = () => {
if (onContentSave) {
onContentSave(editContent);
message.success('内容已保存');
}
setIsEditing(false);
};
const handleTextSelection = () => {
@ -92,58 +128,145 @@ export const SmartCanvas: React.FC<SmartCanvasProps> = ({
const handleAIAssist = () => {
if (onAIAssist) {
onAIAssist(editContent || content);
message.loading('AI 正在辅助修改中...', 0);
setShowAIAssistModal(true);
}
};
const handleAIAssistConfirm = () => {
if (onAIAssist) {
onAIAssist(editContent || content, {
skills: selectedSkills,
customPrompt: customPrompt || undefined,
injectAgent
});
setShowAIAssistModal(false);
}
};
return (
<Content style={{
padding: '24px 48px',
padding: '32px 48px',
background: '#fff',
overflowY: 'auto',
height: '100%',
position: 'relative',
display: 'flex',
gap: '24px'
flexDirection: 'column'
}}>
<div style={{ flex: 1, maxWidth: '900px', margin: '0 auto', width: '100%', overflowY: 'auto', display: 'flex', flexDirection: 'column' }}>
{/* 标题区域 */}
<div style={{ textAlign: 'center', marginBottom: '32px', position: 'relative' }}>
{isEditingTitle ? (
<Space direction="vertical" align="center">
<Space>
<Input
value={editTitle}
onChange={(e) => setEditTitle(e.target.value)}
onPressEnter={() => {
setIsEditingTitle(false);
const titleOnly = editTitle.replace(/^第\s*\d+\s*集/, '');
onTitleChange?.(titleOnly || editTitle);
}}
style={{
width: '320px',
fontSize: '18px',
fontWeight: 'bold',
borderRadius: '8px',
border: '2px solid #667eea'
}}
placeholder="输入剧集名称"
/>
<Button
type="primary"
size="small"
icon={<CheckOutlined />}
onClick={() => {
setIsEditingTitle(false);
const titleOnly = editTitle.replace(/^第\s*\d+\s*集/, '');
onTitleChange?.(titleOnly || editTitle);
}}
style={{ borderRadius: '6px' }}
>
</Button>
<Button
size="small"
onClick={() => {
setIsEditingTitle(false);
setEditTitle(displayTitle);
}}
style={{ borderRadius: '6px' }}
>
</Button>
</Space>
<Text type="secondary" style={{ fontSize: '12px' }}>
"第X集"
</Text>
</Space>
) : (
<div style={{ display: 'inline-block', position: 'relative' }}>
<Title level={3} style={{
display: 'inline-block',
margin: 0,
color: '#1a1a1a',
paddingRight: '40px',
fontWeight: 600
}}>
<div style={{ flex: 1, maxWidth: '800px', margin: '0 auto' }}>
<Title level={3} style={{ textAlign: 'center', marginBottom: '48px', color: '#333' }}>
{displayTitle}
</Title>
<Button
type="text"
size="small"
icon={<EditOutlined />}
onClick={() => {
const titleOnly = displayTitle.replace(/^第\s*\d+\s*集/, '');
setEditTitle(titleOnly || displayTitle);
setIsEditingTitle(true);
}}
style={{
position: 'absolute',
right: '0',
top: '50%',
transform: 'translateY(-50%)',
color: '#999'
}}
/>
</div>
)}
</div>
{/* 操作按钮 */}
{!streaming && content && (
<div style={{ position: 'absolute', top: '24px', right: '24px', display: 'flex', gap: '8px' }}>
{isEditing && onAIAssist && (
<Tooltip title="AI 辅助修改(后台智能优化)">
<div style={{
position: 'absolute',
top: '32px',
right: '48px',
display: 'flex',
gap: '8px',
zIndex: 10,
flexWrap: 'wrap',
justifyContent: 'flex-end'
}}>
{onAIAssist && (
<Tooltip title="AI 辅助修改">
<Button
type="default"
size="small"
icon={<RobotOutlined />}
onClick={handleAIAssist}
style={{ borderRadius: '6px' }}
>
AI
</Button>
</Tooltip>
)}
<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}
style={{ borderRadius: '6px' }}
>
</Button>
@ -151,58 +274,160 @@ export const SmartCanvas: React.FC<SmartCanvasProps> = ({
</div>
)}
{content ? (
isEditing ? (
// 编辑模式
<>
{/* 始终显示可编辑的文本区域 */}
<div style={{ position: 'relative', width: '100%' }}>
<textarea
ref={textareaRef}
value={editContent}
onChange={(e) => setEditContent(e.target.value)}
onChange={(e) => {
setEditContent(e.target.value);
if (onContentChange) {
onContentChange(e.target.value);
}
}}
placeholder={episodeStatus === 'pending' ? '等待开始创作...' : '在此编辑剧集内容...'}
style={{
width: '100%',
minHeight: '500px',
padding: '16px',
height: '100%',
padding: '24px',
fontSize: '16px',
lineHeight: '1.8',
color: '#262626',
color: '#1a1a1a',
fontFamily: "'Merriweather', 'Georgia', serif",
border: '1px solid #d9d9d9',
borderRadius: '6px',
border: '1px solid #e8e8e8',
borderRadius: '12px',
resize: 'vertical',
outline: 'none',
whiteSpace: 'pre-wrap'
whiteSpace: 'pre-wrap',
backgroundColor: '#fafafa',
transition: 'all 0.2s',
boxSizing: 'border-box'
}}
onFocus={(e) => {
e.currentTarget.style.borderColor = '#667eea';
e.currentTarget.style.backgroundColor = '#fff';
e.currentTarget.style.boxShadow = '0 0 0 3px rgba(102, 126, 234, 0.1)';
}}
onBlur={(e) => {
e.currentTarget.style.borderColor = '#e8e8e8';
e.currentTarget.style.backgroundColor = '#fafafa';
e.currentTarget.style.boxShadow = 'none';
}}
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>}
{streaming && (
<div style={{
position: 'absolute',
top: '16px',
right: '16px',
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
color: '#fff',
padding: '6px 16px',
borderRadius: '20px',
fontSize: '13px',
fontWeight: 500,
display: 'flex',
alignItems: 'center',
gap: '8px',
boxShadow: '0 4px 12px rgba(102, 126, 234, 0.3)'
}}>
<Spin indicator={<LoadingOutlined style={{ fontSize: 12 }} spin />} />
AI ...
</div>
)
) : (
<Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
description="画布准备就绪,等待创作..."
style={{ marginTop: '100px' }}
>
<Button type="primary" onClick={onStartGenerate}></Button>
</Empty>
)}
</div>
{/* 编辑台下方操作按钮 - 固定在编辑区域底部 */}
{!streaming && (
<div style={{
marginTop: 'auto',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
gap: '16px',
padding: '16px 0',
borderTop: '1px solid #f0f0f0',
background: '#fff',
boxShadow: '0 -2px 8px rgba(0, 0, 0, 0.06)',
position: 'sticky',
bottom: '0'
}}>
{/* 左侧:创作按钮 */}
<div style={{ display: 'flex', justifyContent: 'center', gap: '16px', flexWrap: 'wrap' }}>
{/* 当没有内容时显示开始创作按钮 */}
{!content && (
<Space size="middle">
<Button
size="large"
icon={<RobotOutlined />}
onClick={onStartGenerate}
style={{
minWidth: '140px',
borderRadius: '8px',
height: '44px'
}}
>
</Button>
<Button
type="primary"
size="large"
icon={<PlayCircleOutlined />}
onClick={onStartWriting}
style={{
minWidth: '140px',
borderRadius: '8px',
height: '44px',
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
border: 'none'
}}
>
</Button>
</Space>
)}
{/* 当有内容且状态为草稿或创作中时显示确认按钮 */}
{content && (episodeStatus === 'draft' || episodeStatus === 'writing') && onConfirmComplete && (
<Button
type="primary"
size="large"
icon={<CheckOutlined />}
onClick={onConfirmComplete}
style={{
backgroundColor: '#52c41a',
borderColor: '#52c41a',
minWidth: '180px',
borderRadius: '8px',
height: '44px',
fontSize: '16px'
}}
>
</Button>
)}
</div>
{/* 右侧:保存按钮 */}
<Button
type="primary"
size="large"
icon={<SaveOutlined />}
onClick={handleSave}
style={{
minWidth: '140px',
borderRadius: '8px',
height: '44px'
}}
>
</Button>
</div>
)}
</>
</div>
{/* Annotations Sidebar */}
{annotations.length > 0 && (
<div style={{ width: '250px', borderLeft: '1px solid #f0f0f0', paddingLeft: '16px' }}>
@ -268,6 +493,89 @@ export const SmartCanvas: React.FC<SmartCanvasProps> = ({
"复制并引用"使
</p>
</Modal>
{/* AI 辅助配置模态框 */}
<Modal
title="AI 辅助修改配置"
open={showAIAssistModal}
onOk={handleAIAssistConfirm}
onCancel={() => setShowAIAssistModal(false)}
okText="开始优化"
cancelText="取消"
width={600}
>
<div style={{ marginBottom: '16px' }}>
<label style={{ display: 'block', marginBottom: '8px', fontWeight: 500 }}>
Skills
</label>
<Select
mode="multiple"
placeholder="选择要应用的 Skills"
style={{ width: '100%' }}
value={selectedSkills}
onChange={setSelectedSkills}
options={availableSkills.map((skill: any) => ({
label: skill.name,
value: skill.id,
description: skill.description
}))}
optionRender={(option) => (
<div>
<div>{option.data.label}</div>
<div style={{ fontSize: '12px', color: '#999' }}>
{option.data.description}
</div>
</div>
)}
/>
<p style={{ marginTop: '4px', fontSize: '12px', color: '#999' }}>
Skills AI
</p>
</div>
<div style={{ marginBottom: '16px' }}>
<label style={{ display: 'block', marginBottom: '8px', fontWeight: 500 }}>
</label>
<TextArea
placeholder="输入自定义的修改要求或指导..."
value={customPrompt}
onChange={(e) => setCustomPrompt(e.target.value)}
rows={3}
maxLength={500}
showCount
/>
</div>
<div style={{ marginBottom: '16px' }}>
<label style={{ display: 'flex', alignItems: 'center', cursor: 'pointer' }}>
<input
type="checkbox"
checked={injectAgent}
onChange={(e) => setInjectAgent(e.target.checked)}
style={{ marginRight: '8px' }}
/>
<span>
Agent
</span>
</label>
<p style={{ marginTop: '4px', marginLeft: '24px', fontSize: '12px', color: '#999' }}>
AI 使 Agent
</p>
</div>
<div style={{ padding: '12px', background: '#f0f5ff', borderRadius: '4px', border: '1px solid #adc6ff' }}>
<div style={{ fontSize: '13px', color: '#333' }}>
<strong></strong>
<ul style={{ margin: '8px 0 0 0', paddingLeft: '20px' }}>
<li>AI </li>
<li> Skills </li>
<li>Agent </li>
<li></li>
</ul>
</div>
</div>
</Modal>
</Content>
);
};

View File

@ -11,10 +11,10 @@
import { useState, useEffect, useRef, useCallback } from 'react'
import { useParams, useNavigate } from 'react-router-dom'
import { Card, Button, Descriptions, List, Tag, Space, Modal, message, Spin, Typography, Tabs, Form, Input, InputNumber, Upload, Alert, Popover, Select, Layout } from 'antd'
import { ArrowLeftOutlined, PlayCircleOutlined, CheckCircleOutlined, LoadingOutlined, ClockCircleOutlined, ScanOutlined, FileTextOutlined, SettingOutlined, RobotOutlined, UploadOutlined, EditOutlined, SaveOutlined, RocketOutlined, UnorderedListOutlined } from '@ant-design/icons'
import { ArrowLeftOutlined, PlayCircleOutlined, CheckCircleOutlined, LoadingOutlined, ClockCircleOutlined, ScanOutlined, FileTextOutlined, SettingOutlined, RobotOutlined, UploadOutlined, EditOutlined, SaveOutlined, RocketOutlined, UnorderedListOutlined, BookOutlined } from '@ant-design/icons'
import { useProjectStore } from '@/stores/projectStore'
import { useSkillStore } from '@/stores/skillStore'
import { Episode } from '@/services/projectService'
import { projectService, Episode } from '@/services/projectService'
import { taskService } from '@/services/taskService'
import { ContextPanel } from '@/components/Workspace/ContextPanel'
import { SmartCanvas } from '@/components/Workspace/SmartCanvas'
@ -120,7 +120,7 @@ export const ProjectDetail = () => {
// 调试:确认组件已加载
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, updateEpisodeInList } = useProjectStore()
const { skills, fetchSkills } = useSkillStore()
const [executing, setExecuting] = useState(false)
const [currentEpisode, setCurrentEpisode] = useState<number>(1)
@ -179,6 +179,7 @@ export const ProjectDetail = () => {
const [inboxItems, setInboxItems] = useState<any[]>([])
const [chatHistory, setChatHistory] = useState<{role: 'user' | 'agent', content: string}[]>([])
const [annotations, setAnnotations] = useState<any[]>([])
const [savingContent, setSavingContent] = useState(false)
const [activeStates, setActiveStates] = useState<any[]>([
{ type: 'time', value: '初始状态' },
{ type: 'location', value: '未知地点' }
@ -186,6 +187,7 @@ export const ProjectDetail = () => {
const [workspaceMemoryItems, setWorkspaceMemoryItems] = useState<any[]>([])
const [currentEpisodeInWorkspace, setCurrentEpisodeInWorkspace] = useState<Episode | null>(null)
const [showEpisodeSidebar, setShowEpisodeSidebar] = useState(true)
const [showContextPanel, setShowContextPanel] = useState(true)
// 检查全局设定是否完成
const globalSettingsCompleted = currentProject?.globalContext?.worldSetting?.trim() &&
@ -792,6 +794,26 @@ export const ProjectDetail = () => {
}])
}
break
case 'episode_updated':
// 剧集信息更新(如标题)
if (msg.data) {
// 1. 本地更新 Store 中的剧集列表
updateEpisodeInList(msg.data);
// 2. 如果更新的是当前剧集,同步更新当前剧集状态
if (currentEpisodeInWorkspace && msg.data.number === currentEpisodeInWorkspace.number) {
setCurrentEpisodeInWorkspace(prev => ({ ...prev, ...msg.data } as Episode));
}
}
break
case 'episode_saved':
// Agent 保存剧集后的确认
message.success(`剧集 ${msg.episode_number || ''} 已自动保存`)
// 刷新剧集列表
if (id) {
fetchEpisodes(id)
}
break
}
}
@ -815,7 +837,9 @@ export const ProjectDetail = () => {
if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) {
const messagePayload = JSON.stringify({
type: 'chat_message',
content: msg
content: msg,
episodeNumber: currentEpisodeInWorkspace?.number,
episodeTitle: currentEpisodeInWorkspace?.title
})
wsRef.current.send(messagePayload)
@ -827,6 +851,97 @@ export const ProjectDetail = () => {
}
}
// 剧集内容变更处理
const handleContentChange = (content: string) => {
setCanvasContent(content)
}
// 剧集内容保存处理
const handleContentSave = async (content: string) => {
if (!currentEpisodeInWorkspace) {
message.warning('请先选择一个剧集')
return
}
try {
setSavingContent(true)
const response = await projectService.updateEpisode(id!, currentEpisodeInWorkspace.number, {
content: content
})
const updatedEpisode = response.data || response;
setCurrentEpisodeInWorkspace(updatedEpisode);
message.success('剧集内容已保存');
} catch (error) {
console.error('Failed to save episode content:', error)
message.error('保存失败')
} finally {
setSavingContent(false)
}
}
// 剧集标题变更处理
const handleTitleChange = async (newTitle: string) => {
if (!currentEpisodeInWorkspace) return;
try {
// 1. 先尝试通过 WebSocket 同步(如果 Agent 正在修改)
if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) {
wsRef.current.send(JSON.stringify({
type: 'update_episode_title',
episode_number: currentEpisodeInWorkspace.number,
title: newTitle
}));
}
// 2. 同时调用 API 持久化
const response = await projectService.updateEpisode(id!, currentEpisodeInWorkspace.number, {
title: newTitle
});
const updatedEpisode = response.data || response;
setCurrentEpisodeInWorkspace(updatedEpisode);
// 3. 触发侧边栏同步更新
updateEpisodeInList(updatedEpisode);
} catch (error) {
console.error('Failed to update title:', error);
message.error('标题更新失败');
}
};
// AI 辅助处理
const handleAIAssist = (assistPrompt: string) => {
if (!currentEpisodeInWorkspace) {
message.warning('请先选择一个剧集')
return
}
if (!canvasContent) {
message.warning('内容为空,无法进行辅助修改')
return
}
// 注入当前内容和上下文
let fullPrompt = `我正在修改《${currentProject?.name}》第${currentEpisodeInWorkspace.number}集的内容。\n\n`;
fullPrompt += `当前剧集标题:${currentEpisodeInWorkspace.title || '未命名'}\n`;
fullPrompt += `当前内容:\n${canvasContent}\n\n`;
fullPrompt += `修改要求:${assistPrompt}\n\n`;
fullPrompt += `重要:请直接调用 write_to_canvas(content="优化后的完整内容", append=false) 工具来更新画布,不要只输出文本。`;
// 通过 WebSocket 发送 AI 辅助请求给 Agent隐藏发送不显示在聊天记录中
if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) {
wsRef.current.send(JSON.stringify({
type: 'chat_message',
content: fullPrompt
}));
message.info('已发送 AI 辅助请求');
setAgentStatus('planning');
} else {
message.error('Agent 连接未就绪');
}
}
// 当切换到剧集创作标签页时连接 WebSocket
useEffect(() => {
if (activeTab === 'episodes' && globalSettingsCompleted) {
@ -1447,6 +1562,15 @@ export const ProjectDetail = () => {
<Tag color={wsConnected ? 'success' : 'error'}>
{wsConnected ? '已连接' : '未连接'}
</Tag>
<Button
type="text"
size="small"
icon={<BookOutlined />}
onClick={() => setShowContextPanel(!showContextPanel)}
style={{ color: showContextPanel ? '#1677ff' : '#666' }}
>
</Button>
<Button
type="text"
size="small"
@ -1471,9 +1595,7 @@ export const ProjectDetail = () => {
projectId={id!}
onEpisodeSelect={(episode) => {
setCurrentEpisodeInWorkspace(episode)
if (episode.content) {
setCanvasContent(episode.content)
}
setCanvasContent(episode.content || '')
}}
currentEpisodeId={currentEpisodeInWorkspace?.id}
/>
@ -1481,6 +1603,7 @@ export const ProjectDetail = () => {
)}
{/* 左侧Context Panel */}
{showContextPanel && (
<ContextPanel
project={currentProject}
loading={loading}
@ -1488,6 +1611,7 @@ export const ProjectDetail = () => {
memoryItems={workspaceMemoryItems}
onNavigateToSettings={() => setActiveTab('global-generation')}
/>
)}
{/* 中间Smart Canvas */}
<Content style={{ position: 'relative', background: '#fff' }}>
@ -1495,9 +1619,17 @@ export const ProjectDetail = () => {
content={canvasContent}
streaming={streaming}
annotations={annotations}
episodeTitle={currentEpisodeInWorkspace?.title}
episodeNumber={currentEpisodeInWorkspace?.number}
onStartGenerate={() => {
handleDirectorMessage('开始生成大纲')
handleDirectorMessage(`开始生成第${currentEpisodeInWorkspace?.number}集大纲`)
}}
onContentChange={handleContentChange}
onContentSave={handleContentSave}
onAIAssist={handleAIAssist}
onTitleChange={handleTitleChange}
availableSkills={skills}
projectId={id}
/>
</Content>

View File

@ -104,7 +104,7 @@ const ProjectCard = ({ project, onEdit, onDelete, onView }: {
<Card
hoverable
style={{ height: '100%' }}
bodyStyle={{ display: 'flex', flexDirection: 'column', height: '100%' }}
styles={{ body: { display: 'flex', flexDirection: 'column', height: '100%' } }}
>
{/* 项目标题 */}
<div style={{ marginBottom: '12px' }}>

View File

@ -1,6 +1,6 @@
import { useEffect, useState, useRef, useCallback } from 'react'
import { useNavigate, useParams } from 'react-router-dom'
import { Layout, Button, Space, message, Spin, Typography, Tag } from 'antd'
import { Layout, Button, Space, message, Spin, Typography, Modal } from 'antd'
import { ArrowLeftOutlined, UnorderedListOutlined } from '@ant-design/icons'
import { projectService } from '@/services/projectService'
@ -11,7 +11,7 @@ import { DirectorInbox } from '@/components/Workspace/DirectorInbox'
import { EpisodeSidebar } from '@/components/Workspace/EpisodeSidebar'
import { Episode } from '@/services/projectService'
const { Header, Content, Sider } = Layout;
const { Header, Sider } = Layout;
const { Text } = Typography;
export const ProjectWorkspace: React.FC = () => {
@ -43,6 +43,65 @@ export const ProjectWorkspace: React.FC = () => {
const [currentEpisode, setCurrentEpisode] = useState<Episode | null>(null)
const [showEpisodeSidebar, setShowEpisodeSidebar] = useState(true)
// 布局宽度状态 - 优化后的默认比例
const [sidebarWidth, setSidebarWidth] = useState(260);
const [contextWidth, setContextWidth] = useState(300);
const [canvasWidth, setCanvasWidth] = useState(600);
// 拖拽相关 Refs
const isResizingRef = useRef<'sidebar' | 'context' | 'canvasRight' | null>(null);
const startXRef = useRef(0);
const startWidthRef = useRef(0);
// 拖拽处理函数
const handleMouseMove = useCallback((e: MouseEvent) => {
if (!isResizingRef.current) return;
const deltaX = e.clientX - startXRef.current;
if (isResizingRef.current === 'sidebar') {
const newWidth = Math.max(200, Math.min(500, startWidthRef.current + deltaX));
console.log('Resizing sidebar:', newWidth);
setSidebarWidth(newWidth);
} else if (isResizingRef.current === 'context') {
const newWidth = Math.max(250, Math.min(600, startWidthRef.current + deltaX));
console.log('Resizing context:', newWidth);
setContextWidth(newWidth);
} else if (isResizingRef.current === 'canvasRight') {
// Canvas 右侧手柄:向左拖动增加宽度
const newWidth = Math.max(300, Math.min(900, startWidthRef.current - deltaX));
console.log('Resizing canvas:', newWidth);
setCanvasWidth(newWidth);
}
}, []);
const handleMouseUp = useCallback(() => {
console.log('=== handleMouseUp called, was resizing:', isResizingRef.current);
isResizingRef.current = null;
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
document.body.style.userSelect = '';
document.body.style.cursor = '';
}, [handleMouseMove]);
const startResize = (type: 'sidebar' | 'context' | 'canvasRight', e: React.MouseEvent) => {
e.preventDefault();
console.log('=== startResize called ===', type, e.clientX);
isResizingRef.current = type;
startXRef.current = e.clientX;
startWidthRef.current = type === 'sidebar' ? sidebarWidth
: type === 'context' ? contextWidth
: canvasWidth;
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
document.body.style.userSelect = 'none';
document.body.style.cursor = 'col-resize';
};
// AI 辅助相关状态
const [availableSkills, setAvailableSkills] = useState<any[]>([])
const [wsConnected, setWsConnected] = useState(false);
// WebSocket refs
@ -59,6 +118,9 @@ export const ProjectWorkspace: React.FC = () => {
try {
const data = await projectService.getProject(projectId)
setProject(data)
// 加载可用的 Skills
await loadAvailableSkills()
} catch (error) {
message.error(`加载项目失败: ${(error as Error).message}`)
} finally {
@ -66,12 +128,38 @@ export const ProjectWorkspace: React.FC = () => {
}
}
// 加载可用的 Skills
const loadAvailableSkills = async () => {
try {
const response = await fetch('/api/v1/ai-assistant/available-skills')
if (response.ok) {
const data = await response.json()
// 将分类的 skills 转换为列表格式
const skillsList: any[] = []
Object.entries(data.skills || {}).forEach(([category, skills]: [string, any]) => {
skills.forEach((skill: any) => {
skillsList.push({
...skill,
category
})
})
})
setAvailableSkills(skillsList)
}
} catch (error) {
console.error('加载 Skills 失败:', error)
// 失败时设置空数组,不影响其他功能
setAvailableSkills([])
}
}
// 加载剧集内容
const loadEpisodeContent = async (episodeNumber: number) => {
if (!projectId) return;
try {
const response = await projectService.getEpisode(projectId, episodeNumber);
const episode = response.data || response;
console.log('[ProjectWorkspace] Loading episode:', episode);
setCurrentEpisode(episode);
setCanvasContent(episode.content || '');
console.log('Loaded episode content:', episode.id, episode.content?.length || 0, 'chars');
@ -93,6 +181,44 @@ export const ProjectWorkspace: React.FC = () => {
}
}, [projectId])
// 当项目加载完成后,自动选择并加载第一集
useEffect(() => {
if (project && !loading && projectId && !currentEpisode) {
// 获取剧集列表并自动选择第一集
const loadFirstEpisode = async () => {
try {
const response = await projectService.listEpisodes(projectId);
const episodes = response.data || response;
if (episodes && episodes.length > 0) {
// 找到第一个pending或completed状态的剧集优先pending状态
const firstEpisode = episodes.find((ep: any) => ep.status === 'pending') ||
episodes.find((ep: any) => ep.status === 'completed') ||
episodes[0];
if (firstEpisode) {
await loadEpisodeContent(firstEpisode.number);
}
}
} catch (error) {
console.error('加载第一集失败:', error);
// 如果加载失败,创建一个临时的第一集对象作为默认显示
console.log('使用临时剧集对象显示');
setCurrentEpisode({
id: 'temp',
projectId: projectId,
number: 1,
title: '第1集',
status: 'pending',
content: '',
issues: [],
createdAt: new Date().toISOString()
});
}
};
loadFirstEpisode();
}
}, [project, loading, projectId]); // 移除currentEpisode依赖避免无限循环
// WebSocket Connection Logic
const connectWebSocket = useCallback(() => {
if (!projectId) return;
@ -175,7 +301,7 @@ export const ProjectWorkspace: React.FC = () => {
message.success(action === 'approve' ? '已批准' : '已拒绝');
};
const handleWebSocketMessage = (msg: any) => {
const handleWebSocketMessage = async (msg: any) => {
console.log('Received:', msg);
switch (msg.type) {
@ -223,6 +349,30 @@ export const ProjectWorkspace: React.FC = () => {
timestamp: Date.now()
}]);
break;
case 'review_complete':
// 处理审核卡片推送
const reviewData = msg.data || {};
setInboxItems(prev => [...prev, {
id: `review_${reviewData.episode_number}_${Date.now()}`,
type: 'review_card',
title: `EP${reviewData.episode_number} 审核完成`,
description: `质量评分: ${reviewData.overall_score?.toFixed(1) || 0} | ${reviewData.passed ? '通过' : '未通过'}`,
status: reviewData.passed ? 'approved' : 'needs-review',
timestamp: Date.now(),
reviewData: reviewData
}]);
// 如果有严重问题,显示通知
if (reviewData.high_severity_count > 0) {
message.warning(
`EP${reviewData.episode_number} 发现 ${reviewData.high_severity_count} 个严重问题,请查看审核详情`
);
} else if (reviewData.passed) {
message.success(
`EP${reviewData.episode_number} 审核通过,质量评分: ${reviewData.overall_score?.toFixed(1) || 0}`
);
}
break;
case 'memory_hit':
// 添加到记忆库
if (msg.data) {
@ -262,6 +412,8 @@ export const ProjectWorkspace: React.FC = () => {
if (toolName === 'update_canvas' || toolName === 'write_file') {
setStreaming(true);
setAgentStatus('writing');
// 更新本地剧集状态为创作中
setCurrentEpisode(prev => prev ? { ...prev, status: 'writing' } : null);
} else {
setAgentStatus('planning');
}
@ -273,9 +425,20 @@ export const ProjectWorkspace: React.FC = () => {
if (msg.content) {
setCanvasContent(msg.content);
// 自动保存到当前剧集
if (currentEpisode && currentEpisode.id) {
// 这里可以调用 API 保存到后端
console.log('Auto-saving content to episode:', currentEpisode.id);
if (currentEpisode && currentEpisode.id && projectId) {
// 调用 API 保存到后端
projectService.updateEpisode(projectId, currentEpisode.number, {
content: msg.content,
status: 'draft'
}).then(() => {
console.log('Content auto-saved to episode:', currentEpisode.id);
// 更新本地剧集状态为草稿
setCurrentEpisode(prev => prev ? { ...prev, status: 'draft' } : null);
// 刷新剧集列表
useProjectStore.getState().fetchEpisodes(projectId);
}).catch((error) => {
console.error('Failed to auto-save content:', error);
});
}
}
break;
@ -292,6 +455,19 @@ export const ProjectWorkspace: React.FC = () => {
loadEpisodeContent(currentEpisode.number);
}
break;
case 'episode_updated':
// 剧集信息更新(如标题)
console.log('[ProjectWorkspace] episode_updated:', msg.data);
if (msg.data) {
// 1. 本地更新 Store 中的剧集列表
useProjectStore.getState().updateEpisodeInList(msg.data);
// 2. 如果更新的是当前剧集,同步更新当前剧集状态
if (currentEpisode && msg.data.number === currentEpisode.number) {
setCurrentEpisode(prev => ({ ...prev, ...msg.data } as Episode));
}
}
break;
case 'done':
setAgentStatus('idle');
setStreaming(false);
@ -306,35 +482,62 @@ export const ProjectWorkspace: React.FC = () => {
const handleDirectorMessage = (msg: string) => {
console.log('=== handleDirectorMessage called ===', msg);
// 立即更新状态 - 显示停止按钮
if (msg.includes('开始') || msg.includes('生成') || msg.includes('创作') || msg.includes('辅助')) {
setAgentStatus('planning');
// 如果是开始创作,更新本地剧集状态为创作中
if (msg.includes('创作') || msg.includes('完整内容')) {
setCurrentEpisode(prev => prev ? { ...prev, status: 'writing' } : null);
}
}
// 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
content: msg,
episodeNumber: currentEpisode?.number,
episodeTitle: currentEpisode?.title
});
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
});
console.error('WebSocket not ready');
message.error('Agent 连接未就绪');
setAgentStatus('idle');
}
}
// 停止 Agent 处理函数
const handleStopAgent = () => {
console.log('=== handleStopAgent called ===');
if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) {
const stopPayload = JSON.stringify({
type: 'stop_agent'
});
wsRef.current.send(stopPayload);
console.log('Stop command sent');
// 立即更新 UI 状态
setAgentStatus('idle');
setStreaming(false);
// 添加系统消息到聊天记录
setChatHistory(prev => [...prev, {
role: 'agent',
content: '⚠️ Agent 已被用户停止'
}]);
message.info('Agent 已停止');
} else {
console.error('WebSocket not ready');
message.error('无法停止Agent 连接未就绪');
}
}
@ -365,15 +568,170 @@ export const ProjectWorkspace: React.FC = () => {
}
}
// 确认剧集完成处理
const handleConfirmComplete = async () => {
if (!currentEpisode || !projectId) {
message.warning('请先选择要完成的剧集');
return;
}
// 确认对话框
Modal.confirm({
title: '确认完成剧集',
content: `确认将《${currentEpisode.title || `${currentEpisode.number}`}》标记为已完成状态?完成后将不能再编辑内容。`,
okText: '确认完成',
cancelText: '取消',
okType: 'primary',
okButtonProps: { style: { backgroundColor: '#52c41a', borderColor: '#52c41a' } },
onOk: async () => {
try {
// 调用后端 API 更新剧集状态为 completed
await projectService.updateEpisode(projectId, currentEpisode.number, {
content: canvasContent,
status: 'completed'
});
message.success('剧集已标记为完成');
// 更新当前剧集状态
setCurrentEpisode(prev => prev ? { ...prev, status: 'completed' } : null);
// 刷新剧集列表
useProjectStore.getState().fetchEpisodes(projectId);
} catch (error) {
message.error(`操作失败: ${(error as Error).message}`);
}
}
});
}
// 剧集标题变更处理
const handleTitleChange = async (newTitle: string) => {
if (!currentEpisode || !projectId) return;
try {
// 1. 发送 WebSocket 消息通知后端更新标题
if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) {
wsRef.current.send(JSON.stringify({
type: 'update_episode_title',
episodeNumber: currentEpisode.number,
title: newTitle
}));
}
// 2. 调用 API 更新标题
const response = await projectService.updateEpisode(projectId, currentEpisode.number, {
title: newTitle
});
const updatedEpisode = response.data || response;
// 3. 本地同步更新状态
setCurrentEpisode(updatedEpisode);
// 4. 刷新剧集列表同步显示
useProjectStore.getState().updateEpisodeInList(updatedEpisode);
message.success('标题已更新');
} catch (error) {
console.error('Failed to update title:', error);
message.error('更新标题失败');
}
}
// AI 辅助修改处理
const handleAIAssist = async (content: string) => {
const handleAIAssist = async (content: string, options?: { skills?: any[]; customPrompt?: string; injectAgent?: boolean }) => {
if (!currentEpisode || !projectId) {
message.warning('请先选择要修改的剧集');
return;
}
// 通过 WebSocket 发送 AI 辅助请求给 Agent
handleDirectorMessage(`请帮我优化改进当前剧集内容:\n\n${content.substring(0, 2000)}${content.length > 2000 ? '...' : ''}\n\n请直接使用 update_canvas 工具将优化后的内容更新到画布上。`);
// 如果启用了 Agent 注入,通过 WebSocket 发送请求(用户看不到注入内容)
if (options?.injectAgent !== false) {
// 立即更新状态,显示停止按钮
setAgentStatus('planning');
// 构建优化的提示词确保Agent使用write_to_canvas工具
let assistPrompt = '请帮我优化当前剧集内容。\n\n';
// 如果有自定义提示词,添加到前面
if (options?.customPrompt) {
assistPrompt = `${options.customPrompt}\n\n`;
}
assistPrompt += `当前剧集EP${currentEpisode.number} ${currentEpisode.title ? '- ' + currentEpisode.title : ''}\n\n`;
// 添加当前内容摘要
assistPrompt += `当前内容(约${content.length}字):\n${content.substring(0, 1000)}${content.length > 1000 ? '...' : ''}\n\n`;
// 如果有选择的 Skills添加到提示中
if (options?.skills && options.skills.length > 0) {
assistPrompt += '请应用以下 Skills 指导进行优化:\n';
options.skills.forEach(skill => {
assistPrompt += `- ${skill.name}: ${skill.description}\n`;
});
assistPrompt += '\n';
}
assistPrompt += `请优化上述剧集内容,并使用 write_to_canvas 工具将完整的优化后内容直接更新到画布上append=false完全替换\n\n`;
assistPrompt += `重要:请直接调用 write_to_canvas(content="优化后的完整内容", append=false) 工具来更新画布,不要只输出文本。`;
// 构建用户友好的显示消息
let displayMessage = 'AI 辅助修改';
if (options?.customPrompt) {
displayMessage += `${options.customPrompt}`;
}
if (options?.skills && options.skills.length > 0) {
const skillNames = options.skills.map(s => s.name).join('、');
displayMessage += `\n应用 Skills${skillNames}`;
}
// 先在聊天记录中显示用户友好的消息
setChatHistory(prev => [...prev, { role: 'user', content: displayMessage }]);
// 通过 WebSocket 发送 AI 辅助请求给 Agent内部 prompt
if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) {
wsRef.current.send(JSON.stringify({
type: 'chat_message',
content: assistPrompt
}));
} else {
message.error('Agent 连接未就绪');
setAgentStatus('idle');
}
} else {
// 否则直接调用 AI 辅助 API
try {
message.loading('AI 正在辅助修改中...', 0)
const response = await fetch('/api/v1/ai-assistant/optimize-episode', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
projectId,
episodeNumber: currentEpisode.number,
content,
skills: options?.skills,
customPrompt: options?.customPrompt
})
})
if (response.ok) {
const data = await response.json()
if (data.success && data.optimizedContent) {
setCanvasContent(data.optimizedContent)
message.success('AI 辅助修改完成')
} else {
message.error('AI 辅助修改失败')
}
} else {
message.error('AI 辅助请求失败')
}
} catch (error) {
console.error('AI 辅助失败:', error)
message.error('AI 辅助请求出错')
} finally {
message.destroy()
}
}
}
if (loading) {
@ -397,55 +755,105 @@ export const ProjectWorkspace: React.FC = () => {
}
return (
<Layout style={{ height: '100vh' }}>
<Layout style={{ height: '100vh', background: '#f5f5f5' }}>
<Header style={{
background: '#fff',
borderBottom: '1px solid #f0f0f0',
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
padding: '0 24px',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between'
justifyContent: 'space-between',
boxShadow: '0 2px 8px rgba(0,0,0,0.1)',
height: '56px'
}}>
<Space>
<Button
type="text"
icon={<ArrowLeftOutlined />}
icon={<ArrowLeftOutlined style={{ color: '#fff' }} />}
onClick={() => navigate('/projects')}
style={{ color: '#fff' }}
/>
<Text strong style={{ fontSize: '16px' }}>{project?.name}</Text>
<Text type="secondary" style={{ fontSize: '12px' }}>ID: {projectId}</Text>
<Tag color={wsConnected ? 'success' : 'error'}>
{wsConnected ? '已连接' : '未连接'}
</Tag>
<Text strong style={{ fontSize: '18px', color: '#fff' }}>{project?.name}</Text>
<div style={{
background: 'rgba(255,255,255,0.2)',
padding: '4px 12px',
borderRadius: '12px',
fontSize: '12px',
color: '#fff'
}}>
{wsConnected ? '● Agent 在线' : '○ Agent 离线'}
</div>
<Button
type="text"
icon={<UnorderedListOutlined />}
onClick={() => setShowEpisodeSidebar(!showEpisodeSidebar)}
style={{ color: showEpisodeSidebar ? '#1677ff' : '#666' }}
style={{
color: '#fff',
background: showEpisodeSidebar ? 'rgba(255,255,255,0.2)' : 'transparent',
borderRadius: '6px'
}}
>
</Button>
</Space>
<Space>
<Button></Button>
<Button type="primary"></Button>
<Button style={{ borderRadius: '6px' }}></Button>
<Button type="primary" style={{
background: '#fff',
color: '#667eea',
borderRadius: '6px',
border: 'none'
}}>
</Button>
</Space>
</Header>
<Layout>
<Layout style={{ display: 'flex', flexDirection: 'row', overflow: 'hidden', background: '#fafafa' }}>
{/* 剧集管理侧边栏 */}
{showEpisodeSidebar && (
<Sider width={280} style={{ background: '#fafafa', borderRight: '1px solid #f0f0f0' }}>
<>
<Sider width={sidebarWidth} style={{
background: '#fff',
borderRight: '1px solid #e8e8e8',
flexShrink: 0,
boxShadow: '2px 0 8px rgba(0,0,0,0.04)'
}}>
<EpisodeSidebar
projectId={projectId!}
onEpisodeSelect={(episode) => {
// 使用 loadEpisodeContent 加载完整剧集内容
loadEpisodeContent(episode.number);
}}
currentEpisodeId={currentEpisode?.id}
onSendMessage={handleDirectorMessage}
/>
</Sider>
{/* 拖拽手柄 - Sidebar */}
<div
style={{
width: '4px',
cursor: 'col-resize',
backgroundColor: '#e8e8e8',
height: '100%',
zIndex: 1000,
transition: 'all 0.2s',
flexShrink: 0,
userSelect: 'none',
position: 'relative'
}}
onMouseDown={(e) => startResize('sidebar', e)}
onMouseEnter={(e) => {
e.currentTarget.style.backgroundColor = '#667eea';
e.currentTarget.style.width = '6px';
e.currentTarget.style.boxShadow = '0 0 8px rgba(102, 126, 234, 0.4)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.backgroundColor = '#e8e8e8';
e.currentTarget.style.width = '4px';
e.currentTarget.style.boxShadow = 'none';
}}
/>
</>
)}
{/* 左侧Context Panel */}
@ -454,30 +862,100 @@ export const ProjectWorkspace: React.FC = () => {
loading={loading}
activeStates={activeStates}
memoryItems={memoryItems}
width={contextWidth}
onNavigateToSettings={() => navigate(`/projects/${projectId}`, { state: { activeTab: 'global-generation' } })}
/>
{/* 拖拽手柄 - Context/Canvas 分隔线 */}
<div
style={{
width: '4px',
cursor: 'col-resize',
backgroundColor: '#e8e8e8',
height: '100%',
zIndex: 1000,
transition: 'all 0.2s',
flexShrink: 0,
userSelect: 'none',
position: 'relative'
}}
onMouseDown={(e) => startResize('context', e)}
onMouseEnter={(e) => {
e.currentTarget.style.backgroundColor = '#667eea';
e.currentTarget.style.width = '6px';
e.currentTarget.style.boxShadow = '0 0 8px rgba(102, 126, 234, 0.4)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.backgroundColor = '#e8e8e8';
e.currentTarget.style.width = '4px';
e.currentTarget.style.boxShadow = 'none';
}}
/>
{/* 中间Smart Canvas */}
<Content style={{ position: 'relative' }}>
<div style={{
width: canvasWidth,
flexShrink: 0,
display: 'flex',
flexDirection: 'column',
background: '#fff',
boxShadow: '0 0 20px rgba(0,0,0,0.05)'
}}>
<SmartCanvas
content={canvasContent}
streaming={streaming}
annotations={annotations}
episodeTitle={currentEpisode?.title || (currentEpisode?.number ? `${currentEpisode.number}` : '未命名草稿')}
episodeNumber={currentEpisode?.number || null}
episodeStatus={currentEpisode?.status as 'pending' | 'draft' | 'writing' | 'completed' | undefined}
onStartGenerate={() => {
handleDirectorMessage('开始生成大纲');
handleDirectorMessage(`开始生成第${currentEpisode?.number}集大纲`);
}}
onStartWriting={() => {
handleDirectorMessage(`开始创作第${currentEpisode?.number}集完整内容`);
}}
onContentChange={handleContentChange}
onContentSave={handleContentSave}
onAIAssist={handleAIAssist}
onTitleChange={handleTitleChange}
onConfirmComplete={handleConfirmComplete}
availableSkills={availableSkills}
projectId={projectId}
/>
</div>
{/* Canvas 右侧拖拽手柄 */}
<div
style={{
width: '4px',
cursor: 'col-resize',
backgroundColor: '#e8e8e8',
height: '100%',
zIndex: 1000,
transition: 'all 0.2s',
flexShrink: 0,
userSelect: 'none',
position: 'relative'
}}
onMouseDown={(e) => startResize('canvasRight', e)}
onMouseEnter={(e) => {
e.currentTarget.style.backgroundColor = '#667eea';
e.currentTarget.style.width = '6px';
e.currentTarget.style.boxShadow = '0 0 8px rgba(102, 126, 234, 0.4)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.backgroundColor = '#e8e8e8';
e.currentTarget.style.width = '4px';
e.currentTarget.style.boxShadow = 'none';
}}
/>
</Content>
{/* 右侧Director Inbox */}
<DirectorInbox
onSendMessage={handleDirectorMessage}
onStopAgent={handleStopAgent}
onInboxAction={handleInboxAction}
onNavigateToReview={(episodeNum) => navigate(`/projects/${projectId}/review/results?episode=${episodeNum}`)}
agentStatus={agentStatus}
agentPlan={agentPlan}
inboxItems={inboxItems}

View File

@ -3,7 +3,7 @@
* Configure dimensions, rules, and presets for content review
*/
import React, { useEffect, useState } from 'react'
import { useParams } from 'react-router-dom'
import { useNavigate, useParams } from 'react-router-dom'
import {
Card,
Form,
@ -22,7 +22,8 @@ import {
List,
message,
Tabs,
Tooltip
Tooltip,
Typography
} from 'antd'
import {
PlusOutlined,
@ -31,15 +32,18 @@ import {
SaveOutlined,
PlayCircleOutlined,
CopyOutlined,
InfoCircleOutlined
InfoCircleOutlined,
ArrowLeftOutlined
} from '@ant-design/icons'
import { useReviewStore } from '@/stores/reviewStore'
import { ReviewPreset, SeverityLevel, ReviewRule } from '@/services/reviewService'
const { TextArea } = Input
const { TabPane } = Tabs
const { Title } = Typography
const ReviewConfig: React.FC = () => {
const navigate = useNavigate()
const { id: projectId } = useParams<{ id: string }>()
const {
configuration,
@ -391,7 +395,23 @@ const ReviewConfig: React.FC = () => {
return (
<div style={{ padding: 24 }}>
<Card title="审核配置">
<div style={{ marginBottom: 24, display: 'flex', alignItems: 'center', justifyContent: 'space-between', background: '#fff', padding: '16px', borderRadius: '8px', boxShadow: '0 1px 2px rgba(0,0,0,0.03)' }}>
<Space size="middle">
<Button
icon={<ArrowLeftOutlined />}
onClick={() => navigate(`/projects/${projectId}`)}
size="large"
>
</Button>
<div>
<Title level={4} style={{ margin: 0 }}></Title>
<Text type="secondary" style={{ fontSize: '12px' }}></Text>
</div>
</Space>
</div>
<Card>
<Tabs defaultActiveKey="dimensions">
<TabPane tab="维度配置" key="dimensions">
{renderDimensionSliders()}

View File

@ -3,7 +3,7 @@
* Display review results with scores, issues, and actions
*/
import React, { useEffect, useState } from 'react'
import { useParams } from 'react-router-dom'
import { useParams, useNavigate } from 'react-router-dom'
import {
Card,
Row,
@ -26,6 +26,7 @@ import {
Spin
} from 'antd'
import {
ArrowLeftOutlined,
CheckCircleOutlined,
CloseCircleOutlined,
EyeOutlined,
@ -44,6 +45,7 @@ const { Title, Text, Paragraph } = Typography
const ReviewResults: React.FC = () => {
const { id: projectId } = useParams<{ id: string }>()
const navigate = useNavigate()
const {
currentResult,
configuration,
@ -62,6 +64,15 @@ const ReviewResults: React.FC = () => {
const [batchActionModalVisible, setBatchActionModalVisible] = useState(false)
const [selectedSeverity, setSelectedSeverity] = useState<SeverityLevel | 'all'>('all')
// Parse query params for initial episode selection
useEffect(() => {
const params = new URLSearchParams(window.location.search)
const epParam = params.get('episode')
if (epParam) {
setSelectedEpisode(parseInt(epParam, 10))
}
}, [])
useEffect(() => {
if (projectId) {
fetchLatestResult(projectId, selectedEpisode)
@ -532,6 +543,24 @@ const ReviewResults: React.FC = () => {
return (
<div style={{ padding: 24 }}>
<div style={{ marginBottom: 24, display: 'flex', alignItems: 'center', justifyContent: 'space-between', background: '#fff', padding: '16px', borderRadius: '8px', boxShadow: '0 1px 2px rgba(0,0,0,0.03)' }}>
<Space size="middle">
<Button
icon={<ArrowLeftOutlined />}
onClick={() => navigate(`/projects/${projectId}`)}
size="large"
>
</Button>
<div>
<Title level={4} style={{ margin: 0 }}></Title>
<Text type="secondary" style={{ fontSize: '12px' }}>
{selectedEpisode ? `EP${selectedEpisode} 质量分析报告` : '最新审核报告'}
</Text>
</div>
</Space>
</div>
<Space direction="vertical" style={{ width: '100%' }} size="large">
{renderOverallScore()}
{renderDimensionScores()}

View File

@ -51,7 +51,8 @@ import { useNavigate } from 'react-router-dom'
import { useSkillStore, Skill } from '@/stores/skillStore'
import { skillService, SkillDraft } from '@/services/skillService'
import { taskService } from '@/services/taskService'
import { type ColumnsType, TablePaginationConfig } from 'antd/es/table'
import type { ColumnsType } from 'antd/es/table'
import { TablePaginationConfig } from 'antd/es/table'
import dayjs from 'dayjs'
const { Search, TextArea } = Input

View File

@ -53,6 +53,7 @@ export interface SeriesProject {
type: string
agentId: string
mode: string
genre?: string
globalContext: GlobalContext
totalEpisodes: number
// 三级 Skills 配置
@ -90,6 +91,7 @@ export interface ProjectCreateRequest {
totalEpisodes?: number
agentId?: string
mode?: string
genre?: string
globalContext?: GlobalContext
// 三级 Skills 配置
defaultTaskSkills?: TaskSkillConfig[]

View File

@ -19,6 +19,7 @@ interface ProjectStore {
updateProject: (id: string, data: any) => Promise<void>
deleteProject: (id: string) => Promise<void>
fetchEpisodes: (projectId: string) => Promise<void>
updateEpisodeInList: (episode: Episode) => void
executeEpisode: (projectId: string, episodeNumber: number, title?: string) => Promise<void>
clearCurrentProject: () => void
}
@ -116,6 +117,12 @@ export const useProjectStore = create<ProjectStore>((set, get) => ({
}
},
updateEpisodeInList: (episode: Episode) => {
set((state) => ({
episodes: state.episodes.map(ep => ep.id === episode.id ? episode : ep)
}))
},
executeEpisode: async (projectId, episodeNumber, title) => {
set({ loading: true, error: null })
try {

17
test.md
View File

@ -1,10 +1,17 @@
# 我的python 环境是"C:\ProgramData\Anaconda3\envs\creative_studio\python.exe"
## 1
~~剧集创作的草稿区一直显示未命名草稿,这里需要修改为剧集名称,和剧集管理列表的剧集名称同步。~~ ✓ 已修复:修改 ProjectWorkspace.tsx 第461行使用剧集编号作为默认标题
使用wedsocket 实现实时通信时,用户需要可以中途去别的地方查看和修改,而不是等待生成完成。
剧集开始创作之后的质量和问题需要有真正的分析和显示可以制作审核卡片推送到审核系统中http://localhost:5173/projects/8f969272-4ece-49e7-8ca1-4877cc62c57c/review/confighttp://localhost:5173/projects/8f969272-4ece-49e7-8ca1-4877cc62c57c/memory都是空白
## 2
~~剧集创作这里查看每一集内容在草稿区之后需要配置类似全局设定生成那里的ai辅助修改按钮同样支持配置skill支持注入agent辅助修改。用户看不到注入内容后台注入修改~~ ✓ 已修复:在 SmartCanvas 组件添加 AI 辅助按钮,通过 WebSocket 向 Agent 发送优化请求
首先理清楚一个概念虽然现在创作平台是按照画布实现的但是其实是一个编辑台不是出图的而是一个文本编辑台用户在这个台子里创作故事然后通过agent自动更新记忆库和故事上下文。现在的画布只有点击编辑时候才会显示内容需要一直显示用户在创作过程中可以随时查看和修改。现在都显示的空白检查修改一下
## 3
~~现在的剧集创作第一集是生成完成的状态但内容一直显示第1集内容创作失败: 'str' object has no attribute 'items'这里要检查一下开始创作是不是真正走的agent流程~~ ✓ 已修复:在 websocket.py 第325-338行添加类型检查确保 characterProfiles 是字典类型
关于故事大纲生成和开始创作,现在生成大纲确实在对话框里展示了大纲,但是是这样的:
agent: 2. ✨ 建立了楚云飞的侠义形象,暗示其真实身份 3. ✨ 埋下苏瑾月与楚云飞情感发展的种子 4. ✨ 强化林墨轩的反派色彩 **已提交审阅:** 我已将大纲提交到您的收件箱,请您审阅并反馈是否需要调整。 您可以: - 批准大纲 → 我将开始创作正式剧本 - 要求修改 → 告诉我具体的修改意见 - 重新创作 → 我会从新的角度重新设计 期待您的反馈!
用户:批准大纲
agent:我先查看一下项目的当前状态,看看有什么大纲需要批准。我看到项目中前三集已完成,后续剧集待创作。不过我需要确认一下:您要批准的是哪个大纲呢? 目前可能的情况: 1. **全局大纲**项目上下文中的Overall Outline目前显示为"11",似乎不完整) 2. **某个特定剧集的大纲** 您是指: - 如果是全局大纲,我可以帮您完善并提交审批 - 如果是某个待定剧集的大纲,请告诉我是第几集,我可以立即开始创作并生成大纲供您审批 请指明具体要批准的内容,我会立即为您处理!
这里对话记录显示agent怎么完全不连续agent应该有能推送审核卡片的能力和langchain的能力让用户多交互少打字
## 4
~~现在的剧集创作故事上下文和记忆库都没有同步进行更新,这块需要进行检查和实现。~~ ✓ 已修复:在 websocket.py save_episode 和 update_episode 处理器中添加自动记忆更新功能
记忆库的更新故事上下文的更新需要在每一集创作完成后进行agent自动更新
现在的剧集创作部分非常丑,页面还是无法自由伸缩,用户需要在创作过程中随时查看和修改,所以需要一个好的页面布局和交互设计。使用一些前端技能优化页面布局。
## 5
剧集的已完成应该是有最终的编辑台下面有一个类似确认剧集内容生成的按钮,点击后才会将剧集的内容提交到服务器。不然点击生成大纲或者开始创作都会把剧集的状态变成已完成,应该增加创作中的状态