feat:优化剧集创作平台
This commit is contained in:
parent
5b0f8833ba
commit
9327af6021
@ -892,3 +892,79 @@ async def get_available_skills():
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"获取 Skills 失败: {str(e)}")
|
logger.error(f"获取 Skills 失败: {str(e)}")
|
||||||
raise HTTPException(status_code=500, detail=f"获取失败: {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)}")
|
||||||
|
|||||||
@ -49,7 +49,7 @@ async def create_project(project_data: SeriesProjectCreate):
|
|||||||
id=str(uuid.uuid4()),
|
id=str(uuid.uuid4()),
|
||||||
projectId=project.id,
|
projectId=project.id,
|
||||||
number=episode_num,
|
number=episode_num,
|
||||||
title=f"第{episode_num}集",
|
title=f"第{episode_num}集内容创作",
|
||||||
status="pending",
|
status="pending",
|
||||||
content="" # 初始化为空白
|
content="" # 初始化为空白
|
||||||
)
|
)
|
||||||
@ -139,7 +139,7 @@ async def list_episodes(project_id: str):
|
|||||||
id=str(uuid.uuid4()),
|
id=str(uuid.uuid4()),
|
||||||
projectId=project_id,
|
projectId=project_id,
|
||||||
number=episode_num,
|
number=episode_num,
|
||||||
title=f"第{episode_num}集",
|
title=f"第{episode_num}集内容创作",
|
||||||
status="pending",
|
status="pending",
|
||||||
content="" # 初始化为空白,避免触发前端生成大纲按钮
|
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)
|
@router.post("/{project_id}/execute", response_model=EpisodeExecuteResponse)
|
||||||
async def execute_episode(
|
async def execute_episode(
|
||||||
project_id: str,
|
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)
|
project = await project_repo.get(project_id)
|
||||||
if not project:
|
if not project:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
@ -222,56 +224,44 @@ async def execute_episode(
|
|||||||
detail=f"项目不存在: {project_id}"
|
detail=f"项目不存在: {project_id}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
logger.info(f"接收创作请求(后台执行模式): 项目 {project_id}, EP{request.episodeNumber}")
|
||||||
|
|
||||||
|
# 立即返回响应,创作在后台执行
|
||||||
|
background_tasks.add_task(_execute_episode_in_background, project_id, request.episodeNumber, request.title)
|
||||||
|
|
||||||
|
return EpisodeExecuteResponse(
|
||||||
|
episode=Episode(
|
||||||
|
projectId=project_id,
|
||||||
|
number=request.episodeNumber,
|
||||||
|
status="writing",
|
||||||
|
title=request.title or f"第{request.episodeNumber}集"
|
||||||
|
),
|
||||||
|
success=True,
|
||||||
|
message=f"EP{request.episodeNumber} 已开始在后台创作"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _execute_episode_in_background(
|
||||||
|
project_id: str,
|
||||||
|
episode_number: int,
|
||||||
|
title: str
|
||||||
|
):
|
||||||
|
"""后台执行剧集创作的辅助函数"""
|
||||||
try:
|
try:
|
||||||
# 获取 Agent
|
from app.api.v1.websocket import _execute_episode_creation
|
||||||
agent = get_series_agent()
|
|
||||||
|
|
||||||
# 执行创作
|
logger.info(f"后台创作任务开始: EP{episode_number}")
|
||||||
logger.info(f"开始执行创作: 项目 {project_id}, EP{request.episodeNumber}")
|
|
||||||
|
|
||||||
# 先获取现有剧集记录,避免重复创建
|
|
||||||
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(
|
# 调用 WebSocket 的创作函数(它已经处理了后台执行)
|
||||||
project=project,
|
await _execute_episode_creation(
|
||||||
episode_number=request.episodeNumber,
|
project_id=project_id,
|
||||||
title=request.title
|
episode_number=episode_number,
|
||||||
)
|
analyze_previous_memory=True
|
||||||
|
|
||||||
# 保持原有的 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} 创作完成"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
logger.info(f"后台创作任务完成: EP{episode_number}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"执行创作失败: {str(e)}")
|
logger.error(f"后台创作任务失败: {str(e)}", exc_info=True)
|
||||||
return EpisodeExecuteResponse(
|
|
||||||
episode=Episode(
|
|
||||||
projectId=project_id,
|
|
||||||
number=request.episodeNumber,
|
|
||||||
status="needs-review"
|
|
||||||
),
|
|
||||||
success=False,
|
|
||||||
message=f"创作失败: {str(e)}"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# ============================================
|
# ============================================
|
||||||
|
|||||||
@ -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]):
|
async def _handle_tool_call(project_id: str, event: Dict[str, Any]):
|
||||||
"""
|
"""
|
||||||
处理工具调用,转换为特定的 WebSocket 消息
|
处理工具调用,转换为特定的 WebSocket 消息
|
||||||
@ -660,7 +715,7 @@ async def _handle_tool_call(project_id: str, event: Dict[str, Any]):
|
|||||||
if episode_number:
|
if episode_number:
|
||||||
try:
|
try:
|
||||||
from app.db.repositories import episode_repo, project_repo
|
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)
|
episodes = await episode_repo.list_by_project(project_id)
|
||||||
episode = next((ep for ep in episodes if ep.number == episode_number), None)
|
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:
|
try:
|
||||||
project = await project_repo.get(project_id)
|
project = await project_repo.get(project_id)
|
||||||
if project and episode.content:
|
if project and episode.content:
|
||||||
memory_manager = MemoryManager()
|
memory_manager = get_memory_manager()
|
||||||
await memory_manager.update_memory_from_episode(project, episode)
|
await memory_manager.update_memory_from_episode(project, episode)
|
||||||
logger.info(f"Updated memory after saving episode {episode_number}")
|
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:
|
except Exception as memory_error:
|
||||||
logger.warning(f"Failed to update memory for episode {episode_number}: {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:
|
if episode_number:
|
||||||
try:
|
try:
|
||||||
from app.db.repositories import episode_repo, project_repo
|
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)
|
episodes = await episode_repo.list_by_project(project_id)
|
||||||
episode = next((ep for ep in episodes if ep.number == episode_number), None)
|
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:
|
try:
|
||||||
project = await project_repo.get(project_id)
|
project = await project_repo.get(project_id)
|
||||||
if project:
|
if project:
|
||||||
memory_manager = MemoryManager()
|
memory_manager = get_memory_manager()
|
||||||
await memory_manager.update_memory_from_episode(project, episode)
|
await memory_manager.update_memory_from_episode(project, episode)
|
||||||
logger.info(f"Updated memory after updating episode {episode_number}")
|
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:
|
except Exception as memory_error:
|
||||||
logger.warning(f"Failed to update memory for episode {episode_number}: {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
|
"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)
|
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__ = [
|
__all__ = [
|
||||||
"manager",
|
"manager",
|
||||||
@ -867,5 +1162,6 @@ __all__ = [
|
|||||||
"broadcast_episode_complete",
|
"broadcast_episode_complete",
|
||||||
"broadcast_batch_progress",
|
"broadcast_batch_progress",
|
||||||
"broadcast_error",
|
"broadcast_error",
|
||||||
"broadcast_batch_complete"
|
"broadcast_batch_complete",
|
||||||
|
"broadcast_to_project"
|
||||||
]
|
]
|
||||||
|
|||||||
@ -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.
|
- 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')`.
|
- 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 `list_episodes` to see the current progress of the project.
|
||||||
- Use `focus_episode` to navigate between episodes. This will update the user's view.
|
- 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.
|
- **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.
|
- 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')`.
|
- 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.
|
- NEVER mark a major deliverable as "Final" without Director approval.
|
||||||
- Use `add_inbox_task` to submit drafts or questions to the Director.
|
- Use `add_inbox_task` to submit drafts or questions to the Director.
|
||||||
- Example: `add_inbox_task(title="Review Episode 1 Outline", type="review", ...)`
|
- 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 the story state changes (e.g., location change, character injury), use `update_context`.
|
||||||
- If you spot consistency issues, use `add_annotation` on the canvas.
|
- 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.
|
- Be professional, creative, and concise.
|
||||||
- Use the Director's language (Chinese/English) matching their input.
|
- Use the Director's language (Chinese/English) matching their input.
|
||||||
- When you are working, keep the plan updated.
|
- 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.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# 添加项目上下文(如果有)
|
# 添加项目上下文(如果有)
|
||||||
|
|||||||
@ -585,6 +585,88 @@ def update_episode(
|
|||||||
return f"✓ Episode {episode_number} queued for update."
|
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 = [
|
DIRECTOR_TOOLS = [
|
||||||
update_plan,
|
update_plan,
|
||||||
@ -597,5 +679,6 @@ DIRECTOR_TOOLS = [
|
|||||||
request_review,
|
request_review,
|
||||||
focus_episode,
|
focus_episode,
|
||||||
list_episodes,
|
list_episodes,
|
||||||
update_episode
|
update_episode,
|
||||||
|
create_episode # 新增剧集创作工具
|
||||||
]
|
]
|
||||||
|
|||||||
@ -54,7 +54,7 @@ class SeriesCreationAgent:
|
|||||||
episode = Episode(
|
episode = Episode(
|
||||||
projectId=project.id,
|
projectId=project.id,
|
||||||
number=episode_number,
|
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"
|
status="writing"
|
||||||
)
|
)
|
||||||
if existing_episode:
|
if existing_episode:
|
||||||
@ -90,12 +90,13 @@ class SeriesCreationAgent:
|
|||||||
episode.content = content
|
episode.content = content
|
||||||
|
|
||||||
# ============================================
|
# ============================================
|
||||||
# 阶段 4: 一致性审核
|
# 阶段 4: 质量分析和审核
|
||||||
# ============================================
|
# ============================================
|
||||||
logger.info(f"EP{episode_number} - 阶段 4: 一致性审核")
|
logger.info(f"EP{episode_number} - 阶段 4: 质量分析和审核")
|
||||||
review = await self._review_consistency(project, episode_number, content)
|
review = await self._review_episode_full(project, episode_number, content)
|
||||||
episode.qualityScore = review.get("score", 0)
|
episode.qualityScore = review.get("score", 0)
|
||||||
episode.issues = review.get("issues", [])
|
episode.issues = review.get("issues", [])
|
||||||
|
episode.reviewResult = review.get("reviewResult")
|
||||||
|
|
||||||
# ============================================
|
# ============================================
|
||||||
# 阶段 5: 更新记忆系统
|
# 阶段 5: 更新记忆系统
|
||||||
@ -111,7 +112,8 @@ class SeriesCreationAgent:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"EP{episode_number} 创作失败: {str(e)}", exc_info=True)
|
logger.error(f"EP{episode_number} 创作失败: {str(e)}", exc_info=True)
|
||||||
episode.status = "needs-review"
|
episode.status = "needs-review"
|
||||||
episode.content = f"第{episode_number}集内容创作失败: {str(e)}"
|
# 不将错误信息写入 content,避免错误信息被填入画布
|
||||||
|
episode.content = episode.content or "" # 保持现有内容或为空
|
||||||
episode.issues = [
|
episode.issues = [
|
||||||
EpisodeIssue(
|
EpisodeIssue(
|
||||||
type="execution_error",
|
type="execution_error",
|
||||||
@ -191,7 +193,7 @@ class SeriesCreationAgent:
|
|||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"大纲生成失败: {str(e)}")
|
logger.error(f"大纲生成失败: {str(e)}")
|
||||||
return f"第{episode_number}集大纲:本集讲述..."
|
return "" # 返回空字符串,避免错误信息被当作内容
|
||||||
|
|
||||||
async def _write_dialogue(
|
async def _write_dialogue(
|
||||||
self,
|
self,
|
||||||
@ -258,7 +260,7 @@ class SeriesCreationAgent:
|
|||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"对话创作失败: {str(e)}")
|
logger.error(f"对话创作失败: {str(e)}")
|
||||||
return f"第{episode_number}集内容创作失败: {str(e)}"
|
return "" # 返回空字符串,避免错误信息被当作内容
|
||||||
|
|
||||||
async def _review_consistency(
|
async def _review_consistency(
|
||||||
self,
|
self,
|
||||||
@ -266,54 +268,160 @@ class SeriesCreationAgent:
|
|||||||
episode_number: int,
|
episode_number: int,
|
||||||
content: str
|
content: str
|
||||||
) -> Dict[str, Any]:
|
) -> 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:
|
try:
|
||||||
from app.core.memory.memory_manager import get_memory_manager
|
from app.core.review.review_manager import get_review_manager
|
||||||
from app.models.memory import EnhancedMemory
|
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)
|
review_config = ReviewConfig(
|
||||||
|
enabled_review_skills=["consistency_checker"],
|
||||||
# 使用 MemoryManager 检查一致性
|
overall_strictness=0.7,
|
||||||
issues = await memory_manager.check_consistency(
|
pass_threshold=75.0
|
||||||
episode_content=content,
|
|
||||||
episode_number=episode_number,
|
|
||||||
memory=enhanced_memory
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# 计算质量分数
|
# 添加默认维度
|
||||||
high_issues = [i for i in issues if i.severity.value == "high"]
|
for dim_type in [
|
||||||
medium_issues = [i for i in issues if i.severity.value == "medium"]
|
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)
|
# 创建 Episode 对象用于审核
|
||||||
score = max(0, min(100, score))
|
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 = [
|
episode_issues = [
|
||||||
EpisodeIssue(
|
EpisodeIssue(
|
||||||
type=issue.type,
|
type=issue.type.value,
|
||||||
description=issue.description,
|
description=issue.description,
|
||||||
severity=issue.severity.value,
|
severity=issue.severity.value,
|
||||||
suggestion=issue.suggestion
|
suggestion=issue.suggestion
|
||||||
)
|
)
|
||||||
for issue in issues
|
for issue in review_result.issues
|
||||||
]
|
]
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
f"一致性审核完成: 分数={score}, "
|
f"完整审核完成: 总分={review_result.overall_score:.1f}, "
|
||||||
f"问题={len(issues)} (高={len(high_issues)}, 中={len(medium_issues)})"
|
f"通过={'是' if review_result.passed else '否'}, "
|
||||||
|
f"问题数={len(review_result.issues)}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# 推送审核卡片到前端(通过 WebSocket)
|
||||||
|
await self._push_review_card(project.id, episode_number, review_result)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"score": score,
|
"score": review_result.overall_score,
|
||||||
"issues": episode_issues
|
"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:
|
except Exception as e:
|
||||||
logger.error(f"一致性审核失败: {str(e)}")
|
logger.error(f"完整审核失败: {str(e)}")
|
||||||
return {"score": 80, "issues": []}
|
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(
|
async def _update_memory(
|
||||||
self,
|
self,
|
||||||
@ -321,7 +429,7 @@ class SeriesCreationAgent:
|
|||||||
episode_number: int,
|
episode_number: int,
|
||||||
content: str
|
content: str
|
||||||
):
|
):
|
||||||
"""更新记忆系统"""
|
"""更新记忆系统并同步上下文到前端"""
|
||||||
from app.core.memory.memory_manager import get_memory_manager
|
from app.core.memory.memory_manager import get_memory_manager
|
||||||
|
|
||||||
# 创建 Episode 对象
|
# 创建 Episode 对象
|
||||||
@ -346,8 +454,71 @@ class SeriesCreationAgent:
|
|||||||
f"一致性问题={result.consistency_issues_found}"
|
f"一致性问题={result.consistency_issues_found}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# 同步上下文状态到前端(通过 WebSocket)
|
||||||
|
await self._sync_context_states(project.id, episode_number, project.memory)
|
||||||
|
|
||||||
return result
|
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(
|
def _build_context(
|
||||||
self,
|
self,
|
||||||
project: SeriesProject,
|
project: SeriesProject,
|
||||||
|
|||||||
@ -272,7 +272,7 @@ class BatchExecutor:
|
|||||||
episode = await self.series_agent.execute_episode(
|
episode = await self.series_agent.execute_episode(
|
||||||
project=project,
|
project=project,
|
||||||
episode_number=episode_number,
|
episode_number=episode_number,
|
||||||
title=f"第{episode_number}集"
|
title=f"第{episode_number}集内容创作"
|
||||||
)
|
)
|
||||||
|
|
||||||
# 2. 更新记忆系统
|
# 2. 更新记忆系统
|
||||||
|
|||||||
@ -3,7 +3,7 @@
|
|||||||
"id": "f3cc5c29-9fdd-4403-b48b-0cec9a126bf7",
|
"id": "f3cc5c29-9fdd-4403-b48b-0cec9a126bf7",
|
||||||
"projectId": "8f969272-4ece-49e7-8ca1-4877cc62c57c",
|
"projectId": "8f969272-4ece-49e7-8ca1-4877cc62c57c",
|
||||||
"number": 1,
|
"number": 1,
|
||||||
"title": "第1集",
|
"title": "11",
|
||||||
"status": "completed",
|
"status": "completed",
|
||||||
"structure": {
|
"structure": {
|
||||||
"episodeNumber": 1,
|
"episodeNumber": 1,
|
||||||
@ -11,7 +11,7 @@
|
|||||||
"keyEvents": []
|
"keyEvents": []
|
||||||
},
|
},
|
||||||
"outline": "第1集大纲:本集讲述...",
|
"outline": "第1集大纲:本集讲述...",
|
||||||
"content": "第1集内容创作失败: 'str' object has no attribute 'items'",
|
"content": "方便复旦百年奋斗",
|
||||||
"summary": null,
|
"summary": null,
|
||||||
"qualityScore": 100.0,
|
"qualityScore": 100.0,
|
||||||
"retryCount": 0,
|
"retryCount": 0,
|
||||||
@ -24,35 +24,135 @@
|
|||||||
"id": "5969c386-9615-461f-ad4a-12b0161020d7",
|
"id": "5969c386-9615-461f-ad4a-12b0161020d7",
|
||||||
"projectId": "8f969272-4ece-49e7-8ca1-4877cc62c57c",
|
"projectId": "8f969272-4ece-49e7-8ca1-4877cc62c57c",
|
||||||
"number": 2,
|
"number": 2,
|
||||||
"title": "第2集",
|
"title": "",
|
||||||
"status": "pending",
|
"status": "completed",
|
||||||
"structure": null,
|
"structure": {
|
||||||
"outline": null,
|
"episodeNumber": 2,
|
||||||
"content": null,
|
"scenes": [],
|
||||||
|
"keyEvents": []
|
||||||
|
},
|
||||||
|
"outline": "第2集大纲:本集讲述...",
|
||||||
|
"content": "第2集内容创作失败: 'str' object has no attribute 'items'",
|
||||||
"summary": null,
|
"summary": null,
|
||||||
"qualityScore": null,
|
"qualityScore": 98.6,
|
||||||
"retryCount": 0,
|
"retryCount": 0,
|
||||||
"issues": [],
|
"issues": [
|
||||||
"reviewResult": null,
|
{
|
||||||
"createdAt": "2026-01-28T10:28:53.840823",
|
"type": "custom",
|
||||||
"completedAt": null
|
"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": {
|
"75a7de41-4768-450a-889b-783f818893f2": {
|
||||||
"id": "75a7de41-4768-450a-889b-783f818893f2",
|
"id": "75a7de41-4768-450a-889b-783f818893f2",
|
||||||
"projectId": "8f969272-4ece-49e7-8ca1-4877cc62c57c",
|
"projectId": "8f969272-4ece-49e7-8ca1-4877cc62c57c",
|
||||||
"number": 3,
|
"number": 3,
|
||||||
"title": "第3集",
|
"title": "内容创作",
|
||||||
"status": "pending",
|
"status": "completed",
|
||||||
"structure": null,
|
"structure": {
|
||||||
"outline": null,
|
"episodeNumber": 3,
|
||||||
"content": null,
|
"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,
|
"summary": null,
|
||||||
"qualityScore": null,
|
"qualityScore": 94.4,
|
||||||
"retryCount": 0,
|
"retryCount": 0,
|
||||||
"issues": [],
|
"issues": [
|
||||||
"reviewResult": null,
|
{
|
||||||
"createdAt": "2026-01-28T10:28:53.843865",
|
"type": "custom",
|
||||||
"completedAt": null
|
"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": {
|
"57a6aeb2-bcf0-4eb0-935a-542ccd36b7b4": {
|
||||||
"id": "57a6aeb2-bcf0-4eb0-935a-542ccd36b7b4",
|
"id": "57a6aeb2-bcf0-4eb0-935a-542ccd36b7b4",
|
||||||
|
|||||||
@ -34,6 +34,46 @@
|
|||||||
"role": "user",
|
"role": "user",
|
||||||
"content": "开始生成大纲",
|
"content": "开始生成大纲",
|
||||||
"timestamp": "2026-01-28T10:19:32.449321"
|
"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"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@ -17,8 +17,60 @@
|
|||||||
},
|
},
|
||||||
"memory": {
|
"memory": {
|
||||||
"eventTimeline": [],
|
"eventTimeline": [],
|
||||||
"pendingThreads": [],
|
"pendingThreads": [
|
||||||
"foreshadowing": [],
|
{
|
||||||
|
"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": {}
|
"characterStates": {}
|
||||||
},
|
},
|
||||||
"totalEpisodes": 30,
|
"totalEpisodes": 30,
|
||||||
@ -28,6 +80,6 @@
|
|||||||
"autoRetryConfig": null,
|
"autoRetryConfig": null,
|
||||||
"reviewConfig": null,
|
"reviewConfig": null,
|
||||||
"createdAt": "2026-01-27T16:22:58.755260",
|
"createdAt": "2026-01-27T16:22:58.755260",
|
||||||
"updatedAt": "2026-01-28T10:49:27.517654"
|
"updatedAt": "2026-01-28T16:51:40.612879"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -50,3 +50,5 @@ pytest-asyncio==0.21.1
|
|||||||
black==23.12.0
|
black==23.12.0
|
||||||
flake8==6.1.0
|
flake8==6.1.0
|
||||||
mypy==1.7.1
|
mypy==1.7.1
|
||||||
|
langchain-community>=0.3.0
|
||||||
|
python-multipart==0.0.6
|
||||||
|
|||||||
@ -35,7 +35,12 @@ function App() {
|
|||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<BrowserRouter>
|
<BrowserRouter
|
||||||
|
future={{
|
||||||
|
v7_startTransition: true,
|
||||||
|
v7_relativeSplatPath: true
|
||||||
|
}}
|
||||||
|
>
|
||||||
<Layout style={{ minHeight: '100vh' }}>
|
<Layout style={{ minHeight: '100vh' }}>
|
||||||
<Header style={{
|
<Header style={{
|
||||||
background: '#fff',
|
background: '#fff',
|
||||||
|
|||||||
@ -20,6 +20,7 @@ interface ContextPanelProps {
|
|||||||
loading: boolean;
|
loading: boolean;
|
||||||
activeStates?: any[];
|
activeStates?: any[];
|
||||||
memoryItems?: any[];
|
memoryItems?: any[];
|
||||||
|
width?: number;
|
||||||
onUpdateContext?: (type: string, data: any) => void;
|
onUpdateContext?: (type: string, data: any) => void;
|
||||||
onNavigateToSettings?: () => void;
|
onNavigateToSettings?: () => void;
|
||||||
}
|
}
|
||||||
@ -29,6 +30,7 @@ export const ContextPanel: React.FC<ContextPanelProps> = ({
|
|||||||
loading,
|
loading,
|
||||||
activeStates = [],
|
activeStates = [],
|
||||||
memoryItems = [],
|
memoryItems = [],
|
||||||
|
width = 350,
|
||||||
onNavigateToSettings
|
onNavigateToSettings
|
||||||
}) => {
|
}) => {
|
||||||
const [activeTab, setActiveTab] = useState('world');
|
const [activeTab, setActiveTab] = useState('world');
|
||||||
@ -49,13 +51,14 @@ export const ContextPanel: React.FC<ContextPanelProps> = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Sider
|
<Sider
|
||||||
width={350}
|
width={width}
|
||||||
theme="light"
|
theme="light"
|
||||||
style={{
|
style={{
|
||||||
borderRight: '1px solid #f0f0f0',
|
borderRight: '1px solid #f0f0f0',
|
||||||
height: '100%',
|
height: '100%',
|
||||||
overflowY: 'auto',
|
overflowY: 'auto',
|
||||||
background: '#fff'
|
background: '#fff',
|
||||||
|
flexShrink: 0
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div style={{ padding: '16px' }}>
|
<div style={{ padding: '16px' }}>
|
||||||
|
|||||||
@ -1,13 +1,14 @@
|
|||||||
import React, { useState, useEffect, useRef } from 'react';
|
import React, { useState, useEffect, useRef } from 'react';
|
||||||
import { Layout, Input, List, Avatar, Button, Card, Tag, Badge, Tooltip, Divider } from 'antd';
|
import { Layout, Input, List, Avatar, Button, Card, Tag, Badge, Tooltip, Divider } from 'antd';
|
||||||
import {
|
import {
|
||||||
SendOutlined,
|
SendOutlined,
|
||||||
RobotOutlined,
|
RobotOutlined,
|
||||||
UserOutlined,
|
UserOutlined,
|
||||||
CheckCircleOutlined,
|
CheckCircleOutlined,
|
||||||
CloseCircleOutlined,
|
CloseCircleOutlined,
|
||||||
ExclamationCircleOutlined,
|
ExclamationCircleOutlined,
|
||||||
LoadingOutlined
|
LoadingOutlined,
|
||||||
|
StopOutlined
|
||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
|
|
||||||
const { Sider } = Layout;
|
const { Sider } = Layout;
|
||||||
@ -15,30 +16,37 @@ const { TextArea } = Input;
|
|||||||
|
|
||||||
export interface InboxItem {
|
export interface InboxItem {
|
||||||
id: string;
|
id: string;
|
||||||
type: 'review' | 'decision' | 'notification';
|
type: 'review' | 'decision' | 'notification' | 'review_card';
|
||||||
title: string;
|
title: string;
|
||||||
description: string;
|
description: string;
|
||||||
status: 'pending' | 'approved' | 'rejected' | 'ignored';
|
status: 'pending' | 'approved' | 'rejected' | 'ignored';
|
||||||
timestamp: number;
|
timestamp: number;
|
||||||
options?: string[];
|
options?: string[];
|
||||||
|
reviewData?: any;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface DirectorInboxProps {
|
interface DirectorInboxProps {
|
||||||
onSendMessage: (message: string) => void;
|
onSendMessage: (message: string) => void;
|
||||||
|
onStopAgent?: () => void;
|
||||||
onInboxAction?: (itemId: string, action: 'approve' | 'reject') => void;
|
onInboxAction?: (itemId: string, action: 'approve' | 'reject') => void;
|
||||||
|
onNavigateToReview?: (episodeNum: number) => void;
|
||||||
agentStatus: 'idle' | 'planning' | 'writing' | 'reviewing';
|
agentStatus: 'idle' | 'planning' | 'writing' | 'reviewing';
|
||||||
agentPlan?: string[];
|
agentPlan?: string[];
|
||||||
inboxItems?: InboxItem[];
|
inboxItems?: InboxItem[];
|
||||||
chatHistory?: {role: 'user' | 'agent', content: string}[];
|
chatHistory?: {role: 'user' | 'agent', content: string}[];
|
||||||
|
width?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DirectorInbox: React.FC<DirectorInboxProps> = ({
|
export const DirectorInbox: React.FC<DirectorInboxProps> = ({
|
||||||
onSendMessage,
|
onSendMessage,
|
||||||
|
onStopAgent,
|
||||||
onInboxAction,
|
onInboxAction,
|
||||||
|
onNavigateToReview,
|
||||||
agentStatus,
|
agentStatus,
|
||||||
agentPlan = [],
|
agentPlan = [],
|
||||||
inboxItems = [],
|
inboxItems = [],
|
||||||
chatHistory = []
|
chatHistory = [],
|
||||||
|
width = 400
|
||||||
}) => {
|
}) => {
|
||||||
const [inputValue, setInputValue] = useState('');
|
const [inputValue, setInputValue] = useState('');
|
||||||
// Use local state for immediate feedback, but sync with props if provided
|
// Use local state for immediate feedback, but sync with props if provided
|
||||||
@ -85,104 +93,151 @@ 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 (
|
return (
|
||||||
<Sider
|
<Sider
|
||||||
width={400}
|
width={width}
|
||||||
theme="light"
|
theme="light"
|
||||||
style={{
|
style={{
|
||||||
borderLeft: '1px solid #f0f0f0',
|
borderLeft: '1px solid #f0f0f0',
|
||||||
height: '100%',
|
height: '100%',
|
||||||
display: 'flex',
|
background: '#fff',
|
||||||
flexDirection: 'column',
|
flexShrink: 0
|
||||||
background: '#fff'
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Agent 状态与计划 */}
|
<div style={{
|
||||||
<div style={{ padding: '16px', borderBottom: '1px solid #f0f0f0', background: '#fafafa' }}>
|
display: 'flex',
|
||||||
<div style={{ display: 'flex', alignItems: 'center', marginBottom: '8px' }}>
|
flexDirection: 'column',
|
||||||
<Avatar icon={<RobotOutlined />} style={{ backgroundColor: '#1890ff', marginRight: '8px' }} />
|
height: '100%',
|
||||||
<div>
|
overflow: 'hidden'
|
||||||
<div style={{ fontWeight: 'bold' }}>AI Director Agent</div>
|
}}>
|
||||||
<Tag color={getStatusColor(agentStatus)}>
|
{/* Agent 状态与计划 */}
|
||||||
{agentStatus !== 'idle' && <LoadingOutlined style={{ marginRight: '4px' }} />}
|
<div style={{ padding: '16px', borderBottom: '1px solid #f0f0f0', background: '#fafafa', flexShrink: 0 }}>
|
||||||
{getStatusText(agentStatus)}
|
<div style={{ display: 'flex', alignItems: 'center', marginBottom: '8px' }}>
|
||||||
</Tag>
|
<Avatar icon={<RobotOutlined />} style={{ backgroundColor: '#1890ff', marginRight: '8px' }} />
|
||||||
</div>
|
<div>
|
||||||
</div>
|
<div style={{ fontWeight: 'bold' }}>AI Director Agent</div>
|
||||||
|
<Tag color={getStatusColor(agentStatus)}>
|
||||||
{Array.isArray(agentPlan) && agentPlan.length > 0 && (
|
{agentStatus !== 'idle' && <LoadingOutlined style={{ marginRight: '4px' }} />}
|
||||||
<Card size="small" title="当前执行计划" style={{ marginTop: '8px' }}>
|
{getStatusText(agentStatus)}
|
||||||
<ul style={{ paddingLeft: '20px', margin: 0 }}>
|
</Tag>
|
||||||
{agentPlan.map((step, idx) => (
|
|
||||||
<li key={idx} style={{ color: idx === 0 ? '#1890ff' : '#666' }}>{step}</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 导演信箱 (Inbox) */}
|
|
||||||
<div style={{ flex: 1, overflowY: 'auto', padding: '16px', background: '#f0f2f5' }}>
|
|
||||||
<Divider orientation="left" style={{ margin: '0 0 16px 0', fontSize: '12px' }}>待处理任务 (Inbox)</Divider>
|
|
||||||
|
|
||||||
{Array.isArray(inboxItems) && inboxItems.map(item => (
|
|
||||||
<Card
|
|
||||||
key={item.id}
|
|
||||||
size="small"
|
|
||||||
style={{ marginBottom: '8px', borderLeft: '3px solid #faad14' }}
|
|
||||||
actions={[
|
|
||||||
<Tooltip title="批准/确认"><Button type="text" size="small" icon={<CheckCircleOutlined style={{ color: '#52c41a' }} />} onClick={() => onInboxAction?.(item.id, 'approve')} /></Tooltip>,
|
|
||||||
<Tooltip title="拒绝/修改"><Button type="text" size="small" icon={<CloseCircleOutlined style={{ color: '#ff4d4f' }} />} onClick={() => onInboxAction?.(item.id, 'reject')} /></Tooltip>
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
<Card.Meta
|
|
||||||
avatar={<ExclamationCircleOutlined style={{ color: '#faad14', fontSize: '20px' }} />}
|
|
||||||
title={<span style={{ fontSize: '14px' }}>{item.title}</span>}
|
|
||||||
description={<span style={{ fontSize: '12px' }}>{item.description}</span>}
|
|
||||||
/>
|
|
||||||
</Card>
|
|
||||||
))}
|
|
||||||
|
|
||||||
<Divider orientation="left" style={{ margin: '16px 0', fontSize: '12px' }}>对话记录</Divider>
|
|
||||||
|
|
||||||
{Array.isArray(localMessages) && localMessages.map((msg, idx) => (
|
|
||||||
<div key={idx} style={{
|
|
||||||
display: 'flex',
|
|
||||||
justifyContent: msg.role === 'user' ? 'flex-end' : 'flex-start',
|
|
||||||
marginBottom: '12px'
|
|
||||||
}}>
|
|
||||||
<div style={{
|
|
||||||
maxWidth: '80%',
|
|
||||||
padding: '8px 12px',
|
|
||||||
borderRadius: '8px',
|
|
||||||
background: msg.role === 'user' ? '#1890ff' : '#fff',
|
|
||||||
color: msg.role === 'user' ? '#fff' : '#333',
|
|
||||||
boxShadow: '0 1px 2px rgba(0,0,0,0.1)'
|
|
||||||
}}>
|
|
||||||
{msg.content}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
|
||||||
<div ref={messagesEndRef} />
|
{Array.isArray(agentPlan) && agentPlan.length > 0 && (
|
||||||
</div>
|
<Card size="small" title="当前执行计划" style={{ marginTop: '8px' }}>
|
||||||
|
<ul style={{ paddingLeft: '20px', margin: 0 }}>
|
||||||
|
{agentPlan.map((step, idx) => (
|
||||||
|
<li key={idx} style={{ color: idx === 0 ? '#1890ff' : '#666' }}>{step}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* 输入框 */}
|
{/* 导演信箱 (Inbox) */}
|
||||||
<div style={{ padding: '16px', borderTop: '1px solid #f0f0f0' }}>
|
<div style={{
|
||||||
<div style={{ display: 'flex' }}>
|
padding: '16px 16px 0 16px',
|
||||||
<TextArea
|
background: '#f0f2f5',
|
||||||
rows={2}
|
maxHeight: '40%',
|
||||||
value={inputValue}
|
overflowY: 'auto',
|
||||||
onChange={e => setInputValue(e.target.value)}
|
flexShrink: 0,
|
||||||
onPressEnter={(e) => {
|
borderBottom: '1px solid #e8e8e8'
|
||||||
if (!e.shiftKey) {
|
}}>
|
||||||
e.preventDefault();
|
<Divider orientation="left" style={{ margin: '0 0 16px 0', fontSize: '12px' }}>待处理任务 (Inbox)</Divider>
|
||||||
handleSend();
|
|
||||||
}
|
{Array.isArray(inboxItems) && inboxItems.map(item => (
|
||||||
}}
|
<Card
|
||||||
placeholder="给 Agent 下达指令..."
|
key={item.id}
|
||||||
style={{ resize: 'none', marginRight: '8px' }}
|
size="small"
|
||||||
/>
|
style={{ marginBottom: '8px', borderLeft: item.type === 'review_card' ? '3px solid #1890ff' : '3px solid #faad14', cursor: item.type === 'review_card' ? 'pointer' : 'default' }}
|
||||||
<Button type="primary" shape="circle" icon={<SendOutlined />} size="large" onClick={handleSend} />
|
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={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>
|
||||||
|
|
||||||
|
{/* 对话记录 */}
|
||||||
|
<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={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: msg.role === 'user' ? 'flex-end' : 'flex-start',
|
||||||
|
marginBottom: '12px'
|
||||||
|
}}>
|
||||||
|
<div style={{
|
||||||
|
maxWidth: '80%',
|
||||||
|
padding: '8px 12px',
|
||||||
|
borderRadius: '8px',
|
||||||
|
background: msg.role === 'user' ? '#1890ff' : '#fff',
|
||||||
|
color: msg.role === 'user' ? '#fff' : '#333',
|
||||||
|
boxShadow: '0 1px 2px rgba(0,0,0,0.1)'
|
||||||
|
}}>
|
||||||
|
{msg.content}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<div ref={messagesEndRef} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 输入框 */}
|
||||||
|
<div style={{ padding: '16px', borderTop: '1px solid #f0f0f0', flexShrink: 0 }}>
|
||||||
|
<div style={{ display: 'flex' }}>
|
||||||
|
<TextArea
|
||||||
|
rows={2}
|
||||||
|
value={inputValue}
|
||||||
|
onChange={e => setInputValue(e.target.value)}
|
||||||
|
onPressEnter={(e) => {
|
||||||
|
if (!e.shiftKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (agentStatus !== 'idle') {
|
||||||
|
onStopAgent?.();
|
||||||
|
} else {
|
||||||
|
handleSend();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
placeholder={agentStatus !== 'idle' ? 'Agent 正在运行中...' : '给 Agent 下达指令...'}
|
||||||
|
disabled={agentStatus !== 'idle'}
|
||||||
|
style={{ resize: 'none', marginRight: '8px' }}
|
||||||
|
/>
|
||||||
|
<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>
|
||||||
</div>
|
</div>
|
||||||
</Sider>
|
</Sider>
|
||||||
|
|||||||
@ -7,15 +7,17 @@ import { Episode } from '@/services/projectService'
|
|||||||
const { Text } = Typography
|
const { Text } = Typography
|
||||||
|
|
||||||
interface EpisodeSidebarProps {
|
interface EpisodeSidebarProps {
|
||||||
projectId: string
|
projectId: string;
|
||||||
onEpisodeSelect?: (episode: Episode) => void
|
onEpisodeSelect?: (episode: Episode) => void;
|
||||||
currentEpisodeId?: string
|
currentEpisodeId?: string;
|
||||||
|
onSendMessage?: (msg: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const EpisodeSidebar: React.FC<EpisodeSidebarProps> = ({
|
export const EpisodeSidebar: React.FC<EpisodeSidebarProps> = ({
|
||||||
projectId,
|
projectId,
|
||||||
onEpisodeSelect,
|
onEpisodeSelect,
|
||||||
currentEpisodeId
|
currentEpisodeId,
|
||||||
|
onSendMessage
|
||||||
}) => {
|
}) => {
|
||||||
const { episodes, loading, fetchEpisodes, executeEpisode } = useProjectStore()
|
const { episodes, loading, fetchEpisodes, executeEpisode } = useProjectStore()
|
||||||
const [executing, setExecuting] = useState<number | null>(null)
|
const [executing, setExecuting] = useState<number | null>(null)
|
||||||
@ -38,8 +40,9 @@ export const EpisodeSidebar: React.FC<EpisodeSidebarProps> = ({
|
|||||||
|
|
||||||
const getStatusText = (status: string) => {
|
const getStatusText = (status: string) => {
|
||||||
const texts: Record<string, string> = {
|
const texts: Record<string, string> = {
|
||||||
'pending': '待开始',
|
'pending': '待创作',
|
||||||
'writing': '创作中',
|
'draft': '草稿',
|
||||||
|
'writing': '制作中',
|
||||||
'completed': '已完成',
|
'completed': '已完成',
|
||||||
'needs-review': '需审核'
|
'needs-review': '需审核'
|
||||||
}
|
}
|
||||||
@ -61,13 +64,25 @@ export const EpisodeSidebar: React.FC<EpisodeSidebarProps> = ({
|
|||||||
|
|
||||||
setExecuting(episodeNum)
|
setExecuting(episodeNum)
|
||||||
try {
|
try {
|
||||||
await executeEpisode(projectId, episodeNum)
|
// 通过 WebSocket 发送创作请求,让 DirectorAgent 处理
|
||||||
message.success(`EP${episodeNum} 创作完成!`)
|
if (onSendMessage) {
|
||||||
await fetchEpisodes(projectId)
|
onSendMessage(`开始创作第 ${episodeNum} 集`)
|
||||||
|
message.info(`正在处理创作请求:第 ${episodeNum} 集`)
|
||||||
|
} else {
|
||||||
|
// 降级方案:如果 WebSocket 不可用,使用原有的 API 方式
|
||||||
|
await executeEpisode(projectId, episodeNum)
|
||||||
|
message.success(`EP${episodeNum} 创作完成!`)
|
||||||
|
await fetchEpisodes(projectId)
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
message.error(`创作失败: ${(error as Error).message}`)
|
message.error(`创作失败: ${(error as Error).message}`)
|
||||||
} finally {
|
} finally {
|
||||||
setExecuting(null)
|
// 延迟重置执行状态,给用户一些视觉反馈
|
||||||
|
setTimeout(() => {
|
||||||
|
if (executing === episodeNum) {
|
||||||
|
setExecuting(null)
|
||||||
|
}
|
||||||
|
}, 1000)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -78,7 +93,7 @@ export const EpisodeSidebar: React.FC<EpisodeSidebarProps> = ({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
width: '280px',
|
width: '100%',
|
||||||
height: '100%',
|
height: '100%',
|
||||||
background: '#fafafa',
|
background: '#fafafa',
|
||||||
borderRight: '1px solid #f0f0f0',
|
borderRight: '1px solid #f0f0f0',
|
||||||
@ -200,18 +215,6 @@ export const EpisodeSidebar: React.FC<EpisodeSidebarProps> = ({
|
|||||||
</Space>
|
</Space>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 底部:添加剧集按钮 */}
|
|
||||||
<div style={{ padding: '12px', borderTop: '1px solid #f0f0f0' }}>
|
|
||||||
<Button
|
|
||||||
type="dashed"
|
|
||||||
block
|
|
||||||
icon={<PlusOutlined />}
|
|
||||||
onClick={() => message.info('剧集会根据项目总集数自动创建')}
|
|
||||||
>
|
|
||||||
添加剧集
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,20 +1,34 @@
|
|||||||
import React, { useState, useRef, useEffect } from 'react';
|
import React, { useState, useRef, useEffect, useMemo } from 'react';
|
||||||
import { Layout, Typography, Spin, Empty, Button, Card, Tooltip, message, Modal } from 'antd';
|
import { Layout, Typography, Spin, Button, Card, Tooltip, message, Modal, Select, Input, Space } from 'antd';
|
||||||
import { LoadingOutlined, WarningOutlined, SaveOutlined, EditOutlined, CheckOutlined, RobotOutlined } from '@ant-design/icons';
|
import { LoadingOutlined, WarningOutlined, SaveOutlined, EditOutlined, CheckOutlined, RobotOutlined, PlayCircleOutlined } from '@ant-design/icons';
|
||||||
|
|
||||||
const { Content } = Layout;
|
const { Content } = Layout;
|
||||||
const { Title, Text } = Typography;
|
const { Title, Text } = Typography;
|
||||||
|
const { Option } = Select;
|
||||||
|
const { TextArea } = Input;
|
||||||
|
|
||||||
interface SmartCanvasProps {
|
interface SmartCanvasProps {
|
||||||
content: string;
|
content: string;
|
||||||
streaming: boolean;
|
streaming: boolean;
|
||||||
annotations?: any[];
|
annotations?: any[];
|
||||||
onStartGenerate?: () => void;
|
onStartGenerate?: () => void;
|
||||||
|
onStartWriting?: () => void;
|
||||||
onContentChange?: (content: string) => void;
|
onContentChange?: (content: string) => void;
|
||||||
onContentSave?: (content: string) => void;
|
onContentSave?: (content: string) => void;
|
||||||
onAIAssist?: (content: string) => void;
|
onAIAssist?: (content: string, options?: AIAssistOptions) => void;
|
||||||
episodeTitle?: string;
|
episodeTitle?: string;
|
||||||
episodeNumber?: number | null;
|
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> = ({
|
export const SmartCanvas: React.FC<SmartCanvasProps> = ({
|
||||||
@ -22,51 +36,73 @@ export const SmartCanvas: React.FC<SmartCanvasProps> = ({
|
|||||||
streaming,
|
streaming,
|
||||||
annotations = [],
|
annotations = [],
|
||||||
onStartGenerate,
|
onStartGenerate,
|
||||||
|
onStartWriting,
|
||||||
onContentChange,
|
onContentChange,
|
||||||
onContentSave,
|
onContentSave,
|
||||||
onAIAssist,
|
onAIAssist,
|
||||||
episodeTitle = '未命名草稿',
|
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 [editContent, setEditContent] = useState(content);
|
||||||
const [showSaveModal, setShowSaveModal] = useState(false);
|
const [showSaveModal, setShowSaveModal] = useState(false);
|
||||||
const [selectedText, setSelectedText] = useState('');
|
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 textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||||
|
|
||||||
// 显示标题处理
|
// 显示标题处理 - 与侧边栏保持一致,直接显示剧集标题(如"第1集")
|
||||||
const displayTitle = episodeNumber !== null
|
const displayTitle = useMemo(() => {
|
||||||
? `第 ${episodeNumber} 集:${episodeTitle}`
|
// 优先使用传入的 episodeTitle(来自数据库的剧集标题)
|
||||||
: episodeTitle;
|
if (episodeTitle && episodeTitle !== '未命名草稿') {
|
||||||
|
// 如果 episodeTitle 本身不包含 "第X集",且有 episodeNumber,则组合
|
||||||
// Update editContent when content changes (e.g., from agent streaming)
|
if (episodeNumber !== null && !episodeTitle.includes(`第${episodeNumber}集`)) {
|
||||||
useEffect(() => {
|
return `第${episodeNumber}集:${episodeTitle}`;
|
||||||
if (!isEditing) {
|
|
||||||
setEditContent(content);
|
|
||||||
}
|
|
||||||
}, [content, isEditing]);
|
|
||||||
|
|
||||||
const handleEditToggle = () => {
|
|
||||||
if (isEditing) {
|
|
||||||
// Save and exit edit mode
|
|
||||||
setIsEditing(false);
|
|
||||||
if (onContentChange) {
|
|
||||||
onContentChange(editContent);
|
|
||||||
}
|
}
|
||||||
message.success('内容已更新');
|
return episodeTitle;
|
||||||
} else {
|
|
||||||
// Enter edit mode
|
|
||||||
setIsEditing(true);
|
|
||||||
setEditContent(content);
|
|
||||||
}
|
}
|
||||||
};
|
// 如果没有 episodeTitle,根据 episodeNumber 生成默认标题(与侧边栏一致)
|
||||||
|
if (episodeNumber !== null && episodeNumber !== undefined) {
|
||||||
|
return `第${episodeNumber}集`;
|
||||||
|
}
|
||||||
|
return '请选择剧集';
|
||||||
|
}, [episodeNumber, episodeTitle]);
|
||||||
|
|
||||||
|
// Update editContent and editTitle when props change
|
||||||
|
useEffect(() => {
|
||||||
|
// 自动清理内容中的重复标题
|
||||||
|
let cleaned = content?.trim() || '';
|
||||||
|
if (episodeNumber !== null) {
|
||||||
|
const titlePrefixPattern = new RegExp(`^第\\s*${episodeNumber}\\s*集[::\\s]*.*?(?:\\n|$)`, 'i');
|
||||||
|
cleaned = cleaned.replace(titlePrefixPattern, '').trim();
|
||||||
|
}
|
||||||
|
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]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// 使用 displayTitle 作为编辑标题的初始值
|
||||||
|
setEditTitle(displayTitle);
|
||||||
|
}, [displayTitle]);
|
||||||
|
|
||||||
const handleSave = () => {
|
const handleSave = () => {
|
||||||
if (onContentSave) {
|
if (onContentSave) {
|
||||||
onContentSave(editContent);
|
onContentSave(editContent);
|
||||||
message.success('内容已保存');
|
message.success('内容已保存');
|
||||||
}
|
}
|
||||||
setIsEditing(false);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleTextSelection = () => {
|
const handleTextSelection = () => {
|
||||||
@ -92,58 +128,145 @@ export const SmartCanvas: React.FC<SmartCanvasProps> = ({
|
|||||||
|
|
||||||
const handleAIAssist = () => {
|
const handleAIAssist = () => {
|
||||||
if (onAIAssist) {
|
if (onAIAssist) {
|
||||||
onAIAssist(editContent || content);
|
setShowAIAssistModal(true);
|
||||||
message.loading('AI 正在辅助修改中...', 0);
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAIAssistConfirm = () => {
|
||||||
|
if (onAIAssist) {
|
||||||
|
onAIAssist(editContent || content, {
|
||||||
|
skills: selectedSkills,
|
||||||
|
customPrompt: customPrompt || undefined,
|
||||||
|
injectAgent
|
||||||
|
});
|
||||||
|
setShowAIAssistModal(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Content style={{
|
<Content style={{
|
||||||
padding: '24px 48px',
|
padding: '32px 48px',
|
||||||
background: '#fff',
|
background: '#fff',
|
||||||
overflowY: 'auto',
|
|
||||||
height: '100%',
|
height: '100%',
|
||||||
position: 'relative',
|
position: 'relative',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
gap: '24px'
|
flexDirection: 'column'
|
||||||
}}>
|
}}>
|
||||||
<div style={{ flex: 1, maxWidth: '800px', margin: '0 auto' }}>
|
<div style={{ flex: 1, maxWidth: '900px', margin: '0 auto', width: '100%', overflowY: 'auto', display: 'flex', flexDirection: 'column' }}>
|
||||||
<Title level={3} style={{ textAlign: 'center', marginBottom: '48px', color: '#333' }}>
|
{/* 标题区域 */}
|
||||||
{displayTitle}
|
<div style={{ textAlign: 'center', marginBottom: '32px', position: 'relative' }}>
|
||||||
</Title>
|
{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
|
||||||
|
}}>
|
||||||
|
{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 && (
|
{!streaming && content && (
|
||||||
<div style={{ position: 'absolute', top: '24px', right: '24px', display: 'flex', gap: '8px' }}>
|
<div style={{
|
||||||
{isEditing && onAIAssist && (
|
position: 'absolute',
|
||||||
<Tooltip title="AI 辅助修改(后台智能优化)">
|
top: '32px',
|
||||||
|
right: '48px',
|
||||||
|
display: 'flex',
|
||||||
|
gap: '8px',
|
||||||
|
zIndex: 10,
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
justifyContent: 'flex-end'
|
||||||
|
}}>
|
||||||
|
{onAIAssist && (
|
||||||
|
<Tooltip title="AI 辅助修改">
|
||||||
<Button
|
<Button
|
||||||
type="default"
|
type="default"
|
||||||
size="small"
|
size="small"
|
||||||
icon={<RobotOutlined />}
|
icon={<RobotOutlined />}
|
||||||
onClick={handleAIAssist}
|
onClick={handleAIAssist}
|
||||||
|
style={{ borderRadius: '6px' }}
|
||||||
>
|
>
|
||||||
AI 辅助
|
AI 辅助
|
||||||
</Button>
|
</Button>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
<Tooltip title={isEditing ? '保存编辑' : '编辑内容'}>
|
|
||||||
<Button
|
|
||||||
type={isEditing ? 'primary' : 'default'}
|
|
||||||
size="small"
|
|
||||||
icon={isEditing ? <CheckOutlined /> : <EditOutlined />}
|
|
||||||
onClick={handleEditToggle}
|
|
||||||
>
|
|
||||||
{isEditing ? '完成' : '编辑'}
|
|
||||||
</Button>
|
|
||||||
</Tooltip>
|
|
||||||
<Tooltip title="保存到草稿">
|
<Tooltip title="保存到草稿">
|
||||||
<Button
|
<Button
|
||||||
type="primary"
|
type="primary"
|
||||||
size="small"
|
size="small"
|
||||||
icon={<SaveOutlined />}
|
icon={<SaveOutlined />}
|
||||||
onClick={handleSave}
|
onClick={handleSave}
|
||||||
disabled={isEditing}
|
style={{ borderRadius: '6px' }}
|
||||||
>
|
>
|
||||||
保存
|
保存
|
||||||
</Button>
|
</Button>
|
||||||
@ -151,56 +274,158 @@ export const SmartCanvas: React.FC<SmartCanvasProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{content ? (
|
<>
|
||||||
isEditing ? (
|
{/* 始终显示可编辑的文本区域 */}
|
||||||
// 编辑模式
|
<div style={{ position: 'relative', width: '100%' }}>
|
||||||
<textarea
|
<textarea
|
||||||
ref={textareaRef}
|
ref={textareaRef}
|
||||||
value={editContent}
|
value={editContent}
|
||||||
onChange={(e) => setEditContent(e.target.value)}
|
onChange={(e) => {
|
||||||
|
setEditContent(e.target.value);
|
||||||
|
if (onContentChange) {
|
||||||
|
onContentChange(e.target.value);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
placeholder={episodeStatus === 'pending' ? '等待开始创作...' : '在此编辑剧集内容...'}
|
||||||
style={{
|
style={{
|
||||||
width: '100%',
|
width: '100%',
|
||||||
minHeight: '500px',
|
minHeight: '500px',
|
||||||
padding: '16px',
|
height: '100%',
|
||||||
|
padding: '24px',
|
||||||
fontSize: '16px',
|
fontSize: '16px',
|
||||||
lineHeight: '1.8',
|
lineHeight: '1.8',
|
||||||
color: '#262626',
|
color: '#1a1a1a',
|
||||||
fontFamily: "'Merriweather', 'Georgia', serif",
|
fontFamily: "'Merriweather', 'Georgia', serif",
|
||||||
border: '1px solid #d9d9d9',
|
border: '1px solid #e8e8e8',
|
||||||
borderRadius: '6px',
|
borderRadius: '12px',
|
||||||
resize: 'vertical',
|
resize: 'vertical',
|
||||||
outline: 'none',
|
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}
|
onMouseUp={handleTextSelection}
|
||||||
/>
|
/>
|
||||||
) : (
|
{streaming && (
|
||||||
// 查看模式 - 支持选择文本引用
|
<div style={{
|
||||||
<div
|
position: 'absolute',
|
||||||
style={{
|
top: '16px',
|
||||||
fontSize: '16px',
|
right: '16px',
|
||||||
lineHeight: '1.8',
|
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||||
color: '#262626',
|
color: '#fff',
|
||||||
whiteSpace: 'pre-wrap',
|
padding: '6px 16px',
|
||||||
fontFamily: "'Merriweather', 'Georgia', serif",
|
borderRadius: '20px',
|
||||||
userSelect: 'text',
|
fontSize: '13px',
|
||||||
cursor: 'text'
|
fontWeight: 500,
|
||||||
}}
|
display: 'flex',
|
||||||
onMouseUp={handleTextSelection}
|
alignItems: 'center',
|
||||||
>
|
gap: '8px',
|
||||||
{editContent}
|
boxShadow: '0 4px 12px rgba(102, 126, 234, 0.3)'
|
||||||
{streaming && <span className="cursor-blink" style={{ borderLeft: '2px solid #1890ff', marginLeft: '2px' }}></span>}
|
}}>
|
||||||
|
<Spin indicator={<LoadingOutlined style={{ fontSize: 12 }} spin />} />
|
||||||
|
AI 正在创作中...
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</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>
|
||||||
)
|
)}
|
||||||
) : (
|
</>
|
||||||
<Empty
|
|
||||||
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
|
||||||
description="画布准备就绪,等待创作..."
|
|
||||||
style={{ marginTop: '100px' }}
|
|
||||||
>
|
|
||||||
<Button type="primary" onClick={onStartGenerate}>开始生成大纲</Button>
|
|
||||||
</Empty>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Annotations Sidebar */}
|
{/* Annotations Sidebar */}
|
||||||
@ -268,6 +493,89 @@ export const SmartCanvas: React.FC<SmartCanvasProps> = ({
|
|||||||
点击"复制并引用"将复制到剪贴板,可以在对话框中粘贴使用
|
点击"复制并引用"将复制到剪贴板,可以在对话框中粘贴使用
|
||||||
</p>
|
</p>
|
||||||
</Modal>
|
</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>
|
</Content>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -11,10 +11,10 @@
|
|||||||
import { useState, useEffect, useRef, useCallback } from 'react'
|
import { useState, useEffect, useRef, useCallback } from 'react'
|
||||||
import { useParams, useNavigate } from 'react-router-dom'
|
import { useParams, useNavigate } from 'react-router-dom'
|
||||||
import { Card, Button, Descriptions, List, Tag, Space, Modal, message, Spin, Typography, Tabs, Form, Input, InputNumber, Upload, Alert, Popover, Select, Layout } from 'antd'
|
import { Card, Button, Descriptions, List, Tag, Space, Modal, message, Spin, Typography, Tabs, Form, Input, InputNumber, Upload, Alert, Popover, Select, Layout } from 'antd'
|
||||||
import { ArrowLeftOutlined, PlayCircleOutlined, CheckCircleOutlined, LoadingOutlined, ClockCircleOutlined, ScanOutlined, FileTextOutlined, SettingOutlined, RobotOutlined, UploadOutlined, EditOutlined, SaveOutlined, 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 { useProjectStore } from '@/stores/projectStore'
|
||||||
import { useSkillStore } from '@/stores/skillStore'
|
import { useSkillStore } from '@/stores/skillStore'
|
||||||
import { Episode } from '@/services/projectService'
|
import { projectService, Episode } from '@/services/projectService'
|
||||||
import { taskService } from '@/services/taskService'
|
import { taskService } from '@/services/taskService'
|
||||||
import { ContextPanel } from '@/components/Workspace/ContextPanel'
|
import { ContextPanel } from '@/components/Workspace/ContextPanel'
|
||||||
import { SmartCanvas } from '@/components/Workspace/SmartCanvas'
|
import { SmartCanvas } from '@/components/Workspace/SmartCanvas'
|
||||||
@ -120,7 +120,7 @@ export const ProjectDetail = () => {
|
|||||||
// 调试:确认组件已加载
|
// 调试:确认组件已加载
|
||||||
console.log('=== ProjectDetail component loaded ===')
|
console.log('=== ProjectDetail component loaded ===')
|
||||||
console.log('id:', id)
|
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 { skills, fetchSkills } = useSkillStore()
|
||||||
const [executing, setExecuting] = useState(false)
|
const [executing, setExecuting] = useState(false)
|
||||||
const [currentEpisode, setCurrentEpisode] = useState<number>(1)
|
const [currentEpisode, setCurrentEpisode] = useState<number>(1)
|
||||||
@ -179,6 +179,7 @@ export const ProjectDetail = () => {
|
|||||||
const [inboxItems, setInboxItems] = useState<any[]>([])
|
const [inboxItems, setInboxItems] = useState<any[]>([])
|
||||||
const [chatHistory, setChatHistory] = useState<{role: 'user' | 'agent', content: string}[]>([])
|
const [chatHistory, setChatHistory] = useState<{role: 'user' | 'agent', content: string}[]>([])
|
||||||
const [annotations, setAnnotations] = useState<any[]>([])
|
const [annotations, setAnnotations] = useState<any[]>([])
|
||||||
|
const [savingContent, setSavingContent] = useState(false)
|
||||||
const [activeStates, setActiveStates] = useState<any[]>([
|
const [activeStates, setActiveStates] = useState<any[]>([
|
||||||
{ type: 'time', value: '初始状态' },
|
{ type: 'time', value: '初始状态' },
|
||||||
{ type: 'location', value: '未知地点' }
|
{ type: 'location', value: '未知地点' }
|
||||||
@ -186,6 +187,7 @@ export const ProjectDetail = () => {
|
|||||||
const [workspaceMemoryItems, setWorkspaceMemoryItems] = useState<any[]>([])
|
const [workspaceMemoryItems, setWorkspaceMemoryItems] = useState<any[]>([])
|
||||||
const [currentEpisodeInWorkspace, setCurrentEpisodeInWorkspace] = useState<Episode | null>(null)
|
const [currentEpisodeInWorkspace, setCurrentEpisodeInWorkspace] = useState<Episode | null>(null)
|
||||||
const [showEpisodeSidebar, setShowEpisodeSidebar] = useState(true)
|
const [showEpisodeSidebar, setShowEpisodeSidebar] = useState(true)
|
||||||
|
const [showContextPanel, setShowContextPanel] = useState(true)
|
||||||
|
|
||||||
// 检查全局设定是否完成
|
// 检查全局设定是否完成
|
||||||
const globalSettingsCompleted = currentProject?.globalContext?.worldSetting?.trim() &&
|
const globalSettingsCompleted = currentProject?.globalContext?.worldSetting?.trim() &&
|
||||||
@ -792,6 +794,26 @@ export const ProjectDetail = () => {
|
|||||||
}])
|
}])
|
||||||
}
|
}
|
||||||
break
|
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) {
|
if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) {
|
||||||
const messagePayload = JSON.stringify({
|
const messagePayload = JSON.stringify({
|
||||||
type: 'chat_message',
|
type: 'chat_message',
|
||||||
content: msg
|
content: msg,
|
||||||
|
episodeNumber: currentEpisodeInWorkspace?.number,
|
||||||
|
episodeTitle: currentEpisodeInWorkspace?.title
|
||||||
})
|
})
|
||||||
wsRef.current.send(messagePayload)
|
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
|
// 当切换到剧集创作标签页时连接 WebSocket
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (activeTab === 'episodes' && globalSettingsCompleted) {
|
if (activeTab === 'episodes' && globalSettingsCompleted) {
|
||||||
@ -1447,6 +1562,15 @@ export const ProjectDetail = () => {
|
|||||||
<Tag color={wsConnected ? 'success' : 'error'}>
|
<Tag color={wsConnected ? 'success' : 'error'}>
|
||||||
{wsConnected ? '已连接' : '未连接'}
|
{wsConnected ? '已连接' : '未连接'}
|
||||||
</Tag>
|
</Tag>
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
size="small"
|
||||||
|
icon={<BookOutlined />}
|
||||||
|
onClick={() => setShowContextPanel(!showContextPanel)}
|
||||||
|
style={{ color: showContextPanel ? '#1677ff' : '#666' }}
|
||||||
|
>
|
||||||
|
故事上下文
|
||||||
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
type="text"
|
type="text"
|
||||||
size="small"
|
size="small"
|
||||||
@ -1471,9 +1595,7 @@ export const ProjectDetail = () => {
|
|||||||
projectId={id!}
|
projectId={id!}
|
||||||
onEpisodeSelect={(episode) => {
|
onEpisodeSelect={(episode) => {
|
||||||
setCurrentEpisodeInWorkspace(episode)
|
setCurrentEpisodeInWorkspace(episode)
|
||||||
if (episode.content) {
|
setCanvasContent(episode.content || '')
|
||||||
setCanvasContent(episode.content)
|
|
||||||
}
|
|
||||||
}}
|
}}
|
||||||
currentEpisodeId={currentEpisodeInWorkspace?.id}
|
currentEpisodeId={currentEpisodeInWorkspace?.id}
|
||||||
/>
|
/>
|
||||||
@ -1481,13 +1603,15 @@ export const ProjectDetail = () => {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 左侧:Context Panel */}
|
{/* 左侧:Context Panel */}
|
||||||
<ContextPanel
|
{showContextPanel && (
|
||||||
project={currentProject}
|
<ContextPanel
|
||||||
loading={loading}
|
project={currentProject}
|
||||||
activeStates={activeStates}
|
loading={loading}
|
||||||
memoryItems={workspaceMemoryItems}
|
activeStates={activeStates}
|
||||||
onNavigateToSettings={() => setActiveTab('global-generation')}
|
memoryItems={workspaceMemoryItems}
|
||||||
/>
|
onNavigateToSettings={() => setActiveTab('global-generation')}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 中间:Smart Canvas */}
|
{/* 中间:Smart Canvas */}
|
||||||
<Content style={{ position: 'relative', background: '#fff' }}>
|
<Content style={{ position: 'relative', background: '#fff' }}>
|
||||||
@ -1495,9 +1619,17 @@ export const ProjectDetail = () => {
|
|||||||
content={canvasContent}
|
content={canvasContent}
|
||||||
streaming={streaming}
|
streaming={streaming}
|
||||||
annotations={annotations}
|
annotations={annotations}
|
||||||
|
episodeTitle={currentEpisodeInWorkspace?.title}
|
||||||
|
episodeNumber={currentEpisodeInWorkspace?.number}
|
||||||
onStartGenerate={() => {
|
onStartGenerate={() => {
|
||||||
handleDirectorMessage('开始生成大纲')
|
handleDirectorMessage(`开始生成第${currentEpisodeInWorkspace?.number}集大纲`)
|
||||||
}}
|
}}
|
||||||
|
onContentChange={handleContentChange}
|
||||||
|
onContentSave={handleContentSave}
|
||||||
|
onAIAssist={handleAIAssist}
|
||||||
|
onTitleChange={handleTitleChange}
|
||||||
|
availableSkills={skills}
|
||||||
|
projectId={id}
|
||||||
/>
|
/>
|
||||||
</Content>
|
</Content>
|
||||||
|
|
||||||
|
|||||||
@ -104,7 +104,7 @@ const ProjectCard = ({ project, onEdit, onDelete, onView }: {
|
|||||||
<Card
|
<Card
|
||||||
hoverable
|
hoverable
|
||||||
style={{ height: '100%' }}
|
style={{ height: '100%' }}
|
||||||
bodyStyle={{ display: 'flex', flexDirection: 'column', height: '100%' }}
|
styles={{ body: { display: 'flex', flexDirection: 'column', height: '100%' } }}
|
||||||
>
|
>
|
||||||
{/* 项目标题 */}
|
{/* 项目标题 */}
|
||||||
<div style={{ marginBottom: '12px' }}>
|
<div style={{ marginBottom: '12px' }}>
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useState, useRef, useCallback } from 'react'
|
import { useEffect, useState, useRef, useCallback } from 'react'
|
||||||
import { useNavigate, useParams } from 'react-router-dom'
|
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 { ArrowLeftOutlined, UnorderedListOutlined } from '@ant-design/icons'
|
||||||
|
|
||||||
import { projectService } from '@/services/projectService'
|
import { projectService } from '@/services/projectService'
|
||||||
@ -11,7 +11,7 @@ import { DirectorInbox } from '@/components/Workspace/DirectorInbox'
|
|||||||
import { EpisodeSidebar } from '@/components/Workspace/EpisodeSidebar'
|
import { EpisodeSidebar } from '@/components/Workspace/EpisodeSidebar'
|
||||||
import { Episode } from '@/services/projectService'
|
import { Episode } from '@/services/projectService'
|
||||||
|
|
||||||
const { Header, Content, Sider } = Layout;
|
const { Header, Sider } = Layout;
|
||||||
const { Text } = Typography;
|
const { Text } = Typography;
|
||||||
|
|
||||||
export const ProjectWorkspace: React.FC = () => {
|
export const ProjectWorkspace: React.FC = () => {
|
||||||
@ -43,6 +43,65 @@ export const ProjectWorkspace: React.FC = () => {
|
|||||||
const [currentEpisode, setCurrentEpisode] = useState<Episode | null>(null)
|
const [currentEpisode, setCurrentEpisode] = useState<Episode | null>(null)
|
||||||
const [showEpisodeSidebar, setShowEpisodeSidebar] = useState(true)
|
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);
|
const [wsConnected, setWsConnected] = useState(false);
|
||||||
|
|
||||||
// WebSocket refs
|
// WebSocket refs
|
||||||
@ -59,6 +118,9 @@ export const ProjectWorkspace: React.FC = () => {
|
|||||||
try {
|
try {
|
||||||
const data = await projectService.getProject(projectId)
|
const data = await projectService.getProject(projectId)
|
||||||
setProject(data)
|
setProject(data)
|
||||||
|
|
||||||
|
// 加载可用的 Skills
|
||||||
|
await loadAvailableSkills()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
message.error(`加载项目失败: ${(error as Error).message}`)
|
message.error(`加载项目失败: ${(error as Error).message}`)
|
||||||
} finally {
|
} 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) => {
|
const loadEpisodeContent = async (episodeNumber: number) => {
|
||||||
if (!projectId) return;
|
if (!projectId) return;
|
||||||
try {
|
try {
|
||||||
const response = await projectService.getEpisode(projectId, episodeNumber);
|
const response = await projectService.getEpisode(projectId, episodeNumber);
|
||||||
const episode = response.data || response;
|
const episode = response.data || response;
|
||||||
|
console.log('[ProjectWorkspace] Loading episode:', episode);
|
||||||
setCurrentEpisode(episode);
|
setCurrentEpisode(episode);
|
||||||
setCanvasContent(episode.content || '');
|
setCanvasContent(episode.content || '');
|
||||||
console.log('Loaded episode content:', episode.id, episode.content?.length || 0, 'chars');
|
console.log('Loaded episode content:', episode.id, episode.content?.length || 0, 'chars');
|
||||||
@ -82,7 +170,7 @@ export const ProjectWorkspace: React.FC = () => {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadProject()
|
loadProject()
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
if (reconnectTimeoutRef.current) {
|
if (reconnectTimeoutRef.current) {
|
||||||
clearTimeout(reconnectTimeoutRef.current);
|
clearTimeout(reconnectTimeoutRef.current);
|
||||||
@ -93,6 +181,44 @@ export const ProjectWorkspace: React.FC = () => {
|
|||||||
}
|
}
|
||||||
}, [projectId])
|
}, [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
|
// WebSocket Connection Logic
|
||||||
const connectWebSocket = useCallback(() => {
|
const connectWebSocket = useCallback(() => {
|
||||||
if (!projectId) return;
|
if (!projectId) return;
|
||||||
@ -175,7 +301,7 @@ export const ProjectWorkspace: React.FC = () => {
|
|||||||
message.success(action === 'approve' ? '已批准' : '已拒绝');
|
message.success(action === 'approve' ? '已批准' : '已拒绝');
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleWebSocketMessage = (msg: any) => {
|
const handleWebSocketMessage = async (msg: any) => {
|
||||||
console.log('Received:', msg);
|
console.log('Received:', msg);
|
||||||
|
|
||||||
switch (msg.type) {
|
switch (msg.type) {
|
||||||
@ -223,6 +349,30 @@ export const ProjectWorkspace: React.FC = () => {
|
|||||||
timestamp: Date.now()
|
timestamp: Date.now()
|
||||||
}]);
|
}]);
|
||||||
break;
|
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':
|
case 'memory_hit':
|
||||||
// 添加到记忆库
|
// 添加到记忆库
|
||||||
if (msg.data) {
|
if (msg.data) {
|
||||||
@ -258,10 +408,12 @@ export const ProjectWorkspace: React.FC = () => {
|
|||||||
case 'tool_call':
|
case 'tool_call':
|
||||||
const toolData = msg.data || {};
|
const toolData = msg.data || {};
|
||||||
const toolName = toolData.name || msg.name;
|
const toolName = toolData.name || msg.name;
|
||||||
|
|
||||||
if (toolName === 'update_canvas' || toolName === 'write_file') {
|
if (toolName === 'update_canvas' || toolName === 'write_file') {
|
||||||
setStreaming(true);
|
setStreaming(true);
|
||||||
setAgentStatus('writing');
|
setAgentStatus('writing');
|
||||||
|
// 更新本地剧集状态为创作中
|
||||||
|
setCurrentEpisode(prev => prev ? { ...prev, status: 'writing' } : null);
|
||||||
} else {
|
} else {
|
||||||
setAgentStatus('planning');
|
setAgentStatus('planning');
|
||||||
}
|
}
|
||||||
@ -273,9 +425,20 @@ export const ProjectWorkspace: React.FC = () => {
|
|||||||
if (msg.content) {
|
if (msg.content) {
|
||||||
setCanvasContent(msg.content);
|
setCanvasContent(msg.content);
|
||||||
// 自动保存到当前剧集
|
// 自动保存到当前剧集
|
||||||
if (currentEpisode && currentEpisode.id) {
|
if (currentEpisode && currentEpisode.id && projectId) {
|
||||||
// 这里可以调用 API 保存到后端
|
// 调用 API 保存到后端
|
||||||
console.log('Auto-saving content to episode:', currentEpisode.id);
|
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;
|
break;
|
||||||
@ -292,6 +455,19 @@ export const ProjectWorkspace: React.FC = () => {
|
|||||||
loadEpisodeContent(currentEpisode.number);
|
loadEpisodeContent(currentEpisode.number);
|
||||||
}
|
}
|
||||||
break;
|
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':
|
case 'done':
|
||||||
setAgentStatus('idle');
|
setAgentStatus('idle');
|
||||||
setStreaming(false);
|
setStreaming(false);
|
||||||
@ -306,35 +482,62 @@ export const ProjectWorkspace: React.FC = () => {
|
|||||||
const handleDirectorMessage = (msg: string) => {
|
const handleDirectorMessage = (msg: string) => {
|
||||||
console.log('=== handleDirectorMessage called ===', msg);
|
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
|
// Optimistically update chat history
|
||||||
setChatHistory(prev => [...prev, { role: 'user', content: msg }]);
|
setChatHistory(prev => [...prev, { role: 'user', content: msg }]);
|
||||||
|
|
||||||
// Send message to backend via WebSocket
|
// 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) {
|
if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) {
|
||||||
const messagePayload = JSON.stringify({
|
const messagePayload = JSON.stringify({
|
||||||
type: 'chat_message',
|
type: 'chat_message',
|
||||||
content: msg
|
content: msg,
|
||||||
|
episodeNumber: currentEpisode?.number,
|
||||||
|
episodeTitle: currentEpisode?.title
|
||||||
});
|
});
|
||||||
console.log('Sending message:', messagePayload);
|
console.log('Sending message:', messagePayload);
|
||||||
|
|
||||||
wsRef.current.send(messagePayload);
|
wsRef.current.send(messagePayload);
|
||||||
console.log('Message sent successfully');
|
console.log('Message sent successfully');
|
||||||
|
|
||||||
// Optimistic UI update could happen here
|
|
||||||
if (msg.includes("开始")) {
|
|
||||||
setAgentStatus('planning');
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
console.error('WebSocket not ready:', {
|
console.error('WebSocket not ready');
|
||||||
exists: !!wsRef.current,
|
|
||||||
readyState: wsRef.current?.readyState,
|
|
||||||
OPEN: WebSocket.OPEN
|
|
||||||
});
|
|
||||||
message.error('Agent 连接未就绪');
|
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 辅助修改处理
|
// AI 辅助修改处理
|
||||||
const handleAIAssist = async (content: string) => {
|
const handleAIAssist = async (content: string, options?: { skills?: any[]; customPrompt?: string; injectAgent?: boolean }) => {
|
||||||
if (!currentEpisode || !projectId) {
|
if (!currentEpisode || !projectId) {
|
||||||
message.warning('请先选择要修改的剧集');
|
message.warning('请先选择要修改的剧集');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 通过 WebSocket 发送 AI 辅助请求给 Agent
|
// 如果启用了 Agent 注入,通过 WebSocket 发送请求(用户看不到注入内容)
|
||||||
handleDirectorMessage(`请帮我优化改进当前剧集内容:\n\n${content.substring(0, 2000)}${content.length > 2000 ? '...' : ''}\n\n请直接使用 update_canvas 工具将优化后的内容更新到画布上。`);
|
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) {
|
if (loading) {
|
||||||
@ -397,55 +755,105 @@ export const ProjectWorkspace: React.FC = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Layout style={{ height: '100vh' }}>
|
<Layout style={{ height: '100vh', background: '#f5f5f5' }}>
|
||||||
<Header style={{
|
<Header style={{
|
||||||
background: '#fff',
|
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||||
borderBottom: '1px solid #f0f0f0',
|
|
||||||
padding: '0 24px',
|
padding: '0 24px',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'space-between'
|
justifyContent: 'space-between',
|
||||||
|
boxShadow: '0 2px 8px rgba(0,0,0,0.1)',
|
||||||
|
height: '56px'
|
||||||
}}>
|
}}>
|
||||||
<Space>
|
<Space>
|
||||||
<Button
|
<Button
|
||||||
type="text"
|
type="text"
|
||||||
icon={<ArrowLeftOutlined />}
|
icon={<ArrowLeftOutlined style={{ color: '#fff' }} />}
|
||||||
onClick={() => navigate('/projects')}
|
onClick={() => navigate('/projects')}
|
||||||
|
style={{ color: '#fff' }}
|
||||||
/>
|
/>
|
||||||
<Text strong style={{ fontSize: '16px' }}>{project?.name}</Text>
|
<Text strong style={{ fontSize: '18px', color: '#fff' }}>{project?.name}</Text>
|
||||||
<Text type="secondary" style={{ fontSize: '12px' }}>ID: {projectId}</Text>
|
<div style={{
|
||||||
<Tag color={wsConnected ? 'success' : 'error'}>
|
background: 'rgba(255,255,255,0.2)',
|
||||||
{wsConnected ? '已连接' : '未连接'}
|
padding: '4px 12px',
|
||||||
</Tag>
|
borderRadius: '12px',
|
||||||
|
fontSize: '12px',
|
||||||
|
color: '#fff'
|
||||||
|
}}>
|
||||||
|
{wsConnected ? '● Agent 在线' : '○ Agent 离线'}
|
||||||
|
</div>
|
||||||
<Button
|
<Button
|
||||||
type="text"
|
type="text"
|
||||||
icon={<UnorderedListOutlined />}
|
icon={<UnorderedListOutlined />}
|
||||||
onClick={() => setShowEpisodeSidebar(!showEpisodeSidebar)}
|
onClick={() => setShowEpisodeSidebar(!showEpisodeSidebar)}
|
||||||
style={{ color: showEpisodeSidebar ? '#1677ff' : '#666' }}
|
style={{
|
||||||
|
color: '#fff',
|
||||||
|
background: showEpisodeSidebar ? 'rgba(255,255,255,0.2)' : 'transparent',
|
||||||
|
borderRadius: '6px'
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
剧集列表
|
剧集列表
|
||||||
</Button>
|
</Button>
|
||||||
</Space>
|
</Space>
|
||||||
|
|
||||||
<Space>
|
<Space>
|
||||||
<Button>导出剧本</Button>
|
<Button style={{ borderRadius: '6px' }}>导出剧本</Button>
|
||||||
<Button type="primary">发布</Button>
|
<Button type="primary" style={{
|
||||||
|
background: '#fff',
|
||||||
|
color: '#667eea',
|
||||||
|
borderRadius: '6px',
|
||||||
|
border: 'none'
|
||||||
|
}}>
|
||||||
|
发布
|
||||||
|
</Button>
|
||||||
</Space>
|
</Space>
|
||||||
</Header>
|
</Header>
|
||||||
|
|
||||||
<Layout>
|
<Layout style={{ display: 'flex', flexDirection: 'row', overflow: 'hidden', background: '#fafafa' }}>
|
||||||
{/* 剧集管理侧边栏 */}
|
{/* 剧集管理侧边栏 */}
|
||||||
{showEpisodeSidebar && (
|
{showEpisodeSidebar && (
|
||||||
<Sider width={280} style={{ background: '#fafafa', borderRight: '1px solid #f0f0f0' }}>
|
<>
|
||||||
<EpisodeSidebar
|
<Sider width={sidebarWidth} style={{
|
||||||
projectId={projectId!}
|
background: '#fff',
|
||||||
onEpisodeSelect={(episode) => {
|
borderRight: '1px solid #e8e8e8',
|
||||||
// 使用 loadEpisodeContent 加载完整剧集内容
|
flexShrink: 0,
|
||||||
loadEpisodeContent(episode.number);
|
boxShadow: '2px 0 8px rgba(0,0,0,0.04)'
|
||||||
|
}}>
|
||||||
|
<EpisodeSidebar
|
||||||
|
projectId={projectId!}
|
||||||
|
onEpisodeSelect={(episode) => {
|
||||||
|
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';
|
||||||
}}
|
}}
|
||||||
currentEpisodeId={currentEpisode?.id}
|
|
||||||
/>
|
/>
|
||||||
</Sider>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 左侧:Context Panel */}
|
{/* 左侧:Context Panel */}
|
||||||
@ -454,30 +862,100 @@ export const ProjectWorkspace: React.FC = () => {
|
|||||||
loading={loading}
|
loading={loading}
|
||||||
activeStates={activeStates}
|
activeStates={activeStates}
|
||||||
memoryItems={memoryItems}
|
memoryItems={memoryItems}
|
||||||
|
width={contextWidth}
|
||||||
onNavigateToSettings={() => navigate(`/projects/${projectId}`, { state: { activeTab: 'global-generation' } })}
|
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 */}
|
{/* 中间: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
|
<SmartCanvas
|
||||||
content={canvasContent}
|
content={canvasContent}
|
||||||
streaming={streaming}
|
streaming={streaming}
|
||||||
annotations={annotations}
|
annotations={annotations}
|
||||||
episodeTitle={currentEpisode?.title || (currentEpisode?.number ? `第${currentEpisode.number}集` : '未命名草稿')}
|
episodeTitle={currentEpisode?.title || (currentEpisode?.number ? `第${currentEpisode.number}集` : '未命名草稿')}
|
||||||
episodeNumber={currentEpisode?.number || null}
|
episodeNumber={currentEpisode?.number || null}
|
||||||
|
episodeStatus={currentEpisode?.status as 'pending' | 'draft' | 'writing' | 'completed' | undefined}
|
||||||
onStartGenerate={() => {
|
onStartGenerate={() => {
|
||||||
handleDirectorMessage('开始生成大纲');
|
handleDirectorMessage(`开始生成第${currentEpisode?.number}集大纲`);
|
||||||
|
}}
|
||||||
|
onStartWriting={() => {
|
||||||
|
handleDirectorMessage(`开始创作第${currentEpisode?.number}集完整内容`);
|
||||||
}}
|
}}
|
||||||
onContentChange={handleContentChange}
|
onContentChange={handleContentChange}
|
||||||
onContentSave={handleContentSave}
|
onContentSave={handleContentSave}
|
||||||
onAIAssist={handleAIAssist}
|
onAIAssist={handleAIAssist}
|
||||||
|
onTitleChange={handleTitleChange}
|
||||||
|
onConfirmComplete={handleConfirmComplete}
|
||||||
|
availableSkills={availableSkills}
|
||||||
|
projectId={projectId}
|
||||||
/>
|
/>
|
||||||
</Content>
|
</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';
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* 右侧:Director Inbox */}
|
{/* 右侧:Director Inbox */}
|
||||||
<DirectorInbox
|
<DirectorInbox
|
||||||
onSendMessage={handleDirectorMessage}
|
onSendMessage={handleDirectorMessage}
|
||||||
|
onStopAgent={handleStopAgent}
|
||||||
onInboxAction={handleInboxAction}
|
onInboxAction={handleInboxAction}
|
||||||
|
onNavigateToReview={(episodeNum) => navigate(`/projects/${projectId}/review/results?episode=${episodeNum}`)}
|
||||||
agentStatus={agentStatus}
|
agentStatus={agentStatus}
|
||||||
agentPlan={agentPlan}
|
agentPlan={agentPlan}
|
||||||
inboxItems={inboxItems}
|
inboxItems={inboxItems}
|
||||||
|
|||||||
@ -3,7 +3,7 @@
|
|||||||
* Configure dimensions, rules, and presets for content review
|
* Configure dimensions, rules, and presets for content review
|
||||||
*/
|
*/
|
||||||
import React, { useEffect, useState } from 'react'
|
import React, { useEffect, useState } from 'react'
|
||||||
import { useParams } from 'react-router-dom'
|
import { useNavigate, useParams } from 'react-router-dom'
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
Form,
|
Form,
|
||||||
@ -22,7 +22,8 @@ import {
|
|||||||
List,
|
List,
|
||||||
message,
|
message,
|
||||||
Tabs,
|
Tabs,
|
||||||
Tooltip
|
Tooltip,
|
||||||
|
Typography
|
||||||
} from 'antd'
|
} from 'antd'
|
||||||
import {
|
import {
|
||||||
PlusOutlined,
|
PlusOutlined,
|
||||||
@ -31,15 +32,18 @@ import {
|
|||||||
SaveOutlined,
|
SaveOutlined,
|
||||||
PlayCircleOutlined,
|
PlayCircleOutlined,
|
||||||
CopyOutlined,
|
CopyOutlined,
|
||||||
InfoCircleOutlined
|
InfoCircleOutlined,
|
||||||
|
ArrowLeftOutlined
|
||||||
} from '@ant-design/icons'
|
} from '@ant-design/icons'
|
||||||
import { useReviewStore } from '@/stores/reviewStore'
|
import { useReviewStore } from '@/stores/reviewStore'
|
||||||
import { ReviewPreset, SeverityLevel, ReviewRule } from '@/services/reviewService'
|
import { ReviewPreset, SeverityLevel, ReviewRule } from '@/services/reviewService'
|
||||||
|
|
||||||
const { TextArea } = Input
|
const { TextArea } = Input
|
||||||
const { TabPane } = Tabs
|
const { TabPane } = Tabs
|
||||||
|
const { Title } = Typography
|
||||||
|
|
||||||
const ReviewConfig: React.FC = () => {
|
const ReviewConfig: React.FC = () => {
|
||||||
|
const navigate = useNavigate()
|
||||||
const { id: projectId } = useParams<{ id: string }>()
|
const { id: projectId } = useParams<{ id: string }>()
|
||||||
const {
|
const {
|
||||||
configuration,
|
configuration,
|
||||||
@ -391,7 +395,23 @@ const ReviewConfig: React.FC = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ padding: 24 }}>
|
<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">
|
<Tabs defaultActiveKey="dimensions">
|
||||||
<TabPane tab="维度配置" key="dimensions">
|
<TabPane tab="维度配置" key="dimensions">
|
||||||
{renderDimensionSliders()}
|
{renderDimensionSliders()}
|
||||||
|
|||||||
@ -3,7 +3,7 @@
|
|||||||
* Display review results with scores, issues, and actions
|
* Display review results with scores, issues, and actions
|
||||||
*/
|
*/
|
||||||
import React, { useEffect, useState } from 'react'
|
import React, { useEffect, useState } from 'react'
|
||||||
import { useParams } from 'react-router-dom'
|
import { useParams, useNavigate } from 'react-router-dom'
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
Row,
|
Row,
|
||||||
@ -26,6 +26,7 @@ import {
|
|||||||
Spin
|
Spin
|
||||||
} from 'antd'
|
} from 'antd'
|
||||||
import {
|
import {
|
||||||
|
ArrowLeftOutlined,
|
||||||
CheckCircleOutlined,
|
CheckCircleOutlined,
|
||||||
CloseCircleOutlined,
|
CloseCircleOutlined,
|
||||||
EyeOutlined,
|
EyeOutlined,
|
||||||
@ -44,6 +45,7 @@ const { Title, Text, Paragraph } = Typography
|
|||||||
|
|
||||||
const ReviewResults: React.FC = () => {
|
const ReviewResults: React.FC = () => {
|
||||||
const { id: projectId } = useParams<{ id: string }>()
|
const { id: projectId } = useParams<{ id: string }>()
|
||||||
|
const navigate = useNavigate()
|
||||||
const {
|
const {
|
||||||
currentResult,
|
currentResult,
|
||||||
configuration,
|
configuration,
|
||||||
@ -62,6 +64,15 @@ const ReviewResults: React.FC = () => {
|
|||||||
const [batchActionModalVisible, setBatchActionModalVisible] = useState(false)
|
const [batchActionModalVisible, setBatchActionModalVisible] = useState(false)
|
||||||
const [selectedSeverity, setSelectedSeverity] = useState<SeverityLevel | 'all'>('all')
|
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(() => {
|
useEffect(() => {
|
||||||
if (projectId) {
|
if (projectId) {
|
||||||
fetchLatestResult(projectId, selectedEpisode)
|
fetchLatestResult(projectId, selectedEpisode)
|
||||||
@ -532,6 +543,24 @@ const ReviewResults: React.FC = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ padding: 24 }}>
|
<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">
|
<Space direction="vertical" style={{ width: '100%' }} size="large">
|
||||||
{renderOverallScore()}
|
{renderOverallScore()}
|
||||||
{renderDimensionScores()}
|
{renderDimensionScores()}
|
||||||
|
|||||||
@ -51,7 +51,8 @@ import { useNavigate } from 'react-router-dom'
|
|||||||
import { useSkillStore, Skill } from '@/stores/skillStore'
|
import { useSkillStore, Skill } from '@/stores/skillStore'
|
||||||
import { skillService, SkillDraft } from '@/services/skillService'
|
import { skillService, SkillDraft } from '@/services/skillService'
|
||||||
import { taskService } from '@/services/taskService'
|
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'
|
import dayjs from 'dayjs'
|
||||||
|
|
||||||
const { Search, TextArea } = Input
|
const { Search, TextArea } = Input
|
||||||
|
|||||||
@ -53,6 +53,7 @@ export interface SeriesProject {
|
|||||||
type: string
|
type: string
|
||||||
agentId: string
|
agentId: string
|
||||||
mode: string
|
mode: string
|
||||||
|
genre?: string
|
||||||
globalContext: GlobalContext
|
globalContext: GlobalContext
|
||||||
totalEpisodes: number
|
totalEpisodes: number
|
||||||
// 三级 Skills 配置
|
// 三级 Skills 配置
|
||||||
@ -90,6 +91,7 @@ export interface ProjectCreateRequest {
|
|||||||
totalEpisodes?: number
|
totalEpisodes?: number
|
||||||
agentId?: string
|
agentId?: string
|
||||||
mode?: string
|
mode?: string
|
||||||
|
genre?: string
|
||||||
globalContext?: GlobalContext
|
globalContext?: GlobalContext
|
||||||
// 三级 Skills 配置
|
// 三级 Skills 配置
|
||||||
defaultTaskSkills?: TaskSkillConfig[]
|
defaultTaskSkills?: TaskSkillConfig[]
|
||||||
|
|||||||
@ -19,6 +19,7 @@ interface ProjectStore {
|
|||||||
updateProject: (id: string, data: any) => Promise<void>
|
updateProject: (id: string, data: any) => Promise<void>
|
||||||
deleteProject: (id: string) => Promise<void>
|
deleteProject: (id: string) => Promise<void>
|
||||||
fetchEpisodes: (projectId: string) => Promise<void>
|
fetchEpisodes: (projectId: string) => Promise<void>
|
||||||
|
updateEpisodeInList: (episode: Episode) => void
|
||||||
executeEpisode: (projectId: string, episodeNumber: number, title?: string) => Promise<void>
|
executeEpisode: (projectId: string, episodeNumber: number, title?: string) => Promise<void>
|
||||||
clearCurrentProject: () => 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) => {
|
executeEpisode: async (projectId, episodeNumber, title) => {
|
||||||
set({ loading: true, error: null })
|
set({ loading: true, error: null })
|
||||||
try {
|
try {
|
||||||
|
|||||||
17
test.md
17
test.md
@ -1,10 +1,17 @@
|
|||||||
# 我的python 环境是"C:\ProgramData\Anaconda3\envs\creative_studio\python.exe"
|
# 我的python 环境是"C:\ProgramData\Anaconda3\envs\creative_studio\python.exe"
|
||||||
## 1
|
## 1
|
||||||
~~剧集创作的草稿区一直显示未命名草稿,这里需要修改为剧集名称,和剧集管理列表的剧集名称同步。~~ ✓ 已修复:修改 ProjectWorkspace.tsx 第461行,使用剧集编号作为默认标题
|
使用wedsocket 实现实时通信时,用户需要可以中途去别的地方查看和修改,而不是等待生成完成。
|
||||||
|
剧集开始创作之后的质量和问题需要有真正的分析和显示,可以制作审核卡片推送到审核系统中,http://localhost:5173/projects/8f969272-4ece-49e7-8ca1-4877cc62c57c/review/config,http://localhost:5173/projects/8f969272-4ece-49e7-8ca1-4877cc62c57c/memory都是空白
|
||||||
## 2
|
## 2
|
||||||
~~剧集创作这里查看每一集内容在草稿区之后需要配置类似全局设定生成那里的ai辅助修改按钮(同样支持配置skill),支持注入agent辅助修改。(用户看不到注入内容,后台注入修改)~~ ✓ 已修复:在 SmartCanvas 组件添加 AI 辅助按钮,通过 WebSocket 向 Agent 发送优化请求
|
首先理清楚一个概念,虽然现在创作平台是按照画布实现的,但是其实是一个编辑台,不是出图的,而是一个文本编辑台,用户在这个台子里创作故事,然后通过agent自动更新记忆库和故事上下文。现在的画布只有点击编辑时候才会显示内容,需要一直显示,用户在创作过程中可以随时查看和修改。现在都显示的空白,检查修改一下
|
||||||
## 3
|
## 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
|
## 4
|
||||||
~~现在的剧集创作故事上下文和记忆库都没有同步进行更新,这块需要进行检查和实现。~~ ✓ 已修复:在 websocket.py save_episode 和 update_episode 处理器中添加自动记忆更新功能
|
现在的剧集创作部分非常丑,页面还是无法自由伸缩,用户需要在创作过程中随时查看和修改,所以需要一个好的页面布局和交互设计。使用一些前端技能优化页面布局。
|
||||||
记忆库的更新,故事上下文的更新需要在每一集创作完成后进行(agent自动更新)。
|
## 5
|
||||||
|
剧集的已完成应该是有最终的编辑台下面有一个类似确认剧集内容生成的按钮,点击后才会将剧集的内容提交到服务器。不然点击生成大纲或者开始创作都会把剧集的状态变成已完成,应该增加创作中的状态
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user