feat: 实现审核系统核心功能与UI优化

- 新增审核卡片和确认卡片模型,支持Agent推送审核任务和用户确认
- 实现审核卡片API服务,支持创建、更新、批准、驳回等操作
- 扩展审核维度配置,新增角色一致性、剧情连贯性等维度
- 优化前端审核配置页面,修复API路径错误和状态枚举问题
- 改进剧集创作平台布局,新增左侧边栏用于剧集管理和上下文查看
- 增强Skill管理,支持从审核系统跳转创建/编辑Skill
- 修复episodes.json数据问题,清理聊天历史记录
- 更新Agent提示词,明确Skill引用加载流程
- 统一前端主题配置,优化整体UI体验
This commit is contained in:
hjjjj 2026-01-30 18:32:48 +08:00
parent 9327af6021
commit 5487450f34
56 changed files with 11602 additions and 2946 deletions

View File

@ -1 +1,5 @@
# API v1 module # API v1 module
from app.api.v1 import review, projects, review_task, websocket, memory
__all__ = ["review", "projects", "review_task", "websocket", "memory"]

View File

@ -0,0 +1,319 @@
"""
Confirm Cards API Routes
确认卡片API路由 - 提供确认卡片的CRUD操作
"""
from fastapi import APIRouter, Depends, HTTPException, Query
from typing import List, Optional
from datetime import datetime, timedelta
from app.models.confirm_card import (
ConfirmCard, ConfirmCardStatus, ConfirmCardOption
)
from app.db.confirm_card_repository import get_confirm_card_repo
from app.api.v1.websocket import manager
from app.utils.logger import get_logger
logger = get_logger(__name__)
router = APIRouter(prefix="/confirm-cards", tags=["确认卡片"])
# ============================================
# 依赖注入
# ============================================
async def get_repo():
"""获取确认卡片仓储"""
return get_confirm_card_repo()
# ============================================
# CRUD 端点
# ============================================
@router.post("")
async def create_confirm_card(
card: ConfirmCard,
repo=Depends(get_repo)
):
"""
创建确认卡片
由Agent推送用户可中途退出后随时确认
"""
try:
created_card = await repo.create(card)
# WebSocket推送
await manager.send_to_project(card.project_id, {
"type": "confirm_card_created",
"data": {
"card_id": card.id,
"card_type": card.card_type.value,
"title": card.title,
"description": card.description[:200] + "..." if len(card.description) > 200 else card.description,
"options_count": len(card.options),
"created_at": card.created_at.isoformat()
}
})
logger.info(f"创建确认卡片成功: {card.id}")
return {"success": True, "card": created_card}
except Exception as e:
logger.error(f"创建确认卡片失败: {str(e)}")
raise HTTPException(status_code=500, detail=f"创建确认卡片失败: {str(e)}")
@router.post("/simple")
async def create_confirm_card_simple(
project_id: str,
card_type: str,
title: str,
description: str,
options: Optional[List[dict]] = None,
episode_number: Optional[int] = None,
allow_custom_response: bool = False,
expires_in_hours: Optional[int] = None,
repo=Depends(get_repo)
):
"""
简化创建确认卡片
用于Agent快速推送确认任务
"""
try:
# 构建选项列表
card_options = []
if options:
for i, opt in enumerate(options):
if isinstance(opt, dict):
card_options.append(ConfirmCardOption(
id=opt.get("id", f"opt_{i}"),
label=opt.get("label", f"选项 {i+1}"),
description=opt.get("description", ""),
implications=opt.get("implications")
))
elif isinstance(opt, str):
card_options.append(ConfirmCardOption(
id=f"opt_{i}",
label=opt,
description=""
))
# 计算过期时间
expires_at = None
if expires_in_hours:
expires_at = datetime.now() + timedelta(hours=expires_in_hours)
from app.models.confirm_card import ConfirmCardType
# 创建卡片
card = ConfirmCard(
id=None, # 由repository生成
project_id=project_id,
episode_number=episode_number,
card_type=ConfirmCardType(card_type),
title=title,
description=description,
options=card_options,
allow_custom_response=allow_custom_response,
expires_at=expires_at
)
created_card = await repo.create(card)
# WebSocket推送
await manager.send_to_project(project_id, {
"type": "confirm_card_created",
"data": {
"card_id": created_card.id,
"card_type": card.card_type.value,
"title": card.title,
"description": card.description[:200] + "..." if len(card.description) > 200 else card.description,
"options": [{"id": o.id, "label": o.label} for o in card_options],
"created_at": created_card.created_at.isoformat()
}
})
logger.info(f"简化创建确认卡片成功: {created_card.id}")
return {"success": True, "card_id": created_card.id, "card": created_card}
except Exception as e:
logger.error(f"简化创建确认卡片失败: {str(e)}")
raise HTTPException(status_code=500, detail=f"创建确认卡片失败: {str(e)}")
@router.get("")
async def list_confirm_cards(
project_id: str,
status: Optional[ConfirmCardStatus] = None,
include_expired: bool = False,
repo=Depends(get_repo)
) -> List[ConfirmCard]:
"""
获取确认卡片列表
默认不包含过期的卡片
"""
try:
return await repo.list_by_project(project_id, status, include_expired)
except Exception as e:
logger.error(f"获取确认卡片列表失败: {str(e)}")
raise HTTPException(status_code=500, detail=f"获取列表失败: {str(e)}")
@router.get("/{card_id}")
async def get_confirm_card(
card_id: str,
repo=Depends(get_repo)
) -> ConfirmCard:
"""获取确认卡片详情"""
card = await repo.get(card_id)
if not card:
raise HTTPException(status_code=404, detail="确认卡片不存在")
# 检查是否过期
if card.expires_at and card.expires_at < datetime.now():
if card.status == ConfirmCardStatus.PENDING:
card.status = ConfirmCardStatus.EXPIRED
return card
@router.delete("/{card_id}")
async def delete_confirm_card(
card_id: str,
repo=Depends(get_repo)
):
"""删除确认卡片"""
try:
success = await repo.delete(card_id)
if not success:
raise HTTPException(status_code=404, detail="确认卡片不存在")
logger.info(f"删除确认卡片成功: {card_id}")
return {"success": True}
except HTTPException:
raise
except Exception as e:
logger.error(f"删除确认卡片失败: {str(e)}")
raise HTTPException(status_code=500, detail=f"删除失败: {str(e)}")
# ============================================
# 操作端点
# ============================================
@router.post("/{card_id}/confirm")
async def confirm_card(
card_id: str,
selected_option_id: Optional[str] = None,
custom_response: Optional[str] = None,
user_notes: Optional[str] = None,
repo=Depends(get_repo)
):
"""
确认卡片
用户选择一个选项或提供自定义回复
"""
try:
card = await repo.get(card_id)
if not card:
raise HTTPException(status_code=404, detail="确认卡片不存在")
# 检查是否过期
if card.expires_at and card.expires_at < datetime.now():
await repo.update_status(card_id, ConfirmCardStatus.EXPIRED)
raise HTTPException(status_code=400, detail="确认卡片已过期")
# 更新状态
updated_card = await repo.update_status(
card_id,
ConfirmCardStatus.APPROVED,
selected_option_id,
custom_response,
user_notes
)
# WebSocket通知
await manager.send_to_project(card.project_id, {
"type": "confirm_card_confirmed",
"data": {
"card_id": card_id,
"status": "approved",
"selected_option": selected_option_id,
"custom_response": custom_response,
"confirmed_at": updated_card.confirmed_at.isoformat() if updated_card.confirmed_at else None
}
})
logger.info(f"确认卡片成功: {card_id}, 选择: {selected_option_id}")
return {"success": True, "card": updated_card}
except HTTPException:
raise
except Exception as e:
logger.error(f"确认卡片失败: {str(e)}")
raise HTTPException(status_code=500, detail=f"操作失败: {str(e)}")
@router.post("/{card_id}/reject")
async def reject_card(
card_id: str,
user_notes: Optional[str] = None,
repo=Depends(get_repo)
):
"""
拒绝确认卡片
用户拒绝该确认任务
"""
try:
updated_card = await repo.update_status(
card_id,
ConfirmCardStatus.REJECTED,
user_notes=user_notes
)
if not updated_card:
raise HTTPException(status_code=404, detail="确认卡片不存在")
# WebSocket通知
await manager.send_to_project(updated_card.project_id, {
"type": "confirm_card_rejected",
"data": {
"card_id": card_id,
"status": "rejected",
"user_notes": user_notes
}
})
logger.info(f"拒绝确认卡片成功: {card_id}")
return {"success": True, "card": updated_card}
except HTTPException:
raise
except Exception as e:
logger.error(f"拒绝确认卡片失败: {str(e)}")
raise HTTPException(status_code=500, detail=f"操作失败: {str(e)}")
@router.post("/cleanup-expired")
async def cleanup_expired_cards(repo=Depends(get_repo)):
"""
清理过期的确认卡片
将过期且仍为pending状态的卡片标记为expired
"""
try:
count = await repo.cleanup_expired()
logger.info(f"清理过期确认卡片: {count}")
return {"success": True, "cleaned_count": count}
except Exception as e:
logger.error(f"清理过期卡片失败: {str(e)}")
raise HTTPException(status_code=500, detail=f"清理失败: {str(e)}")

View File

@ -550,6 +550,131 @@ async def get_foreshadowing(
return foreshadowing return foreshadowing
@router.post("/foreshadowing")
async def create_foreshadowing(
project_id: str,
request: dict
):
"""
手动添加伏笔
允许用户手动添加伏笔
"""
project = await verify_project(project_id)
# 直接创建伏笔字典
new_foreshadowing = {
'id': str(uuid.uuid4()),
'description': request.get('description', ''),
'introduced_at': request.get('plantedEpisode', 1),
'payoff_at': request.get('payoffEpisode'),
'type': request.get('type', 'plot'),
'importance': request.get('importance', ImportanceLevel.MEDIUM),
'is_payed_off': False,
'related_events': [],
'notes': request.get('notes', '')
}
# 添加到记忆
if project.memory.foreshadowing is None:
project.memory.foreshadowing = []
project.memory.foreshadowing.append(new_foreshadowing)
# 保存
await project_repo.update(project_id, {
"memory": project.memory.dict()
})
logger.info(f"创建伏笔: {new_foreshadowing['id']}")
return new_foreshadowing
@router.put("/foreshadowing/{foreshadowing_id}")
async def update_foreshadowing(
project_id: str,
foreshadowing_id: str,
request: dict
):
"""
更新伏笔信息
允许修改伏笔的描述状态等
"""
project = await verify_project(project_id)
# 查找伏笔
foreshadowing = None
for f in project.memory.foreshadowing or []:
if f.get('id') == foreshadowing_id:
foreshadowing = f
break
if not foreshadowing:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"伏笔不存在: {foreshadowing_id}"
)
# 更新字段
if request.get('description'):
foreshadowing['description'] = request['description']
if request.get('importance'):
foreshadowing['importance'] = request['importance']
if 'payoffEpisode' in request:
foreshadowing['payoff_at'] = request['payoffEpisode']
foreshadowing['is_payed_off'] = request['payoffEpisode'] is not None
if request.get('notes'):
foreshadowing['notes'] = request['notes']
# 保存
await project_repo.update(project_id, {
"memory": project.memory.dict()
})
logger.info(f"更新伏笔: {foreshadowing_id}")
return foreshadowing
@router.delete("/foreshadowing/{foreshadowing_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_foreshadowing(
project_id: str,
foreshadowing_id: str
):
"""
删除伏笔
移除不需要的伏笔记录
"""
project = await verify_project(project_id)
# 直接操作 memory.foreshadowing (List[Dict])
foreshadowing_list = project.memory.foreshadowing or []
original_count = len(foreshadowing_list)
# 查找并删除
project.memory.foreshadowing = [
f for f in foreshadowing_list
if f.get('id') != foreshadowing_id
]
if len(project.memory.foreshadowing) == original_count:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"伏笔不存在: {foreshadowing_id}"
)
# 保存
await project_repo.update(project_id, {
"memory": project.memory.dict()
})
logger.info(f"删除伏笔: {foreshadowing_id}")
return None
# ============================================ # ============================================
# 一致性检查 # 一致性检查
# ============================================ # ============================================

View File

@ -3,7 +3,7 @@
提供项目的 CRUD 操作和剧集执行功能 提供项目的 CRUD 操作和剧集执行功能
""" """
from fastapi import APIRouter, Depends, HTTPException, status, BackgroundTasks from fastapi import APIRouter, Depends, HTTPException, status, BackgroundTasks, Body
from typing import List, Optional from typing import List, Optional
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
@ -851,3 +851,375 @@ async def delete_episode_skill_config(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"删除失败: {str(e)}" detail=f"删除失败: {str(e)}"
) )
# ============================================
# 审核配置管理与前端API路径匹配
# ============================================
@router.get("/{project_id}/review-config")
async def get_review_config(project_id: str):
"""
获取项目的审核配置
API路径与前端调用匹配/projects/{project_id}/review-config
"""
project = await project_repo.get(project_id)
if not project:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"项目不存在: {project_id}"
)
# 从项目配置中获取审核配置
raw_config = getattr(project, 'reviewConfig', None)
# 如果是字典,转换为 ReviewConfig 对象
if raw_config and isinstance(raw_config, dict):
from app.models.review import ReviewConfig
try:
review_config = ReviewConfig(**raw_config)
except Exception:
review_config = None
else:
review_config = raw_config
# 转换为前端期望的格式
if review_config:
from app.models.review import DimensionType, DimensionConfig
# 构建前端期望的数据格式
frontend_config = {
"preset": "standard",
"pass_threshold": getattr(review_config, 'pass_threshold', 75),
"overall_strictness": int(getattr(review_config, 'overall_strictness', 0.7) * 100),
"dimensions": {},
"customRules": [],
"createdAt": project.createdAt,
"updatedAt": project.updatedAt
}
# 转换维度配置
if hasattr(review_config, 'dimension_settings'):
dimension_names = {
DimensionType.character_consistency: "character_consistency",
DimensionType.consistency: "character_consistency",
DimensionType.quality: "quality",
DimensionType.dialogue_quality: "dialogue_quality",
DimensionType.dialogue: "dialogue_quality",
DimensionType.pacing: "pacing",
DimensionType.character: "character_consistency",
DimensionType.plot: "plot_coherence",
DimensionType.plot_coherence: "plot_coherence",
DimensionType.emotional_depth: "emotional_depth",
DimensionType.thematic_strength: "thematic_strength"
}
for dim_type, dim_config in review_config.dimension_settings.items():
dim_name = dimension_names.get(dim_type, dim_type.value if hasattr(dim_type, 'value') else str(dim_type))
frontend_config["dimensions"][dim_name] = {
"name": getattr(dim_config, 'name', dim_name),
"enabled": getattr(dim_config, 'enabled', True),
"strictness": int(getattr(dim_config, 'strictness', 0.7) * 100),
"weight": getattr(dim_config, 'weight', 1.0)
}
# 确保所有维度都有默认值
default_dimensions = [
"character_consistency", "plot_coherence", "dialogue_quality",
"pacing", "emotional_depth", "thematic_strength", "quality"
]
for dim_name in default_dimensions:
if dim_name not in frontend_config["dimensions"]:
frontend_config["dimensions"][dim_name] = {
"name": dim_name,
"enabled": True,
"strictness": 50,
"weight": 0.7
}
# 转换自定义规则
if hasattr(review_config, 'custom_rules'):
frontend_config["customRules"] = [
{
"id": rule.id,
"name": rule.name,
"description": rule.description,
"triggerCondition": rule.trigger_condition,
"severity": rule.severity,
"category": rule.dimension,
"isActive": getattr(rule, 'enabled', True)
}
for rule in review_config.custom_rules
]
return frontend_config
# 如果没有配置,返回默认配置
return {
"preset": "standard",
"pass_threshold": 75,
"overall_strictness": 70,
"dimensions": {
"character_consistency": {"name": "角色一致性", "enabled": True, "strictness": 50, "weight": 0.8},
"plot_coherence": {"name": "剧情连贯性", "enabled": True, "strictness": 50, "weight": 0.9},
"dialogue_quality": {"name": "对话质量", "enabled": True, "strictness": 50, "weight": 0.7},
"pacing": {"name": "节奏控制", "enabled": True, "strictness": 50, "weight": 0.6},
"emotional_depth": {"name": "情感深度", "enabled": True, "strictness": 50, "weight": 0.7},
"thematic_strength": {"name": "主题强度", "enabled": True, "strictness": 50, "weight": 0.8},
"quality": {"name": "整体质量", "enabled": True, "strictness": 50, "weight": 0.7}
},
"customRules": [],
"createdAt": project.createdAt,
"updatedAt": project.updatedAt
}
@router.put("/{project_id}/review-config")
async def update_review_config(project_id: str, config_update: dict):
"""
更新项目的审核配置
API路径与前端调用匹配/projects/{project_id}/review-config
"""
project = await project_repo.get(project_id)
if not project:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"项目不存在: {project_id}"
)
try:
from app.models.review import ReviewConfig, DimensionConfig, DimensionType, CustomRule
# 获取现有配置
raw_config = getattr(project, 'reviewConfig', None)
# 如果是字典,转换为 ReviewConfig 对象;否则创建新的
if isinstance(raw_config, dict):
current_config = ReviewConfig(**raw_config)
else:
current_config = raw_config if raw_config else ReviewConfig()
# 更新基本配置
if 'pass_threshold' in config_update:
current_config.pass_threshold = float(config_update['pass_threshold'])
if 'overall_strictness' in config_update:
current_config.overall_strictness = float(config_update['overall_strictness']) / 100
# 更新预设
if 'preset' in config_update:
# 这里可以根据预设应用不同的配置
pass
# 更新维度配置
if 'dimensions' in config_update:
dimension_map = {
"character_consistency": DimensionType.character_consistency,
"plot_coherence": DimensionType.plot_coherence,
"dialogue_quality": DimensionType.dialogue_quality,
"pacing": DimensionType.pacing,
"emotional_depth": DimensionType.emotional_depth,
"thematic_strength": DimensionType.thematic_strength,
"quality": DimensionType.quality
}
for dim_name, dim_data in config_update['dimensions'].items():
dim_type = dimension_map.get(dim_name, DimensionType.quality)
current_config.dimension_settings[dim_type] = DimensionConfig(
enabled=dim_data.get('enabled', True),
strictness=dim_data.get('strictness', 50) / 100,
weight=dim_data.get('weight', 0.7)
)
# 更新自定义规则
if 'customRules' in config_update:
current_config.custom_rules = []
for rule_data in config_update['customRules']:
if isinstance(rule_data, dict):
rule = CustomRule(
id=rule_data.get('id', str(__import__('uuid').uuid4())),
name=rule_data.get('name', ''),
description=rule_data.get('description', ''),
trigger_condition=rule_data.get('triggerCondition', ''),
dimension=rule_data.get('category', 'other'),
severity=rule_data.get('severity', 'medium'),
enabled=rule_data.get('isActive', True)
)
current_config.custom_rules.append(rule)
# 保存到项目 - 使用 .json() 方法确保枚举被正确序列化为字符串
import json
await project_repo.update(project_id, {
"reviewConfig": json.loads(current_config.json())
})
logger.info(f"更新项目审核配置: {project_id}")
# 返回更新后的配置
return await get_review_config(project_id)
except Exception as e:
logger.error(f"更新审核配置失败: {str(e)}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"更新配置失败: {str(e)}"
)
@router.post("/{project_id}/review-config/preset")
async def apply_review_preset(project_id: str, data: dict):
"""
应用审核预设
API路径与前端调用匹配/projects/{project_id}/review-config/preset
"""
project = await project_repo.get(project_id)
if not project:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"项目不存在: {project_id}"
)
preset = data.get('preset', 'standard')
try:
from app.models.review import ReviewConfig, DimensionConfig, DimensionType
# 根据预设创建配置
config = ReviewConfig()
if preset == 'draft':
config.overall_strictness = 0.5
config.pass_threshold = 70.0
elif preset == 'strict':
config.overall_strictness = 0.9
config.pass_threshold = 85.0
else: # standard
config.overall_strictness = 0.7
config.pass_threshold = 75.0
# 应用维度设置
for dim_type in [DimensionType.consistency, DimensionType.quality, DimensionType.dialogue]:
config.dimension_settings[dim_type] = DimensionConfig(
enabled=True,
strictness=config.overall_strictness,
weight=1.0
)
# 保存配置 - 使用 .json() 方法确保枚举被正确序列化为字符串
import json
await project_repo.update(project_id, {
"reviewConfig": json.loads(config.json())
})
logger.info(f"应用审核预设: {project_id} - {preset}")
return await get_review_config(project_id)
except Exception as e:
logger.error(f"应用预设失败: {str(e)}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"应用预设失败: {str(e)}"
)
# ============================================
# 审核 Skills 管理
# ============================================
@router.get("/{project_id}/review/skills")
async def get_project_review_skills(project_id: str):
"""获取项目的审核 Skills"""
from app.core.skills.skill_manager import get_skill_manager
project = await project_repo.get(project_id)
if not project:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"项目不存在: {project_id}"
)
skill_manager = get_skill_manager()
# 获取所有 Skills不限制分类
all_skills = await skill_manager.list_skills()
# 获取项目已启用的审核 Skills
raw_config = getattr(project, 'reviewConfig', None)
enabled_skill_ids = []
if raw_config and isinstance(raw_config, dict):
enabled_skill_ids = raw_config.get('enabled_review_skills', [])
# 为每个 Skill 添加启用状态,并转换为前端期望的格式
skills_with_status = []
for skill in all_skills:
skill_dict = skill.dict() if hasattr(skill, 'dict') else skill
# 转换为前端期望的格式
frontend_skill = {
"id": skill_dict.get("id", ""),
"name": skill_dict.get("name", ""),
"description": skill_dict.get("behavior_guide", "") or skill_dict.get("description", ""),
"category": skill_dict.get("category", "other"),
"enabled": skill_dict.get("id", "") in enabled_skill_ids,
"type": skill_dict.get("type", "user"),
"tags": skill_dict.get("tags", [])
}
skills_with_status.append(frontend_skill)
return {
"skills": skills_with_status,
"enabled_skill_ids": enabled_skill_ids
}
@router.put("/{project_id}/review/skills/{skill_id}")
async def update_project_review_skill(
project_id: str,
skill_id: str,
data: dict = None
):
"""更新项目审核 Skill 的启用状态"""
from fastapi import Body
from app.models.review import ReviewConfig
enabled = True
if data and 'enabled' in data:
enabled = data['enabled']
project = await project_repo.get(project_id)
if not project:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"项目不存在: {project_id}"
)
# 获取现有配置
raw_config = getattr(project, 'reviewConfig', None)
if isinstance(raw_config, dict):
current_config = ReviewConfig(**raw_config)
else:
current_config = ReviewConfig()
# 更新启用的 Skills 列表
enabled_skills = set(current_config.enabled_review_skills or [])
if enabled:
enabled_skills.add(skill_id)
else:
enabled_skills.discard(skill_id)
current_config.enabled_review_skills = list(enabled_skills)
# 保存配置
import json
await project_repo.update(project_id, {
"reviewConfig": json.loads(current_config.json())
})
logger.info(f"更新项目审核Skill: {project_id} - {skill_id} = {enabled}, enabled_skills: {list(enabled_skills)}")
return {
"success": True,
"skill_id": skill_id,
"enabled": enabled,
"enabled_skills": list(enabled_skills)
}

View File

@ -5,7 +5,7 @@ Review System API Routes
提供审核配置执行维度和自定义规则的管理接口 提供审核配置执行维度和自定义规则的管理接口
""" """
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends, HTTPException, status, Query, Body
from typing import List, Optional from typing import List, Optional
import uuid import uuid
from datetime import datetime from datetime import datetime
@ -32,14 +32,14 @@ from app.utils.logger import get_logger
logger = get_logger(__name__) logger = get_logger(__name__)
router = APIRouter(prefix="/review", tags=["审核系统"]) router = APIRouter(tags=["审核系统"])
# ============================================ # ============================================
# 项目审核配置管理 # 项目审核配置管理
# ============================================ # ============================================
@router.get("/projects/{project_id}/review-config", response_model=ReviewConfig) @router.get("/projects/{project_id}/review-config")
async def get_review_config(project_id: str): async def get_review_config(project_id: str):
""" """
获取项目的审核配置 获取项目的审核配置
@ -48,7 +48,7 @@ async def get_review_config(project_id: str):
project_id: 项目ID project_id: 项目ID
Returns: Returns:
ReviewConfig: 审核配置 审核配置前端格式
""" """
project = await project_repo.get(project_id) project = await project_repo.get(project_id)
if not project: if not project:
@ -59,14 +59,115 @@ async def get_review_config(project_id: str):
# 从项目配置中获取审核配置 # 从项目配置中获取审核配置
# 如果还没有配置,返回默认配置 # 如果还没有配置,返回默认配置
review_config = getattr(project, 'reviewConfig', None) raw_config = getattr(project, 'reviewConfig', None)
if not review_config:
# 如果配置是字典,需要转换为 ReviewConfig 对象
if isinstance(raw_config, dict):
review_config = ReviewConfig(**raw_config)
elif raw_config is None:
review_config = ReviewConfig() review_config = ReviewConfig()
else:
# 已经是 ReviewConfig 对象
review_config = raw_config
return review_config # 转换为前端格式
frontend_config = _convert_to_frontend_config(review_config)
return frontend_config
@router.put("/projects/{project_id}/review-config", response_model=ReviewConfig) def _convert_to_frontend_config(config: ReviewConfig) -> dict:
"""
将后端 ReviewConfig 转换为前端格式
Args:
config: 后端配置
Returns:
前端格式配置
"""
# 定义默认维度配置(前端格式)
default_dimensions = {
"character_consistency": {"name": "角色一致性", "enabled": True, "strictness": 50, "weight": 0.8},
"plot_coherence": {"name": "剧情连贯性", "enabled": True, "strictness": 50, "weight": 0.9},
"dialogue_quality": {"name": "对话质量", "enabled": True, "strictness": 50, "weight": 0.7},
"pacing": {"name": "节奏控制", "enabled": True, "strictness": 50, "weight": 0.6},
"emotional_depth": {"name": "情感深度", "enabled": True, "strictness": 50, "weight": 0.7},
"thematic_strength": {"name": "主题强度", "enabled": True, "strictness": 50, "weight": 0.8}
}
# 从后端配置中提取维度设置
dimensions = default_dimensions.copy()
for dim_type, dim_config in config.dimension_settings.items():
dim_key = dim_type.value
if dim_key in default_dimensions:
dimensions[dim_key] = {
"name": default_dimensions[dim_key]["name"],
"enabled": dim_config.enabled,
"strictness": int(dim_config.strictness * 100), # 转换 0-1 为 0-100
"weight": dim_config.weight
}
else:
# 处理未知维度
dimensions[dim_key] = {
"name": dim_type.value,
"enabled": dim_config.enabled,
"strictness": int(dim_config.strictness * 100),
"weight": dim_config.weight
}
# 转换自定义规则
custom_rules = []
for rule in config.custom_rules:
dimension_to_category = {
DimensionType.character: "character",
DimensionType.plot: "plot",
DimensionType.dialogue: "dialogue",
DimensionType.pacing: "pacing",
DimensionType.emotional_depth: "emotion",
DimensionType.thematic_strength: "theme",
DimensionType.custom: "other"
}
custom_rules.append({
"id": rule.id,
"name": rule.name,
"description": rule.description,
"triggerCondition": rule.trigger_condition,
"severity": rule.severity.value if hasattr(rule.severity, 'value') else rule.severity,
"category": dimension_to_category.get(rule.dimension, "other"),
"isActive": rule.enabled
})
# 根据 overall_strictness 确定 preset
strictness_to_preset = {
(0.0, 0.6): "draft",
(0.6, 0.8): "standard",
(0.8, 1.0): "strict"
}
preset = "custom"
for (low, high), p in strictness_to_preset.items():
if low <= config.overall_strictness < high:
preset = p
break
return {
"preset": preset,
"dimensions": dimensions,
"customRules": custom_rules,
"createdAt": datetime.now().isoformat(),
"updatedAt": datetime.now().isoformat(),
# 额外的配置信息
"overall_strictness": int(config.overall_strictness * 100), # 转换为 0-100
"pass_threshold": config.pass_threshold,
"auto_fix_enabled": config.auto_fix_enabled,
"enabled_review_skills": config.enabled_review_skills,
"custom_rules_count": len(config.custom_rules),
"enabled_dimensions_count": sum(1 for d in config.dimension_settings.values() if d.enabled),
"total_dimensions_count": len(config.dimension_settings)
}
@router.put("/projects/{project_id}/review-config")
async def update_review_config( async def update_review_config(
project_id: str, project_id: str,
config_update: ReviewConfigUpdate config_update: ReviewConfigUpdate
@ -79,7 +180,7 @@ async def update_review_config(
config_update: 配置更新数据 config_update: 配置更新数据
Returns: Returns:
ReviewConfig: 更新后的配置 更新后的配置前端格式
""" """
project = await project_repo.get(project_id) project = await project_repo.get(project_id)
if not project: if not project:
@ -90,12 +191,98 @@ async def update_review_config(
try: try:
# 获取现有配置 # 获取现有配置
current_config = getattr(project, 'reviewConfig', ReviewConfig()) raw_config = getattr(project, 'reviewConfig', None)
# 更新配置 # 如果配置是字典,需要转换为 ReviewConfig 对象
update_data = config_update.dict(exclude_unset=True) if isinstance(raw_config, dict):
current_config = ReviewConfig(**raw_config)
elif raw_config is None:
current_config = ReviewConfig()
else:
# 已经是 ReviewConfig 对象
current_config = raw_config
# 确保 dimension_settings 存在并是 dict 类型
if not hasattr(current_config, 'dimension_settings') or not isinstance(current_config.dimension_settings, dict):
current_config.dimension_settings = {}
# 获取更新数据
update_data = config_update.dict(exclude_unset=True, exclude_none=True)
# 处理前端格式转换
if "dimensions" in update_data and update_data["dimensions"]:
# 前端发送的 dimensions 需要转换为后端的 dimension_settings
dimension_settings = {}
for dim_key, dim_config in update_data["dimensions"].items():
# 转换为 DimensionType
try:
dim_type = DimensionType(dim_key)
except ValueError:
# 如果不是有效的 DimensionType使用 custom
dim_type = DimensionType.custom
# 转换前端格式到后端格式
# frontend: strictness 0-100, backend: 0-1
strictness = dim_config.get("strictness", 50) / 100.0
dimension_settings[dim_type] = DimensionConfig(
enabled=dim_config.get("enabled", True),
strictness=strictness,
weight=dim_config.get("weight", 0.5),
custom_rules=[]
)
current_config.dimension_settings = dimension_settings
if "customRules" in update_data and update_data["customRules"]:
# 前端发送的 customRules 需要转换为后端的 custom_rules
custom_rules = []
for rule_data in update_data["customRules"]:
# 映射 severity
severity = rule_data.get("severity", "medium")
# 映射 category 到 dimension
category_to_dimension = {
"character": DimensionType.character,
"plot": DimensionType.plot,
"dialogue": DimensionType.dialogue,
"pacing": DimensionType.pacing,
"emotion": DimensionType.emotional_depth,
"theme": DimensionType.thematic_strength,
"other": DimensionType.custom
}
dimension = category_to_dimension.get(rule_data.get("category", "other"), DimensionType.custom)
custom_rules.append(CustomRule(
id=rule_data.get("id", str(uuid.uuid4())),
name=rule_data.get("name", ""),
description=rule_data.get("description", ""),
trigger_condition=rule_data.get("triggerCondition", ""),
dimension=dimension,
severity=severity,
enabled=rule_data.get("isActive", True),
created_at=datetime.now(),
updated_at=datetime.now()
))
current_config.custom_rules = custom_rules
# 处理 preset
if "preset" in update_data and update_data["preset"]:
# 根据 preset 设置 overall_strictness
preset_strictness = {
"draft": 0.5,
"standard": 0.7,
"strict": 0.9,
"custom": current_config.overall_strictness
}
current_config.overall_strictness = preset_strictness.get(
update_data["preset"], current_config.overall_strictness
)
# 更新其他字段(排除已处理的前端字段)
frontend_fields = {"dimensions", "customRules", "preset"}
for field, value in update_data.items(): for field, value in update_data.items():
if field not in frontend_fields:
setattr(current_config, field, value) setattr(current_config, field, value)
# 保存到项目 # 保存到项目
@ -105,7 +292,8 @@ async def update_review_config(
logger.info(f"更新项目审核配置: {project_id}") logger.info(f"更新项目审核配置: {project_id}")
return current_config # 返回前端格式
return _convert_to_frontend_config(current_config)
except Exception as e: except Exception as e:
logger.error(f"更新审核配置失败: {str(e)}") logger.error(f"更新审核配置失败: {str(e)}")
@ -206,6 +394,149 @@ async def get_review_presets(project_id: str = None):
return presets return presets
# ============================================
# 项目审核 Skills 管理
# ============================================
@router.get("/projects/{project_id}/review/skills")
async def get_project_review_skills(project_id: str):
"""
获取项目的审核 Skills
返回所有可用的 Skills不仅是review类型以及项目中已启用的审核 Skills
Args:
project_id: 项目ID
Returns:
包含可用 Skills 和已启用 Skills 的响应
"""
from app.core.skills.skill_manager import get_skill_manager
project = await project_repo.get(project_id)
if not project:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"项目不存在: {project_id}"
)
skill_manager = get_skill_manager()
# 获取所有 Skills不限制分类
all_skills = await skill_manager.list_skills()
# 获取项目已启用的审核 Skills
raw_config = getattr(project, 'reviewConfig', None)
enabled_skill_ids = []
if raw_config and isinstance(raw_config, dict):
enabled_skill_ids = raw_config.get('enabled_review_skills', [])
# 获取所有有效的 skill IDs
valid_skill_ids = {skill_dict.get("id") if hasattr(skill, 'dict') else skill.get("id")
for skill in all_skills
for skill_dict in [skill.dict() if hasattr(skill, 'dict') else skill]}
# 过滤掉不存在的 skill IDs防止已删除的技能仍在启用列表中
valid_enabled_skill_ids = [sid for sid in enabled_skill_ids if sid in valid_skill_ids]
# 如果过滤后发现有不存在的 skill IDs更新项目配置
if len(valid_enabled_skill_ids) != len(enabled_skill_ids):
logger.warning(f"Project {project_id} has invalid enabled_review_skills: "
f"removed {set(enabled_skill_ids) - set(valid_enabled_skill_ids)}")
# 异步更新项目配置(不阻塞响应)
import json
from app.models.review import ReviewConfig
if isinstance(raw_config, dict):
config = ReviewConfig(**raw_config)
else:
config = ReviewConfig()
config.enabled_review_skills = valid_enabled_skill_ids
await project_repo.update(project_id, {
"reviewConfig": json.loads(config.json())
})
# 为每个 Skill 添加启用状态,并转换为前端期望的格式
skills_with_status = []
for skill in all_skills:
skill_dict = skill.dict() if hasattr(skill, 'dict') else skill
# 转换为前端期望的格式
frontend_skill = {
"id": skill_dict.get("id", ""),
"name": skill_dict.get("name", ""),
"description": skill_dict.get("behavior_guide", "") or skill_dict.get("description", ""),
"category": skill_dict.get("category", "other"),
"enabled": skill_dict.get("id", "") in valid_enabled_skill_ids,
"type": skill_dict.get("type", "user"),
"tags": skill_dict.get("tags", [])
}
skills_with_status.append(frontend_skill)
return {
"skills": skills_with_status,
"enabled_skill_ids": valid_enabled_skill_ids
}
@router.put("/projects/{project_id}/review/skills/{skill_id}")
async def update_project_review_skill(
project_id: str,
skill_id: str,
data: dict = Body(...)
):
"""
更新项目审核 Skill 的启用状态
Args:
project_id: 项目ID
skill_id: Skill ID
data: 请求体包含 enabled 字段
Returns:
更新后的状态
"""
from app.models.review import ReviewConfig
import json
enabled = data.get('enabled', True)
project = await project_repo.get(project_id)
if not project:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"项目不存在: {project_id}"
)
# 获取现有配置
raw_config = getattr(project, 'reviewConfig', None)
if isinstance(raw_config, dict):
current_config = ReviewConfig(**raw_config)
else:
current_config = ReviewConfig()
# 更新启用的 Skills 列表
enabled_skills = set(current_config.enabled_review_skills or [])
if enabled:
enabled_skills.add(skill_id)
else:
enabled_skills.discard(skill_id)
current_config.enabled_review_skills = list(enabled_skills)
# 保存配置
await project_repo.update(project_id, {
"reviewConfig": json.loads(current_config.json())
})
logger.info(f"更新项目审核Skill: {project_id} - {skill_id} = {enabled}, enabled_skills: {list(enabled_skills)}")
return {
"success": True,
"skill_id": skill_id,
"enabled": enabled,
"enabled_skills": list(enabled_skills)
}
# ============================================ # ============================================
# 审核执行 # 审核执行
# ============================================ # ============================================
@ -261,7 +592,11 @@ async def review_episode(
try: try:
# 获取审核配置 # 获取审核配置
config = getattr(project, 'reviewConfig', ReviewConfig()) raw_config = getattr(project, 'reviewConfig', None)
if isinstance(raw_config, dict):
config = ReviewConfig(**raw_config)
else:
config = raw_config or ReviewConfig()
# 执行审核 # 执行审核
logger.info(f"开始审核: 项目 {project_id}, EP{episode_number}") logger.info(f"开始审核: 项目 {project_id}, EP{episode_number}")
@ -415,7 +750,11 @@ async def list_custom_rules(
detail=f"项目不存在: {project_id}" detail=f"项目不存在: {project_id}"
) )
config = getattr(project, 'reviewConfig', ReviewConfig()) raw_config = getattr(project, 'reviewConfig', None)
if isinstance(raw_config, dict):
config = ReviewConfig(**raw_config)
else:
config = raw_config or ReviewConfig()
rules = config.custom_rules rules = config.custom_rules
else: else:
# 返回空列表(暂时不支持全局规则) # 返回空列表(暂时不支持全局规则)
@ -431,7 +770,7 @@ async def list_custom_rules(
@router.post("/custom-rules", response_model=CustomRule, status_code=status.HTTP_201_CREATED) @router.post("/custom-rules", response_model=CustomRule, status_code=status.HTTP_201_CREATED)
async def create_custom_rule( async def create_custom_rule(
rule_data: CustomRuleCreate, rule_data: CustomRuleCreate,
project_id: str project_id: str = Query(..., description="项目ID")
): ):
""" """
创建自定义规则 创建自定义规则
@ -451,19 +790,39 @@ async def create_custom_rule(
) )
try: try:
# 处理 category 到 dimension 的映射
if rule_data.category:
# 映射 category 到 dimension
category_to_dimension = {
"character": DimensionType.character,
"plot": DimensionType.plot,
"dialogue": DimensionType.dialogue,
"pacing": DimensionType.pacing,
"emotion": DimensionType.emotional_depth,
"theme": DimensionType.thematic_strength,
"other": DimensionType.custom
}
dimension = category_to_dimension.get(rule_data.category, DimensionType.custom)
else:
dimension = rule_data.dimension or DimensionType.custom
# 创建规则 # 创建规则
new_rule = CustomRule( new_rule = CustomRule(
id=str(uuid.uuid4()), id=str(uuid.uuid4()),
name=rule_data.name, name=rule_data.name,
description=rule_data.description, description=rule_data.description,
trigger_condition=rule_data.trigger_condition, trigger_condition=rule_data.trigger_condition,
dimension=rule_data.dimension, dimension=dimension,
severity=rule_data.severity, severity=rule_data.severity,
enabled=True enabled=True
) )
# 添加到项目配置 # 添加到项目配置
config = getattr(project, 'reviewConfig', ReviewConfig()) raw_config = getattr(project, 'reviewConfig', None)
if isinstance(raw_config, dict):
config = ReviewConfig(**raw_config)
else:
config = raw_config or ReviewConfig()
config.custom_rules.append(new_rule) config.custom_rules.append(new_rule)
# 保存 # 保存
@ -487,7 +846,7 @@ async def create_custom_rule(
async def update_custom_rule( async def update_custom_rule(
rule_id: str, rule_id: str,
rule_update: CustomRuleUpdate, rule_update: CustomRuleUpdate,
project_id: str project_id: str = Query(..., description="项目ID")
): ):
""" """
更新自定义规则 更新自定义规则
@ -507,7 +866,11 @@ async def update_custom_rule(
detail=f"项目不存在: {project_id}" detail=f"项目不存在: {project_id}"
) )
config = getattr(project, 'reviewConfig', ReviewConfig()) raw_config = getattr(project, 'reviewConfig', None)
if isinstance(raw_config, dict):
config = ReviewConfig(**raw_config)
else:
config = raw_config or ReviewConfig()
# 查找规则 # 查找规则
rule = None rule = None
@ -526,6 +889,24 @@ async def update_custom_rule(
# 更新规则 # 更新规则
update_data = rule_update.dict(exclude_unset=True) update_data = rule_update.dict(exclude_unset=True)
# 处理 category 到 dimension 的映射
if 'category' in update_data:
category_to_dimension = {
"character": DimensionType.character,
"plot": DimensionType.plot,
"dialogue": DimensionType.dialogue,
"pacing": DimensionType.pacing,
"emotion": DimensionType.emotional_depth,
"theme": DimensionType.thematic_strength,
"other": DimensionType.custom
}
update_data['dimension'] = category_to_dimension.get(
update_data['category'],
rule.dimension or DimensionType.custom
)
# 移除 category 字段
del update_data['category']
for field, value in update_data.items(): for field, value in update_data.items():
setattr(rule, field, value) setattr(rule, field, value)
@ -549,7 +930,10 @@ async def update_custom_rule(
@router.delete("/custom-rules/{rule_id}", status_code=status.HTTP_204_NO_CONTENT) @router.delete("/custom-rules/{rule_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_custom_rule(rule_id: str, project_id: str): async def delete_custom_rule(
rule_id: str,
project_id: str = Query(..., description="项目ID")
):
""" """
删除自定义规则 删除自定义规则
@ -567,7 +951,11 @@ async def delete_custom_rule(rule_id: str, project_id: str):
detail=f"项目不存在: {project_id}" detail=f"项目不存在: {project_id}"
) )
config = getattr(project, 'reviewConfig', ReviewConfig()) raw_config = getattr(project, 'reviewConfig', None)
if isinstance(raw_config, dict):
config = ReviewConfig(**raw_config)
else:
config = raw_config or ReviewConfig()
# 查找并删除规则 # 查找并删除规则
original_count = len(config.custom_rules) original_count = len(config.custom_rules)
@ -630,7 +1018,11 @@ async def test_custom_rule(
detail=f"剧集不存在: {episode_id}" detail=f"剧集不存在: {episode_id}"
) )
config = getattr(project, 'reviewConfig', ReviewConfig()) raw_config = getattr(project, 'reviewConfig', None)
if isinstance(raw_config, dict):
config = ReviewConfig(**raw_config)
else:
config = raw_config or ReviewConfig()
# 查找规则 # 查找规则
rule = None rule = None
@ -667,3 +1059,121 @@ async def test_custom_rule(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"测试失败: {str(e)}" detail=f"测试失败: {str(e)}"
) )
# ============================================
# 审核配置预览
# ============================================
@router.post("/projects/{project_id}/review/preview")
async def preview_review_config(
project_id: str,
config: ReviewConfigUpdate
):
"""
预览审核配置
整合配置+Skills内容文字形式展示模拟注入Agent的效果
Args:
project_id: 项目ID
config: 配置数据
Returns:
预览文本和统计信息
"""
try:
from app.core.skills.skill_manager import get_skill_manager
# 获取Skills详情
skill_manager = get_skill_manager()
skills_info = []
if config.enabled_review_skills:
for skill_id in config.enabled_review_skills:
skill = skill_manager.get_skill_by_id(skill_id)
if skill:
skills_info.append({
"id": skill.id,
"name": skill.name,
"description": getattr(skill, 'description', ''),
"behavior_guide": skill.behavior_guide
})
# 生成预览文本模拟注入Agent
preview_lines = []
preview_lines.append("# 审核配置预览")
preview_lines.append("")
preview_lines.append("## 基础配置")
preset_text = config.preset if config.preset else "custom"
preset_map = {
"draft": "草稿模式",
"standard": "标准模式",
"strict": "严格模式"
}
preview_lines.append(f"- 预设模式: {preset_map.get(preset_text, preset_text)}")
if config.overall_strictness is not None:
preview_lines.append(f"- 整体严格度: {int(config.overall_strictness * 100)}%")
if config.pass_threshold is not None:
preview_lines.append(f"- 通过阈值: {config.pass_threshold}")
if config.auto_fix_enabled is not None:
preview_lines.append(f"- 自动修复: {'启用' if config.auto_fix_enabled else '禁用'}")
# 维度配置
if config.dimension_settings:
preview_lines.append("")
preview_lines.append("## 维度配置")
for dim_type, dim_config in config.dimension_settings.items():
enabled_text = "启用" if dim_config.enabled else "禁用"
preview_lines.append(f"")
preview_lines.append(f"### {dim_type.value if hasattr(dim_type, 'value') else dim_type}")
preview_lines.append(f"- 状态: {enabled_text}")
preview_lines.append(f"- 严格度: {int(dim_config.strictness * 100)}%")
preview_lines.append(f"- 权重: {dim_config.weight}")
# Skills配置
if skills_info:
preview_lines.append("")
preview_lines.append("## 启用的Skills")
for skill in skills_info:
preview_lines.append(f"")
preview_lines.append(f"### {skill['name']}")
preview_lines.append(f"- ID: `{skill['id']}`")
if skill.get('description'):
preview_lines.append(f"- 描述: {skill['description']}")
preview_lines.append("")
preview_lines.append("**行为指南:**")
preview_lines.append("```")
preview_lines.append(skill['behavior_guide'])
preview_lines.append("```")
# 自定义规则
custom_rules = config.custom_rules if config.custom_rules else []
if custom_rules:
preview_lines.append("")
preview_lines.append("## 自定义规则")
for rule in custom_rules:
preview_lines.append(f"")
preview_lines.append(f"### {rule.name}")
preview_lines.append(f"- 描述: {rule.description}")
preview_lines.append(f"- 维度: {rule.dimension.value if hasattr(rule.dimension, 'value') else rule.dimension}")
preview_lines.append(f"- 严重度: {rule.severity.value if hasattr(rule.severity, 'value') else rule.severity}")
preview_lines.append(f"- 触发条件: {rule.trigger_condition}")
preview_text = "\n".join(preview_lines)
return {
"preview_text": preview_text,
"skills_count": len(skills_info),
"rules_count": len(custom_rules),
"dimensions_count": len(config.dimension_settings) if config.dimension_settings else 0
}
except Exception as e:
logger.error(f"预览审核配置失败: {str(e)}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"预览失败: {str(e)}"
)

View File

@ -0,0 +1,274 @@
"""
Review Cards API Routes
审核卡片API路由 - 提供审核卡片的CRUD操作
"""
from fastapi import APIRouter, Depends, HTTPException, Query
from typing import List, Optional
from datetime import datetime
from app.models.review_card import (
ReviewCard, ReviewCardCreate, ReviewCardUpdate,
ReviewCardType, ReviewCardStatus
)
from app.db.review_card_repository import get_review_card_repo
from app.api.v1.websocket import manager
from app.utils.logger import get_logger
logger = get_logger(__name__)
router = APIRouter(prefix="/review-cards", tags=["审核卡片"])
# ============================================
# 依赖注入
# ============================================
async def get_repo():
"""获取审核卡片仓储"""
return get_review_card_repo()
# ============================================
# CRUD 端点
# ============================================
@router.post("")
async def create_review_card(
data: ReviewCardCreate,
repo=Depends(get_repo)
):
"""
创建审核卡片
触发场景
1. Agent生成剧集内容后自动生成
2. 用户在对话框触发某一集/某几集/整个剧集的审核
"""
try:
card = await repo.create(data)
# 通过WebSocket推送通知
await manager.send_to_project(data.project_id, {
"type": "review_card_created",
"data": {
"card_id": card.id,
"card_type": card.card_type.value,
"episode_numbers": card.episode_numbers,
"severity": card.severity,
"review_reason": card.review_reason,
"created_at": card.created_at.isoformat()
}
})
logger.info(f"创建审核卡片成功: {card.id}")
return {"success": True, "card": card}
except Exception as e:
logger.error(f"创建审核卡片失败: {str(e)}")
raise HTTPException(status_code=500, detail=f"创建审核卡片失败: {str(e)}")
@router.get("")
async def list_review_cards(
project_id: str,
status: Optional[ReviewCardStatus] = None,
card_type: Optional[ReviewCardType] = None,
limit: int = Query(100, ge=1, le=500),
repo=Depends(get_repo)
) -> List[ReviewCard]:
"""
获取审核卡片列表
支持按状态和类型筛选
"""
try:
cards = await repo.list_by_project(project_id, status, limit)
if card_type:
cards = [c for c in cards if c.card_type == card_type]
return cards
except Exception as e:
logger.error(f"获取审核卡片列表失败: {str(e)}")
raise HTTPException(status_code=500, detail=f"获取列表失败: {str(e)}")
@router.get("/stats/{project_id}")
async def get_review_card_stats(
project_id: str,
repo=Depends(get_repo)
):
"""
获取审核卡片统计信息
返回各类状态的数量统计
"""
try:
total = await repo.count_by_project(project_id)
pending = await repo.count_by_status(project_id, ReviewCardStatus.PENDING)
awaiting_user = await repo.count_by_status(project_id, ReviewCardStatus.AWAITING_USER)
approved = await repo.count_by_status(project_id, ReviewCardStatus.APPROVED)
rejected = await repo.count_by_status(project_id, ReviewCardStatus.REJECTED)
return {
"total": total,
"pending": pending,
"awaiting_user": awaiting_user,
"approved": approved,
"rejected": rejected
}
except Exception as e:
logger.error(f"获取审核卡片统计失败: {str(e)}")
raise HTTPException(status_code=500, detail=f"获取统计失败: {str(e)}")
@router.get("/{card_id}")
async def get_review_card(
card_id: str,
repo=Depends(get_repo)
) -> ReviewCard:
"""获取审核卡片详情"""
card = await repo.get(card_id)
if not card:
raise HTTPException(status_code=404, detail="审核卡片不存在")
return card
@router.put("/{card_id}")
async def update_review_card(
card_id: str,
data: ReviewCardUpdate,
repo=Depends(get_repo)
):
"""
更新审核卡片
支持更新状态用户评论修改内容等
"""
try:
card = await repo.update(card_id, data)
if not card:
raise HTTPException(status_code=404, detail="审核卡片不存在")
# 通知WebSocket
await manager.send_to_project(card.project_id, {
"type": "review_card_updated",
"data": {
"card_id": card_id,
"status": card.status.value,
"updated_at": card.updated_at.isoformat()
}
})
logger.info(f"更新审核卡片成功: {card_id}")
return {"success": True, "card": card}
except HTTPException:
raise
except Exception as e:
logger.error(f"更新审核卡片失败: {str(e)}")
raise HTTPException(status_code=500, detail=f"更新失败: {str(e)}")
@router.delete("/{card_id}")
async def delete_review_card(
card_id: str,
repo=Depends(get_repo)
):
"""删除审核卡片"""
try:
success = await repo.delete(card_id)
if not success:
raise HTTPException(status_code=404, detail="审核卡片不存在")
logger.info(f"删除审核卡片成功: {card_id}")
return {"success": True}
except HTTPException:
raise
except Exception as e:
logger.error(f"删除审核卡片失败: {str(e)}")
raise HTTPException(status_code=500, detail=f"删除失败: {str(e)}")
# ============================================
# 操作端点
# ============================================
@router.post("/{card_id}/approve")
async def approve_review_card(
card_id: str,
user_comment: Optional[str] = None,
repo=Depends(get_repo)
):
"""
通过审核卡片
将卡片状态设置为APPROVED
"""
try:
update_data = ReviewCardUpdate(
status=ReviewCardStatus.APPROVED,
user_comment=user_comment
)
result = await update_review_card(card_id, update_data, repo)
return result
except Exception as e:
logger.error(f"通过审核卡片失败: {str(e)}")
raise HTTPException(status_code=500, detail=f"操作失败: {str(e)}")
@router.post("/{card_id}/reject")
async def reject_review_card(
card_id: str,
user_comment: Optional[str] = None,
modified_content: Optional[str] = None,
repo=Depends(get_repo)
):
"""
驳回审核卡片
将卡片状态设置为REJECTED可选地包含修改内容
"""
try:
update_data = ReviewCardUpdate(
status=ReviewCardStatus.REJECTED,
user_comment=user_comment,
modified_content=modified_content
)
result = await update_review_card(card_id, update_data, repo)
return result
except Exception as e:
logger.error(f"驳回审核卡片失败: {str(e)}")
raise HTTPException(status_code=500, detail=f"操作失败: {str(e)}")
@router.post("/{card_id}/modify")
async def modify_review_card(
card_id: str,
user_comment: Optional[str] = None,
modified_content: Optional[str] = None,
repo=Depends(get_repo)
):
"""
修改审核卡片内容
将卡片状态设置为MODIFIED包含修改后的内容
"""
try:
update_data = ReviewCardUpdate(
status=ReviewCardStatus.AWAITING_USER,
user_comment=user_comment,
modified_content=modified_content
)
result = await update_review_card(card_id, update_data, repo)
return result
except Exception as e:
logger.error(f"修改审核卡片失败: {str(e)}")
raise HTTPException(status_code=500, detail=f"操作失败: {str(e)}")

View File

@ -0,0 +1,431 @@
"""
Review Task API Routes
审核任务API路由 - 提供过程中审核和生成后审核的管理接口
"""
from fastapi import APIRouter, Depends, HTTPException, status, BackgroundTasks
from typing import List, Optional
import asyncio
from app.models.review_task import (
ReviewTask,
ReviewMode,
ReviewAction,
ProcessReviewRequest,
UserReviewActionRequest,
PostCreationReviewRequest,
PostCreationReviewResult,
ReviewTaskStatus
)
from app.core.review.review_task_manager import (
ReviewTaskManager,
get_review_task_manager,
StreamReviewHelper
)
from app.db.repositories import project_repo, episode_repo
from app.models.project import SeriesProject, Episode
from app.utils.logger import get_logger
logger = get_logger(__name__)
router = APIRouter(prefix="/review-task", tags=["审核任务"])
# ============================================
# 依赖注入
# ============================================
async def get_task_manager() -> ReviewTaskManager:
"""获取审核任务管理器"""
return get_review_task_manager()
async def get_stream_helper(project_id: str) -> StreamReviewHelper:
"""获取流式审核助手"""
task_manager = get_review_task_manager()
return StreamReviewHelper(project_id, task_manager)
# ============================================
# 过程中审核 API
# ============================================
@router.post("/process")
async def create_process_review_task(
request: ProcessReviewRequest,
task_manager: ReviewTaskManager = Depends(get_task_manager)
):
"""
创建过程中审核任务
由Agent在流式输出过程中调用推送审核任务到前端
前端通过WebSocket接收通知并等待用户确认
"""
try:
# 创建审核任务
task = await task_manager.create_process_review_task(request)
# 通过WebSocket推送到前端
from app.api.v1.websocket import manager
await manager.send_to_project(request.project_id, {
"type": "review_task_created",
"data": {
"task_id": task.id,
"mode": task.mode.value,
"issue_type": task.issue_type,
"issue_description": task.issue_description,
"severity": task.severity,
"location": task.location.dict(),
"available_actions": [action.value for action in task.available_actions],
"suggestion": task.suggestion,
"trigger_source": task.trigger_source.value,
"created_at": task.created_at.isoformat()
}
})
logger.info(f"过程中审核任务已创建并推送: {task.id}")
return {
"success": True,
"task_id": task.id,
"message": "审核任务已创建"
}
except Exception as e:
logger.error(f"创建过程中审核任务失败: {str(e)}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"创建审核任务失败: {str(e)}"
)
@router.post("/process/{task_id}/action")
async def handle_process_review_action(
task_id: str,
request: UserReviewActionRequest,
task_manager: ReviewTaskManager = Depends(get_task_manager)
):
"""
处理用户对过程中审核任务的响应
Args:
task_id: 任务ID
request: 用户操作请求
Returns:
处理结果
"""
try:
# 处理用户操作
task = await task_manager.handle_user_review_action(request)
# 通过WebSocket通知前端任务已完成
from app.api.v1.websocket import manager
await manager.send_to_project(task.project_id or "unknown", {
"type": "review_task_completed",
"data": {
"task_id": task_id,
"status": task.status.value,
"user_response": task.user_response,
"completed_at": task.completed_at.isoformat() if task.completed_at else None
}
})
logger.info(f"用户操作已处理: {task_id}, 操作: {request.action.value}")
return {
"success": True,
"task_id": task_id,
"status": task.status.value,
"message": "操作已处理"
}
except ValueError as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(e)
)
except Exception as e:
logger.error(f"处理用户操作失败: {str(e)}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"处理失败: {str(e)}"
)
@router.get("/process/pending/{project_id}")
async def get_pending_process_tasks(
project_id: str,
task_manager: ReviewTaskManager = Depends(get_task_manager)
) -> List[ReviewTask]:
"""
获取项目所有待处理的过程审核任务
Args:
project_id: 项目ID
Returns:
List[ReviewTask]: 待处理任务列表
"""
return task_manager.get_pending_process_tasks(project_id)
# ============================================
# 生成后审核 API
# ============================================
@router.post("/post-creation")
async def execute_post_creation_review(
request: PostCreationReviewRequest,
task_manager: ReviewTaskManager = Depends(get_task_manager),
background_tasks: BackgroundTasks = None
):
"""
执行生成后审核
根据用户配置的规则/Skill进行自动检查根据阈值报告问题
Args:
request: 生成后审核请求
background_tasks: FastAPI后台任务管理器
Returns:
PostCreationReviewResult: 审核结果
"""
# 获取项目
project = await project_repo.get(request.project_id)
if not project:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"项目不存在: {request.project_id}"
)
# 获取剧集
episodes = await episode_repo.list_by_project(request.project_id)
episode = next((ep for ep in episodes if ep.id == request.episode_id), None)
if not episode:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"剧集不存在: {request.episode_id}"
)
try:
# 获取审核配置
from app.models.review import ReviewConfig
raw_config = getattr(project, 'reviewConfig', None)
if isinstance(raw_config, dict):
config = ReviewConfig(**raw_config)
elif raw_config is None:
config = ReviewConfig()
else:
config = raw_config
# 确保dimension_settings是dict
if not hasattr(config, 'dimension_settings') or not isinstance(config.dimension_settings, dict):
config.dimension_settings = {}
# 执行审核
result = await task_manager.execute_post_creation_review(
request=request,
project_config=config
)
# 更新剧集审核结果
from app.models.review import ReviewResult
review_result = ReviewResult(
id=f"review_{episode.id}",
episode_id=episode.id,
episode_number=episode.number,
project_id=project.id,
overall_score=result.overall_score or 75.0,
passed=result.passed,
passed_dimensions=0,
total_dimensions=1,
dimension_scores=[],
issues=[],
issue_count=result.task_count,
high_severity_count=sum(1 for t in result.tasks if t.severity == "high"),
medium_severity_count=sum(1 for t in result.tasks if t.severity == "medium"),
low_severity_count=sum(1 for t in result.tasks if t.severity == "low")
)
# 保存审核结果到剧集
await episode_repo.update(episode.id, {
"reviewResult": review_result.dict()
})
# 通过WebSocket推送审核结果
from app.api.v1.websocket import manager
await manager.send_to_project(request.project_id, {
"type": "post_creation_review_completed",
"data": {
"episode_id": episode.id,
"episode_number": episode.number,
"overall_score": result.overall_score,
"passed": result.passed,
"task_count": result.task_count,
"tasks": [
{
"id": task.id,
"issue_type": task.issue_type,
"issue_description": task.issue_description,
"severity": task.severity,
"location": task.location.dict(),
"available_actions": [action.value for action in task.available_actions],
"status": task.status.value,
"suggestion": task.suggestion
}
for task in result.tasks
]
}
})
logger.info(f"生成后审核完成: {episode.id}, 分数: {result.overall_score}")
return result
except Exception as e:
logger.error(f"生成后审核失败: {str(e)}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"审核失败: {str(e)}"
)
@router.post("/post-creation/{task_id}/action")
async def handle_post_creation_task_action(
task_id: str,
request: UserReviewActionRequest,
task_manager: ReviewTaskManager = Depends(get_task_manager)
):
"""
处理用户对生成后审核任务的响应
Args:
task_id: 任务ID
request: 用户操作请求
Returns:
处理结果
"""
try:
# 处理用户操作
task = await task_manager.handle_user_review_action(request)
# 如果修改了内容,更新剧集内容
if request.action == ReviewAction.MODIFY and request.modified_content:
# 获取剧集
episodes = await episode_repo.list_by_project(task.project_id or "")
episode = next((ep for ep in episodes if ep.id == task.episode_id), None)
if episode:
# 更新内容
await episode_repo.update(episode.id, {
"content": request.modified_content
})
# 通过WebSocket通知内容已更新
from app.api.v1.websocket import manager
await manager.send_to_project(task.project_id or "", {
"type": "episode_content_updated",
"data": {
"episode_id": episode.id,
"episode_number": episode.number
}
})
# 通过WebSocket通知任务已完成
from app.api.v1.websocket import manager
await manager.send_to_project(task.project_id or "", {
"type": "review_task_completed",
"data": {
"task_id": task_id,
"status": task.status.value,
"user_response": task.user_response,
"completed_at": task.completed_at.isoformat() if task.completed_at else None
}
})
logger.info(f"生成后审核任务已处理: {task_id}, 操作: {request.action.value}")
return {
"success": True,
"task_id": task_id,
"status": task.status.value,
"message": "操作已处理"
}
except ValueError as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(e)
)
except Exception as e:
logger.error(f"处理生成后审核任务操作失败: {str(e)}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"处理失败: {str(e)}"
)
# ============================================
# Agent流式审核辅助 API
# ============================================
@router.post("/agent/push")
async def agent_push_review_task(
project_id: str,
issue_type: str,
issue_description: str,
severity: str = "medium",
skill_id: Optional[str] = None,
suggestion: Optional[str] = None,
task_manager: ReviewTaskManager = Depends(get_task_manager)
):
"""
Agent推送审核任务简化版
由Agent在流式输出过程中调用推送审核任务
Args:
project_id: 项目ID
issue_type: 问题类型
issue_description: 问题描述
severity: 严重程度
skill_id: 使用的Skill ID
suggestion: 修改建议
Returns:
创建的任务ID
"""
try:
from app.models.review_task import ReviewTaskLocation
task = await task_manager.create_process_review_task(
ProcessReviewRequest(
project_id=project_id,
issue_type=issue_type,
issue_description=issue_description,
severity=severity,
location=ReviewTaskLocation(),
trigger_source="agent_auto",
skill_id=skill_id,
suggestion=suggestion
)
)
return {
"success": True,
"task_id": task.id,
"message": "审核任务已推送"
}
except Exception as e:
logger.error(f"Agent推送审核任务失败: {str(e)}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"推送审核任务失败: {str(e)}"
)

File diff suppressed because it is too large Load Diff

View File

@ -77,11 +77,17 @@ class LangChainSkillsAgent:
Your capabilities include: Your capabilities include:
- Loading and using specialized skills for specific tasks - Loading and using specialized skills for specific tasks
- Loading reference documents from skills when needed (via load_reference)
- Executing bash commands and scripts - Executing bash commands and scripts
- Reading and writing files - Reading and writing files
- Following skill instructions to complete complex tasks - Following skill instructions to complete complex tasks
When a user request matches a skill's description, use the load_skill tool to get detailed instructions before proceeding.""" When a user request matches a skill's description:
1. Use `load_skill(skill_name)` to get detailed instructions
2. If the skill has references and the task requires detailed information, use `load_reference(skill_name, reference_name)` to load specific reference material
3. Follow the skill's instructions to complete the task
**Important**: Reference files in the references/ directory are NOT automatically loaded. Only load them when the skill instructions indicate they are needed for the current task."""
return self.skill_loader.build_system_prompt(base_prompt) return self.skill_loader.build_system_prompt(base_prompt)
def _create_agent(self): def _create_agent(self):

View File

@ -69,6 +69,32 @@ The User is the Director. Your goal is to help the Director create high-quality
- **Series Manager**: You are responsible for the entire series. You can list all episodes, focus on specific ones, and save/update their content. - **Series Manager**: You are responsible for the entire series. You can list all episodes, focus on specific ones, and save/update their content.
- **Structured Executor**: For any complex task (like "Write the whole series" or "Write Episode 1"), you MUST first create a Plan using `update_plan`. - **Structured Executor**: For any complex task (like "Write the whole series" or "Write Episode 1"), you MUST first create a Plan using `update_plan`.
- **Transparent**: Always keep the Director informed of your status via the plan and inbox. - **Transparent**: Always keep the Director informed of your status via the plan and inbox.
- **Context Aware**: You MUST track the conversation context. Remember what was discussed previously and what actions were taken.
## Conversation Context Tracking (CRITICAL)
You MUST maintain awareness of the conversation history:
1. **When the Director asks to "approve outline" or "批准大纲"**:
- Check the conversation history to find the most recently generated outline
- If an outline was just generated in the conversation, treat the approval as confirmation to proceed
- Immediately proceed to content creation using that approved outline
- DO NOT ask which outline - use the most recent one from the current episode
2. **When responding to approval or confirmation**:
- Look at the previous messages to understand what is being approved
- If you just generated an outline and got approval, immediately start content creation
- If you just proposed changes and got approval, immediately apply those changes
3. **Short responses are commands**:
- "批准大纲" or "approve outline" Immediately start writing content using the approved outline
- "修改" or "revise" Apply the suggested changes
- "继续" or "continue" Continue with the next step of the current task
4. **After generating an outline**:
- Always ask for approval explicitly
- Say: "大纲已生成,请审阅。回复'批准大纲'开始创作,或提出修改意见。"
- Then wait for the Director's response
## Workflow Protocols ## Workflow Protocols
@ -83,7 +109,19 @@ The User is the Director. Your goal is to help the Director create high-quality
- The creation runs in the background and updates will be sent via WebSocket - 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 - The Director will see progress in the plan panel and final content on the canvas
3. **Execution & Writing** 3. **Context-Aware Outline & Content Flow**:
- **Outline Generation**: When asked to generate an outline:
a. Generate the outline content
b. Use `write_to_canvas` to display it in the outline editor (append=false)
c. Ask for approval: "大纲已生成,请审阅。回复'批准大纲'开始创作,或提出修改意见。"
- **Content Creation**: When approval is received:
a. Check conversation history for the approved outline
b. Load the outline from the episode data
c. Use `write_to_canvas` to write the full content (append=false)
d. Save the episode using `save_episode` or `update_episode`
4. **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.
@ -92,7 +130,7 @@ 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')`.
4. **AI Editing & Optimization (CRITICAL)** 5. **AI Editing & Optimization (CRITICAL)**
- When the Director asks you to "optimize", "improve", "edit", or "revise" content, you MUST: - 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 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 2. Use `write_to_canvas(content="IMPROVED_FULL_CONTENT", append=false)` to replace the canvas content
@ -102,21 +140,83 @@ The User is the Director. Your goal is to help the Director create high-quality
- Always use append=false when doing full content replacement - Always use append=false when doing full content replacement
- This is the ONLY way to properly update the canvas for the user to see - This is the ONLY way to properly update the canvas for the user to see
5. **Review & Approval** 6. **Automatic Review & Quality Check (CRITICAL - MANDATORY)**
- NEVER mark a major deliverable as "Final" without Director approval. - **After generating ANY content** (outline or full content), you MUST automatically call `request_review`:
- Use `add_inbox_task` to submit drafts or questions to the Director. - For outlines: `request_review(content="<outline_text>", review_type="quality", criteria=["Structure", "Pacing", "Plot logic"])`
- Example: `add_inbox_task(title="Review Episode 1 Outline", type="review", ...)` - For full content: `request_review(content="<content_text>", review_type="consistency", criteria=["Character voice", "Continuity", "Dialogue quality"])`
- **After receiving approval**: Use `save_episode` or `update_episode` to persist the content
- **If review finds issues**: Use `add_inbox_task` to notify the Director and request guidance
- Use `add_annotation` to mark specific issues that need attention
- NEVER mark a deliverable as "Final" without Director approval
6. **Context & Memory** 7. **Automatic Memory Updates (CRITICAL - MANDATORY)**
- If the story state changes (e.g., location change, character injury), use `update_context`. - **After saving ANY episode content**, you MUST automatically call `update_memory` to track:
- If you spot consistency issues, use `add_annotation` on the canvas. - Timeline events: `update_memory(memory_type="timeline", data={"event": "事件描述", "description": "详细描述", "episode": <episode_number>})`
- Character states: `update_memory(memory_type="character_state", data={"character": "角色名", "state": "状态描述", "description": "详细说明"})`
- Pending threads: `update_memory(memory_type="pending_thread", data={"description": "待收线情节描述"})`
- Foreshadowing: `update_memory(memory_type="foreshadowing", data={"description": "伏笔描述"})`
- **Before generating content for a new episode**: Check memory system for:
- Previous character states and locations
- Pending threads that need resolution
- Foreshadowing that should be referenced
- **When you detect state changes**: Immediately update memory (e.g., character injury, location change, new relationship)
8. **Handling Vague/Indirect Requests (CRITICAL - NEW)**
- **When Director uses vague expressions** like:
- "把这个改一下" (change this)
- "这里不对" (this is wrong)
- "调整下" (adjust this)
- "修改一下" (modify this)
- "优化" (optimize)
- Or any request without specifying what to modify
**You MUST follow this workflow:**
1. First call `get_current_view()` to understand what the user is currently looking at
2. Based on the view, call `get_episode_data()` to retrieve the relevant content:
- If viewing outline: `get_episode_data(include_outline=True, include_content=False)`
- If viewing content: `get_episode_data(include_outline=False, include_content=True)`
- If viewing both: `get_episode_data()` (get both)
3. Use the retrieved content as the base for your modifications
4. Apply the requested changes to the content
5. Use `write_to_canvas(content="MODIFIED_CONTENT", append=false)` to replace the content
6. Use `update_episode()` or `save_episode()` to persist the changes
7. Automatically call `request_review()` for quality check
8. Automatically call `update_memory()` if content changed significantly
**Example workflow for vague request:**
- User says: "把这个改一下" (change this)
- You call: `get_current_view()` Returns: "User is viewing: outline of EP1"
- You call: `get_episode_data(episode_number=1, include_outline=True, include_content=False)`
- You receive the current outline content
- You apply reasonable improvements to the outline
- You call: `write_to_canvas(content="IMPROVED_OUTLINE", append=false)`
- You call: `update_episode(episode_number=1, outline="IMPROVED_OUTLINE")`
- You call: `request_review(content="IMPROVED_OUTLINE", review_type="quality")`
9. **Context & Memory Display**
- If the story state changes (e.g., location change, character injury), use `update_context` to inform the Director.
- Track what episode you're currently working on and what the last action was.
9. **Skills Usage**
- Use `load_skill` to access specific writing skills when needed
- User-configured skills from the project settings should be prioritized
## Interaction Style ## Interaction Style
- 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.
- **ALWAYS track conversation context** - remember what you just did and what the Director is responding to.
- 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 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. - CRITICAL: When asked to create episodes, use `create_episode` tool which runs in background and doesn't block the interface.
- CRITICAL: When Director says "批准大纲" or similar, immediately proceed to content creation using the most recent outline.
- CRITICAL: **After generating ANY content**, you MUST automatically:
1. Call `request_review()` to perform quality check
2. After saving, call `update_memory()` to update the memory system
3. Notify the Director of the review results and memory updates
- CRITICAL: **Before generating new episode content**, you MUST check the memory system for:
1. Previous character states and locations
2. Pending threads that may affect this episode
3. Foreshadowing elements to reference or resolve
""" """
# 添加项目上下文(如果有) # 添加项目上下文(如果有)

View File

@ -536,6 +536,146 @@ def list_episodes(
return "\n".join(lines) return "\n".join(lines)
@tool
def get_episode_data(
episode_number: Optional[int] = None,
include_outline: Optional[bool] = None,
include_content: Optional[bool] = None,
runtime: ToolRuntime[SkillAgentContext] = None
) -> str:
"""
Get episode data (outline and/or content) from the database.
This tool retrieves episode information from the project database.
Use this when you need to:
- Review the current outline or content before making changes
- Understand what the user is referring to when they say vague things like "change this"
- Get context for a specific episode number
IMPORTANT: If both include_outline and include_content are None (not specified),
this tool will automatically determine what to return based on the user's current view:
- If user is viewing the outline: returns outline only
- If user is viewing the content: returns content only
- If user is viewing both: returns both
Args:
episode_number: The episode number to retrieve (if None, uses current focused episode)
include_outline: Whether to include the outline (default: auto-detect from view)
include_content: Whether to include the content (default: auto-detect from view)
Returns:
The requested episode data in a structured format
Example:
get_episode_data(episode_number=1, include_outline=True, include_content=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"
# 如果没有指定剧集号,使用当前焦点剧集
if episode_number is None:
episode_number = getattr(runtime.context, 'active_episode_number', None)
if episode_number is None:
return "Error: No episode number specified and no episode is currently focused. Please use focus_episode() first."
# 根据视图上下文自动判断返回内容
view_context = runtime.state.get("current_view_context", {})
view_type = view_context.get("viewType", "")
if include_outline is None and include_content is None:
# 自动判断:根据用户当前在看什么来决定
if view_type == "outline":
include_outline = True
include_content = False
elif view_type == "content":
include_outline = False
include_content = True
elif view_type in ["both", "full"]:
include_outline = True
include_content = True
else:
# 默认返回两者
include_outline = True
include_content = True
# 存储查询请求到状态中,由 WebSocket 处理器执行数据库查询
if "episode_data_requests" not in runtime.state:
runtime.state["episode_data_requests"] = []
request = {
"episode_number": episode_number,
"include_outline": include_outline if include_outline else False,
"include_content": include_content if include_content else False
}
runtime.state["episode_data_requests"].append(request)
# 返回提示信息
return f"""✓ Requesting episode data for EP{episode_number}
(Outline: {'Yes' if include_outline else 'No'}, Content: {'Yes' if include_content else 'No'})
The episode data will be retrieved and provided to you. Use this data to understand the user's request and make appropriate changes."""
@tool
def get_current_view(
runtime: ToolRuntime[SkillAgentContext] = None
) -> str:
"""
Get the current view context (what the user is currently looking at).
This tool returns information about what part of the interface the user
is currently viewing, such as:
- viewType: 'outline', 'content', 'both', or 'none'
- episodeNumber: the episode number currently focused
- Any other view-specific context
Use this tool when you need to understand what the user is referring to
with vague expressions like "change this" or "fix this".
Returns:
The current view context information
Example:
get_current_view() -> "User is viewing: outline of EP1"
"""
if runtime is None:
return "Error: runtime context not available"
view_context = runtime.state.get("current_view_context", {})
view_type = view_context.get("viewType", "unknown")
episode_number = view_context.get("episodeNumber")
# 构建友好的描述
view_descriptions = {
"outline": "大纲编辑器",
"content": "内容编辑器/画布",
"both": "完整视图(大纲+内容)",
"full": "完整视图(大纲+内容)",
"none": "未确定"
}
view_desc = view_descriptions.get(view_type, "未知视图")
episode_info = f",聚焦剧集: EP{episode_number}" if episode_number else ""
result = f"""【用户当前视图】
视图类型: {view_type} ({view_desc}){episode_info}
"""
# 添加建议
if view_type == "outline":
result += "\n建议: 用户可能希望调整大纲。使用 get_episode_data(include_outline=True) 获取大纲内容。"
elif view_type == "content":
result += "\n建议: 用户可能希望调整内容。使用 get_episode_data(include_content=True) 获取内容。"
elif view_type == "both":
result += "\n建议: 用户可能希望调整大纲或内容。使用 get_episode_data() 获取完整数据。"
return result
@tool @tool
def update_episode( def update_episode(
episode_number: int, episode_number: int,
@ -667,6 +807,187 @@ The creation process is running in the background.
Progress will be updated automatically.""" Progress will be updated automatically."""
# ============================================
# 剧集审查规划工具
# ============================================
def _parse_episode_selector(selector: str, runtime: ToolRuntime[SkillAgentContext]) -> List[int]:
"""
解析剧集选择器返回剧集号列表
支持的格式:
- 单集: "1", "EP1", "episode 1" -> [1]
- 多集: "1,2,3", "episodes 1, 2, 3" -> [1, 2, 3]
- 范围: "1-5", "episodes 1-5" -> [1, 2, 3, 4, 5]
- 全部: "all", "全部剧集" -> all episodes from context
"""
selector_lower = selector.lower().strip()
episodes = getattr(runtime.context, 'episodes', [])
all_episode_numbers = [ep.get('number') for ep in episodes if ep.get('number')]
# 处理 "all" 或 "全部剧集"
if selector_lower in ['all', '全部', '全部剧集', 'all episodes']:
return sorted(all_episode_numbers)
# 处理范围 "1-5" 或 "episodes 1-5"
if '-' in selector_lower:
try:
# 提取数字
parts = selector_lower.split('-')
start_part = parts[0].strip()
end_part = parts[1].strip()
# 提取起始和结束数字
start = int(''.join(filter(str.isdigit, start_part)))
end = int(''.join(filter(str.isdigit, end_part)))
return list(range(start, end + 1))
except (ValueError, IndexError):
pass
# 处理逗号分隔 "1,2,3"
if ',' in selector_lower:
try:
numbers = selector_lower.split(',')
result = []
for num in numbers:
extracted = int(''.join(filter(str.isdigit, num)))
result.append(extracted)
return sorted(set(result))
except ValueError:
pass
# 处理单个剧集 "1" 或 "episode 1"
try:
number = int(''.join(filter(str.isdigit, selector_lower)))
return [number]
except ValueError:
pass
# 无法解析
return []
@tool
def plan_episode_review(
episode_selector: str,
review_criteria: Optional[List[str]] = None,
review_dimensions: Optional[List[str]] = None,
runtime: ToolRuntime[SkillAgentContext] = None
) -> str:
"""
Plan and execute an episode review for one or more episodes.
This tool allows the agent to plan a comprehensive review of episodes.
It supports:
- Single episode review (e.g., "1", "EP1", "episode 1")
- Multiple episodes (e.g., "1,2,3", "episodes 1-3")
- All episodes (e.g., "all", "全部剧集")
- Episode ranges (e.g., "1-5", "episodes 1 through 5")
After review, results are automatically pushed to the review system.
Args:
episode_selector: Which episodes to review
- Single: "1", "EP1", "episode 1"
- Multiple: "1,2,3", "episodes 1, 2, 3"
- Range: "1-5", "episodes 1-5"
- All: "all", "全部剧集", "all episodes"
review_criteria: Specific criteria to check (optional)
Examples: ["Character consistency", "Plot logic", "Dialogue quality"]
review_dimensions: Dimensions to evaluate (optional)
Examples: ["quality", "consistency", "plot", "dialogue", "emotion"]
Returns:
Summary of the review plan and execution status
Example:
plan_episode_review(
episode_selector="1-3",
review_criteria=["Character consistency", "Plot progression"],
review_dimensions=["quality", "consistency", "plot"]
)
"""
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"
# 解析剧集选择器
episodes_to_review = _parse_episode_selector(episode_selector, runtime)
if not episodes_to_review:
return f"Error: No valid episodes found for selector: '{episode_selector}'"
# 设置默认的审查维度
if review_dimensions is None:
review_dimensions = ["quality", "consistency", "plot"]
# 设置默认的审查标准
if review_criteria is None:
review_criteria = [
"角色行为一致性",
"剧情逻辑合理性",
"对话自然度",
"场景转换流畅性",
"情感表达准确性"
]
# 存储审查计划到状态中
if "review_plans" not in runtime.state:
runtime.state["review_plans"] = []
review_plan = {
"episodes": episodes_to_review,
"criteria": review_criteria,
"dimensions": review_dimensions,
"project_id": project_id,
"status": "pending",
"created_at": None # Will be set by WebSocket handler
}
runtime.state["review_plans"].append(review_plan)
# 更新执行计划
plan_steps = [
f"解析审查请求: {len(episodes_to_review)} 集待审查",
f"获取剧集数据: EP{', EP'.join(map(str, episodes_to_review))}",
f"执行多维审查: {', '.join(review_dimensions)}",
f"生成审查报告",
f"推送到审核系统"
]
if PLAN_STATE_KEY in runtime.state:
runtime.state[PLAN_STATE_KEY] = {
"steps": plan_steps,
"current_step_index": 0,
"status": "reviewing"
}
episode_list_str = ", ".join(f"EP{ep}" for ep in episodes_to_review)
criteria_str = "\n".join(f"{c}" for c in review_criteria)
dimensions_str = ", ".join(review_dimensions)
return f"""✓ 剧集审查计划已创建
审查剧集: {episode_list_str}
审查维度: {dimensions_str}
审查标准:
{criteria_str}
执行步骤:
1. 解析审查请求: {len(episodes_to_review)} 集待审查
2. 获取剧集数据: EP{', EP'.join(map(str, episodes_to_review))}
3. 执行多维审查: {dimensions_str}
4. 生成审查报告
5. 推送到审核系统
审查将在后台执行完成后结果将自动推送到审核系统
您可以前往审核系统标签页查看详细结果"""
# 导出工具列表 # 导出工具列表
DIRECTOR_TOOLS = [ DIRECTOR_TOOLS = [
update_plan, update_plan,
@ -679,6 +1000,9 @@ DIRECTOR_TOOLS = [
request_review, request_review,
focus_episode, focus_episode,
list_episodes, list_episodes,
get_current_view, # 新增获取当前视图工具
get_episode_data, # 新增剧集数据查询工具
update_episode, update_episode,
create_episode # 新增剧集创作工具 create_episode, # 新增剧集创作工具
plan_episode_review # 新增剧集审查规划工具
] ]

View File

@ -3,8 +3,9 @@ Skills 发现和加载器
""" """
import re import re
import json
from pathlib import Path from pathlib import Path
from typing import Optional from typing import Optional, List, Dict, Any
from dataclasses import dataclass from dataclasses import dataclass
import yaml import yaml
@ -23,9 +24,31 @@ class SkillMetadata:
name: str name: str
description: str description: str
skill_path: Path skill_path: Path
requires: list[str] # 依赖的其他 skill 名称列表
id: Optional[str] = None # Skill ID用于API选择
category: Optional[str] = None # 分类
tags: List[str] = None # 标签
capabilities: Optional[str] = None # 能力描述(用于选择)
def __post_init__(self):
if self.tags is None:
self.tags = []
def to_prompt_line(self) -> str: def to_prompt_line(self) -> str:
return f"- **{self.name}**: {self.description}" requires_suffix = f" (requires: {', '.join(self.requires)})" if self.requires else ""
return f"- **{self.name}**: {self.description}{requires_suffix}"
@dataclass
class SkillReference:
"""Skill 参考文档"""
name: str
path: Path
description: str = "" # 可选:从文件第一行提取描述
def to_prompt_line(self) -> str:
desc = f" - {self.description}" if self.description else ""
return f"- `{self.name}`{desc}"
@dataclass @dataclass
@ -33,6 +56,15 @@ class SkillContent:
"""Skill 完整内容Level 2""" """Skill 完整内容Level 2"""
metadata: SkillMetadata metadata: SkillMetadata
instructions: str instructions: str
references: list[SkillReference] # 可用的参考文档列表
loaded_skills: dict[str, 'SkillContent'] # 已加载的依赖 skills {skill_name: SkillContent}
def get_reference(self, name: str) -> Optional[SkillReference]:
"""根据名称获取参考文档"""
for ref in self.references:
if ref.name == name:
return ref
return None
class SkillLoader: class SkillLoader:
@ -86,6 +118,13 @@ class SkillLoader:
frontmatter = yaml.safe_load(frontmatter_match.group(1)) frontmatter = yaml.safe_load(frontmatter_match.group(1))
name = frontmatter.get("name", "") name = frontmatter.get("name", "")
description = frontmatter.get("description", "") description = frontmatter.get("description", "")
requires = frontmatter.get("requires", [])
# 确保 requires 是列表
if isinstance(requires, str):
requires = [requires]
elif not isinstance(requires, list):
requires = []
if not name: if not name:
return None return None
@ -94,24 +133,42 @@ class SkillLoader:
name=name, name=name,
description=description, description=description,
skill_path=skill_md_path.parent, skill_path=skill_md_path.parent,
requires=requires,
) )
except yaml.YAMLError: except yaml.YAMLError:
return None return None
def load_skill(self, skill_name: str) -> Optional[SkillContent]: def load_skill(self, skill_name: str, loading: Optional[set[str]] = None) -> Optional[SkillContent]:
"""Level 2: 加载 Skill 完整内容""" """
Level 2: 加载 Skill 完整内容包含 references 列表和依赖的 skills
Args:
skill_name: 要加载的 skill 名称
loading: 正在加载的 skill 集合用于检测循环依赖
"""
if loading is None:
loading = set()
# 检测循环依赖
if skill_name in loading:
return None
loading.add(skill_name)
metadata = self._metadata_cache.get(skill_name) metadata = self._metadata_cache.get(skill_name)
if not metadata: if not metadata:
self.scan_skills() self.scan_skills()
metadata = self._metadata_cache.get(skill_name) metadata = self._metadata_cache.get(skill_name)
if not metadata: if not metadata:
loading.discard(skill_name)
return None return None
skill_md = metadata.skill_path / "SKILL.md" skill_md = metadata.skill_path / "SKILL.md"
try: try:
content = skill_md.read_text(encoding="utf-8") content = skill_md.read_text(encoding="utf-8")
except Exception: except Exception:
loading.discard(skill_name)
return None return None
body_match = re.match( body_match = re.match(
@ -121,11 +178,73 @@ class SkillLoader:
) )
instructions = body_match.group(1).strip() if body_match else content instructions = body_match.group(1).strip() if body_match else content
# 扫描 references/ 目录
references = self._scan_references(metadata.skill_path)
# 递归加载依赖的 skills
loaded_skills: dict[str, SkillContent] = {}
for required_skill in metadata.requires:
if required_skill not in loading: # 避免循环
required_content = self.load_skill(required_skill, loading)
if required_content:
loaded_skills[required_skill] = required_content
loading.discard(skill_name)
return SkillContent( return SkillContent(
metadata=metadata, metadata=metadata,
instructions=instructions, instructions=instructions,
references=references,
loaded_skills=loaded_skills,
) )
def _scan_references(self, skill_path: Path) -> list[SkillReference]:
"""扫描 skill 的 references/ 目录"""
references_dir = skill_path / "references"
if not references_dir.exists() or not references_dir.is_dir():
return []
references = []
for ref_file in sorted(references_dir.iterdir()):
if ref_file.is_file() and ref_file.suffix in ['.md', '.txt', '.json']:
# 尝试从文件第一行提取描述
description = ""
try:
first_line = ref_file.read_text(encoding="utf-8").split('\n')[0]
# 移除 Markdown 标题符号
first_line = re.sub(r'^#+\s*', '', first_line).strip()
if first_line and not first_line.startswith('#'):
description = first_line[:100] # 限制描述长度
except Exception:
pass
references.append(SkillReference(
name=ref_file.name,
path=ref_file,
description=description
))
return references
def load_reference(self, skill_name: str, reference_name: str) -> Optional[str]:
"""加载指定 skill 的 reference 文件内容"""
metadata = self._metadata_cache.get(skill_name)
if not metadata:
self.scan_skills()
metadata = self._metadata_cache.get(skill_name)
if not metadata:
return None
ref_path = metadata.skill_path / "references" / reference_name
if not ref_path.exists():
return None
try:
return ref_path.read_text(encoding="utf-8")
except Exception:
return None
def build_system_prompt(self, base_prompt: str = "") -> str: def build_system_prompt(self, base_prompt: str = "") -> str:
"""构建包含 Skills 列表的 system prompt""" """构建包含 Skills 列表的 system prompt"""
skills = self.scan_skills() skills = self.scan_skills()
@ -140,10 +259,14 @@ class SkillLoader:
skills_section += "1. **Discover**: Review the skills list above\n" skills_section += "1. **Discover**: Review the skills list above\n"
skills_section += "2. **Load**: When a user request matches a skill's description, " skills_section += "2. **Load**: When a user request matches a skill's description, "
skills_section += "use `load_skill(skill_name)` to get detailed instructions\n" skills_section += "use `load_skill(skill_name)` to get detailed instructions\n"
skills_section += "3. **Execute**: Follow the skill's instructions, which may include " skills_section += "3. **References**: If the loaded skill lists available references, "
skills_section += "use `load_reference(skill_name, reference_name)` to load specific reference material when needed\n"
skills_section += "4. **Execute**: Follow the skill's instructions, which may include "
skills_section += "running scripts via `bash`\n\n" skills_section += "running scripts via `bash`\n\n"
skills_section += "**Important**: Only load a skill when it's relevant to the user's request. " skills_section += "**Important**:\n"
skills_section += "Script code never enters the context - only their output does.\n" skills_section += "- Only load a skill when it's relevant to the user's request\n"
skills_section += "- Reference files are NOT automatically loaded - only load them when the skill instructions indicate they are needed\n"
skills_section += "- Script code never enters the context - only their output does\n"
else: else:
skills_section = "## Skills\n\nNo skills currently available.\n" skills_section = "## Skills\n\nNo skills currently available.\n"
@ -151,3 +274,109 @@ class SkillLoader:
return f"{base_prompt}\n\n{skills_section}" return f"{base_prompt}\n\n{skills_section}"
else: else:
return f"You are a helpful coding assistant.\n\n{skills_section}" return f"You are a helpful coding assistant.\n\n{skills_section}"
# ============================================
# 智能Skill选择方法
# ============================================
async def select_skills_for_task(
self,
user_intent: str,
available_skills: Optional[List[Dict[str, Any]]] = None,
context: Optional[Dict[str, Any]] = None
) -> List[str]:
"""
根据用户意图智能选择最相关的Skills
Args:
user_intent: 用户意图描述
available_skills: 可用的技能列表从API获取
context: 额外的上下文信息
Returns:
选择的Skill ID列表
"""
try:
# 如果有可用技能列表从API进行智能选择
if available_skills:
selected = []
# 基于关键词的简单匹配
intent_lower = user_intent.lower()
for skill in available_skills:
skill_id = skill.get('id', '')
name = skill.get('name', '').lower()
description = skill.get('description', '').lower()
capabilities = skill.get('capabilities', '').lower()
tags = skill.get('tags', [])
# 检查描述和能力是否匹配
match_score = 0
if name in intent_lower:
match_score += 10
for tag in tags:
if tag.lower() in intent_lower or any(k in intent_lower for k in tag.lower().split('_')):
match_score += 5
# 检查描述匹配
keywords = ['大纲', '内容', '对话', '角色', '世界观', '审核', '创作', '写作']
for keyword in keywords:
if keyword in intent_lower and keyword in description:
match_score += 3
if match_score > 0:
selected.append((skill_id, match_score))
# 按匹配分数排序
selected.sort(key=lambda x: x[1], reverse=True)
return [skill_id for skill_id, _ in selected[:5]]
# 如果没有可用技能列表,使用本地扫描的技能
skills = self.scan_skills()
selected = []
intent_lower = user_intent.lower()
for skill in skills:
match_score = 0
if skill.name.lower() in intent_lower:
match_score += 10
for tag in skill.tags:
if tag.lower() in intent_lower:
match_score += 5
if match_score > 0:
selected.append((skill.name, match_score))
selected.sort(key=lambda x: x[1], reverse=True)
return [skill_name for skill_name, _ in selected[:5]]
except Exception as e:
from app.utils.logger import get_logger
logger = get_logger(__name__)
logger.warning(f"智能Skill选择失败: {e}")
return []
def get_user_skills_metadata(self, user_skills: List[Dict[str, Any]]) -> str:
"""
获取用户配置Skills的元数据格式
Args:
user_skills: 用户Skills列表
Returns:
格式化后的Skills信息
"""
if not user_skills:
return ""
lines = ["### 用户配置的Skills\n\n"]
for skill in user_skills:
skill_name = skill.get('name', 'Unknown')
skill_id = skill.get('id', '')
behavior = skill.get('behavior', skill.get('description', ''))[:200]
lines.append(f"- **{skill_name}** (ID: `{skill_id}`)")
lines.append(f" {behavior}...")
lines.append("")
return "\n".join(lines)

View File

@ -7,6 +7,7 @@ import re
from pathlib import Path from pathlib import Path
from langchain.tools import tool, ToolRuntime from langchain.tools import tool, ToolRuntime
from typing import List, Optional, Dict, Any
from .skill_loader import SkillLoader from .skill_loader import SkillLoader
from .stream.utils import resolve_path from .stream.utils import resolve_path
@ -16,7 +17,7 @@ from .director_tools import DIRECTOR_TOOLS
@tool @tool
def load_skill(skill_name: str, runtime: ToolRuntime[SkillAgentContext]) -> str: def load_skill(skill_name: str, runtime: ToolRuntime[SkillAgentContext]) -> str:
"""Load a skill's detailed instructions.""" """Load a skill's detailed instructions. Automatically includes any dependent skills. Returns the SKILL.md instructions and a list of available references."""
loader = runtime.context.skill_loader loader = runtime.context.skill_loader
skill_content = loader.load_skill(skill_name) skill_content = loader.load_skill(skill_name)
@ -31,6 +32,31 @@ def load_skill(skill_name: str, runtime: ToolRuntime[SkillAgentContext]) -> str:
skill_path = skill_content.metadata.skill_path skill_path = skill_content.metadata.skill_path
scripts_dir = skill_path / "scripts" scripts_dir = skill_path / "scripts"
# 构建依赖 Skills 部分
dependencies_section = ""
if skill_content.loaded_skills:
dependencies_section = "\n## Dependent Skills\n\n"
dependencies_section += "This skill depends on the following skills, which have been automatically loaded:\n\n"
for dep_name, dep_content in skill_content.loaded_skills.items():
dependencies_section += f"### {dep_name}\n\n"
dependencies_section += f"{dep_content.instructions}\n\n"
# 如果依赖 skill 也有 references也列出来
if dep_content.references:
dependencies_section += f"**Available References for {dep_name}**: "
dependencies_section += ", ".join(f"`{ref.name}`" for ref in dep_content.references)
dependencies_section += "\n\n"
dependencies_section += "---\n\n"
# 构建 References 部分
references_section = ""
if skill_content.references:
references_section = "\n## Available References\n\n"
references_section += "This skill has the following reference documents available:\n\n"
for ref in skill_content.references:
references_section += f"{ref.to_prompt_line()}\n"
references_section += "\n"
references_section += "Use `load_reference(skill_name, reference_name)` to load a specific reference when needed.\n"
path_info = f""" path_info = f"""
## Skill Path Info ## Skill Path Info
@ -45,10 +71,37 @@ uv run {scripts_dir}/script_name.py [args]
return f"""# Skill: {skill_name} return f"""# Skill: {skill_name}
{dependencies_section}
## Instructions ## Instructions
{skill_content.instructions} {skill_content.instructions}
{path_info} {path_info}
{references_section}
"""
@tool
def load_reference(skill_name: str, reference_name: str, runtime: ToolRuntime[SkillAgentContext]) -> str:
"""Load a reference document from a skill's references/ directory. Use this when the skill instructions indicate that additional reference material is needed for a specific task."""
loader = runtime.context.skill_loader
content = loader.load_reference(skill_name, reference_name)
if not content:
# 检查 skill 是否存在
skill_content = loader.load_skill(skill_name)
if not skill_content:
return f"Skill '{skill_name}' not found."
# 列出可用的 references
if skill_content.references:
available = [r.name for r in skill_content.references]
return f"Reference '{reference_name}' not found in skill '{skill_name}'. Available references: {', '.join(available)}"
else:
return f"Skill '{skill_name}' has no references available."
return f"""# Reference: {skill_name}/{reference_name}
{content}
""" """
@ -290,7 +343,15 @@ def list_dir(path: str, runtime: ToolRuntime[SkillAgentContext]) -> str:
ALL_TOOLS = [ ALL_TOOLS = [
load_skill, bash, read_file, write_file, glob, grep, edit, list_dir, load_skill,
load_reference, # 按需加载 skill 的 references/ 文档
bash,
read_file,
write_file,
glob,
grep,
edit,
list_dir,
# Director Tools (includes update_plan, add_inbox_task, add_annotation, update_context, write_to_canvas) # Director Tools (includes update_plan, add_inbox_task, add_annotation, update_context, write_to_canvas)
*DIRECTOR_TOOLS *DIRECTOR_TOOLS,
] ]

View File

@ -1,5 +1,5 @@
""" """
剧集创作 Agent 剧集创作 Agent 现在暂未实现
固定工作流 固定工作流
1. 加载全局上下文和历史记忆 1. 加载全局上下文和历史记忆

View File

@ -48,6 +48,24 @@ class ReviewManager:
"default_strictness": 0.7, "default_strictness": 0.7,
"skill_ids": ["consistency_checker"] "skill_ids": ["consistency_checker"]
}, },
DimensionType.character_consistency: {
"name": "角色一致性审核",
"description": "检查角色性格、行为、对话风格的前后一致性",
"default_strictness": 0.7,
"skill_ids": ["consistency_checker"]
},
DimensionType.plot_coherence: {
"name": "剧情连贯性审核",
"description": "检查剧情逻辑、时间线、因果关系的一致性",
"default_strictness": 0.7,
"skill_ids": []
},
DimensionType.dialogue_quality: {
"name": "对话质量审核",
"description": "检查对话的自然度、角色声音的区分度",
"default_strictness": 0.6,
"skill_ids": []
},
DimensionType.quality: { DimensionType.quality: {
"name": "质量审核", "name": "质量审核",
"description": "检查文字质量、表达流畅度", "description": "检查文字质量、表达流畅度",
@ -77,6 +95,24 @@ class ReviewManager:
"description": "检查剧情逻辑和完整性", "description": "检查剧情逻辑和完整性",
"default_strictness": 0.7, "default_strictness": 0.7,
"skill_ids": [] "skill_ids": []
},
DimensionType.emotional_depth: {
"name": "情感深度审核",
"description": "检查情感表达的真实性、感染力",
"default_strictness": 0.7,
"skill_ids": []
},
DimensionType.thematic_strength: {
"name": "主题强度审核",
"description": "检查主题表达的一致性、深度",
"default_strictness": 0.7,
"skill_ids": []
},
DimensionType.custom: {
"name": "自定义审核",
"description": "用户自定义的审核维度",
"default_strictness": 0.5,
"skill_ids": []
} }
} }

View File

@ -0,0 +1,693 @@
"""
Review Task Manager
审核任务管理器 - 负责管理过程中审核和生成后审核任务
"""
import uuid
from typing import List, Optional, Dict, Any, AsyncIterator
from datetime import datetime, timedelta
from pydantic import ValidationError
from app.models.review_task import (
ReviewTask,
ReviewTaskStatus,
ReviewMode,
ReviewAction,
ProcessReviewRequest,
UserReviewActionRequest,
PostCreationReviewRequest,
PostCreationReviewResult,
ReviewTaskLocation,
ReviewTriggerSource
)
from app.models.review import ReviewConfig, DimensionType, DimensionConfig, CustomRule
from app.core.review.review_manager import ReviewManager
from app.utils.logger import get_logger
logger = get_logger(__name__)
# ============================================
# 内存存储(过程审核任务自动销毁,不需要持久化)
# ============================================
_process_tasks: Dict[str, ReviewTask] = {}
class ReviewTaskManager:
"""
审核任务管理器
职责
1. 管理过程中审核任务内存存储自动销毁
2. 管理生成后审核任务持久化到项目
3. 处理用户对审核任务的响应
4. 调用ReviewManager执行审核
"""
def __init__(self, review_manager: Optional[ReviewManager] = None):
self.review_manager = review_manager
if not self.review_manager:
from app.core.review.review_manager import get_review_manager
self.review_manager = get_review_manager()
logger.info("审核任务管理器初始化完成")
# ============================================
# 过程中审核(内存存储,自动销毁)
# ============================================
async def create_process_review_task(
self,
request: ProcessReviewRequest
) -> ReviewTask:
"""
创建过程中审核任务
过程中审核任务存储在内存中用户确认后自动销毁
Args:
request: 审核请求
Returns:
ReviewTask: 创建的审核任务
"""
task_id = str(uuid.uuid4())
# 根据触发来源确定可用操作
if request.trigger_source == ReviewTriggerSource.AGENT_AUTO:
# Agent自主推送的审核确认继续、补充信息、拒绝
available_actions = [
ReviewAction.CONFIRM,
ReviewAction.MODIFY,
ReviewAction.OTHER
]
else:
# 其他来源:标准操作
available_actions = [
ReviewAction.CONFIRM,
ReviewAction.IGNORE
]
task = ReviewTask(
id=task_id,
project_id=request.project_id,
mode=ReviewMode.PROCESS,
trigger_source=request.trigger_source,
issue_type=request.issue_type,
issue_description=request.issue_description,
severity=request.severity,
location=request.location,
review_config_id=request.review_config_id,
skill_id=request.skill_id,
rule_id=request.rule_id,
available_actions=available_actions,
status=ReviewTaskStatus.AWAITING_USER,
suggestion=request.suggestion,
context_data={
"trigger_source": request.trigger_source.value,
"skill_id": request.skill_id,
"rule_id": request.rule_id
}
)
# 存储到内存
_process_tasks[task_id] = task
logger.info(
f"创建过程中审核任务: {task_id}, "
f"问题: {request.issue_type}, 严重度: {request.severity}"
)
return task
async def handle_user_review_action(
self,
request: UserReviewActionRequest
) -> ReviewTask:
"""
处理用户对审核任务的响应
Args:
request: 用户操作请求
Returns:
ReviewTask: 更新后的任务
"""
task = _process_tasks.get(request.task_id)
if not task:
logger.warning(f"审核任务不存在: {request.task_id}")
raise ValueError(f"审核任务不存在: {request.task_id}")
# 验证操作是否可用
if request.action not in task.available_actions:
logger.warning(
f"操作 {request.action} 对任务 {request.task_id} 不可用, "
f"可用操作: {[a.value for a in task.available_actions]}"
)
raise ValueError(f"操作不可用")
# 更新任务状态
if request.action == ReviewAction.CONFIRM:
task.status = ReviewTaskStatus.COMPLETED
task.user_response = "用户确认继续"
task.completed_at = datetime.now()
elif request.action == ReviewAction.MODIFY:
task.status = ReviewTaskStatus.MODIFIED
task.user_response = request.user_response or "用户修改了内容"
task.completed_at = datetime.now()
# 保存修改后的内容到上下文
if request.modified_content:
task.context_data = task.context_data or {}
task.context_data["modified_content"] = request.modified_content
elif request.action == ReviewAction.OTHER:
task.status = ReviewTaskStatus.COMPLETED
task.user_response = request.user_response or "用户补充了信息"
task.completed_at = datetime.now()
elif request.action == ReviewAction.IGNORE:
task.status = ReviewTaskStatus.IGNORED
task.user_response = "用户忽略问题"
task.completed_at = datetime.now()
logger.info(
f"处理用户操作: {request.action.value}, "
f"任务: {task_id}, 新状态: {task.status.value}"
)
# 从内存中移除已完成/忽略的任务
if task.status in [ReviewTaskStatus.COMPLETED, ReviewTaskStatus.IGNORED]:
_process_tasks.pop(request.task_id, None)
logger.info(f"过程审核任务已销毁: {request.task_id}")
return task
def get_pending_process_tasks(
self,
project_id: str
) -> List[ReviewTask]:
"""
获取项目所有待处理的过程审核任务
Args:
project_id: 项目ID
Returns:
List[ReviewTask]: 待处理任务列表
"""
return [
task for task in _process_tasks.values()
if task.project_id == project_id and
task.status == ReviewTaskStatus.AWAITING_USER
]
# ============================================
# 生成后审核(持久化到项目)
# ============================================
async def execute_post_creation_review(
self,
request: PostCreationReviewRequest,
project_config: ReviewConfig
) -> PostCreationReviewResult:
"""
执行生成后审核
根据用户配置的规则/Skill进行自动检查根据阈值报告问题
Args:
request: 生成后审核请求
project_config: 项目审核配置
Returns:
PostCreationReviewResult: 审核结果
"""
from app.models.project import SeriesProject, Episode
# TODO: 从项目获取剧集
# 这里简化处理实际需要从episode_repo获取
episode = Episode(
projectId=request.project_id,
id=request.episode_id,
number=1,
content="",
status="completed"
)
project = SeriesProject(
id=request.project_id,
name="",
globalContext=None
)
tasks = []
high_severity_count = 0
# 1. 使用配置的Skill进行审核
if request.use_configured_skills and project_config.enabled_review_skills:
for skill_id in project_config.enabled_review_skills:
skill_tasks = await self._execute_skill_review(
project=project,
episode=episode,
skill_id=skill_id,
project_config=project_config
)
tasks.extend(skill_tasks)
high_severity_count += sum(1 for t in skill_tasks if t.severity == "high")
# 2. 使用配置的规则进行审核
if request.use_configured_rules and project_config.custom_rules:
for rule in project_config.custom_rules:
if rule.enabled:
rule_tasks = await self._execute_rule_review(
project=project,
episode=episode,
rule=rule,
project_config=project_config
)
tasks.extend(rule_tasks)
high_severity_count += sum(1 for t in rule_tasks if t.severity == "high")
# 3. 根据阈值判断是否通过
pass_threshold = request.pass_threshold or project_config.pass_threshold
passed = high_severity_count == 0
# 4. 生成总体分数
if tasks:
overall_score = max(0, 100 - len(tasks) * 5 - high_severity_count * 10)
else:
overall_score = 100.0
result = PostCreationReviewResult(
task_count=len(tasks),
passed=passed,
tasks=tasks,
overall_score=overall_score
)
logger.info(
f"生成后审核完成: {request.project_id}, "
f"任务数: {len(tasks)}, 高严重度: {high_severity_count}, "
f"通过: {passed}, 分数: {overall_score}"
)
return result
async def _execute_skill_review(
self,
project,
episode,
skill_id: str,
project_config: ReviewConfig
) -> List[ReviewTask]:
"""
执行Skill审核
Args:
project: 项目
episode: 剧集
skill_id: Skill ID
project_config: 审核配置
Returns:
List[ReviewTask]: 发现的审核任务
"""
tasks = []
try:
# 获取Skill信息
from app.core.skills.skill_manager import get_skill_manager
skill_manager = get_skill_manager()
skill = skill_manager.get_skill_by_id(skill_id)
if not skill:
logger.warning(f"Skill不存在: {skill_id}")
return tasks
# 使用ReviewManager的LLM审核
from app.models.review import Issue, IssueType, SeverityLevel, Location
issues = await self.review_manager._run_llm_review(
project=project,
episode=episode,
dimension=DimensionType.custom, # 使用custom维度代表Skill审核
strictness=0.7
)
# 转换为ReviewTask
for issue in issues:
task = ReviewTask(
id=str(uuid.uuid4()),
project_id=project.id,
episode_id=episode.id,
mode=ReviewMode.POST_CREATION,
trigger_source=ReviewTriggerSource.USER_CONFIGURED_SKILL,
issue_type=issue.type.value,
issue_description=issue.description,
severity=issue.severity.value,
location=ReviewTaskLocation(
episode_number=issue.location.episode,
scene_number=issue.location.scene,
line_number=issue.location.line,
content_snippet=issue.location.context
),
skill_id=skill_id,
available_actions=[ReviewAction.CONFIRM, ReviewAction.IGNORE, ReviewAction.MODIFY],
status=ReviewTaskStatus.AWAITING_USER,
suggestion=issue.suggestion
)
tasks.append(task)
return tasks
except Exception as e:
logger.error(f"Skill审核失败 {skill_id}: {str(e)}")
return tasks
async def _execute_rule_review(
self,
project,
episode,
rule,
project_config: ReviewConfig
) -> List[ReviewTask]:
"""
执行规则审核
Args:
project: 项目
episode: 剧集
rule: 自定义规则
project_config: 审核配置
Returns:
List[ReviewTask]: 发现的审核任务
"""
tasks = []
try:
# 调用ReviewManager的规则检查
issues = await self.review_manager._check_single_rule(
episode=episode,
rule=rule,
config=project_config
)
# 转换为ReviewTask
for issue in issues:
task = ReviewTask(
id=str(uuid.uuid4()),
project_id=project.id,
episode_id=episode.id,
mode=ReviewMode.POST_CREATION,
trigger_source=ReviewTriggerSource.USER_CONFIGURED_RULE,
issue_type=issue.type.value,
issue_description=issue.description,
severity=issue.severity.value,
location=ReviewTaskLocation(
episode_number=issue.location.episode,
scene_number=issue.location.scene,
line_number=issue.location.line,
content_snippet=issue.location.context
),
rule_id=rule.id,
available_actions=[ReviewAction.CONFIRM, ReviewAction.IGNORE, ReviewAction.MODIFY],
status=ReviewTaskStatus.AWAITING_USER,
suggestion=issue.suggestion
)
tasks.append(task)
return tasks
except Exception as e:
logger.error(f"规则审核失败 {rule.id}: {str(e)}")
return tasks
def get_process_task_by_id(
self,
task_id: str
) -> Optional[ReviewTask]:
"""获取过程中审核任务"""
return _process_tasks.get(task_id)
def cleanup_expired_tasks(self):
"""清理已过期的任务超过24小时未处理"""
now = datetime.now()
expired_ids = []
for task_id, task in _process_tasks.items():
if task.status == ReviewTaskStatus.AWAITING_USER:
# 检查是否创建超过24小时
if (now - task.created_at).total_seconds() > 86400:
expired_ids.append(task_id)
for task_id in expired_ids:
_process_tasks.pop(task_id, None)
logger.info(f"清理过期审核任务: {task_id}")
if expired_ids:
return len(expired_ids)
return 0
async def create_task_from_review(
self,
project_id: str,
episode_number: int,
review_data: Dict[str, Any]
) -> ReviewTask:
"""
从审查结果创建审核任务
Agent 完成剧集审查后将审查结果转换为审核任务并推送到审核系统
Args:
project_id: 项目ID
episode_number: 剧集号
review_data: 审查数据包含 score, passed, issue_count, issues 等信息
Returns:
ReviewTask: 创建的审核任务
"""
task_id = str(uuid.uuid4())
# 根据 issue_count 判断是否创建任务
issue_count = review_data.get("issue_count", 0)
issues = review_data.get("issues", [])
# 创建任务位置信息
location = ReviewTaskLocation(
episode_number=episode_number,
scene_number=None,
line_number=None,
content_snippet=f"EP{episode_number} 审查发现 {issue_count} 个问题"
)
# 如果有问题,创建待处理的审核任务
if issue_count > 0:
# 找出最高严重度的问题
high_severity_issues = [i for i in issues if i.get("severity") == "high"]
medium_severity_issues = [i for i in issues if i.get("severity") == "medium"]
# 确定总体严重度
if high_severity_issues:
severity = "high"
elif medium_severity_issues:
severity = "medium"
else:
severity = "low"
# 构建问题描述
issue_types = set(i.get("dimension", "unknown") for i in issues)
issue_description = f"EP{episode_number} 审查发现 {issue_count} 个问题,涉及维度: {', '.join(issue_types)}"
# 列出主要问题
main_issues = []
for issue in issues[:5]: # 只列出前5个
main_issues.append(f"• [{issue.get('dimension', 'unknown')}] {issue.get('description', '')[:50]}")
if main_issues:
issue_description += "\n主要问题:\n" + "\n".join(main_issues)
# 创建审核任务
task = ReviewTask(
id=task_id,
project_id=project_id,
episode_id=f"ep_{episode_number}", # 剧集ID简化
mode=ReviewMode.POST_CREATION,
trigger_source=ReviewTriggerSource.USER_CONFIGURED_RULE, # Agent主动审查
issue_type="episode_review",
issue_description=issue_description,
severity=severity,
location=location,
available_actions=[
ReviewAction.CONFIRM, # 确认问题
ReviewAction.MODIFY, # 修改内容
ReviewAction.IGNORE # 忽略问题
],
status=ReviewTaskStatus.AWAITING_USER,
suggestion=f"建议检查并修复 EP{episode_number} 中发现的问题",
context_data={
"episode_number": episode_number,
"episode_title": review_data.get("episode_title", ""),
"score": review_data.get("score", 0),
"issue_count": issue_count,
"issues": issues,
"trigger_source": "agent_review",
"created_at": datetime.now().isoformat()
}
)
# 存储到内存(生成后审核任务)
_process_tasks[task_id] = task
logger.info(
f"从审查结果创建审核任务: {task_id}, "
f"EP{episode_number}, 问题数: {issue_count}, 严重度: {severity}"
)
return task
else:
# 没有问题,返回一个已确认的任务表示审查通过
task = ReviewTask(
id=task_id,
project_id=project_id,
episode_id=f"ep_{episode_number}",
mode=ReviewMode.POST_CREATION,
trigger_source=ReviewTriggerSource.USER_CONFIGURED_RULE,
issue_type="episode_review",
issue_description=f"EP{episode_number} 审查通过,未发现问题",
severity="low",
location=location,
available_actions=[ReviewAction.CONFIRM],
status=ReviewTaskStatus.COMPLETED, # 直接标记为已完成
context_data={
"episode_number": episode_number,
"episode_title": review_data.get("episode_title", ""),
"score": review_data.get("score", 100),
"issue_count": 0,
"trigger_source": "agent_review",
"created_at": datetime.now().isoformat()
}
)
logger.info(f"EP{episode_number} 审查通过,无问题")
return task
# ============================================
# 流式审核助手 - 支持Agent在生成过程中推送审核
# ============================================
class StreamReviewHelper:
"""
流式审核助手
帮助Agent在流式输出过程中推送审核任务
提供阻塞式等待用户确认的机制
"""
def __init__(self, project_id: str, task_manager: ReviewTaskManager):
self.project_id = project_id
self.task_manager = task_manager
self.pending_tasks: List[str] = []
self.user_responses: Dict[str, str] = {}
async def push_review_task(
self,
issue_type: str,
issue_description: str,
severity: str = "medium",
location: Optional[ReviewTaskLocation] = None,
skill_id: Optional[str] = None,
suggestion: Optional[str] = None
) -> ReviewTask:
"""
推送审核任务由Agent调用
阻塞式等待用户确认后才能继续
Args:
issue_type: 问题类型
issue_description: 问题描述
severity: 严重程度
location: 位置信息
skill_id: 使用的Skill ID
suggestion: 修改建议
Returns:
ReviewTask: 创建的审核任务
"""
request = ProcessReviewRequest(
project_id=self.project_id,
issue_type=issue_type,
issue_description=issue_description,
severity=severity,
location=location or ReviewTaskLocation(),
trigger_source=ReviewTriggerSource.AGENT_AUTO,
skill_id=skill_id,
suggestion=suggestion
)
task = await self.task_manager.create_process_review_task(request)
self.pending_tasks.append(task.id)
logger.info(f"Agent推送审核任务: {task.id}")
return task
async def wait_for_user_response(
self,
task_id: str,
timeout: int = 300 # 5分钟超时
) -> Optional[str]:
"""
等待用户响应阻塞式
Args:
task_id: 任务ID
timeout: 超时时间
Returns:
Optional[str]: 用户响应内容超时返回None
"""
# 检查用户是否已响应
if task_id in self.user_responses:
return self.user_responses[task_id]
# 这里简化处理 - 实际应该通过WebSocket通知前端并等待响应
# 返回None表示等待中
return None
def record_user_response(self, task_id: str, response: str):
"""记录用户响应"""
self.user_responses[task_id] = response
if task_id in self.pending_tasks:
self.pending_tasks.remove(task_id)
logger.info(f"用户响应已记录: {task_id}, 响应: {response}")
def has_pending_tasks(self) -> bool:
"""是否有待处理的审核任务"""
return len(self.pending_tasks) > 0
# ============================================
# 全局单例
# ============================================
_review_task_manager: Optional[ReviewTaskManager] = None
def get_review_task_manager() -> ReviewTaskManager:
"""获取审核任务管理器单例"""
global _review_task_manager
if _review_task_manager is None:
_review_task_manager = ReviewTaskManager()
return _review_task_manager
review_task_manager = get_review_task_manager()

View File

@ -0,0 +1,191 @@
"""
Confirm Card Repository
确认卡片持久化仓储 - JSON文件存储
"""
from pathlib import Path
from typing import List, Optional
import json
import uuid
from datetime import datetime
from app.models.confirm_card import (
ConfirmCard, ConfirmCardStatus, ConfirmCardOption
)
from app.utils.logger import get_logger
logger = get_logger(__name__)
# 数据文件路径
DATA_DIR = Path(__file__).parent.parent.parent / "data"
CONFIRM_CARDS_FILE = DATA_DIR / "confirm_cards.json"
class ConfirmCardRepository:
"""确认卡片仓储"""
def __init__(self):
self.file_path = CONFIRM_CARDS_FILE
self._cards: Dict[str, ConfirmCard] = {}
self._ensure_data_dir()
self._load()
def _ensure_data_dir(self):
"""确保数据目录存在"""
self.file_path.parent.mkdir(parents=True, exist_ok=True)
def _load(self):
"""从文件加载"""
if self.file_path.exists():
try:
content = self.file_path.read_text(encoding="utf-8")
if content.strip():
data = json.loads(content)
for card_id, card_data in data.items():
# 转换选项列表
if "options" in card_data and isinstance(card_data["options"], list):
card_data["options"] = [
ConfirmCardOption(**opt) for opt in card_data["options"]
]
# 确保datetime正确转换
if isinstance(card_data.get("created_at"), str):
card_data["created_at"] = datetime.fromisoformat(
card_data["created_at"].replace('Z', '+00:00')
)
if isinstance(card_data.get("confirmed_at"), str):
card_data["confirmed_at"] = datetime.fromisoformat(
card_data["confirmed_at"].replace('Z', '+00:00')
)
if isinstance(card_data.get("expires_at"), str):
card_data["expires_at"] = datetime.fromisoformat(
card_data["expires_at"].replace('Z', '+00:00')
)
self._cards[card_id] = ConfirmCard(**card_data)
except Exception as e:
logger.error(f"加载确认卡片失败: {e}")
def _save(self):
"""保存到文件"""
try:
data = {}
for card_id, card in self._cards.items():
card_dict = card.dict()
# 确保datetime正确序列化
if isinstance(card_dict.get("created_at"), datetime):
card_dict["created_at"] = card_dict["created_at"].isoformat()
if isinstance(card_dict.get("confirmed_at"), datetime):
card_dict["confirmed_at"] = card_dict["confirmed_at"].isoformat()
if isinstance(card_dict.get("expires_at"), datetime):
card_dict["expires_at"] = card_dict["expires_at"].isoformat()
data[card_id] = card_dict
self.file_path.write_text(
json.dumps(data, ensure_ascii=False, indent=2),
encoding="utf-8"
)
except Exception as e:
logger.error(f"保存确认卡片失败: {e}")
raise
async def create(self, card: ConfirmCard) -> ConfirmCard:
"""创建确认卡片"""
if not card.id:
card.id = str(uuid.uuid4())
self._cards[card.id] = card
self._save()
logger.info(f"创建确认卡片: {card.id}")
return card
async def get(self, card_id: str) -> Optional[ConfirmCard]:
"""获取单个卡片"""
return self._cards.get(card_id)
async def list_by_project(
self,
project_id: str,
status: Optional[ConfirmCardStatus] = None,
include_expired: bool = False
) -> List[ConfirmCard]:
"""列出项目的确认卡片"""
cards = [
card for card in self._cards.values()
if card.project_id == project_id
]
if status:
cards = [c for c in cards if c.status == status]
if not include_expired:
now = datetime.now()
cards = [c for c in cards if not c.expires_at or c.expires_at > now]
# 按创建时间倒序
cards = sorted(cards, key=lambda c: c.created_at, reverse=True)
return cards
async def update_status(
self,
card_id: str,
status: ConfirmCardStatus,
selected_option_id: Optional[str] = None,
custom_response: Optional[str] = None,
user_notes: Optional[str] = None
) -> Optional[ConfirmCard]:
"""更新确认卡片状态"""
card = self._cards.get(card_id)
if not card:
return None
card.status = status
card.selected_option_id = selected_option_id
card.custom_response = custom_response
card.user_notes = user_notes
card.confirmed_at = datetime.now()
self._cards[card_id] = card
self._save()
logger.info(f"更新确认卡片状态: {card_id} -> {status}")
return card
async def delete(self, card_id: str) -> bool:
"""删除确认卡片"""
if card_id in self._cards:
del self._cards[card_id]
self._save()
return True
return False
async def cleanup_expired(self) -> int:
"""清理过期的确认卡片"""
now = datetime.now()
expired_ids = [
card_id for card_id, card in self._cards.items()
if card.expires_at and card.expires_at < now
]
for card_id in expired_ids:
self._cards[card_id].status = ConfirmCardStatus.EXPIRED
if expired_ids:
self._save()
logger.info(f"标记 {len(expired_ids)} 个确认卡片为过期")
return len(expired_ids)
# 全局单例
_confirm_card_repo = None
def get_confirm_card_repo() -> ConfirmCardRepository:
"""获取确认卡片仓储单例"""
global _confirm_card_repo
if _confirm_card_repo is None:
_confirm_card_repo = ConfirmCardRepository()
return _confirm_card_repo
# 导入Dict类型
from typing import Dict

View File

@ -299,4 +299,11 @@ class MessageRepository(JsonRepository):
"""获取项目聊天历史""" """获取项目聊天历史"""
return self._data.get(project_id, []) return self._data.get(project_id, [])
async def clear_history(self, project_id: str):
"""清除项目聊天历史(用于重建对话)"""
if project_id in self._data:
self._data[project_id] = []
self._save()
logger.info(f"Cleared chat history for project {project_id}")
message_repo = MessageRepository() message_repo = MessageRepository()

View File

@ -0,0 +1,180 @@
"""
Review Card Repository
审核卡片持久化仓储 - JSON文件存储
"""
from pathlib import Path
from typing import List, Optional
import json
import uuid
from datetime import datetime
from app.models.review_card import (
ReviewCard, ReviewCardCreate, ReviewCardUpdate, ReviewCardStatus
)
from app.utils.logger import get_logger
logger = get_logger(__name__)
# 数据文件路径
DATA_DIR = Path(__file__).parent.parent.parent / "data"
REVIEW_CARDS_FILE = DATA_DIR / "review_cards.json"
class ReviewCardRepository:
"""审核卡片仓储 - JSON文件持久化"""
def __init__(self):
self.file_path = REVIEW_CARDS_FILE
self._cards: Dict[str, ReviewCard] = {}
self._ensure_data_dir()
self._load()
def _ensure_data_dir(self):
"""确保数据目录存在"""
self.file_path.parent.mkdir(parents=True, exist_ok=True)
def _load(self):
"""从文件加载"""
if self.file_path.exists():
try:
content = self.file_path.read_text(encoding="utf-8")
if content.strip():
data = json.loads(content)
for card_id, card_data in data.items():
# 确保created_at和updated_at是datetime对象
if isinstance(card_data.get("created_at"), str):
card_data["created_at"] = datetime.fromisoformat(
card_data["created_at"].replace('Z', '+00:00')
)
if isinstance(card_data.get("updated_at"), str):
card_data["updated_at"] = datetime.fromisoformat(
card_data["updated_at"].replace('Z', '+00:00')
)
if isinstance(card_data.get("completed_at"), str):
card_data["completed_at"] = datetime.fromisoformat(
card_data["completed_at"].replace('Z', '+00:00')
)
self._cards[card_id] = ReviewCard(**card_data)
except Exception as e:
logger.error(f"加载审核卡片失败: {e}")
self._cards = {}
def _save(self):
"""保存到文件"""
try:
data = {}
for card_id, card in self._cards.items():
card_dict = card.dict()
# 确保datetime正确序列化
if isinstance(card_dict.get("created_at"), datetime):
card_dict["created_at"] = card_dict["created_at"].isoformat()
if isinstance(card_dict.get("updated_at"), datetime):
card_dict["updated_at"] = card_dict["updated_at"].isoformat()
if isinstance(card_dict.get("completed_at"), datetime):
card_dict["completed_at"] = card_dict["completed_at"].isoformat()
data[card_id] = card_dict
self.file_path.write_text(
json.dumps(data, ensure_ascii=False, indent=2),
encoding="utf-8"
)
except Exception as e:
logger.error(f"保存审核卡片失败: {e}")
raise
async def create(self, data: ReviewCardCreate) -> ReviewCard:
"""创建审核卡片"""
card_id = str(uuid.uuid4())
card = ReviewCard(
id=card_id,
**data.dict(),
created_at=datetime.now(),
updated_at=datetime.now()
)
self._cards[card_id] = card
self._save()
logger.info(f"创建审核卡片: {card_id}")
return card
async def get(self, card_id: str) -> Optional[ReviewCard]:
"""获取单个卡片"""
return self._cards.get(card_id)
async def list_by_project(
self,
project_id: str,
status: Optional[ReviewCardStatus] = None,
limit: int = 100
) -> List[ReviewCard]:
"""列出项目的审核卡片"""
cards = [
card for card in self._cards.values()
if card.project_id == project_id
]
if status:
cards = [c for c in cards if c.status == status]
# 按创建时间倒序
cards = sorted(cards, key=lambda c: c.created_at, reverse=True)
return cards[:limit]
async def update(self, card_id: str, data: ReviewCardUpdate) -> Optional[ReviewCard]:
"""更新审核卡片"""
card = self._cards.get(card_id)
if not card:
return None
# 更新字段
update_data = data.dict(exclude_unset=True, exclude_none=True)
for field, value in update_data.items():
setattr(card, field, value)
card.updated_at = datetime.now()
if data.status == ReviewCardStatus.COMPLETED:
card.completed_at = datetime.now()
self._cards[card_id] = card
self._save()
logger.info(f"更新审核卡片: {card_id}")
return card
async def delete(self, card_id: str) -> bool:
"""删除审核卡片"""
if card_id in self._cards:
del self._cards[card_id]
self._save()
return True
return False
async def count_by_project(self, project_id: str) -> int:
"""统计项目的审核卡片数量"""
return sum(1 for card in self._cards.values() if card.project_id == project_id)
async def count_by_status(self, project_id: str, status: ReviewCardStatus) -> int:
"""统计项目特定状态的审核卡片数量"""
return sum(
1 for card in self._cards.values()
if card.project_id == project_id and card.status == status
)
# 全局单例
_review_card_repo = None
def get_review_card_repo() -> ReviewCardRepository:
"""获取审核卡片仓储单例"""
global _review_card_repo
if _review_card_repo is None:
_review_card_repo = ReviewCardRepository()
return _review_card_repo
# 导入Dict类型
from typing import Dict

View File

@ -41,8 +41,11 @@ async def lifespan(app: FastAPI):
# 初始化审核管理器 # 初始化审核管理器
from app.core.review.review_manager import get_review_manager from app.core.review.review_manager import get_review_manager
from app.core.review.review_task_manager import get_review_task_manager
review_mgr = get_review_manager() review_mgr = get_review_manager()
review_task_mgr = get_review_task_manager()
logger.info(f"✅ 审核管理器初始化完成") logger.info(f"✅ 审核管理器初始化完成")
logger.info(f"✅ 审核任务管理器初始化完成")
# 初始化批量执行器 # 初始化批量执行器
from app.core.execution.batch_executor import get_batch_executor from app.core.execution.batch_executor import get_batch_executor
@ -108,7 +111,11 @@ async def health_check():
# ============================================ # ============================================
# API 路由注册 # API 路由注册
# ============================================ # ============================================
from app.api.v1 import skills, projects, ai_assistant, memory, review, websocket, uploads, episodes, async_tasks, ai_async, skills_async from app.api.v1 import (
skills, projects, ai_assistant, memory, review, review_task,
review_cards, confirm_cards, websocket, uploads, episodes,
async_tasks, ai_async, skills_async
)
app.include_router(skills.router, prefix="/api/v1") app.include_router(skills.router, prefix="/api/v1")
app.include_router(skills_async.router, prefix="/api/v1") app.include_router(skills_async.router, prefix="/api/v1")
@ -119,6 +126,9 @@ app.include_router(async_tasks.router, prefix="/api/v1")
app.include_router(ai_async.router, prefix="/api/v1") app.include_router(ai_async.router, prefix="/api/v1")
app.include_router(memory.router, prefix="/api/v1") app.include_router(memory.router, prefix="/api/v1")
app.include_router(review.router, prefix="/api/v1") app.include_router(review.router, prefix="/api/v1")
app.include_router(review_task.router, prefix="/api/v1")
app.include_router(review_cards.router, prefix="/api/v1")
app.include_router(confirm_cards.router, prefix="/api/v1")
app.include_router(uploads.router, prefix="/api/v1") app.include_router(uploads.router, prefix="/api/v1")
app.include_router(websocket.router) # WebSocket router doesn't need prefix app.include_router(websocket.router) # WebSocket router doesn't need prefix

View File

@ -0,0 +1,79 @@
"""
Confirm Card Model
确认卡片模型 - Agent推送的用户确认任务
特点
- 用户可以中途退出后随时确认
- 支持多个选项
- 持久化存储
- Agent可以根据用户选择继续任务
"""
from pydantic import BaseModel, Field
from typing import List, Optional, Dict, Any
from datetime import datetime
from enum import Enum
class ConfirmCardType(str, Enum):
"""确认卡片类型"""
DIRECTION_DECISION = "direction_decision" # 创作方向决策
PLOT_BRANCH = "plot_branch" # 剧情分支选择
CHARACTER_ACTION = "character_action" # 角色行为确认
CONTENT_APPROVAL = "content_approval" # 内容审批
RESOURCE_ALLOCATION = "resource_allocation" # 资源分配确认
CUSTOM_QUESTION = "custom_question" # 自定义问题
class ConfirmCardStatus(str, Enum):
"""确认卡片状态"""
PENDING = "pending" # 待确认
APPROVED = "approved" # 已批准
REJECTED = "rejected" # 已拒绝
MODIFIED = "modified" # 已修改建议
EXPIRED = "expired" # 已过期
class ConfirmCardOption(BaseModel):
"""确认选项"""
id: str = Field(..., description="选项ID")
label: str = Field(..., description="选项标签")
description: str = Field("", description="选项描述")
implications: Optional[str] = Field(None, description="选择此选项的影响")
class ConfirmCard(BaseModel):
"""
确认卡片 - Agent推送的用户确认任务
"""
# 基础信息
id: str = Field(..., description="卡片ID")
project_id: str = Field(..., description="项目ID")
episode_number: Optional[int] = Field(None, description="相关集数")
# 卡片类型
card_type: ConfirmCardType = Field(..., description="确认类型")
title: str = Field(..., description="确认标题")
description: str = Field(..., description="详细描述")
context_data: Optional[str] = Field(None, description="上下文信息(长文本)")
# 确认选项
options: List[ConfirmCardOption] = Field(default_factory=list, description="可选选项")
allow_custom_response: bool = Field(False, description="是否允许自定义回复")
# 用户响应
status: ConfirmCardStatus = Field(default=ConfirmCardStatus.PENDING, description="状态")
selected_option_id: Optional[str] = Field(None, description="选择的选项ID")
custom_response: Optional[str] = Field(None, description="自定义回复内容")
user_notes: Optional[str] = Field(None, description="用户备注")
# 时间戳
created_at: datetime = Field(default_factory=datetime.now, description="创建时间")
expires_at: Optional[datetime] = Field(None, description="过期时间(可选)")
confirmed_at: Optional[datetime] = Field(None, description="确认时间")
# 元数据
metadata: Dict[str, Any] = Field(default_factory=dict, description="附加信息")
class Config:
use_enum_values = False

View File

@ -23,11 +23,16 @@ class SeverityLevel(str, Enum):
class DimensionType(str, Enum): class DimensionType(str, Enum):
"""审核维度类型""" """审核维度类型"""
consistency = "consistency" # 一致性审核 consistency = "consistency" # 一致性审核
character_consistency = "character_consistency" # 角色一致性审核
quality = "quality" # 质量审核 quality = "quality" # 质量审核
pacing = "pacing" # 节奏审核 pacing = "pacing" # 节奏审核
dialogue = "dialogue" # 对话审核 dialogue = "dialogue" # 对话审核
dialogue_quality = "dialogue_quality" # 对话质量审核
character = "character" # 人物审核 character = "character" # 人物审核
plot = "plot" # 剧情审核 plot = "plot" # 剧情审核
plot_coherence = "plot_coherence" # 剧情连贯性审核
emotional_depth = "emotional_depth" # 情感深度审核
thematic_strength = "thematic_strength" # 主题强度审核
custom = "custom" # 自定义审核 custom = "custom" # 自定义审核
@ -181,6 +186,11 @@ class ReviewConfigUpdate(BaseModel):
auto_fix_enabled: Optional[bool] = None auto_fix_enabled: Optional[bool] = None
pass_threshold: Optional[float] = Field(None, ge=0.0, le=100.0) pass_threshold: Optional[float] = Field(None, ge=0.0, le=100.0)
# 前端兼容字段 - 使用 Dict[str, Any] 避免前向引用问题
preset: Optional[str] = Field(None, description="预设模式: draft|standard|strict|custom")
dimensions: Optional[Dict[str, Any]] = Field(None, description="前端维度配置格式")
customRules: Optional[List[Dict[str, Any]]] = Field(None, description="前端自定义规则格式")
class ReviewRequest(BaseModel): class ReviewRequest(BaseModel):
"""执行审核请求""" """执行审核请求"""
@ -211,8 +221,9 @@ class CustomRuleCreate(BaseModel):
name: str = Field(..., description="规则名称") name: str = Field(..., description="规则名称")
description: str = Field("", description="规则描述") description: str = Field("", description="规则描述")
trigger_condition: str = Field(..., description="触发条件") trigger_condition: str = Field(..., description="触发条件")
dimension: DimensionType = Field(..., description="所属维度") dimension: Optional[DimensionType] = Field(None, description="所属维度")
severity: SeverityLevel = Field(SeverityLevel.medium, description="严重程度") severity: SeverityLevel = Field(SeverityLevel.medium, description="严重程度")
category: Optional[str] = Field(None, description="分类(前端使用)")
class CustomRuleUpdate(BaseModel): class CustomRuleUpdate(BaseModel):
@ -223,6 +234,7 @@ class CustomRuleUpdate(BaseModel):
dimension: Optional[DimensionType] = None dimension: Optional[DimensionType] = None
severity: Optional[SeverityLevel] = None severity: Optional[SeverityLevel] = None
enabled: Optional[bool] = None enabled: Optional[bool] = None
category: Optional[str] = None
class ReviewPreset(BaseModel): class ReviewPreset(BaseModel):

View File

@ -0,0 +1,103 @@
"""
Review Card Model
审核卡片模型 - 持久化存储的审核任务卡片
与ReviewTask的关系
- ReviewCard是用户可见的卡片包含审核任务的所有信息
- ReviewTask是系统内部的审核任务可能不持久化
- 一个ReviewCard可能包含多个ReviewTask如多集审核
- 过程中审核完成后转换为ReviewCard持久化存储
"""
from pydantic import BaseModel, Field
from typing import Dict, List, Optional, Any
from datetime import datetime
from enum import Enum
class ReviewCardType(str, Enum):
"""审核卡片类型"""
PROCESS_AUTO = "process_auto" # 过程中Agent自主识别
POST_CREATION_AUTO = "post_creation_auto" # 生成后自动审核
USER_REQUEST_EPISODE = "user_episode" # 用户请求单集审核
USER_REQUEST_MULTIPLE = "user_multiple" # 用户请求多集审核
USER_REQUEST_ALL = "user_all" # 用户请求全集审核
class ReviewCardStatus(str, Enum):
"""审核卡片状态"""
PENDING = "pending" # 待处理
AWAITING_USER = "awaiting_user" # 待确认
APPROVED = "approved" # 已通过
REJECTED = "rejected" # 已驳回
class ReviewCard(BaseModel):
"""
审核卡片 - 持久化存储
"""
# 基础信息
id: str = Field(..., description="卡片ID")
project_id: str = Field(..., description="项目ID")
episode_id: Optional[str] = Field(None, description="剧集ID单集审核")
episode_numbers: List[int] = Field(default_factory=list, description="涉及集数列表(多集审核)")
# 卡片类型和来源
card_type: ReviewCardType = Field(..., description="卡片类型")
trigger_source: str = Field(..., description="触发来源: agent_auto/user_configured/manual")
# 审核内容
episode_outline: Optional[str] = Field(None, description="剧集大纲")
episode_content: Optional[str] = Field(None, description="剧集内容")
review_reason: str = Field(..., description="审核原因")
# 审核结果
issues: List[Dict[str, Any]] = Field(default_factory=list, description="问题列表")
overall_score: Optional[float] = Field(None, description="总体分数")
severity: str = Field("medium", description="严重程度: low/medium/high")
# 审核配置
review_config_id: Optional[str] = Field(None, description="使用的审核配置ID")
applied_skills: List[str] = Field(default_factory=list, description="应用的Skills")
applied_rules: List[str] = Field(default_factory=list, description="应用的规则")
# 用户操作
status: ReviewCardStatus = Field(default=ReviewCardStatus.PENDING, description="卡片状态")
user_comment: Optional[str] = Field(None, description="用户评论")
modified_content: Optional[str] = Field(None, description="修改后的内容")
# 时间戳
created_at: datetime = Field(default_factory=datetime.now, description="创建时间")
updated_at: datetime = Field(default_factory=datetime.now, description="更新时间")
completed_at: Optional[datetime] = Field(None, description="完成时间")
# 附加信息
metadata: Dict[str, Any] = Field(default_factory=dict, description="附加元数据")
class Config:
use_enum_values = False
class ReviewCardCreate(BaseModel):
"""创建审核卡片请求"""
project_id: str
episode_id: Optional[str] = None
episode_numbers: List[int] = Field(default_factory=list)
card_type: ReviewCardType
trigger_source: str
review_reason: str
episode_outline: Optional[str] = None
episode_content: Optional[str] = None
review_config_id: Optional[str] = None
severity: str = "medium"
issues: List[Dict[str, Any]] = Field(default_factory=list)
overall_score: Optional[float] = None
class ReviewCardUpdate(BaseModel):
"""更新审核卡片请求"""
status: Optional[ReviewCardStatus] = None
user_comment: Optional[str] = None
modified_content: Optional[str] = None
issues: Optional[List[Dict[str, Any]]] = None
overall_score: Optional[float] = None

View File

@ -0,0 +1,185 @@
"""
Review Task Model
审核任务模型 - 支持过程中审核和生成后审核两种模式
## 审核模式说明
### 1. 过程中审核 (Process Review)
- 触发时机Agent在流式输出过程中自主识别需要审核的内容
- 阻塞方式阻塞式等待用户确认
- 消失方式用户确认后自动销毁不需要留存记录
- 交互位置在聊天界面完成
### 2. 生成后审核 (Post-Creation Review)
- 触发时机剧集生成完成后自动检查
- 触发源根据用户配置的审核规则/Skill
- 阈值判断根据阈值判断是否报告问题
- 存留方式留存记录用户可以确认问题忽略问题修改内容
"""
from pydantic import BaseModel, Field
from typing import Dict, List, Optional, Any
from datetime import datetime
from enum import Enum
# ============================================
# 枚举类型
# ============================================
class ReviewMode(str, Enum):
"""审核模式"""
PROCESS = "process" # 过程中审核
POST_CREATION = "post_creation" # 生成后审核
class ReviewTriggerSource(str, Enum):
"""审核触发来源"""
AGENT_AUTO = "agent_auto" # Agent自主识别
USER_CONFIGURED_SKILL = "user_skill" # 用户配置的Skill
USER_CONFIGURED_RULE = "user_rule" # 用户配置的规则
MANUAL_REQUEST = "manual" # 手动请求
class ReviewTaskStatus(str, Enum):
"""审核任务状态"""
PENDING = "pending" # 等待处理
AWAITING_USER = "awaiting_user" # 等待用户确认
APPROVED = "approved" # 已确认
REJECTED = "rejected" # 已拒绝
MODIFIED = "modified" # 已修改内容
IGNORED = "ignored" # 已忽略
COMPLETED = "completed" # 已完成(自动销毁)
class ReviewAction(str, Enum):
"""用户可执行的操作"""
CONFIRM = "confirm" # 确认继续
MODIFY = "modify" # 修改内容
IGNORE = "ignore" # 忽略问题
OTHER = "other" # 其他补充信息
# ============================================
# 审核任务模型
# ============================================
class ReviewTaskLocation(BaseModel):
"""审核任务位置信息"""
episode_number: Optional[int] = Field(None, description="集数")
scene_number: Optional[int] = Field(None, description="场景编号")
section: Optional[str] = Field(None, description="章节/段落")
line_number: Optional[int] = Field(None, description="行号")
content_snippet: Optional[str] = Field(None, description="相关内容片段")
class ReviewTask(BaseModel):
"""
审核任务
包含问题类型位置严重程度等信息
"""
id: str = Field(..., description="任务ID")
project_id: str = Field(..., description="项目ID")
episode_id: Optional[str] = Field(None, description="剧集ID")
# 审核模式和来源
mode: ReviewMode = Field(..., description="审核模式")
trigger_source: ReviewTriggerSource = Field(..., description="触发来源")
# 任务信息
issue_type: str = Field(..., description="问题类型")
issue_description: str = Field(..., description="问题描述")
severity: str = Field(..., description="严重程度: low/medium/high")
location: ReviewTaskLocation = Field(..., description="问题位置")
# 审核配置来源
review_config_id: Optional[str] = Field(None, description="触发的审核配置ID")
skill_id: Optional[str] = Field(None, description="使用的Skill ID")
rule_id: Optional[str] = Field(None, description="触发的规则ID")
# 可用操作
available_actions: List[ReviewAction] = Field(
default_factory=list,
description="用户可执行的操作列表"
)
# 状态和用户响应
status: ReviewTaskStatus = Field(
default=ReviewTaskStatus.PENDING,
description="任务状态"
)
user_response: Optional[str] = Field(None, description="用户的响应内容")
# 时间戳
created_at: datetime = Field(default_factory=datetime.now, description="创建时间")
completed_at: Optional[datetime] = Field(None, description="完成时间")
expires_at: Optional[datetime] = Field(None, description="过期时间(过程审核自动销毁)")
# 附加信息
context_data: Optional[Dict[str, Any]] = Field(
default_factory=dict,
description="附加上下文数据"
)
suggestion: Optional[str] = Field("", description="修改建议")
# ============================================
# 请求/响应模型
# ============================================
class ProcessReviewRequest(BaseModel):
"""
过程中审核请求 - 由Agent推送
"""
project_id: str
issue_type: str
issue_description: str
severity: str # low/medium/high
location: ReviewTaskLocation
trigger_source: ReviewTriggerSource = ReviewTriggerSource.AGENT_AUTO
review_config_id: Optional[str] = None
skill_id: Optional[str] = None
rule_id: Optional[str] = None
suggestion: Optional[str] = None
class ProcessReviewResponse(BaseModel):
"""
过程中审核响应
"""
task_id: str
status: ReviewTaskStatus
message: str
class UserReviewActionRequest(BaseModel):
"""
用户审核操作请求
"""
action: ReviewAction
task_id: str
user_response: Optional[str] = Field(None, description="用户补充信息")
modified_content: Optional[str] = Field(None, description="修改后的内容")
class PostCreationReviewRequest(BaseModel):
"""
生成后审核请求
"""
project_id: str
episode_id: str
use_configured_rules: bool = Field(True, description="是否使用配置的规则")
use_configured_skills: bool = Field(True, description="是否使用配置的Skills")
dimensions: Optional[List[str]] = Field(None, description="指定审核维度")
pass_threshold: Optional[float] = Field(None, description="通过阈值")
class PostCreationReviewResult(BaseModel):
"""
生成后审核结果
"""
task_count: int = Field(..., description="生成的任务数")
passed: bool = Field(..., description="是否通过审核(没有高严重度问题)")
tasks: List[ReviewTask] = Field(default_factory=list, description="审核任务列表")
overall_score: Optional[float] = Field(None, description="总体分数")

File diff suppressed because it is too large Load Diff

View File

@ -1,79 +1,3 @@
{ {
"8f969272-4ece-49e7-8ca1-4877cc62c57c": [ "8f969272-4ece-49e7-8ca1-4877cc62c57c": []
{
"role": "user",
"content": "开始生成大纲",
"timestamp": "2026-01-28T09:37:52.162798"
},
{
"role": "user",
"content": "开始生成大纲",
"timestamp": "2026-01-28T10:10:14.049998"
},
{
"role": "user",
"content": "开始生成大纲",
"timestamp": "2026-01-28T10:10:21.410520"
},
{
"role": "user",
"content": "开始生成大纲",
"timestamp": "2026-01-28T10:10:56.234901"
},
{
"role": "user",
"content": "开始生成大纲",
"timestamp": "2026-01-28T10:11:48.922316"
},
{
"role": "agent",
"content": "我需要先了解项目情况,然后为您生成大纲。让我先查看一下当前项目的文件结构。您好!我已经准备好为您生成大纲了。\n\n为了创建一个符合您期望的大纲我需要了解一些关键信息\n\n## 请提供以下信息:\n\n1. **故事类型**:您想创作什么类型的内容?\n - 小说/剧本/网剧/其他\n\n2. **题材/类型**\n - 悬疑/推理\n - 科幻/奇幻\n - 爱情/都市\n - 历史/古装\n - 喜剧\n - 恐怖/惊悚\n - 其他\n\n3. **核心概念/梗概**:您有一个初步的故事想法吗?请简单描述。\n\n4. **篇幅规模**\n - 短篇1-5集/章)\n - 中篇6-20集/章)\n - 长篇20集/章以上)\n\n5. **目标受众**:主要面向哪类观众?\n\n请告诉我您的想法我会根据您的需求制定详细的大纲方案",
"timestamp": "2026-01-28T10:13:01.604878"
},
{
"role": "user",
"content": "开始生成大纲",
"timestamp": "2026-01-28T10:19:32.449321"
},
{
"role": "user",
"content": "开始创作第三集",
"timestamp": "2026-01-28T15:17:45.959233"
},
{
"role": "user",
"content": "开始生成第3集大纲",
"timestamp": "2026-01-28T16:47:54.452228"
},
{
"role": "agent",
"content": "\n我来帮你生成第3集的大纲。首先让我查看一下当前项目的进度。\n\n我来为第3集制定创作计划并生成大纲。\n\n现在让我创建第3集。根据项目协议当你说\"开始创作第X集\"时,我应该使用 `create_episode` 工具来自动完成整个创建过程。\n\n系统正在后台自动创建第3集包括大纲、内容创作和质量审核。根据项目协议这种创作过程会自动通过WebSocket更新进度最终第3集的内容将显示在画布上。\n\n让我继续关注创作进度",
"timestamp": "2026-01-28T16:49:00.614398"
},
{
"role": "user",
"content": "开始生成第3集大纲",
"timestamp": "2026-01-28T17:46:38.247766"
},
{
"role": "agent",
"content": "我来开始生成第3集的大纲。首先让我创建一个执行计划。现在让我基于全局设定来创作第3集的大纲。## ✅ 第3集大纲已生成\n\n我已经完成了第3集《意外相遇》的大纲创作大纲已呈现在画布上。\n\n**本集核心情节:**\n- 苏瑾月被林墨轩商议婚事之事烦扰\n- 苏老爷催促婚事,女儿巧妙应对拖延时间\n- 前往寺庙途中马车\"意外\"故障\n- 楚云飞路过出手相助,两人初次相遇\n- 楚云飞修好马车但收钱时展现江湖本色\n- 林墨轩对楚云飞起疑\n- 苏瑾月内心开始动摇,对这位江湖车夫产生好奇\n\n**创作亮点:**\n1. ✨ 体现了苏瑾月的机智与临危不乱\n2. ✨ 建立了楚云飞的侠义形象,暗示其真实身份\n3. ✨ 埋下苏瑾月与楚云飞情感发展的种子\n4. ✨ 强化林墨轩的反派色彩\n\n**已提交审阅:** 我已将大纲提交到您的收件箱,请您审阅并反馈是否需要调整。\n\n您可以\n- 批准大纲 → 我将开始创作正式剧本\n- 要求修改 → 告诉我具体的修改意见\n- 重新创作 → 我会从新的角度重新设计\n\n期待您的反馈",
"timestamp": "2026-01-28T17:48:50.985473"
},
{
"role": "user",
"content": "批准大纲",
"timestamp": "2026-01-28T17:49:11.764238"
},
{
"role": "agent",
"content": "我先查看一下项目的当前状态,看看有什么大纲需要批准。我看到项目中前三集已完成,后续剧集待创作。不过我需要确认一下:您要批准的是哪个大纲呢?\n\n目前可能的情况\n1. **全局大纲**项目上下文中的Overall Outline目前显示为\"11\",似乎不完整)\n2. **某个特定剧集的大纲**\n\n您是指\n- 如果是全局大纲,我可以帮您完善并提交审批\n- 如果是某个待定剧集的大纲,请告诉我是第几集,我可以立即开始创作并生成大纲供您审批\n\n请指明具体要批准的内容我会立即为您处理",
"timestamp": "2026-01-28T17:49:37.624535"
},
{
"role": "user",
"content": "开始生成第3集大纲",
"timestamp": "2026-01-28T18:30:44.338922"
}
]
} }

View File

@ -17,60 +17,107 @@
}, },
"memory": { "memory": {
"eventTimeline": [], "eventTimeline": [],
"pendingThreads": [ "pendingThreads": [],
{ "foreshadowing": [],
"id": "881661a2-7372-41ce-9f7a-ed77efe5d92c", "characterStates": {}
"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"
}, },
{ "totalEpisodes": 30,
"id": "015785d6-6f16-4a2f-8260-a8c3515a41c2", "defaultTaskSkills": [],
"description": "这个错误是否会影响后续剧情发展?是否会成为解开某个谜题的关键?", "episodeSkillOverrides": {},
"introduced_at": 2, "skillSettings": {},
"importance": "medium", "autoRetryConfig": null,
"resolved": false, "reviewConfig": {
"resolved_at": null, "enabled_review_skills": [],
"reminder_episode": 4, "overall_strictness": 0.83,
"status": "pending", "dimension_settings": {
"characters_involved": [], "consistency": {
"notes": "", "enabled": false,
"created_at": "2026-01-28T16:49:57.524646", "strictness": 0.95,
"updated_at": "2026-01-28T16:49:57.524646" "custom_rules": [],
} "weight": 1.0
],
"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": ""
}, },
{ "quality": {
"id": "8b43d627-9107-430b-aa0f-6d1052ad1511", "enabled": true,
"description": "'str' object has no attribute 'items'这个技术错误可能是一个元叙事元素,暗示剧集本身可能存在问题", "strictness": 0.9,
"introduced_at": 2, "custom_rules": [],
"payoff_at": null, "weight": 0.8
"type": "plot", },
"importance": "medium", "dialogue": {
"is_payed_off": false, "enabled": false,
"related_events": [], "strictness": 0.5,
"notes": "" "custom_rules": [],
"weight": 0.7
},
"plot": {
"enabled": false,
"strictness": 0.5,
"custom_rules": [],
"weight": 0.7
},
"pacing": {
"enabled": false,
"strictness": 0.5,
"custom_rules": [],
"weight": 0.7
},
"emotional_depth": {
"enabled": false,
"strictness": 0.5,
"custom_rules": [],
"weight": 0.7
},
"thematic_strength": {
"enabled": false,
"strictness": 0.5,
"custom_rules": [],
"weight": 0.7
},
"character_consistency": {
"enabled": false,
"strictness": 0.95,
"custom_rules": [],
"weight": 1.0
},
"dialogue_quality": {
"enabled": false,
"strictness": 0.5,
"custom_rules": [],
"weight": 0.7
},
"plot_coherence": {
"enabled": false,
"strictness": 0.5,
"custom_rules": [],
"weight": 0.7
} }
], },
"custom_rules": [],
"auto_fix_enabled": false,
"pass_threshold": 90.0
},
"createdAt": "2026-01-27T16:22:58.755260",
"updatedAt": "2026-01-30T18:27:40.432141"
},
"240eca7f-2587-4380-88d4-081818c6a661": {
"id": "240eca7f-2587-4380-88d4-081818c6a661",
"name": "test1",
"type": "series",
"agentId": "series-creation",
"mode": "batch",
"genre": "古风",
"globalContext": {
"worldSetting": "",
"characterProfiles": {},
"sceneSettings": {},
"overallOutline": "",
"styleGuide": "",
"uploadedScript": "11",
"inspiration": ""
},
"memory": {
"eventTimeline": [],
"pendingThreads": [],
"foreshadowing": [],
"characterStates": {} "characterStates": {}
}, },
"totalEpisodes": 30, "totalEpisodes": 30,
@ -79,7 +126,7 @@
"skillSettings": {}, "skillSettings": {},
"autoRetryConfig": null, "autoRetryConfig": null,
"reviewConfig": null, "reviewConfig": null,
"createdAt": "2026-01-27T16:22:58.755260", "createdAt": "2026-01-29T15:12:51.442460",
"updatedAt": "2026-01-28T16:51:40.612879" "updatedAt": "2026-01-29T15:12:51.442460"
} }
} }

View File

@ -0,0 +1,67 @@
"""
修复episodes.json数据问题
"""
import json
from pathlib import Path
def fix_episodes_data():
"""修复episodes.json中的问题数据"""
episodes_file = Path(__file__).parent.parent / "data" / "episodes.json"
if not episodes_file.exists():
print(f"episodes.json not found at {episodes_file}")
return
with open(episodes_file, 'r', encoding='utf-8') as f:
data = json.load(f)
fixed_count = 0
for episode_id, episode in data.items():
modified = False
# 修复EP1的标题
if episode.get("number") == 1 and episode.get("title") == "11":
episode["title"] = "苏园初会"
modified = True
fixed_count += 1
# 修复EP2的错误内容
if episode.get("number") == 2 and "内容创作失败" in str(episode.get("content", "")):
episode["content"] = ""
episode["status"] = "pending"
modified = True
fixed_count += 1
# 确保所有pending剧集有正确的结构
if episode.get("status") == "pending":
if not episode.get("outline"):
episode["outline"] = None
if not episode.get("content"):
episode["content"] = ""
if not episode.get("structure"):
episode["structure"] = {
"episodeNumber": episode.get("number"),
"scenes": [],
"keyEvents": []
}
modified = True
fixed_count += 1
# 修复qualityScore为None的completed剧集
if episode.get("status") == "completed" and episode.get("qualityScore") is None:
episode["qualityScore"] = 0.0
modified = True
fixed_count += 1
# 保存修复后的数据
with open(episodes_file, 'w', encoding='utf-8') as f:
json.dump(data, f, ensure_ascii=False, indent=2)
print(f"修复完成!共修复 {fixed_count} 个问题")
return True
if __name__ == "__main__":
fix_episodes_data()

View File

@ -1,23 +1,19 @@
import React from 'react' import React from 'react'
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom' import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'
import { ConfigProvider, Layout, Menu, theme } from 'antd' import { Layout, Menu, App as AntApp, theme } from 'antd'
import { DashboardOutlined, BookOutlined, SettingOutlined } from '@ant-design/icons' import { DashboardOutlined, BookOutlined, SettingOutlined } from '@ant-design/icons'
import { ProjectList } from './pages/ProjectList' import { ProjectList } from './pages/ProjectList'
import { ProjectCreateEnhanced } from './pages/ProjectCreateEnhanced' import { ProjectCreateEnhanced } from './pages/ProjectCreateEnhanced'
import { ProjectCreateProgressive } from './pages/ProjectCreateProgressive' import { ProjectCreateProgressive } from './pages/ProjectCreateProgressive'
import { ProjectDetail } from './pages/ProjectDetail' import { ProjectDetail } from './pages/ProjectDetail'
import { ProjectWorkspace } from './pages/ProjectWorkspace'
import { SkillManagement } from './pages/SkillManagement' import { SkillManagement } from './pages/SkillManagement'
import { AgentManagement } from './pages/AgentManagement' import { AgentManagement } from './pages/AgentManagement'
import { ExecutionMonitor } from './pages/ExecutionMonitor' import { ExecutionMonitor } from './pages/ExecutionMonitor'
import MemorySystem from './pages/MemorySystem'
import ReviewConfig from './pages/ReviewConfig'
import ReviewResults from './pages/ReviewResults'
const { Header, Content } = Layout const { Header, Content } = Layout
function App() { function AppRoot() {
const currentPath = window.location.pathname const currentPath = window.location.pathname
const menuItems = [ const menuItems = [
@ -27,14 +23,7 @@ function App() {
] ]
return ( return (
<ConfigProvider <AntApp>
theme={{
algorithm: theme.defaultAlgorithm,
token: {
colorPrimary: '#0ea5e9',
},
}}
>
<BrowserRouter <BrowserRouter
future={{ future={{
v7_startTransition: true, v7_startTransition: true,
@ -75,21 +64,19 @@ function App() {
<Route path="/projects" element={<ProjectList />} /> <Route path="/projects" element={<ProjectList />} />
<Route path="/projects/new" element={<ProjectCreateEnhanced />} /> <Route path="/projects/new" element={<ProjectCreateEnhanced />} />
<Route path="/projects/progressive" element={<ProjectCreateProgressive />} /> <Route path="/projects/progressive" element={<ProjectCreateProgressive />} />
{/* 更具体的路由要放在前面 */} {/* 执行监控路由 */}
<Route path="/projects/:id/workspace" element={<ProjectWorkspace />} />
<Route path="/projects/:id/execute" element={<ExecutionMonitor />} /> <Route path="/projects/:id/execute" element={<ExecutionMonitor />} />
<Route path="/projects/:id/memory" element={<MemorySystem />} /> {/* 项目详情页(包含所有功能:设置、全局设定、剧集创作、记忆系统、审核系统) */}
<Route path="/projects/:id/review/config" element={<ReviewConfig />} />
<Route path="/projects/:id/review/results" element={<ReviewResults />} />
<Route path="/projects/:id" element={<ProjectDetail />} /> <Route path="/projects/:id" element={<ProjectDetail />} />
{/* 全局管理页面 */}
<Route path="/skills" element={<SkillManagement />} /> <Route path="/skills" element={<SkillManagement />} />
<Route path="/agents" element={<AgentManagement />} /> <Route path="/agents" element={<AgentManagement />} />
</Routes> </Routes>
</Content> </Content>
</Layout> </Layout>
</BrowserRouter> </BrowserRouter>
</ConfigProvider> </AntApp>
) )
} }
export default App export default AppRoot

View File

@ -188,6 +188,8 @@ export const SkillCreate: React.FC<SkillCreateProps> = ({
} }
} catch (error) { } catch (error) {
message.error(`加载Skill失败: ${(error as Error).message}`) message.error(`加载Skill失败: ${(error as Error).message}`)
// 关闭弹窗,让父组件处理重定向
handleClose()
} }
} }
@ -544,7 +546,7 @@ export const SkillCreate: React.FC<SkillCreateProps> = ({
onCancel={handleClose} onCancel={handleClose}
footer={null} footer={null}
width={1000} width={1000}
destroyOnClose destroyOnHidden
> >
<Space direction="vertical" style={{ width: '100%' }} size="large"> <Space direction="vertical" style={{ width: '100%' }} size="large">

View File

@ -1,5 +1,5 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { Layout, Card, Typography, Space, Tag, Tabs, List, Button, Input, Empty, Timeline, Badge, Popconfirm } from 'antd'; import { Card, Typography, Space, Tabs, List, Button, Empty, Timeline, Badge } from 'antd';
import { import {
BookOutlined, BookOutlined,
UserOutlined, UserOutlined,
@ -12,7 +12,6 @@ import {
CheckCircleOutlined CheckCircleOutlined
} from '@ant-design/icons'; } from '@ant-design/icons';
const { Sider } = Layout;
const { Title, Text, Paragraph } = Typography; const { Title, Text, Paragraph } = Typography;
interface ContextPanelProps { interface ContextPanelProps {
@ -49,20 +48,10 @@ export const ContextPanel: React.FC<ContextPanelProps> = ({
{ type: 'location', value: '未初始化' } { type: 'location', value: '未初始化' }
]; ];
return ( // 渲染内容
<Sider const renderContent = () => (
width={width} <div style={{ width: '100%', minWidth: 0, overflow: 'hidden' }}>
theme="light" <Title level={4} style={{ width: '100%', minWidth: 0 }}>
style={{
borderRight: '1px solid #f0f0f0',
height: '100%',
overflowY: 'auto',
background: '#fff',
flexShrink: 0
}}
>
<div style={{ padding: '16px' }}>
<Title level={4}>
<BookOutlined /> <BookOutlined />
</Title> </Title>
@ -70,25 +59,25 @@ export const ContextPanel: React.FC<ContextPanelProps> = ({
<Card <Card
size="small" size="small"
title="当前状态 (Active State)" title="当前状态 (Active State)"
style={{ marginBottom: '16px', background: '#f6ffed', borderColor: '#b7eb8f' }} style={{ marginBottom: '16px', background: '#f6ffed', borderColor: '#b7eb8f', width: '100%', minWidth: 0 }}
> >
<Space direction="vertical" style={{ width: '100%' }}> <Space direction="vertical" style={{ width: '100%' }}>
{displayStates.map((state, idx) => ( {displayStates.map((state, idx) => (
<div key={idx} style={{ display: 'flex', justifyContent: 'space-between' }}> <div key={idx} style={{ display: 'flex', justifyContent: 'space-between', minWidth: 0, gap: '8px' }}>
<Text type="secondary">{state.type === 'time' ? <HistoryOutlined /> : state.type === 'location' ? <EnvironmentOutlined /> : <UserOutlined />}</Text> <Text type="secondary" style={{ flexShrink: 0 }}>{state.type === 'time' ? <HistoryOutlined /> : state.type === 'location' ? <EnvironmentOutlined /> : <UserOutlined />}</Text>
<Text strong>{state.value}</Text> <Text strong style={{ textAlign: 'right', flex: 1, wordBreak: 'break-word' }}>{state.value}</Text>
</div> </div>
))} ))}
</Space> </Space>
</Card> </Card>
<Tabs activeKey={activeTab} onChange={setActiveTab} <Tabs activeKey={activeTab} onChange={setActiveTab} style={{ width: '100%', minWidth: 0 }}
items={[ items={[
{ {
key: 'world', key: 'world',
label: '世界观', label: '世界观',
children: ( children: (
<> <div style={{ width: '100%', minWidth: 0 }}>
<Paragraph ellipsis={{ rows: 6, expandable: true, symbol: '展开' }}> <Paragraph ellipsis={{ rows: 6, expandable: true, symbol: '展开' }}>
{worldSetting} {worldSetting}
</Paragraph> </Paragraph>
@ -100,14 +89,14 @@ export const ContextPanel: React.FC<ContextPanelProps> = ({
> >
</Button> </Button>
</> </div>
), ),
}, },
{ {
key: 'characters', key: 'characters',
label: '人物', label: '人物',
children: ( children: (
<> <div style={{ width: '100%', minWidth: 0 }}>
{/* 如果有文本格式的人物设定,优先显示 */} {/* 如果有文本格式的人物设定,优先显示 */}
{charactersText ? ( {charactersText ? (
<Paragraph <Paragraph
@ -123,8 +112,8 @@ export const ContextPanel: React.FC<ContextPanelProps> = ({
<List.Item> <List.Item>
<List.Item.Meta <List.Item.Meta
avatar={<UserOutlined style={{ fontSize: '24px', color: '#1890ff' }} />} avatar={<UserOutlined style={{ fontSize: '24px', color: '#1890ff' }} />}
title={name} title={<span style={{ wordBreak: 'break-word' }}>{name}</span>}
description={<Text ellipsis>{profile}</Text>} description={<Text ellipsis={{ tooltip: profile }} style={{ wordBreak: 'break-word' }}>{profile}</Text>}
/> />
</List.Item> </List.Item>
)} )}
@ -141,14 +130,14 @@ export const ContextPanel: React.FC<ContextPanelProps> = ({
> >
</Button> </Button>
</> </div>
), ),
}, },
{ {
key: 'outline', key: 'outline',
label: '大纲', label: '大纲',
children: ( children: (
<> <div style={{ width: '100%', minWidth: 0 }}>
<Paragraph ellipsis={{ rows: 10, expandable: true, symbol: '展开' }} style={{ whiteSpace: 'pre-wrap' }}> <Paragraph ellipsis={{ rows: 10, expandable: true, symbol: '展开' }} style={{ whiteSpace: 'pre-wrap' }}>
{overallOutline} {overallOutline}
</Paragraph> </Paragraph>
@ -161,7 +150,7 @@ export const ContextPanel: React.FC<ContextPanelProps> = ({
> >
</Button> </Button>
</> </div>
), ),
}, },
{ {
@ -176,7 +165,25 @@ export const ContextPanel: React.FC<ContextPanelProps> = ({
]} ]}
/> />
</div> </div>
</Sider> );
// 使用统一的布局模式(移除 Sider 组件,改用普通 div
return (
<div
style={{
width: '100%',
height: '100%',
display: 'flex',
flexDirection: 'column',
background: '#fff',
overflow: 'hidden',
minWidth: 0
}}
>
<div style={{ padding: '16px', flex: 1, overflowY: 'auto', minWidth: 0 }}>
{renderContent()}
</div>
</div>
); );
}; };

View File

@ -1,5 +1,5 @@
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 { Input, List, Avatar, Button, Card, Tag, Badge, Tooltip, Divider, Space, Popconfirm, Modal, message } from 'antd';
import { import {
SendOutlined, SendOutlined,
RobotOutlined, RobotOutlined,
@ -8,49 +8,123 @@ import {
CloseCircleOutlined, CloseCircleOutlined,
ExclamationCircleOutlined, ExclamationCircleOutlined,
LoadingOutlined, LoadingOutlined,
StopOutlined StopOutlined,
ReloadOutlined,
ClearOutlined,
DeleteOutlined,
WarningOutlined
} from '@ant-design/icons'; } from '@ant-design/icons';
const { Sider } = Layout;
const { TextArea } = Input; const { TextArea } = Input;
export interface InboxItem { export interface InboxItem {
id: string; id: string;
type: 'review' | 'decision' | 'notification' | 'review_card'; type: 'review' | 'decision' | 'notification' | 'review_card' | 'confirm_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?: Array<{ id: string; label: string; description?: string; implications?: string }>;
reviewData?: any; reviewData?: any;
confirmData?: {
card_id: string;
allow_custom_response: boolean;
expires_at?: string;
};
} }
interface DirectorInboxProps { interface DirectorInboxProps {
onSendMessage: (message: string) => void; onSendMessage: (message: string) => void;
onStopAgent?: () => void; onStopAgent?: () => void;
onInboxAction?: (itemId: string, action: 'approve' | 'reject') => void; onInboxAction?: (itemId: string, action: 'approve' | 'reject') => void;
onConfirmCardAction?: (itemId: string, action: 'confirm' | 'reject', selectedOptionId?: string, customResponse?: string) => void;
onNavigateToReview?: (episodeNum: number) => void; onNavigateToReview?: (episodeNum: number) => void;
onResetConversation?: () => 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; width?: number;
wsRef?: React.RefObject<WebSocket | null>; // 新增WebSocket引用
} }
export const DirectorInbox: React.FC<DirectorInboxProps> = ({ export const DirectorInbox: React.FC<DirectorInboxProps> = ({
onSendMessage, onSendMessage,
onStopAgent, onStopAgent,
onInboxAction, onInboxAction,
onConfirmCardAction,
onNavigateToReview, onNavigateToReview,
onResetConversation, // 新增:重建对话回调
agentStatus, agentStatus,
agentPlan = [], agentPlan = [],
inboxItems = [], inboxItems = [],
chatHistory = [], chatHistory = [],
width = 400 width = 400,
wsRef // 新增WebSocket引用
}) => { }) => {
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
const [localMessages, setLocalMessages] = useState<{role: 'user' | 'agent', content: string}[]>([]); const [localMessages, setLocalMessages] = useState<{role: 'user' | 'agent', content: string}[]>([]);
const [showClearConfirm, setShowClearConfirm] = useState(false);
// 新增:处理重建对话(保留上下文,清除显示)
const handleResetConversation = () => {
// 清空本地消息,保留欢迎语
setLocalMessages([{ role: 'agent', content: '导演你好,我是你的 AI 助手。' }]);
// 通过WebSocket发送重建对话请求
if (wsRef && wsRef.current && wsRef.current.readyState === WebSocket.OPEN) {
wsRef.current.send(JSON.stringify({
type: 'reset_conversation'
}));
}
// 调用父组件回调
onResetConversation?.();
message.success('对话已刷新,上下文已保留');
};
// 新增:完全清除对话(包括上下文)
const handleClearConversation = () => {
setShowClearConfirm(true);
};
const confirmClearConversation = () => {
// 清空本地消息
setLocalMessages([{ role: 'agent', content: '导演你好,我是你的 AI 助手。' }]);
// 通过WebSocket发送清除对话请求完全重置
if (wsRef && wsRef.current && wsRef.current.readyState === WebSocket.OPEN) {
wsRef.current.send(JSON.stringify({
type: 'clear_conversation'
}));
}
// 调用父组件回调
onResetConversation?.();
setShowClearConfirm(false);
message.success('对话已完全清除,上下文已重置');
};
// 检查对话长度,超过阈值提醒用户
useEffect(() => {
const MESSAGE_THRESHOLD = 30; // 超过30条消息提醒
if (localMessages.length >= MESSAGE_THRESHOLD && localMessages.length === MESSAGE_THRESHOLD) {
// 只有 Agent 才能发送提醒
const hasReminder = localMessages.some(m =>
m.role === 'agent' && m.content.includes('对话记录较长,建议您')
);
if (!hasReminder) {
message.warning({
content: '对话记录较长,建议您刷新或清除对话以获得更好性能',
duration: 5,
icon: <WarningOutlined style={{ color: '#faad14' }} />
});
}
}
}, [localMessages]);
useEffect(() => { useEffect(() => {
// 确保 chatHistory 是数组 // 确保 chatHistory 是数组
@ -102,14 +176,18 @@ export const DirectorInbox: React.FC<DirectorInboxProps> = ({
}; };
return ( return (
<Sider <div
width={width}
theme="light"
style={{ style={{
borderLeft: '1px solid #f0f0f0', width: '100%',
height: '100%', height: '100%',
background: '#fff', background: '#fafbfc',
flexShrink: 0 border: '1px solid #e0e4e7',
borderLeft: 'none',
display: 'flex',
flexDirection: 'column',
overflow: 'hidden',
borderRadius: '0 8px 8px 0',
boxShadow: 'inset 1px 0 0 #e5e7eb'
}} }}
> >
<div style={{ <div style={{
@ -119,7 +197,7 @@ export const DirectorInbox: React.FC<DirectorInboxProps> = ({
overflow: 'hidden' overflow: 'hidden'
}}> }}>
{/* Agent 状态与计划 */} {/* Agent 状态与计划 */}
<div style={{ padding: '16px', borderBottom: '1px solid #f0f0f0', background: '#fafafa', flexShrink: 0 }}> <div style={{ padding: '16px', borderBottom: '2px solid #e0e4e7', background: 'linear-gradient(180deg, #ffffff 0%, #f8fafc 100%)', flexShrink: 0, boxShadow: '0 1px 3px rgba(0,0,0,0.04)' }}>
<div style={{ display: 'flex', alignItems: 'center', marginBottom: '8px' }}> <div style={{ display: 'flex', alignItems: 'center', marginBottom: '8px' }}>
<Avatar icon={<RobotOutlined />} style={{ backgroundColor: '#1890ff', marginRight: '8px' }} /> <Avatar icon={<RobotOutlined />} style={{ backgroundColor: '#1890ff', marginRight: '8px' }} />
<div> <div>
@ -144,62 +222,180 @@ export const DirectorInbox: React.FC<DirectorInboxProps> = ({
{/* 导演信箱 (Inbox) */} {/* 导演信箱 (Inbox) */}
<div style={{ <div style={{
padding: '16px 16px 0 16px', display: 'flex',
background: '#f0f2f5', flexDirection: 'column',
maxHeight: '40%', maxHeight: '40%',
overflowY: 'auto', background: '#f5f6f8',
flexShrink: 0, borderBottom: '1px solid #e0e4e7',
borderBottom: '1px solid #e8e8e8' flexShrink: 0
}}>
<div style={{ padding: '8px 12px', background: '#f0f2f5', borderBottom: '1px solid #e8e8e8', flexShrink: 0 }}>
<Divider orientation="left" style={{ margin: 0, fontSize: '12px', borderTopColor: '#d9d9d9' }}> (Inbox)</Divider>
</div>
<div style={{
padding: '12px 16px',
overflowY: 'auto',
overflowX: 'hidden',
flex: 1
}}> }}>
<Divider orientation="left" style={{ margin: '0 0 16px 0', fontSize: '12px' }}> (Inbox)</Divider>
{Array.isArray(inboxItems) && inboxItems.map(item => ( {Array.isArray(inboxItems) && inboxItems.map(item => (
<Card <Card
key={item.id} key={item.id}
size="small" size="small"
style={{ marginBottom: '8px', borderLeft: item.type === 'review_card' ? '3px solid #1890ff' : '3px solid #faad14', cursor: item.type === 'review_card' ? 'pointer' : 'default' }} style={{
onClick={() => handleInboxItemClick(item)} marginBottom: '8px',
borderLeft: item.type === 'review_card' ? '3px solid #1890ff' :
item.type === 'confirm_card' ? '3px solid #52c41a' : '3px solid #faad14',
cursor: item.type === 'review_card' ? 'pointer' : 'default',
borderRadius: '6px'
}}
onClick={() => item.type !== 'confirm_card' ? handleInboxItemClick(item) : undefined}
hoverable={item.type === 'review_card'} hoverable={item.type === 'review_card'}
actions={item.type === 'review_card' ? [ actions={
<Button type="link" size="small" onClick={(e) => { e.stopPropagation(); handleInboxItemClick(item); }}></Button> item.type === 'review_card' ? [] :
item.type === 'confirm_card' ? [
<Tooltip key="reject" title="拒绝">
<Button
type="text"
size="small"
icon={<CloseCircleOutlined style={{ color: '#ff4d4f' }} />}
onClick={() => onConfirmCardAction?.(item.id, 'reject')}
/>
</Tooltip>
] : [ ] : [
<Tooltip title="批准/确认"><Button type="text" size="small" icon={<CheckCircleOutlined style={{ color: '#52c41a' }} />} onClick={() => onInboxAction?.(item.id, 'approve')} /></Tooltip>, <Tooltip key="approve" title="批准/确认">
<Tooltip title="拒绝/修改"><Button type="text" size="small" icon={<CloseCircleOutlined style={{ color: '#ff4d4f' }} />} onClick={() => onInboxAction?.(item.id, 'reject')} /></Tooltip> <Button
]} type="text"
size="small"
icon={<CheckCircleOutlined style={{ color: '#52c41a' }} />}
onClick={() => onInboxAction?.(item.id, 'approve')}
/>
</Tooltip>,
<Tooltip key="reject" title="拒绝/修改">
<Button
type="text"
size="small"
icon={<CloseCircleOutlined style={{ color: '#ff4d4f' }} />}
onClick={() => onInboxAction?.(item.id, 'reject')}
/>
</Tooltip>
]
}
> >
<Card.Meta <Card.Meta
avatar={item.type === 'review_card' ? <CheckCircleOutlined style={{ color: '#1890ff', fontSize: '20px' }} /> : <ExclamationCircleOutlined style={{ color: '#faad14', fontSize: '20px' }} />} avatar={
title={<span style={{ fontSize: '14px' }}>{item.title}</span>} item.type === 'review_card' ? <CheckCircleOutlined style={{ color: '#1890ff', fontSize: '20px' }} /> :
description={<span style={{ fontSize: '12px' }}>{item.description}</span>} item.type === 'confirm_card' ? <CheckCircleOutlined style={{ color: '#52c41a', fontSize: '20px' }} /> :
<ExclamationCircleOutlined style={{ color: '#faad14', fontSize: '20px' }} />
}
title={<span style={{ fontSize: '14px', fontWeight: 600 }}>{item.title}</span>}
description={
<div>
<div style={{ fontSize: '12px', color: '#666', wordBreak: 'break-word' }}>{item.description}</div>
{item.type === 'confirm_card' && item.options && item.options.length > 0 && (
<div style={{ marginTop: '12px' }}>
<Space direction="vertical" size="small" style={{ width: '100%' }}>
{item.options.map((option) => (
<Button
key={option.id}
size="small"
type="primary"
ghost
block
style={{ textAlign: 'left', height: 'auto', padding: '4px 8px' }}
onClick={() => onConfirmCardAction?.(item.id, 'confirm', option.id)}
>
<div>
<div style={{ fontWeight: 500 }}>{option.label}</div>
{option.description && (
<div style={{ fontSize: '11px', color: '#666', marginTop: '2px' }}>
{option.description}
</div>
)}
</div>
</Button>
))}
</Space>
</div>
)}
</div>
}
/> />
</Card> </Card>
))} ))}
{(!inboxItems || inboxItems.length === 0) && (
<div style={{ textAlign: 'center', padding: '12px', color: '#999', fontSize: '12px' }}></div>
)}
</div>
</div> </div>
{/* 对话记录 */} {/* 对话记录 */}
<div style={{ <div style={{
flex: 1, flex: 1,
overflowY: 'auto', display: 'flex',
padding: '16px', flexDirection: 'column',
background: '#f5f6f8',
overflow: 'hidden'
}}>
<div style={{
padding: '8px 12px',
background: '#f0f2f5', background: '#f0f2f5',
borderBottom: '1px solid #e8e8e8',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
flexShrink: 0
}}>
<div style={{ display: 'flex', alignItems: 'center', flex: 1 }}>
<Divider orientation="left" style={{ margin: 0, fontSize: '12px', borderTopColor: '#d9d9d9' }}></Divider>
</div>
<Tooltip title="重建对话(清除历史但保留上下文)">
<Button
type="text"
size="small"
icon={<ReloadOutlined style={{ fontSize: '12px' }} />}
onClick={handleResetConversation}
style={{
fontSize: '11px',
display: 'flex',
alignItems: 'center',
color: '#1890ff',
padding: '0 8px',
height: '24px',
marginLeft: '8px'
}}
>
</Button>
</Tooltip>
</div>
<div style={{
flex: 1,
overflowY: 'auto',
overflowX: 'hidden',
padding: '16px',
display: 'flex', display: 'flex',
flexDirection: 'column' flexDirection: 'column'
}}> }}>
<Divider orientation="left" style={{ margin: '0 0 16px 0', fontSize: '12px' }}></Divider>
{Array.isArray(localMessages) && localMessages.map((msg, idx) => ( {Array.isArray(localMessages) && localMessages.map((msg, idx) => (
<div key={idx} style={{ <div key={idx} style={{
display: 'flex', display: 'flex',
justifyContent: msg.role === 'user' ? 'flex-end' : 'flex-start', justifyContent: msg.role === 'user' ? 'flex-end' : 'flex-start',
marginBottom: '12px' marginBottom: '12px',
width: '100%'
}}> }}>
<div style={{ <div style={{
maxWidth: '80%', maxWidth: '90%',
padding: '8px 12px', padding: '8px 12px',
borderRadius: '8px', borderRadius: '12px',
background: msg.role === 'user' ? '#1890ff' : '#fff', background: msg.role === 'user' ? '#1890ff' : '#fff',
color: msg.role === 'user' ? '#fff' : '#333', color: msg.role === 'user' ? '#fff' : '#333',
boxShadow: '0 1px 2px rgba(0,0,0,0.1)' boxShadow: '0 2px 4px rgba(0,0,0,0.05)',
wordBreak: 'break-word',
whiteSpace: 'pre-wrap',
fontSize: '13px',
lineHeight: '1.5'
}}> }}>
{msg.content} {msg.content}
</div> </div>
@ -207,9 +403,10 @@ export const DirectorInbox: React.FC<DirectorInboxProps> = ({
))} ))}
<div ref={messagesEndRef} /> <div ref={messagesEndRef} />
</div> </div>
</div>
{/* 输入框 */} {/* 输入框 */}
<div style={{ padding: '16px', borderTop: '1px solid #f0f0f0', flexShrink: 0 }}> <div style={{ padding: '16px', borderTop: '2px solid #e0e4e7', background: '#ffffff', flexShrink: 0, boxShadow: '0 -1px 3px rgba(0,0,0,0.04)' }}>
<div style={{ display: 'flex' }}> <div style={{ display: 'flex' }}>
<TextArea <TextArea
rows={2} rows={2}
@ -239,7 +436,37 @@ export const DirectorInbox: React.FC<DirectorInboxProps> = ({
/> />
</div> </div>
</div> </div>
{/* 清除对话确认弹窗 */}
<Modal
title={
<Space>
<WarningOutlined style={{ color: '#ff4d4f' }} />
<span></span>
</Space>
}
open={showClearConfirm}
onOk={confirmClearConversation}
onCancel={() => setShowClearConfirm(false)}
okText="确认清除"
cancelText="取消"
okButtonProps={{ danger: true }}
>
<div style={{ padding: '16px 0' }}>
<p style={{ marginBottom: '12px', color: '#666' }}>
<strong style={{ color: '#ff4d4f' }}></strong>
</p>
<ul style={{ paddingLeft: '20px', color: '#666' }}>
<li></li>
<li>Agent </li>
<li></li>
</ul>
<p style={{ marginTop: '12px', color: '#999', fontSize: '13px' }}>
使"刷新"
</p>
</div>
</Modal>
</div>
</div> </div>
</Sider>
); );
}; };

View File

@ -1,11 +1,13 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { Card, List, Tag, Space, Button, Typography, Progress, Empty, Spin, Badge, Tooltip, message } from 'antd' import { Card, List, Tag, Space, Button, Typography, Progress, Empty, Spin, Badge, Tooltip, message, Segmented } from 'antd'
import { PlayCircleOutlined, CheckCircleOutlined, ClockCircleOutlined, EditOutlined, EyeOutlined, PlusOutlined, ReloadOutlined } from '@ant-design/icons' import { PlayCircleOutlined, CheckCircleOutlined, ClockCircleOutlined, EditOutlined, EyeOutlined, PlusOutlined, ReloadOutlined } from '@ant-design/icons'
import { useProjectStore } from '@/stores/projectStore' import { useProjectStore } from '@/stores/projectStore'
import { Episode } from '@/services/projectService' import { Episode } from '@/services/projectService'
const { Text } = Typography const { Text } = Typography
type EpisodeStatusFilter = 'all' | 'pending' | 'writing' | 'completed'
interface EpisodeSidebarProps { interface EpisodeSidebarProps {
projectId: string; projectId: string;
onEpisodeSelect?: (episode: Episode) => void; onEpisodeSelect?: (episode: Episode) => void;
@ -21,6 +23,7 @@ export const EpisodeSidebar: React.FC<EpisodeSidebarProps> = ({
}) => { }) => {
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)
const [statusFilter, setStatusFilter] = useState<EpisodeStatusFilter>('all')
useEffect(() => { useEffect(() => {
if (projectId) { if (projectId) {
@ -90,6 +93,15 @@ export const EpisodeSidebar: React.FC<EpisodeSidebarProps> = ({
const completedEpisodes = episodes.filter(ep => ep.status === 'completed').length const completedEpisodes = episodes.filter(ep => ep.status === 'completed').length
const progress = totalEpisodes > 0 ? Math.round((completedEpisodes / totalEpisodes) * 100) : 0 const progress = totalEpisodes > 0 ? Math.round((completedEpisodes / totalEpisodes) * 100) : 0
// 根据筛选状态过滤剧集
const filteredEpisodes = episodes.filter(episode => {
if (statusFilter === 'all') return true
if (statusFilter === 'pending') return episode.status === 'pending'
if (statusFilter === 'writing') return episode.status === 'draft' || episode.status === 'writing'
if (statusFilter === 'completed') return episode.status === 'completed'
return true
})
return ( return (
<div <div
style={{ style={{
@ -103,7 +115,7 @@ export const EpisodeSidebar: React.FC<EpisodeSidebarProps> = ({
}} }}
> >
{/* 头部:进度统计 */} {/* 头部:进度统计 */}
<div style={{ padding: '16px', borderBottom: '1px solid #f0f0f0' }}> <div style={{ padding: '12px 16px', borderBottom: '1px solid #f0f0f0' }}>
<Space direction="vertical" style={{ width: '100%' }} size="small"> <Space direction="vertical" style={{ width: '100%' }} size="small">
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}> <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Text strong style={{ fontSize: '14px' }}></Text> <Text strong style={{ fontSize: '14px' }}></Text>
@ -124,13 +136,30 @@ export const EpisodeSidebar: React.FC<EpisodeSidebarProps> = ({
</Space> </Space>
</div> </div>
{/* 状态筛选器 - 响应式处理 */}
<div style={{ padding: '8px 12px', borderBottom: '1px solid #f0f0f0', overflowX: 'hidden' }}>
<Segmented
value={statusFilter}
onChange={setStatusFilter}
size="middle"
block
options={[
{ label: `全部${totalEpisodes}`, value: 'all' },
{ label: `待创作${episodes.filter(ep => ep.status === 'pending').length}`, value: 'pending' },
{ label: `创作中${episodes.filter(ep => ep.status === 'draft' || ep.status === 'writing').length}`, value: 'writing' },
{ label: `已完成${completedEpisodes}`, value: 'completed' }
]}
style={{ fontSize: '12px', whiteSpace: 'nowrap' }}
/>
</div>
{/* 剧集列表 */} {/* 剧集列表 */}
<div style={{ flex: 1, overflowY: 'auto', padding: '12px' }}> <div style={{ flex: 1, overflowY: 'auto', padding: '12px' }}>
{loading ? ( {loading ? (
<div style={{ textAlign: 'center', padding: '20px' }}> <div style={{ textAlign: 'center', padding: '20px' }}>
<Spin tip="加载中..." /> <Spin tip="加载中..." />
</div> </div>
) : episodes.length === 0 ? ( ) : filteredEpisodes.length === 0 ? (
<Empty <Empty
description="暂无剧集" description="暂无剧集"
image={Empty.PRESENTED_IMAGE_SIMPLE} image={Empty.PRESENTED_IMAGE_SIMPLE}
@ -138,7 +167,7 @@ export const EpisodeSidebar: React.FC<EpisodeSidebarProps> = ({
/> />
) : ( ) : (
<Space direction="vertical" style={{ width: '100%' }} size="small"> <Space direction="vertical" style={{ width: '100%' }} size="small">
{episodes.map((episode) => ( {filteredEpisodes.map((episode) => (
<Card <Card
key={episode.id} key={episode.id}
size="small" size="small"
@ -153,11 +182,11 @@ export const EpisodeSidebar: React.FC<EpisodeSidebarProps> = ({
<Space direction="vertical" style={{ width: '100%' }} size="small"> <Space direction="vertical" style={{ width: '100%' }} size="small">
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}> <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Space> <Space>
<Text strong style={{ fontSize: '13px' }}> <Text strong style={{ fontSize: '14px' }}>
EP{String(episode.number).padStart(2, '0')} EP{String(episode.number).padStart(2, '0')}
</Text> </Text>
{episode.title && ( {episode.title && (
<Text style={{ fontSize: '12px', color: '#666' }} ellipsis> <Text style={{ fontSize: '13px', color: '#666' }} ellipsis>
{episode.title} {episode.title}
</Text> </Text>
)} )}
@ -165,14 +194,14 @@ export const EpisodeSidebar: React.FC<EpisodeSidebarProps> = ({
<Tag <Tag
color={getStatusColor(episode.status)} color={getStatusColor(episode.status)}
icon={getStatusIcon(episode.status)} icon={getStatusIcon(episode.status)}
style={{ margin: 0, fontSize: '11px' }} style={{ margin: 0, fontSize: '12px' }}
> >
{getStatusText(episode.status)} {getStatusText(episode.status)}
</Tag> </Tag>
</div> </div>
{episode.qualityScore && ( {episode.qualityScore && (
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: '11px', color: '#999' }}> <div style={{ display: 'flex', justifyContent: 'space-between', fontSize: '12px', color: '#999' }}>
<span>: {episode.qualityScore}</span> <span>: {episode.qualityScore}</span>
{episode.issues && episode.issues.length > 0 && ( {episode.issues && episode.issues.length > 0 && (
<span>: {episode.issues.length}</span> <span>: {episode.issues.length}</span>

View File

@ -0,0 +1,114 @@
import { useState } from 'react'
import { Button, Space, Tooltip } from 'antd'
import {
BookOutlined,
UnorderedListOutlined
} from '@ant-design/icons'
import { EpisodeSidebar } from './EpisodeSidebar'
import { ContextPanel } from './ContextPanel'
type SidebarTab = 'episodes' | 'context'
interface LeftSidebarProps {
projectId: string
project: any
loading: boolean
activeStates?: any[]
memoryItems?: any[]
width?: number
currentEpisodeId?: string
onEpisodeSelect?: (episode: any) => void
onSendMessage?: (msg: string) => void
onNavigateToSettings?: () => void
}
export const LeftSidebar: React.FC<LeftSidebarProps> = ({
projectId,
project,
loading,
activeStates = [],
memoryItems = [],
width = 600,
currentEpisodeId,
onEpisodeSelect,
onSendMessage,
onNavigateToSettings
}) => {
const [activeTab, setActiveTab] = useState<SidebarTab>('episodes')
return (
<div
style={{
width: `${width}px`,
minWidth: '280px',
maxWidth: '600px',
height: '100%',
background: '#fafbfc',
border: '1px solid #e0e4e7',
borderRight: '2px solid #d1d5db',
display: 'flex',
flexDirection: 'column',
flexShrink: 0,
borderRadius: '8px 0 0 8px',
overflow: 'hidden',
boxShadow: 'inset -1px 0 0 #e5e7eb'
}}
>
{/* Tab 头部 */}
<div
style={{
display: 'flex',
alignItems: 'center',
padding: '12px 16px',
borderBottom: '2px solid #e0e4e7',
background: 'linear-gradient(180deg, #ffffff 0%, #f8fafc 100%)',
gap: '8px',
boxShadow: '0 1px 3px rgba(0,0,0,0.04)'
}}
>
<Button
type={activeTab === 'episodes' ? 'primary' : 'default'}
icon={<UnorderedListOutlined />}
onClick={() => setActiveTab('episodes')}
size="small"
style={{ flex: 1, fontSize: '12px' }}
>
</Button>
<Button
type={activeTab === 'context' ? 'primary' : 'default'}
icon={<BookOutlined />}
onClick={() => setActiveTab('context')}
size="small"
style={{ flex: 1, fontSize: '12px' }}
>
</Button>
</div>
{/* Tab 内容 */}
{activeTab === 'episodes' ? (
<div style={{ flex: 1, overflow: 'hidden', display: 'flex', flexDirection: 'column', minWidth: 0 }}>
<EpisodeSidebar
projectId={projectId}
onEpisodeSelect={onEpisodeSelect}
currentEpisodeId={currentEpisodeId}
onSendMessage={onSendMessage}
/>
</div>
) : (
<div style={{ flex: 1, overflow: 'hidden', display: 'flex', flexDirection: 'column', minWidth: 0 }}>
<ContextPanel
project={project}
loading={loading}
activeStates={activeStates}
memoryItems={memoryItems}
width={width}
onNavigateToSettings={onNavigateToSettings}
/>
</div>
)}
</div>
)
}

View File

@ -0,0 +1,346 @@
/**
* Review Task Card Component
*
* -
*
*
* 1.
* 2.
* 3.
* 4.
*/
import React, { useState, useEffect } from 'react'
import {
Card,
Tag,
Button,
Space,
Alert,
Modal,
Input,
Radio,
Divider,
Tooltip,
Typography
} from 'antd'
import {
CheckCircleOutlined,
CloseCircleOutlined,
EditOutlined,
EyeInvisibleOutlined,
WarningOutlined,
InfoCircleOutlined
} from '@ant-design/icons'
import { message } from 'antd'
import api from '@/services/api'
import type { ReviewTask } from '@/services/reviewService'
const { Text, Paragraph } = Typography
interface ReviewTaskCardProps {
task: ReviewTask
projectId: string
onActionComplete?: (taskId: string, action: string, response?: string) => void
}
const ReviewTaskCard: React.FC<ReviewTaskCardProps> = ({
task,
projectId,
onActionComplete
}) => {
const [actionModalVisible, setActionModalVisible] = useState(false)
const [selectedAction, setSelectedAction] = useState<string>('')
const [userResponse, setUserResponse] = useState('')
const [modifiedContent, setModifiedContent] = useState('')
// 严重程度颜色
const severityColors: Record<string, string> = {
low: 'green',
medium: 'orange',
high: 'red'
}
const severityLabels: Record<string, string> = {
low: '低',
medium: '中',
high: '高'
}
// 模式标签
const modeLabels: Record<string, string> = {
process: '过程中审核',
'post_creation': '生成后审核'
}
// 触发来源标签
const triggerSourceLabels: Record<string, string> = {
agent_auto: 'Agent自主',
user_skill: '用户配置Skill',
user_rule: '用户配置规则',
manual: '手动请求'
}
// 操作标签
const actionLabels: Record<string, string> = {
confirm: '确认继续',
modify: '修改内容',
ignore: '忽略问题',
other: '补充信息'
}
// 处理操作
const handleAction = async (action: string) => {
if (!taskId || action === 'other') {
// 其他操作需要打开模态框
setSelectedAction(action)
setActionModalVisible(true)
return
}
try {
const response = await api.post(`/review-task/process/${task.id}/action`, {
action,
user_response: userResponse || undefined,
modified_content: modifiedContent || undefined
})
message.success('操作已提交')
if (onActionComplete) {
onActionComplete(task.id, action, userResponse)
}
// 如果是确认或忽略,关闭卡片(过程中审核自动销毁)
if (action === 'confirm' || action === 'ignore') {
setActionModalVisible(false)
}
} catch (error) {
message.error('操作失败')
}
}
const handleSubmitAction = async () => {
await handleAction(selectedAction)
}
// 生成后审核的操作处理
const handlePostCreationAction = async (action: string) => {
if (action === 'other') {
setSelectedAction(action)
setActionModalVisible(true)
return
}
try {
const response = await api.post(`/review-task/post-creation/${task.id}/action`, {
action,
user_response: userResponse || undefined,
modified_content: modifiedContent || undefined
})
message.success('操作已提交')
if (onActionComplete) {
onActionComplete(task.id, action, userResponse)
}
if (action === 'confirm' || action === 'ignore') {
setActionModalVisible(false)
}
} catch (error) {
message.error('操作失败')
}
}
// 根据模式选择处理函数
const isProcessReview = task.mode === 'process'
const actionHandler = isProcessReview ? handleAction : handlePostCreationAction
const taskId = task.id
// 渲染操作按钮
const renderActionButtons = () => (
<Space direction="vertical" style={{ width: '100%' }}>
{task.available_actions.map(action => (
<Button
key={action}
type={action === 'confirm' ? 'primary' : 'default'}
icon={
action === 'confirm' ? <CheckCircleOutlined /> :
action === 'modify' ? <EditOutlined /> :
action === 'ignore' ? <EyeInvisibleOutlined /> :
<InfoCircleOutlined />
}
onClick={() => actionHandler(action)}
style={{ width: '100%' }}
>
{actionLabels[action]}
</Button>
))}
</Space>
)
// 渲染位置信息
const renderLocation = () => {
const { episode_number, scene_number, section, line_number, content_snippet } = task.location || {}
const parts = []
if (episode_number) parts.push(`${episode_number}`)
if (scene_number) parts.push(`场景 ${scene_number}`)
if (section) parts.push(`段落: ${section}`)
if (line_number) parts.push(`${line_number}`)
return (
<div style={{ marginBottom: 12 }}>
<Text type="secondary">
<span style={{ fontWeight: 'bold' }}>: </span>
{parts.join(' · ')}
</Text>
{content_snippet && (
<div style={{
marginTop: 8,
padding: 8,
background: '#f5f5f5',
borderRadius: 4,
fontSize: 12
}}>
<Text type="secondary">
<Tooltip title={content_snippet}>
{content_snippet.length > 100
? content_snippet.substring(0, 100) + '...'
: content_snippet}
</Tooltip>
</Text>
</div>
)}
</div>
)
}
// 渲染操作模态框
const renderActionModal = () => (
<Modal
title="确认操作"
open={actionModalVisible}
onCancel={() => setActionModalVisible(false)}
footer={[
<Button key="cancel" onClick={() => setActionModalVisible(false)}>
</Button>,
<Button
key="submit"
type="primary"
onClick={handleSubmitAction}
disabled={selectedAction === 'other' && !userResponse.trim()}
>
</Button>
]}
>
<Space direction="vertical" style={{ width: '100%' }}>
<Text strong>: {actionLabels[selectedAction]}</Text>
<Divider />
{selectedAction === 'modify' && (
<>
<Text>:</Text>
<Input.TextArea
rows={6}
placeholder="输入修改后的内容..."
value={modifiedContent}
onChange={e => setModifiedContent(e.target.value)}
style={{ marginTop: 8 }}
/>
</>
)}
{selectedAction === 'other' && (
<>
<Text>:</Text>
<Input.TextArea
rows={4}
placeholder="输入补充说明..."
value={userResponse}
onChange={e => setUserResponse(e.target.value)}
style={{ marginTop: 8 }}
/>
</>
)}
{task.suggestion && (
<Alert
message="建议"
description={task.suggestion}
type="info"
showIcon
style={{ marginTop: 16 }}
/>
)}
</Space>
</Modal>
)
return (
<Card
size="small"
bordered={true}
style={{
marginBottom: 16,
borderLeftWidth: 4,
borderLeftColor: severityColors[task.severity]
}}
>
{/* 头部:状态标签 */}
<Space style={{ width: '100%', marginBottom: 12 }} wrap>
<Tag color={severityColors[task.severity]}>
{severityLabels[task.severity]}
</Tag>
<Tag color="blue">
{task.issue_type}
</Tag>
<Tag color="cyan">
{modeLabels[task.mode] || task.mode}
</Tag>
{task.trigger_source && (
<Tag color="default">
: {triggerSourceLabels[task.trigger_source] || task.trigger_source}
</Tag>
)}
</Space>
{/* 问题描述 */}
<Paragraph
style={{
marginBottom: 12,
padding: 12,
background: '#fffbe6',
borderRadius: 4,
border: '1px solid #ffd591'
}}
>
{task.issue_description}
</Paragraph>
{/* 位置信息 */}
{renderLocation()}
{/* 操作按钮 */}
{renderActionButtons()}
{/* 操作模态框 */}
{renderActionModal()}
{/* 修改建议 */}
{task.suggestion && (
<Alert
message="修改建议"
description={task.suggestion}
type="info"
showIcon
style={{ marginTop: 8 }}
/>
)}
</Card>
)
}
export default ReviewTaskCard

File diff suppressed because it is too large Load Diff

View File

@ -1,13 +1,21 @@
import React from 'react' import React from 'react'
import ReactDOM from 'react-dom/client' import ReactDOM from 'react-dom/client'
import { ConfigProvider } from 'antd' import { ConfigProvider, theme } from 'antd'
import zhCN from 'antd/locale/zh_CN' import zhCN from 'antd/locale/zh_CN'
import App from './App' import App from './App'
import './index.css' import './index.css'
ReactDOM.createRoot(document.getElementById('root')!).render( ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode> <React.StrictMode>
<ConfigProvider locale={zhCN}> <ConfigProvider
locale={zhCN}
theme={{
algorithm: theme.defaultAlgorithm,
token: {
colorPrimary: '#0ea5e9',
},
}}
>
<App /> <App />
</ConfigProvider> </ConfigProvider>
</React.StrictMode>, </React.StrictMode>,

View File

@ -24,7 +24,8 @@ import {
Empty, Empty,
Spin, Spin,
message, message,
Progress Progress,
Alert
} from 'antd' } from 'antd'
import { import {
PlusOutlined, PlusOutlined,
@ -41,8 +42,13 @@ import { ImportanceLevel } from '@/services/memoryService'
const { TabPane } = Tabs const { TabPane } = Tabs
const { TextArea } = Input const { TextArea } = Input
const MemorySystem: React.FC = () => { interface MemorySystemProps {
const { id: projectId } = useParams<{ id: string }>() projectId?: string
}
const MemorySystem: React.FC<MemorySystemProps> = ({ projectId: propProjectId }) => {
const { id: routeProjectId } = useParams<{ id: string }>()
const projectId = propProjectId || routeProjectId
const { const {
memorySystem, memorySystem,
timeline, timeline,
@ -215,63 +221,104 @@ const MemorySystem: React.FC = () => {
if (loading && !memorySystem) { if (loading && !memorySystem) {
return ( return (
<div style={{ textAlign: 'center', padding: '100px 0' }}> <div style={{ textAlign: 'center', padding: '100px 0' }}>
<Spin size="large" /> <Spin size="large" tip="正在加载记忆系统..." />
</div> </div>
) )
} }
if (error) { if (error) {
return <div style={{ padding: '24px', color: 'red' }}>Error: {error}</div> return (
<div style={{ padding: '24px' }}>
<Card>
<Alert
message="加载失败"
description={error}
type="error"
showIcon
action={
<Button size="small" onClick={() => projectId && fetchMemorySystem(projectId)}>
</Button>
}
/>
</Card>
</div>
)
}
if (!memorySystem) {
return (
<div style={{ padding: '24px' }}>
<Card>
<Empty
description="暂无记忆系统数据"
image={Empty.PRESENTED_IMAGE_SIMPLE}
>
<Button type="primary" onClick={() => projectId && fetchMemorySystem(projectId)}>
</Button>
</Empty>
</Card>
</div>
)
} }
// Statistics Overview // Statistics Overview
const renderStatistics = () => { const renderStatistics = () => {
const stats = memorySystem?.statistics const stats = memorySystem?.statistics
const titleStyle = { fontSize: '12px', color: '#8c8c8c' }
const valueStyle = { fontSize: '18px', fontWeight: 600 }
const iconStyle = { fontSize: '14px' }
return ( return (
<Row gutter={16} style={{ marginBottom: 24 }}> <Row gutter={12} style={{ marginBottom: 20 }}>
<Col span={4}> <Col flex={1}>
<Card> <Card size="small" bodyStyle={{ padding: '12px' }}>
<Statistic title="总事件" value={stats?.totalEvents || 0} prefix={<ClockCircleOutlined />} /> <Statistic
title={<span style={titleStyle}></span>}
value={stats?.totalEvents || 0}
prefix={<ClockCircleOutlined style={iconStyle} />}
valueStyle={valueStyle}
/>
</Card> </Card>
</Col> </Col>
<Col span={4}> <Col flex={1}>
<Card> <Card size="small" bodyStyle={{ padding: '12px' }}>
<Statistic <Statistic
title="剧情线" title={<span style={titleStyle}>线</span>}
value={stats?.totalThreads || 0} value={stats?.totalThreads || 0}
suffix={`/ ${stats?.resolvedThreads || 0} 已解决`} suffix={<span style={{ fontSize: '12px', color: '#bfbfbf', fontWeight: 'normal' }}>/ {stats?.resolvedThreads || 0} </span>}
valueStyle={valueStyle}
/> />
</Card> </Card>
</Col> </Col>
<Col span={4}> <Col flex={1}>
<Card> <Card size="small" bodyStyle={{ padding: '12px' }}>
<Statistic title="角色" value={stats?.totalCharacters || 0} prefix={<UserOutlined />} />
</Card>
</Col>
<Col span={4}>
<Card>
<Statistic <Statistic
title="伏笔" title={<span style={titleStyle}></span>}
value={stats?.totalForeshadowing || 0} value={stats?.totalCharacters || 0}
suffix={`/ ${stats?.paidOffForeshadowing || 0} 已收线`} prefix={<UserOutlined style={iconStyle} />}
valueStyle={valueStyle}
/> />
</Card> </Card>
</Col> </Col>
<Col span={4}> <Col flex={1}>
<Card> <Card size="small" bodyStyle={{ padding: '12px' }}>
<Statistic title="关系" value={stats?.totalRelationships || 0} /> <Statistic
title={<span style={titleStyle}></span>}
value={stats?.totalForeshadowing || 0}
suffix={<span style={{ fontSize: '12px', color: '#bfbfbf', fontWeight: 'normal' }}>/ {stats?.paidOffForeshadowing || 0} 线</span>}
valueStyle={valueStyle}
/>
</Card> </Card>
</Col> </Col>
<Col span={4}> <Col flex={1}>
<Card> <Card size="small" bodyStyle={{ padding: '12px' }}>
<Button <Statistic
icon={<ReloadOutlined />} title={<span style={titleStyle}></span>}
onClick={() => projectId && fetchMemorySystem(projectId)} value={stats?.totalRelationships || 0}
loading={loading} valueStyle={valueStyle}
> />
</Button>
</Card> </Card>
</Col> </Col>
</Row> </Row>
@ -289,7 +336,7 @@ const MemorySystem: React.FC = () => {
onChange={(value) => setTimelineFilters({ ...timelineFilters, character: value })} onChange={(value) => setTimelineFilters({ ...timelineFilters, character: value })}
value={timelineFilters.character} value={timelineFilters.character}
> >
{characterStates.map(cs => ( {(Array.isArray(characterStates) ? characterStates : []).map(cs => (
<Select.Option key={cs.characterName} value={cs.characterName}> <Select.Option key={cs.characterName} value={cs.characterName}>
{cs.characterName} {cs.characterName}
</Select.Option> </Select.Option>
@ -302,7 +349,7 @@ const MemorySystem: React.FC = () => {
onChange={(value) => setTimelineFilters({ ...timelineFilters, episode: value })} onChange={(value) => setTimelineFilters({ ...timelineFilters, episode: value })}
value={timelineFilters.episode} value={timelineFilters.episode}
> >
{Array.from(new Set(timeline.map(t => t.episodeNumber))).map(ep => ( {Array.from(new Set((Array.isArray(timeline) ? timeline : []).map(t => t.episodeNumber))).map(ep => (
<Select.Option key={ep} value={ep}> {ep} </Select.Option> <Select.Option key={ep} value={ep}> {ep} </Select.Option>
))} ))}
</Select> </Select>
@ -320,11 +367,11 @@ const MemorySystem: React.FC = () => {
<Button onClick={clearTimelineFilters}></Button> <Button onClick={clearTimelineFilters}></Button>
</Space> </Space>
{timeline.length === 0 ? ( {(Array.isArray(timeline) ? timeline : []).length === 0 ? (
<Empty description="暂无时间线事件" /> <Empty description="暂无时间线事件" />
) : ( ) : (
<Timeline mode="left"> <Timeline mode="left">
{timeline.map(event => ( {(Array.isArray(timeline) ? timeline : []).map(event => (
<Timeline.Item <Timeline.Item
key={event.id} key={event.id}
dot={<Avatar size="small" icon={<ClockCircleOutlined />} />} dot={<Avatar size="small" icon={<ClockCircleOutlined />} />}
@ -368,11 +415,11 @@ const MemorySystem: React.FC = () => {
线 线
</Button> </Button>
{threads.length === 0 ? ( {(Array.isArray(threads) ? threads : []).length === 0 ? (
<Empty description="暂无待处理剧情线" /> <Empty description="暂无待处理剧情线" />
) : ( ) : (
<Space direction="vertical" style={{ width: '100%' }} size="middle"> <Space direction="vertical" style={{ width: '100%' }} size="middle">
{threads.map(thread => ( {(Array.isArray(threads) ? threads : []).map(thread => (
<Card <Card
key={thread.id} key={thread.id}
size="small" size="small"
@ -433,11 +480,11 @@ const MemorySystem: React.FC = () => {
// Character States Tab // Character States Tab
const renderCharacterStates = () => ( const renderCharacterStates = () => (
<div> <div>
{characterStates.length === 0 ? ( {(Array.isArray(characterStates) ? characterStates : []).length === 0 ? (
<Empty description="暂无角色状态信息" /> <Empty description="暂无角色状态信息" />
) : ( ) : (
<Space direction="vertical" style={{ width: '100%' }} size="large"> <Space direction="vertical" style={{ width: '100%' }} size="large">
{characterStates.map(cs => ( {(Array.isArray(characterStates) ? characterStates : []).map(cs => (
<Card <Card
key={cs.characterName} key={cs.characterName}
title={ title={
@ -485,11 +532,11 @@ const MemorySystem: React.FC = () => {
</Button> </Button>
{foreshadowing.length === 0 ? ( {(Array.isArray(foreshadowing) ? foreshadowing : []).length === 0 ? (
<Empty description="暂无伏笔记录" /> <Empty description="暂无伏笔记录" />
) : ( ) : (
<Space direction="vertical" style={{ width: '100%' }} size="middle"> <Space direction="vertical" style={{ width: '100%' }} size="middle">
{foreshadowing.map(item => ( {(Array.isArray(foreshadowing) ? foreshadowing : []).map(item => (
<Card <Card
key={item.id} key={item.id}
size="small" size="small"
@ -534,11 +581,11 @@ const MemorySystem: React.FC = () => {
// Relationships Tab // Relationships Tab
const renderRelationships = () => ( const renderRelationships = () => (
<div> <div>
{relationships.length === 0 ? ( {(Array.isArray(relationships) ? relationships : []).length === 0 ? (
<Empty description="暂无关系数据" /> <Empty description="暂无关系数据" />
) : ( ) : (
<Space direction="vertical" style={{ width: '100%' }} size="middle"> <Space direction="vertical" style={{ width: '100%' }} size="middle">
{relationships.map((rel, idx) => ( {(Array.isArray(relationships) ? relationships : []).map((rel, idx) => (
<Card key={idx} size="small"> <Card key={idx} size="small">
<Space direction="vertical" style={{ width: '100%' }}> <Space direction="vertical" style={{ width: '100%' }}>
<Space> <Space>
@ -565,13 +612,32 @@ const MemorySystem: React.FC = () => {
) )
return ( return (
<div style={{ padding: 24 }}> <div style={{ height: '100%', display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
<Card title="记忆系统" extra={<ReloadOutlined onClick={() => projectId && fetchMemorySystem(projectId)} />}> <div style={{ flexShrink: 0 }}>
{renderStatistics()} {renderStatistics()}
</div>
<Tabs activeKey={activeTab} onChange={setActiveTab}> <Tabs
activeKey={activeTab}
onChange={setActiveTab}
type="card"
style={{ flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}
tabBarExtraContent={
<Button
type="text"
size="small"
icon={<ReloadOutlined spin={loading} />}
onClick={() => projectId && fetchMemorySystem(projectId)}
loading={loading}
>
</Button>
}
>
<TabPane tab="时间线" key="timeline"> <TabPane tab="时间线" key="timeline">
<div style={{ padding: '16px 0 24px 0', height: '100%', overflowY: 'auto' }}>
{renderTimeline()} {renderTimeline()}
</div>
</TabPane> </TabPane>
<TabPane <TabPane
tab={ tab={
@ -582,19 +648,26 @@ const MemorySystem: React.FC = () => {
} }
key="threads" key="threads"
> >
<div style={{ padding: '16px 0 24px 0', height: '100%', overflowY: 'auto' }}>
{renderThreads()} {renderThreads()}
</div>
</TabPane> </TabPane>
<TabPane tab="角色状态" key="characters"> <TabPane tab="角色状态" key="characters">
<div style={{ padding: '16px 0 24px 0', height: '100%', overflowY: 'auto' }}>
{renderCharacterStates()} {renderCharacterStates()}
</div>
</TabPane> </TabPane>
<TabPane tab="伏笔管理" key="foreshadowing"> <TabPane tab="伏笔管理" key="foreshadowing">
<div style={{ padding: '16px 0 24px 0', height: '100%', overflowY: 'auto' }}>
{renderForeshadowing()} {renderForeshadowing()}
</div>
</TabPane> </TabPane>
<TabPane tab="关系图" key="relationships"> <TabPane tab="关系图" key="relationships">
<div style={{ padding: '16px 0 24px 0', height: '100%', overflowY: 'auto' }}>
{renderRelationships()} {renderRelationships()}
</div>
</TabPane> </TabPane>
</Tabs> </Tabs>
</Card>
{/* Thread Modal */} {/* Thread Modal */}
<Modal <Modal
@ -636,7 +709,7 @@ const MemorySystem: React.FC = () => {
</Form.Item> </Form.Item>
<Form.Item name="relatedCharacters" label="相关角色"> <Form.Item name="relatedCharacters" label="相关角色">
<Select mode="tags" placeholder="选择或输入角色名"> <Select mode="tags" placeholder="选择或输入角色名">
{characterStates.map(cs => ( {(Array.isArray(characterStates) ? characterStates : []).map(cs => (
<Select.Option key={cs.characterName} value={cs.characterName}> <Select.Option key={cs.characterName} value={cs.characterName}>
{cs.characterName} {cs.characterName}
</Select.Option> </Select.Option>

View File

@ -10,21 +10,29 @@
*/ */
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, Tag, Space, Modal, message, Spin, Tabs, Form, Input, InputNumber, Upload, Alert, Popover, Select } from 'antd'
import { ArrowLeftOutlined, PlayCircleOutlined, CheckCircleOutlined, LoadingOutlined, ClockCircleOutlined, ScanOutlined, FileTextOutlined, SettingOutlined, RobotOutlined, UploadOutlined, EditOutlined, SaveOutlined, RocketOutlined, UnorderedListOutlined, BookOutlined } from '@ant-design/icons' import { ArrowLeftOutlined, LoadingOutlined, ClockCircleOutlined, ScanOutlined, SettingOutlined, RobotOutlined, UploadOutlined, EditOutlined, SaveOutlined } 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 { projectService, Episode } from '@/services/projectService' import { projectService, Episode } from '@/services/projectService'
import { taskService } from '@/services/taskService' import { taskService, CreateTaskResponse } from '@/services/taskService'
import { ContextPanel } from '@/components/Workspace/ContextPanel' import { TaskProgress } from '@/components/TaskProgressTracker'
import { SmartCanvas } from '@/components/Workspace/SmartCanvas' import { SmartCanvas } from '@/components/Workspace/SmartCanvas'
import { DirectorInbox } from '@/components/Workspace/DirectorInbox' import { DirectorInbox } from '@/components/Workspace/DirectorInbox'
import { EpisodeSidebar } from '@/components/Workspace/EpisodeSidebar' import { LeftSidebar } from '@/components/Workspace/LeftSidebar'
import MemorySystem from './MemorySystem'
import ReviewConfig from './ReviewConfig'
import ReviewResults from './ReviewResults'
const { Paragraph } = Typography
const { TabPane } = Tabs const { TabPane } = Tabs
const { TextArea } = Input const { TextArea } = Input
const { Header, Content, Sider } = Layout
// AI辅助选项接口
interface AIAssistOptions {
skills?: any[]
customPrompt?: string
injectAgent?: boolean
}
// Skill 选择器和自定义提示词组件 // Skill 选择器和自定义提示词组件
const SkillSelectorWithPrompt = ({ const SkillSelectorWithPrompt = ({
@ -115,15 +123,12 @@ const SkillSelectorWithPrompt = ({
export const ProjectDetail = () => { export const ProjectDetail = () => {
const { id } = useParams<{ id: string }>() const { id } = useParams<{ id: string }>()
const navigate = useNavigate() const navigate = useNavigate()
const location = window.location // Or use useLocation from react-router-dom
// 调试:确认组件已加载 // 调试:确认组件已加载
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, updateEpisodeInList } = useProjectStore() const { currentProject, projects, episodes, loading, error, fetchProject, fetchEpisodes, updateProject, updateEpisodeInList } = useProjectStore()
const { skills, fetchSkills } = useSkillStore() const { skills, fetchSkills } = useSkillStore()
const [executing, setExecuting] = useState(false)
const [currentEpisode, setCurrentEpisode] = useState<number>(1)
const [selectedEpisode, setSelectedEpisode] = useState<Episode | null>(null) const [selectedEpisode, setSelectedEpisode] = useState<Episode | null>(null)
// Use location state to set initial tab if provided // Use location state to set initial tab if provided
@ -135,6 +140,9 @@ export const ProjectDetail = () => {
if (state?.activeTab) { if (state?.activeTab) {
setActiveTab(state.activeTab); setActiveTab(state.activeTab);
} }
if (state?.reviewSubTab) {
setReviewSubTab(state.reviewSubTab);
}
}, []); }, []);
// 项目设置相关状态 // 项目设置相关状态
@ -157,6 +165,9 @@ export const ProjectDetail = () => {
// 步骤0=未开始, 1=世界观完成, 2=人物完成, 3=大纲完成 // 步骤0=未开始, 1=世界观完成, 2=人物完成, 3=大纲完成
const [generationStep, setGenerationStep] = useState(0) const [generationStep, setGenerationStep] = useState(0)
// 异步任务状态
// const [activeTasks, setActiveTasks] = useState<Record<string, { type: string; status: string; progress: number }>>({})
// Skills 配置 // Skills 配置
const [worldSkills, setWorldSkills] = useState<string[]>([]) const [worldSkills, setWorldSkills] = useState<string[]>([])
const [characterSkills, setCharacterSkills] = useState<string[]>([]) const [characterSkills, setCharacterSkills] = useState<string[]>([])
@ -174,20 +185,30 @@ export const ProjectDetail = () => {
const [wsConnected, setWsConnected] = useState(false) const [wsConnected, setWsConnected] = useState(false)
const [streaming, setStreaming] = useState(false) const [streaming, setStreaming] = useState(false)
const [canvasContent, setCanvasContent] = useState<string>('') const [canvasContent, setCanvasContent] = useState<string>('')
const [outlineContent, setOutlineContent] = useState<string>('')
const [outlineStreaming, setOutlineStreaming] = useState(false)
const [agentStatus, setAgentStatus] = useState<'idle' | 'planning' | 'writing' | 'reviewing'>('idle') const [agentStatus, setAgentStatus] = useState<'idle' | 'planning' | 'writing' | 'reviewing'>('idle')
const [agentPlan, setAgentPlan] = useState<string[]>([]) const [agentPlan, setAgentPlan] = useState<string[]>([])
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: '未知地点' }
]) ])
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 [reviewSubTab, setReviewSubTab] = useState('config')
const [showContextPanel, setShowContextPanel] = useState(true)
// 执行和保存状态
// const [executing, setExecuting] = useState(false)
// const [savingContent, setSavingContent] = useState(false)
// 工作台侧边栏宽度状态
const [leftPanelWidth, setLeftPanelWidth] = useState(360)
const [rightPanelWidth, setRightPanelWidth] = useState(280)
const [isResizing, setIsResizing] = useState<'left' | 'right' | null>(null)
const resizeStartRef = useRef<{ x: number; leftWidth: number; rightWidth: number } | null>(null)
// 检查全局设定是否完成 // 检查全局设定是否完成
const globalSettingsCompleted = currentProject?.globalContext?.worldSetting?.trim() && const globalSettingsCompleted = currentProject?.globalContext?.worldSetting?.trim() &&
@ -197,6 +218,42 @@ export const ProjectDetail = () => {
const wsRef = useRef<WebSocket | null>(null) const wsRef = useRef<WebSocket | null>(null)
const reconnectTimeoutRef = useRef<number | null>(null) const reconnectTimeoutRef = useRef<number | null>(null)
// 拖拽调整侧边栏宽度
useEffect(() => {
const handleMouseMove = (e: MouseEvent) => {
if (!isResizing || !resizeStartRef.current) return
const deltaX = e.clientX - resizeStartRef.current.x
if (isResizing === 'left') {
const newWidth = Math.max(280, Math.min(600, resizeStartRef.current.leftWidth + deltaX))
setLeftPanelWidth(newWidth)
} else if (isResizing === 'right') {
const newWidth = Math.max(220, Math.min(450, resizeStartRef.current.rightWidth - deltaX))
setRightPanelWidth(newWidth)
}
}
const handleMouseUp = () => {
setIsResizing(null)
resizeStartRef.current = null
}
if (isResizing) {
document.addEventListener('mousemove', handleMouseMove)
document.addEventListener('mouseup', handleMouseUp)
document.body.style.userSelect = 'none'
document.body.style.cursor = 'col-resize'
}
return () => {
document.removeEventListener('mousemove', handleMouseMove)
document.removeEventListener('mouseup', handleMouseUp)
document.body.style.userSelect = ''
document.body.style.cursor = ''
}
}, [isResizing])
// 加载 Skills // 加载 Skills
useEffect(() => { useEffect(() => {
const loadSkills = async () => { const loadSkills = async () => {
@ -233,9 +290,11 @@ export const ProjectDetail = () => {
if (hasScript) { if (hasScript) {
setCreationMode('script') setCreationMode('script')
setScriptContent(currentProject.globalContext.uploadedScript || '') setScriptContent(currentProject.globalContext.uploadedScript || '')
setScriptEditing(true) // Show content editor when script exists
} else if (hasInspiration) { } else if (hasInspiration) {
setCreationMode('inspiration') setCreationMode('inspiration')
setInspirationContent(currentProject.globalContext.inspiration || '') setInspirationContent(currentProject.globalContext.inspiration || '')
setInspirationEditing(true) // Show content editor when inspiration exists
} }
// 初始化全局设定表单 // 初始化全局设定表单
@ -346,7 +405,71 @@ export const ProjectDetail = () => {
} }
} }
// AI生成/分析世界观 // 轮询任务状态的辅助函数(非阻塞)
const monitorTask = async (taskId: string, _type: string) => {
try {
// 添加到活动任务
/*
setActiveTasks(prev => ({
...prev,
[taskId]: { type, status: 'running', progress: 0 }
}))
*/
// 使用轮询但不会阻塞UI
const checkInterval = setInterval(async () => {
try {
const task = (await taskService.getTask(taskId)) as unknown as TaskProgress
// 兼容不同的进度格式
/*
const progressValue = typeof task.progress === 'number' ? task.progress :
(task.progress?.current || 0)
setActiveTasks(prev => ({
...prev,
[taskId]: { type, status: task.status || 'running', progress: progressValue }
}))
*/
if (task.status === 'completed') {
clearInterval(checkInterval)
/*
setActiveTasks(prev => {
const newTasks = { ...prev }
delete newTasks[taskId]
return newTasks
})
*/
return task.result
}
if (task.status === 'failed') {
clearInterval(checkInterval)
/*
setActiveTasks(prev => {
const newTasks = { ...prev }
delete newTasks[taskId]
return newTasks
})
*/
throw new Error(task.error || '任务执行失败')
}
} catch (err) {
clearInterval(checkInterval)
console.error('Task monitoring error:', err)
}
}, 1000)
// 返回taskId供调用者跟踪
return taskId
} catch (error) {
console.error('Failed to start task monitoring:', error)
throw error
}
}
// AI生成/分析世界观(完全异步)
const handleGenerateWorld = async () => { const handleGenerateWorld = async () => {
if (!id) return if (!id) return
if (generatingWorld) return // 防止重复点击 if (generatingWorld) return // 防止重复点击
@ -373,7 +496,9 @@ export const ProjectDetail = () => {
try { try {
setGeneratingWorld(true) setGeneratingWorld(true)
const response = await taskService.generateWorld({
// 使用异步API创建任务
const response = (await taskService.generateWorld({
idea, idea,
projectName, projectName,
genre: projectGenre, genre: projectGenre,
@ -384,20 +509,16 @@ export const ProjectDetail = () => {
})), })),
customPrompt: worldPrompt || undefined, customPrompt: worldPrompt || undefined,
projectId: id projectId: id
}) })) as unknown as CreateTaskResponse
// 轮询任务直到完成
const result = await taskService.pollTask(response.taskId)
const worldSetting = result?.worldSetting || result
const worldText = typeof worldSetting === 'string' ? worldSetting : JSON.stringify(worldSetting, null, 2)
globalForm.setFieldsValue({ worldSetting: worldText })
// 更新步骤状态
setGenerationStep(1)
hideMessage() hideMessage()
message.success(isAnalysis ? '世界观分析完成!' : '世界观设定生成完成!') message.success('世界观生成任务已启动,可在后台进行')
// 启动非阻塞监控
monitorTask(response.taskId, 'world')
// 立即返回,不等待完成
setGenerationStep(1)
} catch (error: any) { } catch (error: any) {
hideMessage() hideMessage()
message.error(`生成失败: ${error.message || '未知错误'}`) message.error(`生成失败: ${error.message || '未知错误'}`)
@ -406,7 +527,7 @@ export const ProjectDetail = () => {
} }
} }
// AI生成/分析人物设定 // AI生成/分析人物设定(完全异步)
const handleGenerateCharacters = async () => { const handleGenerateCharacters = async () => {
if (!id) return if (!id) return
if (generatingCharacters) return // 防止重复点击 if (generatingCharacters) return // 防止重复点击
@ -443,7 +564,9 @@ export const ProjectDetail = () => {
try { try {
setGeneratingCharacters(true) setGeneratingCharacters(true)
const response = await taskService.generateCharacters({
// 使用异步API创建任务
const response = (await taskService.generateCharacters({
idea, idea,
projectName, projectName,
genre: projectGenre, genre: projectGenre,
@ -455,11 +578,22 @@ export const ProjectDetail = () => {
})), })),
customPrompt: characterPrompt || undefined, customPrompt: characterPrompt || undefined,
projectId: id projectId: id
}) })) as unknown as CreateTaskResponse
// 轮询任务直到完成 hideMessage()
const result = await taskService.pollTask(response.taskId) message.success('人物生成任务已启动,可在后台进行')
// 启动非阻塞监控,并在完成后自动填充结果
const taskId = response.taskId
monitorTask(taskId, 'characters')
// 设置完成后回调
const checkInterval = setInterval(async () => {
try {
const task = (await taskService.getTask(taskId)) as unknown as TaskProgress
if (task.status === 'completed') {
clearInterval(checkInterval)
const result = task.result
const characters = result?.characters || result const characters = result?.characters || result
let characterText = '' let characterText = ''
if (typeof characters === 'string') { if (typeof characters === 'string') {
@ -476,12 +610,17 @@ export const ProjectDetail = () => {
characterText = JSON.stringify(characters, null, 2) characterText = JSON.stringify(characters, null, 2)
} }
globalForm.setFieldsValue({ characters: characterText }) globalForm.setFieldsValue({ characters: characterText })
// 更新步骤状态
setGenerationStep(2) setGenerationStep(2)
hideMessage()
message.success(isAnalysis ? '人物设定分析完成!' : '人物设定生成完成!') message.success(isAnalysis ? '人物设定分析完成!' : '人物设定生成完成!')
}
if (task.status === 'failed') {
clearInterval(checkInterval)
message.error(`人物生成失败: ${task.error || '未知错误'}`)
}
} catch (err) {
clearInterval(checkInterval)
}
}, 2000)
} catch (error: any) { } catch (error: any) {
hideMessage() hideMessage()
message.error(`生成失败: ${error.message || '未知错误'}`) message.error(`生成失败: ${error.message || '未知错误'}`)
@ -490,7 +629,7 @@ export const ProjectDetail = () => {
} }
} }
// AI生成/分析大纲 // AI生成/分析大纲(完全异步)
const handleGenerateOutline = async () => { const handleGenerateOutline = async () => {
if (!id) return if (!id) return
if (generatingOutline) return // 防止重复点击 if (generatingOutline) return // 防止重复点击
@ -531,7 +670,9 @@ export const ProjectDetail = () => {
try { try {
setGeneratingOutline(true) setGeneratingOutline(true)
const response = await taskService.generateOutline({
// 使用异步API创建任务
const response = (await taskService.generateOutline({
idea, idea,
totalEpisodes, totalEpisodes,
genre: projectGenre, genre: projectGenre,
@ -543,20 +684,36 @@ export const ProjectDetail = () => {
})), })),
customPrompt: outlinePrompt || undefined, customPrompt: outlinePrompt || undefined,
projectId: id projectId: id
}) })) as unknown as CreateTaskResponse
// 轮询任务直到完成 hideMessage()
const result = await taskService.pollTask(response.taskId) message.success('大纲生成任务已启动,可在后台进行')
// 启动非阻塞监控,并在完成后自动填充结果
const taskId = response.taskId
monitorTask(taskId, 'outline')
// 设置完成后回调
const checkInterval = setInterval(async () => {
try {
const task = (await taskService.getTask(taskId)) as unknown as TaskProgress
if (task.status === 'completed') {
clearInterval(checkInterval)
const result = task.result
const outline = result?.outline || result const outline = result?.outline || result
const outlineText = typeof outline === 'string' ? outline : JSON.stringify(outline, null, 2) const outlineText = typeof outline === 'string' ? outline : JSON.stringify(outline, null, 2)
globalForm.setFieldsValue({ overallOutline: outlineText }) globalForm.setFieldsValue({ overallOutline: outlineText })
// 更新步骤状态
setGenerationStep(3) setGenerationStep(3)
hideMessage()
message.success(isAnalysis ? '整体大纲分析完成!' : '整体大纲生成完成!') message.success(isAnalysis ? '整体大纲分析完成!' : '整体大纲生成完成!')
}
if (task.status === 'failed') {
clearInterval(checkInterval)
message.error(`大纲生成失败: ${task.error || '未知错误'}`)
}
} catch (err) {
clearInterval(checkInterval)
}
}, 2000)
} catch (error: any) { } catch (error: any) {
hideMessage() hideMessage()
message.error(`生成失败: ${error.message || '未知错误'}`) message.error(`生成失败: ${error.message || '未知错误'}`)
@ -566,6 +723,7 @@ export const ProjectDetail = () => {
} }
// 一键生成全部(世界观 → 人物设定 → 大纲) // 一键生成全部(世界观 → 人物设定 → 大纲)
/*
const handleGenerateAll = async () => { const handleGenerateAll = async () => {
if (!id) return if (!id) return
if (generatingAll || generatingWorld || generatingCharacters || generatingOutline) return if (generatingAll || generatingWorld || generatingCharacters || generatingOutline) return
@ -601,6 +759,7 @@ export const ProjectDetail = () => {
setGeneratingAll(false) setGeneratingAll(false)
} }
} }
*/
// 保存全局设定 // 保存全局设定
const handleSaveGlobalSettings = async () => { const handleSaveGlobalSettings = async () => {
@ -629,8 +788,9 @@ export const ProjectDetail = () => {
} }
// 执行剧集创作 // 执行剧集创作
/*
const handleExecuteEpisode = async (epNum: number) => { const handleExecuteEpisode = async (epNum: number) => {
setExecuting(true) // setExecuting(true)
try { try {
await executeEpisode(id!, epNum) await executeEpisode(id!, epNum)
message.success(`EP${epNum} 创作完成!`) message.success(`EP${epNum} 创作完成!`)
@ -638,9 +798,10 @@ export const ProjectDetail = () => {
} catch (error) { } catch (error) {
message.error(`创作失败: ${(error as Error).message}`) message.error(`创作失败: ${(error as Error).message}`)
} finally { } finally {
setExecuting(false) // setExecuting(false)
} }
} }
*/
// ==================== 工作台 WebSocket 相关函数 ==================== // ==================== 工作台 WebSocket 相关函数 ====================
@ -736,6 +897,45 @@ export const ProjectDetail = () => {
timestamp: Date.now() timestamp: Date.now()
}]) }])
break break
case 'review_completed':
// 处理审核完成消息
if (msg.data) {
const { score, passed, issue_count, issues } = msg.data
// 显示审核结果通知
if (passed) {
message.success(`✅ 审核通过!得分: ${score?.toFixed(1) || 'N/A'}`)
} else {
message.warning(`⚠️ 审核发现问题!得分: ${score?.toFixed(1) || 'N/A'},问题数: ${issue_count || 0}`)
}
// 如果有问题,添加到收件箱供用户查看
if (issues && issues.length > 0) {
setInboxItems(prev => [...prev, {
id: `review_${Date.now()}`,
type: 'review_card',
title: `审核结果 - ${passed ? '通过' : '发现问题'}`,
description: `发现 ${issues.length} 个问题。得分: ${score?.toFixed(1) || 'N/A'}`,
status: passed ? 'approved' : 'pending',
timestamp: Date.now(),
reviewData: {
score,
passed,
issues,
episode_number: currentEpisodeInWorkspace?.number || 1
}
}])
}
// 更新 annotation 列表显示问题
if (issues && issues.length > 0) {
const newAnnotations = issues.map((issue: any) => ({
content: `${issue.dimension}: ${issue.description}`,
type: issue.severity || 'review',
suggestion: issue.suggestion || '',
timestamp: Date.now()
}))
setAnnotations(prev => [...prev, ...newAnnotations])
}
}
break
case 'annotation_add': case 'annotation_add':
setAnnotations(prev => [...prev, msg.annotation]) setAnnotations(prev => [...prev, msg.annotation])
break break
@ -762,6 +962,17 @@ export const ProjectDetail = () => {
setCanvasContent(msg.content) setCanvasContent(msg.content)
} }
break break
case 'outline_update':
if (msg.content) {
setOutlineContent(msg.content)
}
break
case 'outline_streaming_start':
setOutlineStreaming(true)
break
case 'outline_streaming_end':
setOutlineStreaming(false)
break
case 'done': case 'done':
setAgentStatus('idle') setAgentStatus('idle')
setStreaming(false) setStreaming(false)
@ -797,12 +1008,22 @@ export const ProjectDetail = () => {
case 'episode_updated': case 'episode_updated':
// 剧集信息更新(如标题) // 剧集信息更新(如标题)
if (msg.data) { if (msg.data) {
const episodeData = msg.data as Partial<Episode>;
// 找到现有的完整剧集对象
const existingEpisode = episodes.find(ep => ep.number === episodeData.number || ep.id === episodeData.id);
if (existingEpisode) {
// 合并数据创建完整的 Episode 对象
const updatedEpisode: Episode = {
...existingEpisode,
...episodeData
};
// 1. 本地更新 Store 中的剧集列表 // 1. 本地更新 Store 中的剧集列表
updateEpisodeInList(msg.data); updateEpisodeInList(updatedEpisode);
// 2. 如果更新的是当前剧集,同步更新当前剧集状态 // 2. 如果更新的是当前剧集,同步更新当前剧集状态
if (currentEpisodeInWorkspace && msg.data.number === currentEpisodeInWorkspace.number) { if (currentEpisodeInWorkspace && updatedEpisode.number === currentEpisodeInWorkspace.number) {
setCurrentEpisodeInWorkspace(prev => ({ ...prev, ...msg.data } as Episode)); setCurrentEpisodeInWorkspace(updatedEpisode);
}
} }
} }
break break
@ -863,20 +1084,19 @@ export const ProjectDetail = () => {
return return
} }
// setSavingContent(true)
try { try {
setSavingContent(true) const updatedEpisode = (await projectService.updateEpisode(id!, currentEpisodeInWorkspace.number, {
const response = await projectService.updateEpisode(id!, currentEpisodeInWorkspace.number, {
content: content content: content
}) })) as unknown as Episode
const updatedEpisode = response.data || response;
setCurrentEpisodeInWorkspace(updatedEpisode); setCurrentEpisodeInWorkspace(updatedEpisode);
message.success('剧集内容已保存'); message.success('剧集内容已保存');
} catch (error) { } catch (error) {
console.error('Failed to save episode content:', error) console.error('Failed to save episode content:', error)
message.error('保存失败') message.error('保存失败')
} finally { } finally {
setSavingContent(false) // setSavingContent(false)
} }
} }
@ -895,11 +1115,10 @@ export const ProjectDetail = () => {
} }
// 2. 同时调用 API 持久化 // 2. 同时调用 API 持久化
const response = await projectService.updateEpisode(id!, currentEpisodeInWorkspace.number, { const updatedEpisode = (await projectService.updateEpisode(id!, currentEpisodeInWorkspace.number, {
title: newTitle title: newTitle
}); })) as unknown as Episode
const updatedEpisode = response.data || response;
setCurrentEpisodeInWorkspace(updatedEpisode); setCurrentEpisodeInWorkspace(updatedEpisode);
// 3. 触发侧边栏同步更新 // 3. 触发侧边栏同步更新
@ -911,29 +1130,47 @@ export const ProjectDetail = () => {
}; };
// AI 辅助处理 // AI 辅助处理
const handleAIAssist = (assistPrompt: string) => { const handleAIAssist = (assistPrompt: string, options?: AIAssistOptions) => {
if (!currentEpisodeInWorkspace) { if (!currentEpisodeInWorkspace) {
message.warning('请先选择一个剧集') message.warning('请先选择一个剧集')
return return
} }
if (!canvasContent) { // 检查是否是"开始创作"场景assistPrompt为空且customPrompt包含"创作"
message.warning('内容为空,无法进行辅助修改') const isStartCreation = !assistPrompt && options?.customPrompt && options.customPrompt.includes('创作');
return
let fullPrompt = '';
if (isStartCreation) {
// 开始创作场景直接发送customPrompt让后端注入大纲
fullPrompt = options.customPrompt + '\n\n';
fullPrompt += `项目:《${currentProject?.name}\n`;
fullPrompt += `剧集:第${currentEpisodeInWorkspace.number}集 - ${currentEpisodeInWorkspace.title || '未命名'}\n`;
fullPrompt += `重要:请直接调用 write_to_canvas(content="创作的完整内容", append=false) 工具来写入内容,不要只输出文本。`;
} else {
// AI辅助修改场景注入当前内容和上下文
fullPrompt = `我正在修改《${currentProject?.name}》第${currentEpisodeInWorkspace.number}集的内容。\n\n`;
fullPrompt += `当前剧集标题:${currentEpisodeInWorkspace.title || '未命名'}\n`;
if (canvasContent) {
fullPrompt += `当前内容:\n${canvasContent}\n\n`;
}
fullPrompt += `修改要求:${assistPrompt}\n\n`;
// 如果有自定义提示词,添加进去
if (options?.customPrompt) {
fullPrompt += `自定义要求:${options.customPrompt}\n\n`;
} }
// 注入当前内容和上下文
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) 工具来更新画布,不要只输出文本。`; fullPrompt += `重要:请直接调用 write_to_canvas(content="优化后的完整内容", append=false) 工具来更新画布,不要只输出文本。`;
}
// 通过 WebSocket 发送 AI 辅助请求给 Agent隐藏发送不显示在聊天记录中 // 通过 WebSocket 发送 AI 辅助请求给 Agent隐藏发送不显示在聊天记录中
if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) { if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) {
wsRef.current.send(JSON.stringify({ wsRef.current.send(JSON.stringify({
type: 'chat_message', type: 'chat_message',
content: fullPrompt content: fullPrompt,
episodeNumber: currentEpisodeInWorkspace?.number,
episodeTitle: currentEpisodeInWorkspace?.title
})); }));
message.info('已发送 AI 辅助请求'); message.info('已发送 AI 辅助请求');
setAgentStatus('planning'); setAgentStatus('planning');
@ -942,6 +1179,77 @@ export const ProjectDetail = () => {
} }
} }
// 大纲内容变更处理
const handleOutlineChange = (newOutline: string) => {
setOutlineContent(newOutline)
}
// 大纲保存处理
const handleOutlineSave = async (newOutline: string) => {
if (!currentEpisodeInWorkspace) {
message.warning('请先选择一个剧集')
return
}
try {
const updatedEpisode = (await projectService.updateEpisode(id!, currentEpisodeInWorkspace.number, {
outline: newOutline
})) as unknown as Episode
// 更新本地状态
setCurrentEpisodeInWorkspace(updatedEpisode)
updateEpisodeInList(updatedEpisode)
setOutlineContent(newOutline)
message.success('大纲已保存')
} catch (error) {
console.error('Failed to save outline:', error)
message.error('保存大纲失败')
}
}
// 大纲AI辅助处理
const handleOutlineAIAssist = (assistPrompt: string, options?: AIAssistOptions) => {
if (!currentEpisodeInWorkspace) {
message.warning('请先选择一个剧集')
return
}
// 注入当前大纲内容和上下文
let fullPrompt = `我正在修改《${currentProject?.name}》第${currentEpisodeInWorkspace.number}集的大纲。\n\n`
fullPrompt += `当前剧集标题:${currentEpisodeInWorkspace.title || '未命名'}\n`
if (outlineContent) {
fullPrompt += `当前大纲:\n${outlineContent}\n\n`
} else {
fullPrompt += `当前大纲:空\n\n`
}
fullPrompt += `修改要求:${assistPrompt}\n\n`
// 如果有自定义提示词,添加进去
if (options?.customPrompt) {
fullPrompt += `自定义要求:${options.customPrompt}\n\n`
}
fullPrompt += `重要:请直接调用 write_to_canvas(content="优化后的完整大纲", append=false) 工具来更新大纲编辑区,不要只输出文本。`
fullPrompt += `\n注意这是大纲内容不是剧本内容。请专注于大纲的结构和逻辑。`
// 通过 WebSocket 发送 AI 辅助请求给 Agent
if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) {
wsRef.current.send(JSON.stringify({
type: 'chat_message',
content: fullPrompt,
episodeNumber: currentEpisodeInWorkspace?.number,
episodeTitle: currentEpisodeInWorkspace?.title,
contentType: 'outline' // 标记这是大纲修改
}))
message.info('已发送大纲 AI 辅助请求')
setAgentStatus('planning')
setOutlineStreaming(true)
} else {
message.error('Agent 连接未就绪')
}
}
// 当切换到剧集创作标签页时连接 WebSocket // 当切换到剧集创作标签页时连接 WebSocket
useEffect(() => { useEffect(() => {
if (activeTab === 'episodes' && globalSettingsCompleted) { if (activeTab === 'episodes' && globalSettingsCompleted) {
@ -961,6 +1269,7 @@ export const ProjectDetail = () => {
// ==================== 工作台函数结束 ==================== // ==================== 工作台函数结束 ====================
/*
const getStatusColor = (status: string) => { const getStatusColor = (status: string) => {
const colors: Record<string, string> = { const colors: Record<string, string> = {
'pending': 'default', 'pending': 'default',
@ -980,6 +1289,7 @@ export const ProjectDetail = () => {
} }
return texts[status] || status return texts[status] || status
} }
*/
// 检查是否可以进入全局设定生成 // 检查是否可以进入全局设定生成
const canProceedToGlobalGeneration = creationMode && const canProceedToGlobalGeneration = creationMode &&
@ -1545,95 +1855,168 @@ export const ProjectDetail = () => {
} }
/> />
) : ( ) : (
<Layout style={{ height: 'calc(100vh - 200px)', background: '#fff' }}> <div style={{
height: 'calc(100vh - 220px)',
display: 'flex',
flexDirection: 'column',
background: '#fff',
borderRadius: '8px',
border: '1px solid #f0f0f0',
overflow: 'hidden'
}}>
{/* 工作台头部 */} {/* 工作台头部 */}
<div style={{ <div style={{
background: '#fff', background: '#fafafa',
borderBottom: '1px solid #f0f0f0', borderBottom: '1px solid #f0f0f0',
padding: '12px 24px', padding: '10px 16px',
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
justifyContent: 'space-between' justifyContent: 'space-between',
flexShrink: 0
}}> }}>
<Space> <Space size="small">
<span style={{ fontWeight: 600, fontSize: '14px' }}></span> <span style={{ fontWeight: 600, fontSize: '13px' }}></span>
<span style={{ color: '#999', fontSize: '12px' }}>{currentProject?.name}</span> <span style={{ color: '#999', fontSize: '11px' }}>{currentProject?.name}</span>
<span style={{ color: '#999', fontSize: '12px' }}>ID: {id}</span> <Tag color={wsConnected ? 'success' : 'error'} style={{ fontSize: '11px' }}>
<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
type="text"
size="small"
icon={<UnorderedListOutlined />}
onClick={() => setShowEpisodeSidebar(!showEpisodeSidebar)}
style={{ color: showEpisodeSidebar ? '#1677ff' : '#666' }}
>
</Button>
</Space> </Space>
<Space> <Space size="small">
<Button size="small"></Button> <Button size="small"></Button>
<Button size="small" type="primary"></Button> <Button size="small" type="primary"></Button>
</Space> </Space>
</div> </div>
<Layout> {/* 工作台主体 */}
{/* 剧集管理侧边栏 */} <div style={{
{showEpisodeSidebar && ( flex: 1,
<Sider width={280} style={{ background: '#fafafa', borderRight: '1px solid #f0f0f0' }}> display: 'flex',
<EpisodeSidebar overflow: 'hidden',
gap: 0,
background: '#f3f4f6'
}}>
{/* 左侧:剧集管理 + 故事上下文 */}
<div style={{ flex: '0 0 ' + leftPanelWidth + 'px', minWidth: '280px', maxWidth: '600px' }}>
<LeftSidebar
projectId={id!} projectId={id!}
onEpisodeSelect={(episode) => {
setCurrentEpisodeInWorkspace(episode)
setCanvasContent(episode.content || '')
}}
currentEpisodeId={currentEpisodeInWorkspace?.id}
/>
</Sider>
)}
{/* 左侧Context Panel */}
{showContextPanel && (
<ContextPanel
project={currentProject} project={currentProject}
loading={loading} loading={loading}
activeStates={activeStates} activeStates={activeStates}
memoryItems={workspaceMemoryItems} memoryItems={workspaceMemoryItems}
width={leftPanelWidth}
currentEpisodeId={currentEpisodeInWorkspace?.id}
onEpisodeSelect={(episode) => {
setCurrentEpisodeInWorkspace(episode)
setCanvasContent(episode.content || '')
}}
onSendMessage={handleDirectorMessage}
onNavigateToSettings={() => setActiveTab('global-generation')} onNavigateToSettings={() => setActiveTab('global-generation')}
/> />
)} </div>
{/* 左侧拖拽手柄 */}
<div
style={{
width: '4px',
cursor: 'col-resize',
background: isResizing === 'left' ? '#1890ff' : '#f0f0f0',
flexShrink: 0,
userSelect: 'none',
transition: 'all 0.2s',
position: 'relative',
zIndex: 10,
borderLeft: '1px solid #e8e8e8',
borderRight: '1px solid #e8e8e8'
}}
onMouseDown={(e) => {
e.preventDefault()
setIsResizing('left')
resizeStartRef.current = {
x: e.clientX,
leftWidth: leftPanelWidth,
rightWidth: rightPanelWidth
}
}}
onMouseEnter={(e) => {
if (isResizing !== 'left') {
e.currentTarget.style.background = '#1890ff'
}
}}
onMouseLeave={(e) => {
if (isResizing !== 'left') {
e.currentTarget.style.background = '#f0f0f0'
}
}}
/>
{/* 中间Smart Canvas */} {/* 中间Smart Canvas */}
<Content style={{ position: 'relative', background: '#fff' }}> <div style={{
flex: 1,
minWidth: 0,
display: 'flex',
flexDirection: 'column',
background: '#ffffff',
position: 'relative',
zIndex: 1
}}>
<SmartCanvas <SmartCanvas
content={canvasContent} content={canvasContent}
streaming={streaming} streaming={streaming}
annotations={annotations} annotations={annotations}
episodeTitle={currentEpisodeInWorkspace?.title} episodeTitle={currentEpisodeInWorkspace?.title}
episodeNumber={currentEpisodeInWorkspace?.number} episodeNumber={currentEpisodeInWorkspace?.number}
onStartGenerate={() => {
handleDirectorMessage(`开始生成第${currentEpisodeInWorkspace?.number}集大纲`)
}}
onContentChange={handleContentChange} onContentChange={handleContentChange}
onContentSave={handleContentSave} onContentSave={handleContentSave}
onAIAssist={handleAIAssist} onAIAssist={handleAIAssist}
onTitleChange={handleTitleChange} onTitleChange={handleTitleChange}
availableSkills={skills} availableSkills={skills}
projectId={id} projectId={id}
// 大纲相关props
outlineContent={outlineContent}
onOutlineChange={handleOutlineChange}
onOutlineSave={handleOutlineSave}
onOutlineAIAssist={handleOutlineAIAssist}
outlineStreaming={outlineStreaming}
/>
</div>
{/* 右侧拖拽手柄 */}
<div
style={{
width: '4px',
cursor: 'col-resize',
background: isResizing === 'right' ? '#1890ff' : '#f0f0f0',
flexShrink: 0,
userSelect: 'none',
transition: 'all 0.2s',
position: 'relative',
zIndex: 10,
borderLeft: '1px solid #e8e8e8',
borderRight: '1px solid #e8e8e8'
}}
onMouseDown={(e) => {
e.preventDefault()
setIsResizing('right')
resizeStartRef.current = {
x: e.clientX,
leftWidth: leftPanelWidth,
rightWidth: rightPanelWidth
}
}}
onMouseEnter={(e) => {
if (isResizing !== 'right') {
e.currentTarget.style.background = '#1890ff'
}
}}
onMouseLeave={(e) => {
if (isResizing !== 'right') {
e.currentTarget.style.background = '#f0f0f0'
}
}}
/> />
</Content>
{/* 右侧Director Inbox */} {/* 右侧Director Inbox */}
<div style={{ flex: '0 0 ' + rightPanelWidth + 'px', minWidth: '220px', maxWidth: '450px' }}>
<DirectorInbox <DirectorInbox
onSendMessage={handleDirectorMessage} onSendMessage={handleDirectorMessage}
onInboxAction={handleWorkspaceInboxAction} onInboxAction={handleWorkspaceInboxAction}
@ -1641,13 +2024,15 @@ export const ProjectDetail = () => {
agentPlan={agentPlan} agentPlan={agentPlan}
inboxItems={inboxItems} inboxItems={inboxItems}
chatHistory={chatHistory} chatHistory={chatHistory}
wsRef={wsRef}
/> />
</Layout> </div>
</Layout> </div>
</div>
)} )}
</TabPane> </TabPane>
{/* 记忆系统标签页 */} {/* 记忆系统标签页 - 直接嵌入 MemorySystem 组件 */}
<TabPane <TabPane
tab={ tab={
<span> <span>
@ -1657,15 +2042,12 @@ export const ProjectDetail = () => {
} }
key="memory" key="memory"
> >
<Space direction="vertical" style={{ width: '100%' }}> <div style={{ height: 'calc(100dvh - 200px)', overflow: 'hidden' }}>
<p>线线</p> <MemorySystem projectId={id} />
<Button type="primary" onClick={() => navigate(`/projects/${id}/memory`)}> </div>
</Button>
</Space>
</TabPane> </TabPane>
{/* 审核系统标签页 */} {/* 审核系统标签页 - 直接嵌入审核配置和结果组件 */}
<TabPane <TabPane
tab={ tab={
<span> <span>
@ -1675,13 +2057,20 @@ export const ProjectDetail = () => {
} }
key="review" key="review"
> >
<Space direction="vertical" style={{ width: '100%' }}> <div>
<p></p> <Tabs
<Space> activeKey={reviewSubTab}
<Button onClick={() => navigate(`/projects/${id}/review/config`)}></Button> onChange={setReviewSubTab}
<Button type="primary" onClick={() => navigate(`/projects/${id}/review/results`)}></Button> type="card"
</Space> >
</Space> <TabPane tab="审核平台" key="results">
<ReviewResults projectId={id} />
</TabPane>
<TabPane tab="审核配置" key="config">
<ReviewConfig projectId={id} />
</TabPane>
</Tabs>
</div>
</TabPane> </TabPane>
</Tabs> </Tabs>
</Card> </Card>

View File

@ -1,967 +0,0 @@
import { useEffect, useState, useRef, useCallback } from 'react'
import { useNavigate, useParams } from 'react-router-dom'
import { Layout, Button, Space, message, Spin, Typography, Modal } from 'antd'
import { ArrowLeftOutlined, UnorderedListOutlined } from '@ant-design/icons'
import { projectService } from '@/services/projectService'
import { useProjectStore } from '@/stores/projectStore'
import { ContextPanel } from '@/components/Workspace/ContextPanel'
import { SmartCanvas } from '@/components/Workspace/SmartCanvas'
import { DirectorInbox } from '@/components/Workspace/DirectorInbox'
import { EpisodeSidebar } from '@/components/Workspace/EpisodeSidebar'
import { Episode } from '@/services/projectService'
const { Header, Sider } = Layout;
const { Text } = Typography;
export const ProjectWorkspace: React.FC = () => {
const navigate = useNavigate()
const { id: projectId } = useParams<{ id: string }>()
// 调试:确认组件已加载
console.log('=== ProjectWorkspace component loaded ===')
console.log('projectId:', projectId)
const [project, setProject] = useState<any>(null)
const [loading, setLoading] = useState(true)
const [streaming, setStreaming] = useState(false)
const [canvasContent, setCanvasContent] = useState<string>('')
const [agentStatus, setAgentStatus] = useState<'idle' | 'planning' | 'writing' | 'reviewing'>('idle')
const [agentPlan, setAgentPlan] = useState<string[]>([])
const [inboxItems, setInboxItems] = useState<any[]>([])
const [chatHistory, setChatHistory] = useState<{role: 'user' | 'agent', content: string}[]>([])
const [annotations, setAnnotations] = useState<any[]>([])
const [activeStates, setActiveStates] = useState<any[]>([
{ type: 'time', value: '初始状态' },
{ type: 'location', value: '未知地点' }
])
// 记忆库状态
const [memoryItems, setMemoryItems] = useState<any[]>([])
// 剧集相关状态
const [currentEpisode, setCurrentEpisode] = useState<Episode | null>(null)
const [showEpisodeSidebar, setShowEpisodeSidebar] = useState(true)
// 布局宽度状态 - 优化后的默认比例
const [sidebarWidth, setSidebarWidth] = useState(260);
const [contextWidth, setContextWidth] = useState(300);
const [canvasWidth, setCanvasWidth] = useState(600);
// 拖拽相关 Refs
const isResizingRef = useRef<'sidebar' | 'context' | 'canvasRight' | null>(null);
const startXRef = useRef(0);
const startWidthRef = useRef(0);
// 拖拽处理函数
const handleMouseMove = useCallback((e: MouseEvent) => {
if (!isResizingRef.current) return;
const deltaX = e.clientX - startXRef.current;
if (isResizingRef.current === 'sidebar') {
const newWidth = Math.max(200, Math.min(500, startWidthRef.current + deltaX));
console.log('Resizing sidebar:', newWidth);
setSidebarWidth(newWidth);
} else if (isResizingRef.current === 'context') {
const newWidth = Math.max(250, Math.min(600, startWidthRef.current + deltaX));
console.log('Resizing context:', newWidth);
setContextWidth(newWidth);
} else if (isResizingRef.current === 'canvasRight') {
// Canvas 右侧手柄:向左拖动增加宽度
const newWidth = Math.max(300, Math.min(900, startWidthRef.current - deltaX));
console.log('Resizing canvas:', newWidth);
setCanvasWidth(newWidth);
}
}, []);
const handleMouseUp = useCallback(() => {
console.log('=== handleMouseUp called, was resizing:', isResizingRef.current);
isResizingRef.current = null;
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
document.body.style.userSelect = '';
document.body.style.cursor = '';
}, [handleMouseMove]);
const startResize = (type: 'sidebar' | 'context' | 'canvasRight', e: React.MouseEvent) => {
e.preventDefault();
console.log('=== startResize called ===', type, e.clientX);
isResizingRef.current = type;
startXRef.current = e.clientX;
startWidthRef.current = type === 'sidebar' ? sidebarWidth
: type === 'context' ? contextWidth
: canvasWidth;
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
document.body.style.userSelect = 'none';
document.body.style.cursor = 'col-resize';
};
// AI 辅助相关状态
const [availableSkills, setAvailableSkills] = useState<any[]>([])
const [wsConnected, setWsConnected] = useState(false);
// WebSocket refs
const wsRef = useRef<WebSocket | null>(null);
const reconnectTimeoutRef = useRef<number | null>(null);
const loadProject = async () => {
if (!projectId) {
setLoading(false);
return;
}
setLoading(true)
try {
const data = await projectService.getProject(projectId)
setProject(data)
// 加载可用的 Skills
await loadAvailableSkills()
} catch (error) {
message.error(`加载项目失败: ${(error as Error).message}`)
} finally {
setLoading(false)
}
}
// 加载可用的 Skills
const loadAvailableSkills = async () => {
try {
const response = await fetch('/api/v1/ai-assistant/available-skills')
if (response.ok) {
const data = await response.json()
// 将分类的 skills 转换为列表格式
const skillsList: any[] = []
Object.entries(data.skills || {}).forEach(([category, skills]: [string, any]) => {
skills.forEach((skill: any) => {
skillsList.push({
...skill,
category
})
})
})
setAvailableSkills(skillsList)
}
} catch (error) {
console.error('加载 Skills 失败:', error)
// 失败时设置空数组,不影响其他功能
setAvailableSkills([])
}
}
// 加载剧集内容
const loadEpisodeContent = async (episodeNumber: number) => {
if (!projectId) return;
try {
const response = await projectService.getEpisode(projectId, episodeNumber);
const episode = response.data || response;
console.log('[ProjectWorkspace] Loading episode:', episode);
setCurrentEpisode(episode);
setCanvasContent(episode.content || '');
console.log('Loaded episode content:', episode.id, episode.content?.length || 0, 'chars');
} catch (error) {
message.error(`加载剧集内容失败: ${(error as Error).message}`);
}
}
useEffect(() => {
loadProject()
return () => {
if (reconnectTimeoutRef.current) {
clearTimeout(reconnectTimeoutRef.current);
}
if (wsRef.current) {
wsRef.current.close();
}
}
}, [projectId])
// 当项目加载完成后,自动选择并加载第一集
useEffect(() => {
if (project && !loading && projectId && !currentEpisode) {
// 获取剧集列表并自动选择第一集
const loadFirstEpisode = async () => {
try {
const response = await projectService.listEpisodes(projectId);
const episodes = response.data || response;
if (episodes && episodes.length > 0) {
// 找到第一个pending或completed状态的剧集优先pending状态
const firstEpisode = episodes.find((ep: any) => ep.status === 'pending') ||
episodes.find((ep: any) => ep.status === 'completed') ||
episodes[0];
if (firstEpisode) {
await loadEpisodeContent(firstEpisode.number);
}
}
} catch (error) {
console.error('加载第一集失败:', error);
// 如果加载失败,创建一个临时的第一集对象作为默认显示
console.log('使用临时剧集对象显示');
setCurrentEpisode({
id: 'temp',
projectId: projectId,
number: 1,
title: '第1集',
status: 'pending',
content: '',
issues: [],
createdAt: new Date().toISOString()
});
}
};
loadFirstEpisode();
}
}, [project, loading, projectId]); // 移除currentEpisode依赖避免无限循环
// WebSocket Connection Logic
const connectWebSocket = useCallback(() => {
if (!projectId) return;
// Close existing connection if any
if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) {
console.log('Closing existing WebSocket connection');
wsRef.current.close();
}
// Use relative URL which will be proxied by Vite
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const host = window.location.host; // e.g. localhost:5173
const wsUrl = `${protocol}//${host}/ws/projects/${projectId}/execute`;
console.log('Connecting to WebSocket:', wsUrl);
const ws = new WebSocket(wsUrl);
ws.onopen = () => {
console.log('WebSocket Connected');
setWsConnected(true);
message.success('已连接到 Agent Runtime');
};
ws.onmessage = (event) => {
try {
const msg = JSON.parse(event.data);
handleWebSocketMessage(msg);
} catch (e) {
console.error('Failed to parse WS message:', e);
}
};
ws.onclose = () => {
console.log('WebSocket Disconnected');
setWsConnected(false);
// Clear ref to allow reconnection
if (wsRef.current === ws) {
wsRef.current = null;
}
};
ws.onerror = (error) => {
console.error('WebSocket Error:', error);
// Don't close here, let onclose handle it
};
wsRef.current = ws;
}, [projectId]); // Only depend on projectId
useEffect(() => {
if (projectId && !loading) {
connectWebSocket();
}
// Cleanup on unmount or projectId change
return () => {
if (reconnectTimeoutRef.current) {
clearTimeout(reconnectTimeoutRef.current);
}
if (wsRef.current) {
wsRef.current.close();
wsRef.current = null;
}
};
}, [projectId, loading, connectWebSocket]);
const handleInboxAction = (itemId: string, action: 'approve' | 'reject') => {
// Optimistic update
setInboxItems(prev => prev.filter(item => item.id !== itemId));
// Send to backend
if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) {
wsRef.current.send(JSON.stringify({
type: 'inbox_action',
itemId,
action
}));
}
message.success(action === 'approve' ? '已批准' : '已拒绝');
};
const handleWebSocketMessage = async (msg: any) => {
console.log('Received:', msg);
switch (msg.type) {
case 'connected':
break;
case 'history':
if (msg.messages) {
setChatHistory(msg.messages);
}
break;
case 'thinking':
// Optional: show thinking indicator in chat
break;
case 'text':
setChatHistory(prev => {
const lastMsg = prev[prev.length - 1];
// 如果上一条消息也是 agent 发送的,则认为是流式输出,追加内容
if (lastMsg && lastMsg.role === 'agent') {
const newHistory = [...prev];
newHistory[newHistory.length - 1] = {
...lastMsg,
content: lastMsg.content + msg.content
};
return newHistory;
}
// 否则添加新消息
return [...prev, { role: 'agent', content: msg.content }];
});
break;
case 'plan_update':
if (msg.plan) {
setAgentPlan(msg.plan);
}
if (msg.status) {
setAgentStatus(msg.status);
}
break;
case 'review_request':
setInboxItems(prev => [...prev, {
id: msg.id || Date.now().toString(),
type: 'review',
title: msg.title || '需要审核',
description: msg.description,
status: 'pending',
timestamp: Date.now()
}]);
break;
case 'review_complete':
// 处理审核卡片推送
const reviewData = msg.data || {};
setInboxItems(prev => [...prev, {
id: `review_${reviewData.episode_number}_${Date.now()}`,
type: 'review_card',
title: `EP${reviewData.episode_number} 审核完成`,
description: `质量评分: ${reviewData.overall_score?.toFixed(1) || 0} | ${reviewData.passed ? '通过' : '未通过'}`,
status: reviewData.passed ? 'approved' : 'needs-review',
timestamp: Date.now(),
reviewData: reviewData
}]);
// 如果有严重问题,显示通知
if (reviewData.high_severity_count > 0) {
message.warning(
`EP${reviewData.episode_number} 发现 ${reviewData.high_severity_count} 个严重问题,请查看审核详情`
);
} else if (reviewData.passed) {
message.success(
`EP${reviewData.episode_number} 审核通过,质量评分: ${reviewData.overall_score?.toFixed(1) || 0}`
);
}
break;
case 'memory_hit':
// 添加到记忆库
if (msg.data) {
setMemoryItems(prev => [...prev, {
type: msg.data.memory_type || 'timeline',
title: msg.data.title || '记忆更新',
description: msg.data.description || JSON.stringify(msg.data.data),
timestamp: Date.now()
}]);
}
break;
case 'memory_update':
// 从 update_memory 工具触发的记忆更新
if (msg.data) {
setMemoryItems(prev => [...prev, {
type: msg.data.type || msg.data.memory_type || 'timeline',
title: msg.data.title || '记忆更新',
description: msg.data.description || '',
timestamp: msg.data.timestamp || Date.now(),
character: msg.data.character,
state: msg.data.state
}]);
}
break;
case 'annotation_add':
setAnnotations(prev => [...prev, msg.annotation]);
break;
case 'context_update':
if (msg.states) {
setActiveStates(msg.states);
}
break;
case 'tool_call':
const toolData = msg.data || {};
const toolName = toolData.name || msg.name;
if (toolName === 'update_canvas' || toolName === 'write_file') {
setStreaming(true);
setAgentStatus('writing');
// 更新本地剧集状态为创作中
setCurrentEpisode(prev => prev ? { ...prev, status: 'writing' } : null);
} else {
setAgentStatus('planning');
}
break;
case 'tool_result':
setStreaming(false);
break;
case 'canvas_update':
if (msg.content) {
setCanvasContent(msg.content);
// 自动保存到当前剧集
if (currentEpisode && currentEpisode.id && projectId) {
// 调用 API 保存到后端
projectService.updateEpisode(projectId, currentEpisode.number, {
content: msg.content,
status: 'draft'
}).then(() => {
console.log('Content auto-saved to episode:', currentEpisode.id);
// 更新本地剧集状态为草稿
setCurrentEpisode(prev => prev ? { ...prev, status: 'draft' } : null);
// 刷新剧集列表
useProjectStore.getState().fetchEpisodes(projectId);
}).catch((error) => {
console.error('Failed to auto-save content:', error);
});
}
}
break;
case 'episode_saved':
// Agent 保存剧集后的确认
message.success(`剧集 ${msg.episode_number || ''} 已自动保存`);
// 刷新剧集列表
if (projectId) {
useProjectStore.getState().fetchEpisodes(projectId);
}
// 如果保存的是当前剧集,更新画布内容
if (currentEpisode && currentEpisode.number === msg.episode_number) {
// 重新获取剧集内容
loadEpisodeContent(currentEpisode.number);
}
break;
case 'episode_updated':
// 剧集信息更新(如标题)
console.log('[ProjectWorkspace] episode_updated:', msg.data);
if (msg.data) {
// 1. 本地更新 Store 中的剧集列表
useProjectStore.getState().updateEpisodeInList(msg.data);
// 2. 如果更新的是当前剧集,同步更新当前剧集状态
if (currentEpisode && msg.data.number === currentEpisode.number) {
setCurrentEpisode(prev => ({ ...prev, ...msg.data } as Episode));
}
}
break;
case 'done':
setAgentStatus('idle');
setStreaming(false);
break;
case 'error':
message.error(msg.data?.message || 'Unknown error');
setAgentStatus('idle');
break;
}
};
const handleDirectorMessage = (msg: string) => {
console.log('=== handleDirectorMessage called ===', msg);
// 立即更新状态 - 显示停止按钮
if (msg.includes('开始') || msg.includes('生成') || msg.includes('创作') || msg.includes('辅助')) {
setAgentStatus('planning');
// 如果是开始创作,更新本地剧集状态为创作中
if (msg.includes('创作') || msg.includes('完整内容')) {
setCurrentEpisode(prev => prev ? { ...prev, status: 'writing' } : null);
}
}
// Optimistically update chat history
setChatHistory(prev => [...prev, { role: 'user', content: msg }]);
// Send message to backend via WebSocket
if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) {
const messagePayload = JSON.stringify({
type: 'chat_message',
content: msg,
episodeNumber: currentEpisode?.number,
episodeTitle: currentEpisode?.title
});
console.log('Sending message:', messagePayload);
wsRef.current.send(messagePayload);
console.log('Message sent successfully');
} else {
console.error('WebSocket not ready');
message.error('Agent 连接未就绪');
setAgentStatus('idle');
}
}
// 停止 Agent 处理函数
const handleStopAgent = () => {
console.log('=== handleStopAgent called ===');
if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) {
const stopPayload = JSON.stringify({
type: 'stop_agent'
});
wsRef.current.send(stopPayload);
console.log('Stop command sent');
// 立即更新 UI 状态
setAgentStatus('idle');
setStreaming(false);
// 添加系统消息到聊天记录
setChatHistory(prev => [...prev, {
role: 'agent',
content: '⚠️ Agent 已被用户停止'
}]);
message.info('Agent 已停止');
} else {
console.error('WebSocket not ready');
message.error('无法停止Agent 连接未就绪');
}
}
// 画布内容变更处理
const handleContentChange = (content: string) => {
setCanvasContent(content);
}
// 画布内容保存处理
const handleContentSave = async (content: string) => {
if (!currentEpisode || !projectId) {
message.warning('请先选择要保存的剧集');
return;
}
try {
// 调用后端 API 保存剧集内容
await projectService.updateEpisode(projectId, currentEpisode.number, {
content: content,
status: 'draft'
});
message.success('内容已保存');
// 刷新剧集列表
useProjectStore.getState().fetchEpisodes(projectId);
} catch (error) {
message.error(`保存失败: ${(error as Error).message}`);
}
}
// 确认剧集完成处理
const handleConfirmComplete = async () => {
if (!currentEpisode || !projectId) {
message.warning('请先选择要完成的剧集');
return;
}
// 确认对话框
Modal.confirm({
title: '确认完成剧集',
content: `确认将《${currentEpisode.title || `${currentEpisode.number}`}》标记为已完成状态?完成后将不能再编辑内容。`,
okText: '确认完成',
cancelText: '取消',
okType: 'primary',
okButtonProps: { style: { backgroundColor: '#52c41a', borderColor: '#52c41a' } },
onOk: async () => {
try {
// 调用后端 API 更新剧集状态为 completed
await projectService.updateEpisode(projectId, currentEpisode.number, {
content: canvasContent,
status: 'completed'
});
message.success('剧集已标记为完成');
// 更新当前剧集状态
setCurrentEpisode(prev => prev ? { ...prev, status: 'completed' } : null);
// 刷新剧集列表
useProjectStore.getState().fetchEpisodes(projectId);
} catch (error) {
message.error(`操作失败: ${(error as Error).message}`);
}
}
});
}
// 剧集标题变更处理
const handleTitleChange = async (newTitle: string) => {
if (!currentEpisode || !projectId) return;
try {
// 1. 发送 WebSocket 消息通知后端更新标题
if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) {
wsRef.current.send(JSON.stringify({
type: 'update_episode_title',
episodeNumber: currentEpisode.number,
title: newTitle
}));
}
// 2. 调用 API 更新标题
const response = await projectService.updateEpisode(projectId, currentEpisode.number, {
title: newTitle
});
const updatedEpisode = response.data || response;
// 3. 本地同步更新状态
setCurrentEpisode(updatedEpisode);
// 4. 刷新剧集列表同步显示
useProjectStore.getState().updateEpisodeInList(updatedEpisode);
message.success('标题已更新');
} catch (error) {
console.error('Failed to update title:', error);
message.error('更新标题失败');
}
}
// AI 辅助修改处理
const handleAIAssist = async (content: string, options?: { skills?: any[]; customPrompt?: string; injectAgent?: boolean }) => {
if (!currentEpisode || !projectId) {
message.warning('请先选择要修改的剧集');
return;
}
// 如果启用了 Agent 注入,通过 WebSocket 发送请求(用户看不到注入内容)
if (options?.injectAgent !== false) {
// 立即更新状态,显示停止按钮
setAgentStatus('planning');
// 构建优化的提示词确保Agent使用write_to_canvas工具
let assistPrompt = '请帮我优化当前剧集内容。\n\n';
// 如果有自定义提示词,添加到前面
if (options?.customPrompt) {
assistPrompt = `${options.customPrompt}\n\n`;
}
assistPrompt += `当前剧集EP${currentEpisode.number} ${currentEpisode.title ? '- ' + currentEpisode.title : ''}\n\n`;
// 添加当前内容摘要
assistPrompt += `当前内容(约${content.length}字):\n${content.substring(0, 1000)}${content.length > 1000 ? '...' : ''}\n\n`;
// 如果有选择的 Skills添加到提示中
if (options?.skills && options.skills.length > 0) {
assistPrompt += '请应用以下 Skills 指导进行优化:\n';
options.skills.forEach(skill => {
assistPrompt += `- ${skill.name}: ${skill.description}\n`;
});
assistPrompt += '\n';
}
assistPrompt += `请优化上述剧集内容,并使用 write_to_canvas 工具将完整的优化后内容直接更新到画布上append=false完全替换\n\n`;
assistPrompt += `重要:请直接调用 write_to_canvas(content="优化后的完整内容", append=false) 工具来更新画布,不要只输出文本。`;
// 构建用户友好的显示消息
let displayMessage = 'AI 辅助修改';
if (options?.customPrompt) {
displayMessage += `${options.customPrompt}`;
}
if (options?.skills && options.skills.length > 0) {
const skillNames = options.skills.map(s => s.name).join('、');
displayMessage += `\n应用 Skills${skillNames}`;
}
// 先在聊天记录中显示用户友好的消息
setChatHistory(prev => [...prev, { role: 'user', content: displayMessage }]);
// 通过 WebSocket 发送 AI 辅助请求给 Agent内部 prompt
if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) {
wsRef.current.send(JSON.stringify({
type: 'chat_message',
content: assistPrompt
}));
} else {
message.error('Agent 连接未就绪');
setAgentStatus('idle');
}
} else {
// 否则直接调用 AI 辅助 API
try {
message.loading('AI 正在辅助修改中...', 0)
const response = await fetch('/api/v1/ai-assistant/optimize-episode', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
projectId,
episodeNumber: currentEpisode.number,
content,
skills: options?.skills,
customPrompt: options?.customPrompt
})
})
if (response.ok) {
const data = await response.json()
if (data.success && data.optimizedContent) {
setCanvasContent(data.optimizedContent)
message.success('AI 辅助修改完成')
} else {
message.error('AI 辅助修改失败')
}
} else {
message.error('AI 辅助请求失败')
}
} catch (error) {
console.error('AI 辅助失败:', error)
message.error('AI 辅助请求出错')
} finally {
message.destroy()
}
}
}
if (loading) {
return (
<div style={{ padding: '24px', textAlign: 'center', marginTop: '100px' }}>
<Spin size="large" tip="正在初始化数字化片场..." />
</div>
)
}
if (!project) {
return (
<div style={{ padding: '24px', textAlign: 'center', marginTop: '100px' }}>
<Text type="danger"></Text>
<br />
<Button onClick={() => navigate('/projects')} style={{ marginTop: '16px' }}>
</Button>
</div>
)
}
return (
<Layout style={{ height: '100vh', background: '#f5f5f5' }}>
<Header style={{
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
padding: '0 24px',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
boxShadow: '0 2px 8px rgba(0,0,0,0.1)',
height: '56px'
}}>
<Space>
<Button
type="text"
icon={<ArrowLeftOutlined style={{ color: '#fff' }} />}
onClick={() => navigate('/projects')}
style={{ color: '#fff' }}
/>
<Text strong style={{ fontSize: '18px', color: '#fff' }}>{project?.name}</Text>
<div style={{
background: 'rgba(255,255,255,0.2)',
padding: '4px 12px',
borderRadius: '12px',
fontSize: '12px',
color: '#fff'
}}>
{wsConnected ? '● Agent 在线' : '○ Agent 离线'}
</div>
<Button
type="text"
icon={<UnorderedListOutlined />}
onClick={() => setShowEpisodeSidebar(!showEpisodeSidebar)}
style={{
color: '#fff',
background: showEpisodeSidebar ? 'rgba(255,255,255,0.2)' : 'transparent',
borderRadius: '6px'
}}
>
</Button>
</Space>
<Space>
<Button style={{ borderRadius: '6px' }}></Button>
<Button type="primary" style={{
background: '#fff',
color: '#667eea',
borderRadius: '6px',
border: 'none'
}}>
</Button>
</Space>
</Header>
<Layout style={{ display: 'flex', flexDirection: 'row', overflow: 'hidden', background: '#fafafa' }}>
{/* 剧集管理侧边栏 */}
{showEpisodeSidebar && (
<>
<Sider width={sidebarWidth} style={{
background: '#fff',
borderRight: '1px solid #e8e8e8',
flexShrink: 0,
boxShadow: '2px 0 8px rgba(0,0,0,0.04)'
}}>
<EpisodeSidebar
projectId={projectId!}
onEpisodeSelect={(episode) => {
loadEpisodeContent(episode.number);
}}
currentEpisodeId={currentEpisode?.id}
onSendMessage={handleDirectorMessage}
/>
</Sider>
{/* 拖拽手柄 - Sidebar */}
<div
style={{
width: '4px',
cursor: 'col-resize',
backgroundColor: '#e8e8e8',
height: '100%',
zIndex: 1000,
transition: 'all 0.2s',
flexShrink: 0,
userSelect: 'none',
position: 'relative'
}}
onMouseDown={(e) => startResize('sidebar', e)}
onMouseEnter={(e) => {
e.currentTarget.style.backgroundColor = '#667eea';
e.currentTarget.style.width = '6px';
e.currentTarget.style.boxShadow = '0 0 8px rgba(102, 126, 234, 0.4)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.backgroundColor = '#e8e8e8';
e.currentTarget.style.width = '4px';
e.currentTarget.style.boxShadow = 'none';
}}
/>
</>
)}
{/* 左侧Context Panel */}
<ContextPanel
project={project}
loading={loading}
activeStates={activeStates}
memoryItems={memoryItems}
width={contextWidth}
onNavigateToSettings={() => navigate(`/projects/${projectId}`, { state: { activeTab: 'global-generation' } })}
/>
{/* 拖拽手柄 - Context/Canvas 分隔线 */}
<div
style={{
width: '4px',
cursor: 'col-resize',
backgroundColor: '#e8e8e8',
height: '100%',
zIndex: 1000,
transition: 'all 0.2s',
flexShrink: 0,
userSelect: 'none',
position: 'relative'
}}
onMouseDown={(e) => startResize('context', e)}
onMouseEnter={(e) => {
e.currentTarget.style.backgroundColor = '#667eea';
e.currentTarget.style.width = '6px';
e.currentTarget.style.boxShadow = '0 0 8px rgba(102, 126, 234, 0.4)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.backgroundColor = '#e8e8e8';
e.currentTarget.style.width = '4px';
e.currentTarget.style.boxShadow = 'none';
}}
/>
{/* 中间Smart Canvas */}
<div style={{
width: canvasWidth,
flexShrink: 0,
display: 'flex',
flexDirection: 'column',
background: '#fff',
boxShadow: '0 0 20px rgba(0,0,0,0.05)'
}}>
<SmartCanvas
content={canvasContent}
streaming={streaming}
annotations={annotations}
episodeTitle={currentEpisode?.title || (currentEpisode?.number ? `${currentEpisode.number}` : '未命名草稿')}
episodeNumber={currentEpisode?.number || null}
episodeStatus={currentEpisode?.status as 'pending' | 'draft' | 'writing' | 'completed' | undefined}
onStartGenerate={() => {
handleDirectorMessage(`开始生成第${currentEpisode?.number}集大纲`);
}}
onStartWriting={() => {
handleDirectorMessage(`开始创作第${currentEpisode?.number}集完整内容`);
}}
onContentChange={handleContentChange}
onContentSave={handleContentSave}
onAIAssist={handleAIAssist}
onTitleChange={handleTitleChange}
onConfirmComplete={handleConfirmComplete}
availableSkills={availableSkills}
projectId={projectId}
/>
</div>
{/* Canvas 右侧拖拽手柄 */}
<div
style={{
width: '4px',
cursor: 'col-resize',
backgroundColor: '#e8e8e8',
height: '100%',
zIndex: 1000,
transition: 'all 0.2s',
flexShrink: 0,
userSelect: 'none',
position: 'relative'
}}
onMouseDown={(e) => startResize('canvasRight', e)}
onMouseEnter={(e) => {
e.currentTarget.style.backgroundColor = '#667eea';
e.currentTarget.style.width = '6px';
e.currentTarget.style.boxShadow = '0 0 8px rgba(102, 126, 234, 0.4)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.backgroundColor = '#e8e8e8';
e.currentTarget.style.width = '4px';
e.currentTarget.style.boxShadow = 'none';
}}
/>
{/* 右侧Director Inbox */}
<DirectorInbox
onSendMessage={handleDirectorMessage}
onStopAgent={handleStopAgent}
onInboxAction={handleInboxAction}
onNavigateToReview={(episodeNum) => navigate(`/projects/${projectId}/review/results?episode=${episodeNum}`)}
agentStatus={agentStatus}
agentPlan={agentPlan}
inboxItems={inboxItems}
chatHistory={chatHistory}
/>
</Layout>
</Layout>
)
}

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,14 @@
/** /**
* Review Results Page * Review Platform Page
* Display review results with scores, issues, and actions *
* -
*
*
* 1.
* 2.
* 3.
* 4.
* 5. //
*/ */
import React, { useEffect, useState } from 'react' import React, { useEffect, useState } from 'react'
import { useParams, useNavigate } from 'react-router-dom' import { useParams, useNavigate } from 'react-router-dom'
@ -9,598 +17,436 @@ import {
Row, Row,
Col, Col,
Statistic, Statistic,
Progress,
List, List,
Tag, Tag,
Button, Button,
Space, Space,
Alert,
Tabs,
Modal,
Form,
Select, Select,
message, message,
Divider,
Typography, Typography,
Empty, Empty,
Spin Spin,
Badge,
Modal,
Alert,
Descriptions,
Progress,
Input
} from 'antd' } from 'antd'
import { import {
ArrowLeftOutlined,
CheckCircleOutlined, CheckCircleOutlined,
CloseCircleOutlined, ClockCircleOutlined,
WarningOutlined,
EyeOutlined, EyeOutlined,
EyeInvisibleOutlined, FilterOutlined,
ToolOutlined,
DownloadOutlined,
ReloadOutlined, ReloadOutlined,
WarningOutlined CloseCircleOutlined,
EditOutlined
} from '@ant-design/icons' } from '@ant-design/icons'
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip as RechartsTooltip, Legend, ResponsiveContainer, RadarChart, PolarGrid, PolarAngleAxis, PolarRadiusAxis, Radar } from 'recharts' import { useReviewCardStore } from '@/stores/reviewCardStore'
import { useReviewStore } from '@/stores/reviewStore' import { ReviewCard, ReviewCardStatus } from '@/services/reviewCardService'
import { SeverityLevel, Issue } from '@/services/reviewService'
const { TabPane } = Tabs
const { Title, Text, Paragraph } = Typography const { Title, Text, Paragraph } = Typography
const { TextArea } = Input
const ReviewResults: React.FC = () => { interface ReviewResultsProps {
const { id: projectId } = useParams<{ id: string }>() projectId?: string
}
// 状态映射
const statusConfig: Record<ReviewCardStatus, { label: string; color: string; icon: React.ReactNode }> = {
[ReviewCardStatus.PENDING]: { label: '待处理', color: 'default', icon: <ClockCircleOutlined /> },
[ReviewCardStatus.AWAITING_USER]: { label: '待确认', color: 'warning', icon: <WarningOutlined /> },
[ReviewCardStatus.APPROVED]: { label: '已通过', color: 'success', icon: <CheckCircleOutlined /> },
[ReviewCardStatus.REJECTED]: { label: '已驳回', color: 'error', icon: <CloseCircleOutlined /> }
}
// 严重程度映射
const severityConfig: Record<'low' | 'medium' | 'high', { label: string; color: string }> = {
low: { label: '低', color: 'green' },
medium: { label: '中', color: 'orange' },
high: { label: '高', color: 'red' }
}
const ReviewResults: React.FC<ReviewResultsProps> = ({ projectId: propProjectId }) => {
const { id: routeProjectId } = useParams<{ id: string }>()
const navigate = useNavigate() const navigate = useNavigate()
const projectId = propProjectId || routeProjectId
const { const {
currentResult, cards,
configuration, stats,
statusFilter,
loading, loading,
error, fetchCards,
executeReview, fetchStats,
fetchLatestResult, approveCard,
ignoreIssue, rejectCard,
unignoreIssue, modifyCard,
autoFixIssue, setStatusFilter
applyBatchDecisions, } = useReviewCardStore()
exportReport
} = useReviewStore()
const [selectedEpisode, setSelectedEpisode] = useState<number | undefined>() const [selectedCard, setSelectedCard] = useState<ReviewCard | null>(null)
const [batchActionModalVisible, setBatchActionModalVisible] = useState(false) const [detailVisible, setDetailVisible] = useState(false)
const [selectedSeverity, setSelectedSeverity] = useState<SeverityLevel | 'all'>('all') const [actionLoading, setActionLoading] = useState(false)
// Parse query params for initial episode selection // 操作相关
useEffect(() => { const [actionModalVisible, setActionModalVisible] = useState(false)
const params = new URLSearchParams(window.location.search) const [actionType, setActionType] = useState<'approve' | 'reject' | 'modify'>('approve')
const epParam = params.get('episode') const [userComment, setUserComment] = useState('')
if (epParam) {
setSelectedEpisode(parseInt(epParam, 10))
}
}, [])
useEffect(() => { useEffect(() => {
if (projectId) { if (projectId) {
fetchLatestResult(projectId, selectedEpisode) fetchCards(projectId)
fetchStats(projectId)
} }
}, [projectId, selectedEpisode]) }, [projectId])
const severityColors: Record<SeverityLevel, string> = { // 刷新数据
low: 'green', const handleRefresh = async () => {
medium: 'orange', if (projectId) {
high: 'red' await Promise.all([
fetchCards(projectId),
fetchStats(projectId)
])
message.success('刷新成功')
}
} }
const severityLabels: Record<SeverityLevel, string> = { // 筛选变更
low: '低', const handleStatusChange = (value: ReviewCardStatus | 'all') => {
medium: '中', setStatusFilter(value)
high: '高' if (projectId) {
fetchCards(projectId, value === 'all' ? undefined : value)
}
} }
const getScoreColor = (score: number) => { // 查看详情
if (score >= 80) return '#52c41a' const handleViewDetail = (card: ReviewCard) => {
if (score >= 60) return '#faad14' setSelectedCard(card)
return '#f5222d' setDetailVisible(true)
} }
const handleExecuteReview = async () => { // 操作
if (!projectId) return const handleAction = (type: 'approve' | 'reject' | 'modify') => {
setActionType(type)
setUserComment('')
setActionModalVisible(true)
}
const handleActionSubmit = async () => {
if (!selectedCard) return
setActionLoading(true)
try { try {
await executeReview(projectId, selectedEpisode) if (actionType === 'approve') {
message.success('审核完成') await approveCard(selectedCard.id, userComment || undefined)
} catch (error) { message.success('已通过审核')
message.error('审核失败') } else if (actionType === 'reject') {
await rejectCard(selectedCard.id, userComment || undefined)
message.success('已驳回审核')
} else if (actionType === 'modify') {
await modifyCard(selectedCard.id, userComment || undefined)
message.success('已标记为修改')
}
setActionModalVisible(false)
setUserComment('')
setDetailVisible(false)
// 刷新数据
if (projectId) {
await Promise.all([
fetchCards(projectId),
fetchStats(projectId)
])
}
} catch (error: any) {
message.error(`操作失败: ${error?.message || ''}`)
} finally {
setActionLoading(false)
} }
} }
const handleIgnoreIssue = async (issueId: string) => { // 跳转到剧集创作
if (!projectId) return const handleGoToEpisode = () => {
try { if (selectedCard?.episode_numbers && selectedCard.episode_numbers.length > 0) {
await ignoreIssue(projectId, issueId) setDetailVisible(false)
message.success('已忽略问题') navigate(`/projects/${projectId}`, {
} catch (error) { state: { activeTab: 'episodes', episodeNumber: selectedCard.episode_numbers[0] }
message.error('操作失败') })
} }
} }
const handleUnignoreIssue = async (issueId: string) => { // 过滤卡片
if (!projectId) return const filteredCards = cards.filter(card => {
try { const matchStatus = statusFilter === 'all' || card.status === statusFilter
await unignoreIssue(projectId, issueId) return matchStatus
message.success('已恢复问题') })
} catch (error) {
message.error('操作失败')
}
}
const handleAutoFix = async (issueId: string) => {
if (!projectId) return
try {
await autoFixIssue(projectId, issueId)
message.success('自动修复完成')
} catch (error) {
message.error('自动修复失败')
}
}
const handleBatchAction = async (action: 'accept' | 'reject' | 'ignore') => {
if (!projectId || !currentResult) return
try {
const decisions = currentResult.issues
.filter(issue => !issue.ignored)
.filter(issue => selectedSeverity === 'all' || issue.severity === selectedSeverity)
.map(issue => ({ issueId: issue.id, action }))
await applyBatchDecisions(projectId, decisions)
message.success(`批量${action === 'accept' ? '接受' : action === 'reject' ? '拒绝' : '忽略'}完成`)
setBatchActionModalVisible(false)
} catch (error) {
message.error('批量操作失败')
}
}
const handleExportReport = async (format: 'json' | 'pdf' | 'markdown') => {
if (!projectId || !currentResult) return
try {
await exportReport(projectId, currentResult.id || 'latest', format, false)
message.success('导出成功')
} catch (error) {
message.error('导出失败')
}
}
const groupedIssues = currentResult?.issues.reduce((acc, issue) => {
if (!acc[issue.severity]) {
acc[issue.severity] = []
}
if (!issue.ignored) {
acc[issue.severity].push(issue)
}
return acc
}, {} as Record<SeverityLevel, Issue[]>) || {}
const visibleIssues = currentResult?.issues.filter(issue => !issue.ignored) || []
const ignoredIssues = currentResult?.issues.filter(issue => issue.ignored) || []
const renderOverallScore = () => {
if (!currentResult) return null
const score = currentResult.overallScore
return ( return (
<div style={{ maxWidth: 1200, margin: '0 auto', padding: '24px' }}>
{/* 头部操作栏 */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 24 }}>
<Title level={3} style={{ margin: 0 }}></Title>
<Space>
<Select
value={statusFilter}
onChange={handleStatusChange}
style={{ width: 150 }}
>
<Select.Option value="all"></Select.Option>
{Object.entries(statusConfig).map(([key, config]) => (
<Select.Option key={key} value={key}>
{config.icon} {config.label}
</Select.Option>
))}
</Select>
<Button icon={<ReloadOutlined />} onClick={handleRefresh} loading={loading}>
</Button>
</Space>
</div>
{/* 统计卡片 */}
{stats && (
<Row gutter={16} style={{ marginBottom: 24 }}>
<Col span={4}>
<Card> <Card>
<Row gutter={16}> <Statistic title="总计" value={stats.total} valueStyle={{ color: '#1890ff' }} />
<Col span={8}>
<Statistic
title="总体评分"
value={score}
suffix="/ 100"
valueStyle={{ color: getScoreColor(score), fontSize: 48 }}
/>
<Progress
percent={score}
strokeColor={getScoreColor(score)}
showInfo={false}
style={{ marginTop: 16 }}
/>
</Col>
<Col span={8}>
<Statistic
title="问题总数"
value={visibleIssues.length}
suffix={`/ ${currentResult.issues.length}`}
valueStyle={{ color: visibleIssues.length > 0 ? '#f5222d' : '#52c41a' }}
/>
<div style={{ marginTop: 16 }}>
<Space direction="vertical" style={{ width: '100%' }}>
<div>
<Tag color="red">: {groupedIssues.high?.length || 0}</Tag>
<Tag color="orange">: {groupedIssues.medium?.length || 0}</Tag>
<Tag color="green">: {groupedIssues.low?.length || 0}</Tag>
</div>
</Space>
</div>
</Col>
<Col span={8}>
<Space direction="vertical">
<Button
type="primary"
icon={<ReloadOutlined />}
onClick={handleExecuteReview}
loading={loading}
>
</Button>
<Button
icon={<DownloadOutlined />}
onClick={() => setBatchActionModalVisible(true)}
>
</Button>
<Button.Group>
<Button size="small" onClick={() => handleExportReport('json')}>JSON</Button>
<Button size="small" onClick={() => handleExportReport('pdf')}>PDF</Button>
<Button size="small" onClick={() => handleExportReport('markdown')}>MD</Button>
</Button.Group>
</Space>
</Col>
</Row>
</Card> </Card>
)
}
const renderDimensionScores = () => {
if (!currentResult) return null
const chartData = currentResult.dimensionScores.map(ds => ({
dimension: ds.dimension,
score: ds.score,
weight: ds.weight,
fullMark: 100
}))
return (
<Card title="维度评分">
<Row gutter={16}>
<Col span={12}>
<Title level={5}></Title>
<ResponsiveContainer width="100%" height={300}>
<BarChart data={chartData}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="dimension" angle={-45} textAnchor="end" height={100} />
<YAxis />
<RechartsTooltip />
<Legend />
<Bar dataKey="score" fill="#0ea5e9" name="分数" />
</BarChart>
</ResponsiveContainer>
</Col> </Col>
<Col span={12}> <Col span={4}>
<Title level={5}></Title> <Card>
<ResponsiveContainer width="100%" height={300}> <Statistic title="待处理" value={stats.pending} valueStyle={{ color: '#faad14' }} />
<RadarChart data={chartData}> </Card>
<PolarGrid /> </Col>
<PolarAngleAxis dataKey="dimension" /> <Col span={4}>
<PolarRadiusAxis angle={30} domain={[0, 100]} /> <Card>
<Radar name="分数" dataKey="score" stroke="#0ea5e9" fill="#0ea5e9" fillOpacity={0.6} /> <Statistic title="待确认" value={stats.awaiting_user} valueStyle={{ color: '#ff4d4f' }} />
</RadarChart> </Card>
</ResponsiveContainer> </Col>
<Col span={4}>
<Card>
<Statistic title="已通过" value={stats.approved} valueStyle={{ color: '#52c41a' }} />
</Card>
</Col>
<Col span={4}>
<Card>
<Statistic title="已驳回" value={stats.rejected} valueStyle={{ color: '#f5222d' }} />
</Card>
</Col> </Col>
</Row> </Row>
<Divider /> )}
{/* 卡片列表 */}
<Card
title={<><FilterOutlined /> </>}
extra={<Badge count={filteredCards.length} showZero />}
>
{loading ? (
<div style={{ textAlign: 'center', padding: '40px 0' }}>
<Spin size="large" />
</div>
) : filteredCards.length === 0 ? (
<Empty description="暂无审核卡片" image={Empty.PRESENTED_IMAGE_SIMPLE} />
) : (
<List <List
dataSource={currentResult.dimensionScores} dataSource={filteredCards}
renderItem={(ds) => ( renderItem={(card) => (
<List.Item> <List.Item
key={card.id}
style={{
border: '1px solid #f0f0f0',
borderRadius: '8px',
marginBottom: '16px',
padding: '16px',
cursor: 'pointer',
transition: 'all 0.2s'
}}
onClick={() => handleViewDetail(card)}
onMouseEnter={(e) => {
e.currentTarget.style.boxShadow = '0 2px 8px rgba(0,0,0,0.1)'
e.currentTarget.style.borderColor = '#1890ff'
}}
onMouseLeave={(e) => {
e.currentTarget.style.boxShadow = 'none'
e.currentTarget.style.borderColor = '#f0f0f0'
}}
>
<List.Item.Meta <List.Item.Meta
avatar={
<Badge
status={severityConfig[card.severity as keyof typeof severityConfig]?.color as any}
text={
<div style={{ textAlign: 'center' }}>
<div style={{ fontSize: '24px', fontWeight: 'bold' }}>
{card.overall_score ? Math.round(card.overall_score) : '-'}
</div>
<div style={{ fontSize: '12px', color: '#999' }}></div>
</div>
}
/>
}
title={ title={
<Space> <Space>
{ds.dimension} <Text strong>EP{card.episode_numbers.join(', ')}</Text>
<Tag color="blue">: {(ds.weight * 100).toFixed(0)}%</Tag> <Tag color={severityConfig[card.severity as keyof typeof severityConfig]?.color}>
{severityConfig[card.severity as keyof typeof severityConfig]?.label}
</Tag>
<Tag icon={statusConfig[card.status].icon} color={statusConfig[card.status].color}>
{statusConfig[card.status].label}
</Tag>
{card.issues.length > 0 && <Tag color="orange">{card.issues.length} </Tag>}
</Space> </Space>
} }
description={ description={
<Progress <div>
percent={ds.score} <Text type="secondary">{card.review_reason}</Text>
strokeColor={getScoreColor(ds.score)} <div style={{ marginTop: '8px', fontSize: '12px', color: '#999' }}>
format={(percent) => `${percent}`} : {new Date(card.created_at).toLocaleString()}
/> </div>
</div>
} }
/> />
<div style={{ width: 200 }}> <Button type="link" icon={<EyeOutlined />}>
<Statistic
value={ds.score} </Button>
suffix={`/ 100`} </List.Item>
valueStyle={{ color: getScoreColor(ds.score), fontSize: 20 }} )}
/> />
)}
</Card>
{/* 详情模态框 */}
<Modal
title={
<Space>
<span> - EP{selectedCard?.episode_numbers.join(', ')}</span>
<Tag color={selectedCard ? severityConfig[selectedCard.severity]?.color : ''}>
{selectedCard ? severityConfig[selectedCard.severity]?.label : ''}
</Tag>
</Space>
}
open={detailVisible}
onCancel={() => setDetailVisible(false)}
width={900}
footer={
<Space>
<Button onClick={() => setDetailVisible(false)}></Button>
{selectedCard?.status === ReviewCardStatus.AWAITING_USER && (
<>
<Button danger onClick={() => handleAction('reject')}></Button>
<Button onClick={() => handleAction('modify')}></Button>
<Button type="primary" onClick={() => handleAction('approve')}></Button>
</>
)}
{selectedCard?.episode_numbers && selectedCard.episode_numbers.length > 0 && (
<Button icon={<EditOutlined />} onClick={handleGoToEpisode}>
</Button>
)}
</Space>
}
>
{selectedCard && (
<Space direction="vertical" style={{ width: '100%' }} size="large">
{/* 基本信息 */}
<Descriptions column={2} bordered size="small">
<Descriptions.Item label="状态">
<Tag icon={statusConfig[selectedCard.status].icon} color={statusConfig[selectedCard.status].color}>
{statusConfig[selectedCard.status].label}
</Tag>
</Descriptions.Item>
<Descriptions.Item label="严重度">
<Tag color={severityConfig[selectedCard.severity]?.color}>
{severityConfig[selectedCard.severity]?.label}
</Tag>
</Descriptions.Item>
<Descriptions.Item label="总体评分" span={2}>
{selectedCard.overall_score ? (
<Progress
percent={Math.round(selectedCard.overall_score)}
strokeColor={selectedCard.overall_score >= 80 ? '#52c41a' : selectedCard.overall_score >= 60 ? '#faad14' : '#f5222d'}
/>
) : '-'}
</Descriptions.Item>
<Descriptions.Item label="审核原因" span={2}>
{selectedCard.review_reason}
</Descriptions.Item>
<Descriptions.Item label="创建时间" span={2}>
{new Date(selectedCard.created_at).toLocaleString()}
</Descriptions.Item>
</Descriptions>
{/* 问题列表 */}
{selectedCard.issues.length > 0 && (
<Card title={`问题列表 (${selectedCard.issues.length})`} size="small">
<List
dataSource={selectedCard.issues}
renderItem={(issue: any, index) => (
<List.Item key={index}>
<List.Item.Meta
avatar={
<Tag color={issue.severity === 'high' ? 'red' : issue.severity === 'medium' ? 'orange' : 'green'}>
{issue.severity === 'high' ? '高' : issue.severity === 'medium' ? '中' : '低'}
</Tag>
}
title={issue.type}
description={
<div>
<Paragraph style={{ margin: 0 }}>{issue.description}</Paragraph>
{issue.suggestion && (
<Alert type="info" message="建议" description={issue.suggestion} style={{ marginTop: 8 }} />
)}
</div> </div>
}
/>
</List.Item> </List.Item>
)} )}
/> />
</Card> </Card>
)
}
const renderIssueCard = (issue: Issue) => (
<Card
key={issue.id}
size="small"
style={{ marginBottom: 16 }}
title={
<Space>
<Tag color={severityColors[issue.severity]}>{severityLabels[issue.severity]}</Tag>
<span>{issue.type}</span>
<Tag color="blue">{issue.dimension}</Tag>
</Space>
}
extra={
<Space>
{issue.canAutoFix && (
<Button
size="small"
type="primary"
icon={<ToolOutlined />}
onClick={() => handleAutoFix(issue.id)}
>
</Button>
)} )}
<Button
size="small" {/* 剧集内容 */}
icon={<EyeInvisibleOutlined />} {selectedCard.episode_outline && (
onClick={() => handleIgnoreIssue(issue.id)} <Card title="剧集大纲" size="small">
> <Paragraph style={{ whiteSpace: 'pre-wrap', margin: 0 }}>{selectedCard.episode_outline}</Paragraph>
</Card>
</Button> )}
</Space>
} {selectedCard.episode_content && (
> <Card title="剧集内容" size="small">
<Space direction="vertical" style={{ width: '100%' }}> <Paragraph style={{ whiteSpace: 'pre-wrap', margin: 0, maxHeight: 300, overflowY: 'auto' }}>
<div> {selectedCard.episode_content}
<strong>:</strong>{' '}
{issue.location.section && `${issue.location.section} - `}
{issue.location.episodeNumber && `${issue.location.episodeNumber}`}
{issue.location.lineNumber && `:${issue.location.lineNumber}`}
</div>
<div>
<strong>:</strong> {issue.description}
</div>
<div>
<strong>:</strong>
<Paragraph
ellipsis={{ rows: 2, expandable: true, symbol: '展开' }}
style={{
background: '#f5f5f5',
padding: '8px',
borderRadius: '4px',
marginTop: '8px'
}}
>
{issue.originalText}
</Paragraph> </Paragraph>
</div> </Card>
{issue.suggestion && ( )}
<div>
<strong>:</strong> {/* 已有评论 */}
<Paragraph {selectedCard.user_comment && (
ellipsis={{ rows: 2, expandable: true, symbol: '展开' }} <Alert type="info" message="用户评论" description={selectedCard.user_comment} />
style={{
background: '#e6f7ff',
padding: '8px',
borderRadius: '4px',
marginTop: '8px'
}}
>
{issue.suggestion}
</Paragraph>
</div>
)} )}
</Space> </Space>
</Card>
)
const renderIgnoredIssueCard = (issue: Issue) => (
<Card
key={issue.id}
size="small"
style={{ marginBottom: 16, opacity: 0.6 }}
type="inner"
>
<Space direction="vertical" style={{ width: '100%' }}>
<Space>
<Tag color={severityColors[issue.severity]}>{severityLabels[issue.severity]}</Tag>
<span>{issue.type}</span>
<Tag>{issue.dimension}</Tag>
<Tag color="default"></Tag>
<Button
size="small"
icon={<EyeOutlined />}
onClick={() => handleUnignoreIssue(issue.id)}
>
</Button>
</Space>
<div>{issue.description}</div>
</Space>
</Card>
)
const renderIssues = () => {
if (!currentResult) return null
return (
<Card title="问题列表">
<Tabs defaultActiveKey="all">
<TabPane tab={`全部 (${visibleIssues.length})`} key="all">
{visibleIssues.length === 0 ? (
<Empty description="暂无问题" image={Empty.PRESENTED_IMAGE_SIMPLE} />
) : (
visibleIssues.map(issue => renderIssueCard(issue))
)} )}
</TabPane> </Modal>
<TabPane
tab={
<span>
<WarningOutlined style={{ color: '#f5222d' }} />
({groupedIssues.high?.length || 0})
</span>
}
key="high"
>
{groupedIssues.high?.length === 0 ? (
<Empty description="暂无高严重度问题" />
) : (
groupedIssues.high?.map(issue => renderIssueCard(issue))
)}
</TabPane>
<TabPane
tab={
<span>
<WarningOutlined style={{ color: '#faad14' }} />
({groupedIssues.medium?.length || 0})
</span>
}
key="medium"
>
{groupedIssues.medium?.length === 0 ? (
<Empty description="暂无中严重度问题" />
) : (
groupedIssues.medium?.map(issue => renderIssueCard(issue))
)}
</TabPane>
<TabPane
tab={
<span>
<WarningOutlined style={{ color: '#52c41a' }} />
({groupedIssues.low?.length || 0})
</span>
}
key="low"
>
{groupedIssues.low?.length === 0 ? (
<Empty description="暂无低严重度问题" />
) : (
groupedIssues.low?.map(issue => renderIssueCard(issue))
)}
</TabPane>
<TabPane
tab={
<span>
<EyeInvisibleOutlined />
({ignoredIssues.length})
</span>
}
key="ignored"
>
{ignoredIssues.length === 0 ? (
<Empty description="暂无已忽略问题" />
) : (
ignoredIssues.map(issue => renderIgnoredIssueCard(issue))
)}
</TabPane>
</Tabs>
</Card>
)
}
const renderSummary = () => { {/* 操作模态框 */}
if (!currentResult) return null
return (
<Card title="审核摘要">
<Paragraph>{currentResult.summary}</Paragraph>
{currentResult.configuration && (
<div>
<Divider />
<Text type="secondary">
: {currentResult.configuration.preset}
</Text>
</div>
)}
</Card>
)
}
if (loading && !currentResult) {
return (
<div style={{ textAlign: 'center', padding: '100px 0' }}>
<Spin size="large" />
</div>
)
}
if (error) {
return <div style={{ padding: '24px', color: 'red' }}>Error: {error}</div>
}
if (!currentResult) {
return (
<div style={{ padding: 24 }}>
<Card>
<Empty description="暂无审核结果">
<Button type="primary" onClick={handleExecuteReview} loading={loading}>
</Button>
</Empty>
</Card>
</div>
)
}
return (
<div style={{ padding: 24 }}>
<div style={{ marginBottom: 24, display: 'flex', alignItems: 'center', justifyContent: 'space-between', background: '#fff', padding: '16px', borderRadius: '8px', boxShadow: '0 1px 2px rgba(0,0,0,0.03)' }}>
<Space size="middle">
<Button
icon={<ArrowLeftOutlined />}
onClick={() => navigate(`/projects/${projectId}`)}
size="large"
>
</Button>
<div>
<Title level={4} style={{ margin: 0 }}></Title>
<Text type="secondary" style={{ fontSize: '12px' }}>
{selectedEpisode ? `EP${selectedEpisode} 质量分析报告` : '最新审核报告'}
</Text>
</div>
</Space>
</div>
<Space direction="vertical" style={{ width: '100%' }} size="large">
{renderOverallScore()}
{renderDimensionScores()}
{renderIssues()}
{renderSummary()}
</Space>
{/* Batch Action Modal */}
<Modal <Modal
title="批量操作" title={actionType === 'approve' ? '通过审核' : actionType === 'reject' ? '驳回审核' : '标记修改'}
open={batchActionModalVisible} open={actionModalVisible}
onCancel={() => setBatchActionModalVisible(false)} onOk={handleActionSubmit}
footer={null} onCancel={() => setActionModalVisible(false)}
confirmLoading={actionLoading}
> >
<Space direction="vertical" style={{ width: '100%' }}> <TextArea
<Form.Item label="筛选严重度"> placeholder="请输入评论(可选)"
<Select value={userComment}
value={selectedSeverity} onChange={(e) => setUserComment(e.target.value)}
onChange={setSelectedSeverity} rows={4}
style={{ width: '100%' }} maxLength={500}
> showCount
<Select.Option value="all"></Select.Option>
<Select.Option value="high"></Select.Option>
<Select.Option value="medium"></Select.Option>
<Select.Option value="low"></Select.Option>
</Select>
</Form.Item>
<Alert
message={`将对 ${visibleIssues.filter(i => selectedSeverity === 'all' || i.severity === selectedSeverity).length} 个问题执行批量操作`}
type="info"
/> />
<Space>
<Button onClick={() => handleBatchAction('ignore')}>
<EyeInvisibleOutlined />
</Button>
<Button onClick={() => setBatchActionModalVisible(false)}>
</Button>
</Space>
</Space>
</Modal> </Modal>
</div> </div>
) )

View File

@ -47,7 +47,7 @@ import {
CloseCircleOutlined, CloseCircleOutlined,
CheckOutlined CheckOutlined
} from '@ant-design/icons' } from '@ant-design/icons'
import { useNavigate } from 'react-router-dom' import { useNavigate, useSearchParams, useLocation } 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'
@ -76,6 +76,8 @@ type CreateMode = 'ai' | 'manual' | 'template'
export const SkillManagement = () => { export const SkillManagement = () => {
const navigate = useNavigate() const navigate = useNavigate()
const [searchParams] = useSearchParams()
const location = useLocation()
const { skills, builtinSkills, userSkills, loading, fetchSkills, testSkill } = useSkillStore() const { skills, builtinSkills, userSkills, loading, fetchSkills, testSkill } = useSkillStore()
// List view states // List view states
@ -122,6 +124,8 @@ export const SkillManagement = () => {
// 向导状态 // 向导状态
const [wizardVisible, setWizardVisible] = useState(false) const [wizardVisible, setWizardVisible] = useState(false)
// 跳转回退信息(从外部跳转过来时使用)
const [redirectState, setRedirectState] = useState<{ path: string; activeTab?: string; reviewSubTab?: string } | null>(null)
useEffect(() => { useEffect(() => {
fetchSkills() fetchSkills()
@ -153,6 +157,69 @@ export const SkillManagement = () => {
} }
}, []) }, [])
// 处理URL参数和location.state - 支持通过URL参数打开编辑或创建
useEffect(() => {
// 从 location.state 读取 redirect 信息
const state = location.state as any
if (state?.redirect) {
setRedirectState({
path: state.redirect,
activeTab: state.activeTab,
reviewSubTab: state.reviewSubTab
})
// 清除 state 避免重复处理
location.state = null
}
// 处理编辑参数 - 使用新的 SkillCreate 向导组件
const editSkillId = searchParams.get('edit')
if (editSkillId) {
const skillToEdit = skills.find(s => s.id === editSkillId)
if (skillToEdit) {
setEditingSkill(skillToEdit)
setWizardVisible(true)
// 清除URL参数
searchParams.delete('edit')
navigate(`/skills?${searchParams.toString()}`, { replace: true })
return
} else {
// Skill 不在本地列表中,尝试从服务器直接获取
// 这允许编辑在 review 配置中引用但不在主列表中的技能
const fetchSkillForEdit = async () => {
try {
const result = await skillService.getSkillWithReferences(editSkillId, false)
if (result?.skill) {
setEditingSkill(result.skill)
setWizardVisible(true)
} else {
message.error(`Skill ID "${editSkillId}" 不存在`)
}
} catch (error) {
console.error('Failed to fetch skill for edit:', error)
message.error(`无法加载 Skill "${editSkillId}": ${(error as Error).message}`)
} finally {
// 清除URL参数
searchParams.delete('edit')
navigate(`/skills?${searchParams.toString()}`, { replace: true })
}
}
fetchSkillForEdit()
return
}
}
// 处理创建参数 - 使用新的 SkillCreate 向导组件
const shouldCreate = searchParams.get('create')
if (shouldCreate === 'true') {
// 确保重置 editingSkill避免误用之前的编辑状态
setEditingSkill(null)
setWizardVisible(true)
// 清除URL参数
searchParams.delete('create')
navigate(`/skills?${searchParams.toString()}`, { replace: true })
}
}, [skills, searchParams, navigate, location])
// 获取技能生成任务 // 获取技能生成任务
const fetchSkillGenerationTasks = async () => { const fetchSkillGenerationTasks = async () => {
try { try {
@ -841,7 +908,7 @@ export const SkillManagement = () => {
}} }}
width={800} width={800}
footer={null} footer={null}
destroyOnClose destroyOnHidden
> >
{selectedSkill && ( {selectedSkill && (
<Space direction="vertical" style={{ width: '100%' }} size="large"> <Space direction="vertical" style={{ width: '100%' }} size="large">
@ -943,7 +1010,7 @@ export const SkillManagement = () => {
}} }}
width={900} width={900}
footer={null} footer={null}
destroyOnClose destroyOnHidden
> >
<Tabs <Tabs
activeKey={createMode} activeKey={createMode}
@ -1430,7 +1497,7 @@ export const SkillManagement = () => {
</Button> </Button>
]} ]}
destroyOnClose destroyOnHidden
> >
<Form <Form
form={form} form={form}
@ -1491,11 +1558,19 @@ export const SkillManagement = () => {
onClose={() => { onClose={() => {
setWizardVisible(false) setWizardVisible(false)
setEditingSkill(null) setEditingSkill(null)
// 如果有跳转回退信息,关闭时跳转回去
if (redirectState) {
navigate(redirectState.path, {
state: { activeTab: redirectState.activeTab, reviewSubTab: redirectState.reviewSubTab }
})
setRedirectState(null)
}
}} }}
onSuccess={() => { onSuccess={() => {
fetchSkills() fetchSkills()
setWizardVisible(false) // 不自动关闭,让用户查看创建结果后主动关闭
setEditingSkill(null) // setWizardVisible(false)
// setEditingSkill(null)
}} }}
editingSkillId={editingSkill?.id} editingSkillId={editingSkill?.id}
/> />

View File

@ -0,0 +1,180 @@
/**
* Confirm Card API Service
*
* API服务 - CRUD操作
*/
import api from './api'
// ============================================
// Types
// ============================================
export type ConfirmCardType =
| 'direction_decision' // 方向决策
| 'plot_branch' // 剧情分支
| 'character_action' // 角色行动
| 'content_approval' // 内容批准
| 'resource_allocation' // 资源分配
export type ConfirmCardStatus =
| 'pending' // 待确认
| 'approved' // 已确认
| 'rejected' // 已拒绝
| 'expired' // 已过期
export interface ConfirmCardOption {
id: string
label: string
description: string
implications?: string
}
export interface ConfirmCard {
id: string
project_id: string
episode_number?: number
card_type: ConfirmCardType
title: string
description: string
context_data?: string
options: ConfirmCardOption[]
allow_custom_response: boolean
status: ConfirmCardStatus
selected_option_id?: string
custom_response?: string
user_notes?: string
created_at: string
expires_at?: string
confirmed_at?: string
metadata: Record<string, any>
}
export interface ConfirmCardCreate {
project_id: string
episode_number?: number
card_type: ConfirmCardType
title: string
description: string
context_data?: string
options: ConfirmCardOption[]
allow_custom_response?: boolean
expires_at?: string
metadata?: Record<string, any>
}
export interface ConfirmCardResponse {
card_id: string
status: ConfirmCardStatus
selected_option?: string
custom_response?: string
confirmed_at?: string
}
// ============================================
// API Service
// ============================================
export const confirmCardService = {
/**
*
*/
create: async (data: ConfirmCardCreate): Promise<{ success: boolean; card: ConfirmCard }> => {
return await api.post('/confirm-cards', data)
},
/**
* (Agent快速推送)
*/
createSimple: async (
projectId: string,
cardType: string,
title: string,
description: string,
options?: Array<{ id?: string; label: string; description?: string; implications?: string } | string>,
episodeNumber?: number,
allowCustomResponse: boolean = false,
expiresInHours?: number
): Promise<{ success: boolean; card_id: string; card: ConfirmCard }> => {
return await api.post('/confirm-cards/simple', null, {
params: {
project_id: projectId,
card_type: cardType,
title,
description,
episode_number: episodeNumber,
allow_custom_response: allowCustomResponse,
expires_in_hours: expiresInHours
},
data: { options }
})
},
/**
*
*/
list: async (
projectId: string,
status?: ConfirmCardStatus,
includeExpired: boolean = false
): Promise<ConfirmCard[]> => {
const params: Record<string, any> = {
project_id: projectId,
include_expired: includeExpired
}
if (status) params.status = status
return await api.get('/confirm-cards', { params })
},
/**
*
*/
get: async (cardId: string): Promise<ConfirmCard> => {
return await api.get(`/confirm-cards/${cardId}`)
},
/**
*
*/
delete: async (cardId: string): Promise<{ success: boolean }> => {
return await api.delete(`/confirm-cards/${cardId}`)
},
/**
*
*/
confirm: async (
cardId: string,
selectedOptionId?: string,
customResponse?: string,
userNotes?: string
): Promise<{ success: boolean; card: ConfirmCard }> => {
return await api.post(`/confirm-cards/${cardId}/confirm`, null, {
params: {
selected_option_id: selectedOptionId,
custom_response: customResponse,
user_notes: userNotes
}
})
},
/**
*
*/
reject: async (
cardId: string,
userNotes?: string
): Promise<{ success: boolean; card: ConfirmCard }> => {
return await api.post(`/confirm-cards/${cardId}/reject`, null, {
params: { user_notes: userNotes }
})
},
/**
*
*/
cleanupExpired: async (): Promise<{ success: boolean; cleaned_count: number }> => {
return await api.post('/confirm-cards/cleanup-expired')
}
}
export default confirmCardService

View File

@ -0,0 +1,180 @@
/**
* Review Card Service
*
* - API
*/
import api from './api'
// ========================================
// 类型定义
// ========================================
export enum ReviewCardType {
PROCESS_AUTO = 'process_auto', // 过程中Agent自主识别
POST_CREATION_AUTO = 'post_creation_auto', // 生成后自动审核
USER_REQUEST_EPISODE = 'user_episode', // 用户请求单集审核
USER_REQUEST_MULTIPLE = 'user_multiple', // 用户请求多集审核
USER_REQUEST_ALL = 'user_all' // 用户请求全集审核
}
export enum ReviewCardStatus {
PENDING = 'pending', // 待处理
AWAITING_USER = 'awaiting_user', // 待确认
APPROVED = 'approved', // 已通过
REJECTED = 'rejected', // 已驳回
}
export interface ReviewCard {
id: string
project_id: string
episode_id?: string
episode_numbers: number[]
// 卡片类型和来源
card_type: ReviewCardType
trigger_source: string
// 审核内容
episode_outline?: string
episode_content?: string
review_reason: string
// 审核结果
issues: Array<{
id?: string
type: string
severity: 'low' | 'medium' | 'high'
description: string
location?: string
suggestion?: string
ignored?: boolean
}>
overall_score?: number
severity: 'low' | 'medium' | 'high'
// 审核配置
review_config_id?: string
applied_skills: string[]
applied_rules: string[]
// 用户操作
status: ReviewCardStatus
user_comment?: string
modified_content?: string
// 时间戳
created_at: string
updated_at: string
completed_at?: string
// 附加信息
metadata: Record<string, any>
}
export interface ReviewCardStats {
total: number
pending: number
awaiting_user: number
approved: number
rejected: number
}
export interface ReviewCardCreateRequest {
project_id: string
episode_id?: string
episode_numbers?: number[]
card_type: ReviewCardType
trigger_source: string
review_reason: string
episode_outline?: string
episode_content?: string
review_config_id?: string
severity?: 'low' | 'medium' | 'high'
issues?: Array<any>
overall_score?: number
}
export interface ReviewCardUpdateRequest {
status?: ReviewCardStatus
user_comment?: string
modified_content?: string
issues?: Array<any>
overall_score?: number
}
// ========================================
// API 方法
// ========================================
export const reviewCardService = {
/**
*
*/
listCards: async (projectId: string, status?: ReviewCardStatus): Promise<ReviewCard[]> => {
const params: any = { project_id: projectId, limit: 100 }
if (status) params.status = status
return await api.get<ReviewCard[]>('/review-cards', { params })
},
/**
*
*/
getStats: async (projectId: string): Promise<ReviewCardStats> => {
return await api.get<ReviewCardStats>(`/review-cards/stats/${projectId}`)
},
/**
*
*/
getCard: async (cardId: string): Promise<ReviewCard> => {
return await api.get<ReviewCard>(`/review-cards/${cardId}`)
},
/**
*
*/
createCard: async (data: ReviewCardCreateRequest): Promise<{ success: boolean; card: ReviewCard }> => {
return await api.post('/review-cards', data)
},
/**
*
*/
updateCard: async (cardId: string, data: ReviewCardUpdateRequest): Promise<{ success: boolean; card: ReviewCard }> => {
return await api.put(`/review-cards/${cardId}`, data)
},
/**
*
*/
approveCard: async (cardId: string, userComment?: string): Promise<{ success: boolean; card: ReviewCard }> => {
return await api.post(`/review-cards/${cardId}/approve`, null, {
params: { user_comment: userComment }
})
},
/**
*
*/
rejectCard: async (cardId: string, userComment?: string, modifiedContent?: string): Promise<{ success: boolean; card: ReviewCard }> => {
return await api.post(`/review-cards/${cardId}/reject`, null, {
params: { user_comment: userComment, modified_content: modifiedContent }
})
},
/**
*
*/
modifyCard: async (cardId: string, userComment?: string, modifiedContent?: string): Promise<{ success: boolean; card: ReviewCard }> => {
return await api.post(`/review-cards/${cardId}/modify`, null, {
params: { user_comment: userComment, modified_content: modifiedContent }
})
},
/**
*
*/
deleteCard: async (cardId: string): Promise<{ success: boolean }> => {
return await api.delete(`/review-cards/${cardId}`)
}
}

View File

@ -18,10 +18,13 @@ export interface ReviewRule {
id: string id: string
name: string name: string
description: string description: string
triggerCondition: string triggerCondition: string // Frontend format
trigger_condition?: string // Backend format
severity: SeverityLevel severity: SeverityLevel
category: string category: string // Frontend format
dimension?: string // Backend format
isActive: boolean isActive: boolean
enabled?: boolean // Backend format
} }
export interface ReviewConfiguration { export interface ReviewConfiguration {
@ -95,18 +98,18 @@ export interface CreateConfigurationRequest {
export interface CreateRuleRequest { export interface CreateRuleRequest {
name: string name: string
description: string description: string
triggerCondition: string trigger_condition: string
severity: SeverityLevel severity: SeverityLevel
category: string dimension: string
} }
export interface UpdateRuleRequest { export interface UpdateRuleRequest {
name?: string name?: string
description?: string description?: string
triggerCondition?: string trigger_condition?: string
severity?: SeverityLevel severity?: SeverityLevel
category?: string dimension?: string
isActive?: boolean enabled?: boolean
} }
export interface ExecuteReviewRequest { export interface ExecuteReviewRequest {
@ -122,83 +125,90 @@ export interface ExportReportRequest {
export const reviewService = { export const reviewService = {
// Configuration // Configuration
getConfiguration: async (projectId: string): Promise<ReviewConfiguration> => { getConfiguration: async (projectId: string): Promise<ReviewConfiguration> => {
return await api.get(`/projects/${projectId}/review/configuration`) return await api.get(`/projects/${projectId}/review-config`)
}, },
createConfiguration: async (projectId: string, data: CreateConfigurationRequest): Promise<ReviewConfiguration> => { createConfiguration: async (projectId: string, data: CreateConfigurationRequest): Promise<ReviewConfiguration> => {
return await api.post(`/projects/${projectId}/review/configuration`, data) return await api.post(`/projects/${projectId}/review-config`, data)
}, },
updateConfiguration: async (projectId: string, data: Partial<ReviewConfiguration>): Promise<ReviewConfiguration> => { updateConfiguration: async (projectId: string, data: Partial<ReviewConfiguration>): Promise<ReviewConfiguration> => {
return await api.put(`/projects/${projectId}/review/configuration`, data) return await api.put(`/projects/${projectId}/review-config`, data)
}, },
applyPreset: async (projectId: string, preset: ReviewPreset): Promise<ReviewConfiguration> => { applyPreset: async (projectId: string, preset: ReviewPreset): Promise<ReviewConfiguration> => {
return await api.post(`/projects/${projectId}/review/configuration/preset`, { preset }) // Apply preset by updating the configuration with the preset
return await api.put(`/projects/${projectId}/review-config`, { preset })
}, },
// Custom Rules // Custom Rules
getRules: async (projectId: string): Promise<ReviewRule[]> => { getRules: async (projectId: string): Promise<ReviewRule[]> => {
return await api.get(`/projects/${projectId}/review/rules`) return await api.get('/custom-rules', { params: { project_id: projectId } })
}, },
createRule: async (projectId: string, data: CreateRuleRequest): Promise<ReviewRule> => { createRule: async (projectId: string, data: CreateRuleRequest): Promise<ReviewRule> => {
return await api.post(`/projects/${projectId}/review/rules`, data) return await api.post('/custom-rules', data, { params: { project_id: projectId } })
}, },
updateRule: async (projectId: string, ruleId: string, data: UpdateRuleRequest): Promise<ReviewRule> => { updateRule: async (projectId: string, ruleId: string, data: UpdateRuleRequest): Promise<ReviewRule> => {
return await api.put(`/projects/${projectId}/review/rules/${ruleId}`, data) return await api.put(`/custom-rules/${ruleId}`, data, { params: { project_id: projectId } })
}, },
deleteRule: async (projectId: string, ruleId: string): Promise<void> => { deleteRule: async (projectId: string, ruleId: string): Promise<void> => {
return await api.delete(`/projects/${projectId}/review/rules/${ruleId}`) return await api.delete(`/custom-rules/${ruleId}`, { params: { project_id: projectId } })
}, },
testRule: async (projectId: string, data: TestRuleRequest): Promise<TestRuleResponse> => { testRule: async (projectId: string, data: TestRuleRequest & { episodeId: string }): Promise<TestRuleResponse> => {
return await api.post(`/projects/${projectId}/review/rules/test`, data) return await api.post(`/custom-rules/${data.rule?.id || 'test'}/test`, {
episode_id: data.episodeId,
project_id: projectId
})
}, },
// Rule Templates // Rule Templates
getRuleTemplates: async (): Promise<ReviewRule[]> => { getRuleTemplates: async (): Promise<ReviewRule[]> => {
return await api.get('/review/rule-templates') return await api.get('/custom-rules')
}, },
// Review Execution // Review Execution
executeReview: async (projectId: string, data: ExecuteReviewRequest): Promise<ReviewResult> => { executeReview: async (projectId: string, data: ExecuteReviewRequest): Promise<ReviewResult> => {
return await api.post(`/projects/${projectId}/review/execute`, data) return await api.post(`/projects/${projectId}/episodes/${data.episodeNumber || 1}/review`, data)
}, },
getReviewResult: async (projectId: string, reviewId: string): Promise<ReviewResult> => { getReviewResult: async (projectId: string, episodeNumber: number): Promise<ReviewResult> => {
return await api.get(`/projects/${projectId}/review/results/${reviewId}`) return await api.get(`/projects/${projectId}/episodes/${episodeNumber}/review-result`)
}, },
getLatestReviewResult: async (projectId: string, episodeNumber?: number): Promise<ReviewResult> => { getLatestReviewResult: async (projectId: string, episodeNumber?: number): Promise<ReviewResult> => {
return await api.get(`/projects/${projectId}/review/latest`, { params: { episode_number: episodeNumber } }) if (!episodeNumber) {
throw new Error('episodeNumber is required')
}
return await api.get(`/projects/${projectId}/episodes/${episodeNumber}/review-result`)
}, },
// Issue Management // Issue Management
ignoreIssue: async (projectId: string, issueId: string): Promise<void> => { ignoreIssue: async (projectId: string, issueId: string): Promise<void> => {
return await api.post(`/projects/${projectId}/review/issues/${issueId}/ignore`) return await api.post(`/custom-rules/${issueId}/ignore`, { project_id: projectId })
}, },
unignoreIssue: async (projectId: string, issueId: string): Promise<void> => { unignoreIssue: async (projectId: string, issueId: string): Promise<void> => {
return await api.post(`/projects/${projectId}/review/issues/${issueId}/unignore`) return await api.post(`/custom-rules/${issueId}/unignore`, { project_id: projectId })
}, },
autoFixIssue: async (projectId: string, issueId: string): Promise<{ fixed: boolean; fixedText?: string }> => { autoFixIssue: async (projectId: string, issueId: string): Promise<{ fixed: boolean; fixedText?: string }> => {
return await api.post(`/projects/${projectId}/review/issues/${issueId}/autofix`) return await api.post(`/custom-rules/${issueId}/test`, { project_id: projectId })
}, },
applyBatchDecisions: async ( applyBatchDecisions: async (
projectId: string, projectId: string,
decisions: { issueId: string; action: 'accept' | 'reject' | 'ignore' }[] decisions: { issueId: string; action: 'accept' | 'reject' | 'ignore' }[]
): Promise<{ applied: number }> => { ): Promise<{ applied: number }> => {
return await api.post(`/projects/${projectId}/review/batch`, { decisions }) return await api.post(`/projects/${projectId}/review-config`, { decisions })
}, },
// Export // Export
exportReport: async (projectId: string, reviewId: string, data: ExportReportRequest): Promise<Blob> => { exportReport: async (projectId: string, reviewId: string, data: ExportReportRequest): Promise<Blob> => {
const response = await api.get(`/projects/${projectId}/review/results/${reviewId}/export`, { const response = await api.get(`/projects/${projectId}/episodes/${reviewId}/review-result`, {
params: data, params: data,
responseType: 'blob' responseType: 'blob'
}) })

View File

@ -350,5 +350,59 @@ export const skillService = {
params: { tool_name: toolName }, params: { tool_name: toolName },
data: parameters data: parameters
}) })
},
// ========================================
// LLM 自动选择 Skills智能路由
// ========================================
/**
* LLM Skills
* @param userIntent
* @param context
* @param excludeSkills Skills
* @param requiredCategory
* @param requiredTags
* @returns
*/
selectSkills: async (request: {
user_intent: string
context?: Record<string, any>
exclude_skills?: string[]
required_category?: string
required_tags?: string[]
}): Promise<{
selected_skills: Array<{
id: string
name: string
description: string
category: string
tags: string[]
}>
selection_reason: string
confidence_scores: Record<string, number>
}> => {
return await api.post('/skills/select-skills', request)
},
/**
* Agent Skill 使
* @param config
* @returns
*/
configureAgentWorkflow: async (config: {
workflow_type: 'sequential' | 'parallel' | 'adaptive'
default_skills?: string[]
steps?: Array<{
step_name: string
skills: string[]
enabled: boolean
}>
}): Promise<{
success: boolean
message: string
config: Record<string, any>
}> => {
return await api.post('/skills/agent-workflow/configure', config)
} }
} }

View File

@ -73,6 +73,7 @@ export const taskService = {
generateCharacters: async (params: { generateCharacters: async (params: {
idea: string idea: string
projectName?: string projectName?: string
genre?: string
totalEpisodes?: number totalEpisodes?: number
skills?: Array<{ id: string; name: string; behavior: string }> skills?: Array<{ id: string; name: string; behavior: string }>
customPrompt?: string customPrompt?: string
@ -137,7 +138,7 @@ export const taskService = {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const poll = async () => { const poll = async () => {
try { try {
const progress = await api.get<TaskProgress>(`/tasks/${taskId}`) const progress = await api.get<TaskProgress>(`/tasks/${taskId}`) as unknown as TaskProgress
onUpdate?.(progress) onUpdate?.(progress)
if (progress.status === 'completed') { if (progress.status === 'completed') {

View File

@ -2,7 +2,7 @@
* Memory System State Management (Zustand) * Memory System State Management (Zustand)
*/ */
import { create } from 'zustand' import { create } from 'zustand'
import { memoryService, MemorySystem, TimelineEvent, PendingThread, CharacterState, Foreshadowing, Relationship, CreateThreadRequest, UpdateThreadRequest, CreateForeshadowingRequest, ImportanceLevel } from '@/services/memoryService' import { memoryService, MemorySystem, TimelineEvent, PendingThread, CharacterState, Foreshadowing, Relationship, CreateThreadRequest, UpdateThreadRequest, CreateForeshadowingRequest, ImportanceLevel, MemoryStatistics } from '@/services/memoryService'
interface MemoryStore { interface MemoryStore {
// State // State
@ -13,7 +13,7 @@ interface MemoryStore {
characterStates: CharacterState[] characterStates: CharacterState[]
foreshadowing: Foreshadowing[] foreshadowing: Foreshadowing[]
relationships: Relationship[] relationships: Relationship[]
statistics: MemorySystem | null statistics: MemoryStatistics | null
loading: boolean loading: boolean
error: string | null error: string | null
@ -60,16 +60,40 @@ interface MemoryStore {
clearMemory: () => void clearMemory: () => void
} }
export const useMemoryStore = create<MemoryStore>((set, get) => ({ export const useMemoryStore = create<MemoryStore>((set) => ({
// Initial State // Initial State with empty but structured data
projectId: null, projectId: null,
memorySystem: null, memorySystem: {
projectId: '',
timeline: [],
pendingThreads: [],
characterStates: [],
foreshadowing: [],
relationships: [],
statistics: {
totalEvents: 0,
totalThreads: 0,
resolvedThreads: 0,
totalCharacters: 0,
totalForeshadowing: 0,
paidOffForeshadowing: 0,
totalRelationships: 0
}
},
timeline: [], timeline: [],
threads: [], threads: [],
characterStates: [], characterStates: [],
foreshadowing: [], foreshadowing: [],
relationships: [], relationships: [],
statistics: null, statistics: {
totalEvents: 0,
totalThreads: 0,
resolvedThreads: 0,
totalCharacters: 0,
totalForeshadowing: 0,
paidOffForeshadowing: 0,
totalRelationships: 0
},
loading: false, loading: false,
error: null, error: null,
timelineFilters: {}, timelineFilters: {},
@ -81,14 +105,85 @@ export const useMemoryStore = create<MemoryStore>((set, get) => ({
set({ loading: true, error: null, projectId }) set({ loading: true, error: null, projectId })
try { try {
const memorySystem = await memoryService.getMemory(projectId) const memorySystem = await memoryService.getMemory(projectId)
// Transform characterStates from dictionary to array format
const characterStatesArray: CharacterState[] = memorySystem.characterStates
? Object.entries(memorySystem.characterStates).map(([characterName, stateChanges]: [string, any]) => ({
characterName,
currentState: stateChanges[stateChanges.length - 1]?.state || '',
history: stateChanges
}))
: []
// Transform relationships from nested dict to array format
const relationshipsArray: Relationship[] = memorySystem.relationships
? Object.entries(memorySystem.relationships).flatMap(([sourceChar, targets]: [string, any]) =>
Object.entries(targets).map(([targetChar, relationData]: [string, any]) => ({
sourceCharacter: sourceChar,
targetCharacter: targetChar,
relationType: relationData.type || 'neutral',
strength: relationData.strength || 50,
description: relationData.description,
evolutionHistory: relationData.history || []
}))
)
: []
// Transform timeline events to match frontend format
const timelineArray: TimelineEvent[] = (memorySystem.timeline || []).map((event: any) => ({
id: event.id || `${event.episode}_${event.timestamp}`,
episodeNumber: event.episode || event.episodeNumber,
timestamp: event.timestamp || event.createdAt,
characters: event.characters_involved || event.characters || [],
description: event.event || event.description,
importance: event.importance || 'medium',
category: event.tags?.[0] || event.category || 'general',
createdAt: event.createdAt || event.timestamp
}))
// Transform threads to match frontend format
const threadsArray: PendingThread[] = (memorySystem.pendingThreads || []).map((thread: any) => ({
id: thread.id,
description: thread.description,
importance: thread.importance,
status: thread.status || 'pending',
reminderEpisode: thread.reminder_episode,
relatedCharacters: thread.characters_involved || thread.relatedCharacters || [],
createdAt: thread.created_at || thread.createdAt,
resolvedAt: thread.resolved_at || thread.resolvedAt
}))
// Transform foreshadowing to match frontend format
const foreshadowingArray: Foreshadowing[] = (memorySystem.foreshadowing || []).map((item: any) => ({
id: item.id,
description: item.description,
importance: item.importance,
payoffEventId: item.related_events?.[0],
payoffEpisode: item.payoff_at,
status: (item.is_payed_off ? 'paid_off' : 'planted') as 'paid_off' | 'planted',
plantedEpisode: item.introduced_at,
createdAt: item.createdAt || new Date().toISOString()
}))
// Transform statistics
const statistics: MemoryStatistics = {
totalEvents: timelineArray.length,
totalThreads: threadsArray.length,
resolvedThreads: threadsArray.filter((t: any) => t.status === 'resolved').length,
totalCharacters: characterStatesArray.length,
totalForeshadowing: foreshadowingArray.length,
paidOffForeshadowing: foreshadowingArray.filter((f: any) => f.status === 'paid_off').length,
totalRelationships: relationshipsArray.length
}
set({ set({
memorySystem, memorySystem,
timeline: memorySystem.timeline, timeline: timelineArray,
threads: memorySystem.pendingThreads, threads: threadsArray,
characterStates: memorySystem.characterStates, characterStates: characterStatesArray,
foreshadowing: memorySystem.foreshadowing, foreshadowing: foreshadowingArray,
relationships: memorySystem.relationships, relationships: relationshipsArray,
statistics: memorySystem.statistics, statistics,
loading: false loading: false
}) })
} catch (error) { } catch (error) {
@ -99,8 +194,19 @@ export const useMemoryStore = create<MemoryStore>((set, get) => ({
fetchTimeline: async (projectId, filters) => { fetchTimeline: async (projectId, filters) => {
set({ loading: true, error: null }) set({ loading: true, error: null })
try { try {
const timeline = await memoryService.getTimeline(projectId, filters) const response = await memoryService.getTimeline(projectId, filters)
set({ timeline, loading: false }) // Transform to match frontend format
const timelineArray = (response || []).map((event: any) => ({
id: event.id || `${event.episode}_${event.timestamp}`,
episodeNumber: event.episode || event.episodeNumber,
timestamp: event.timestamp || event.createdAt,
characters: event.characters_involved || event.characters || [],
description: event.event || event.description,
importance: event.importance || 'medium',
category: event.tags?.[0] || event.category || 'general',
createdAt: event.createdAt || event.timestamp
}))
set({ timeline: timelineArray, loading: false })
} catch (error) { } catch (error) {
set({ error: (error as Error).message, loading: false }) set({ error: (error as Error).message, loading: false })
} }
@ -109,8 +215,19 @@ export const useMemoryStore = create<MemoryStore>((set, get) => ({
fetchThreads: async (projectId) => { fetchThreads: async (projectId) => {
set({ loading: true, error: null }) set({ loading: true, error: null })
try { try {
const threads = await memoryService.getThreads(projectId) const response = await memoryService.getThreads(projectId)
set({ threads, loading: false }) // Transform to match frontend format
const threadsArray = (response || []).map((thread: any) => ({
id: thread.id,
description: thread.description,
importance: thread.importance,
status: thread.status || 'pending',
reminderEpisode: thread.reminder_episode,
relatedCharacters: thread.characters_involved || thread.relatedCharacters || [],
createdAt: thread.created_at || thread.createdAt,
resolvedAt: thread.resolved_at || thread.resolvedAt
}))
set({ threads: threadsArray, loading: false })
} catch (error) { } catch (error) {
set({ error: (error as Error).message, loading: false }) set({ error: (error as Error).message, loading: false })
} }
@ -119,8 +236,16 @@ export const useMemoryStore = create<MemoryStore>((set, get) => ({
fetchCharacterStates: async (projectId) => { fetchCharacterStates: async (projectId) => {
set({ loading: true, error: null }) set({ loading: true, error: null })
try { try {
const characterStates = await memoryService.getCharacterStates(projectId) const response = await memoryService.getCharacterStates(projectId)
set({ characterStates, loading: false }) // Transform from dict to array format
const characterStatesArray = response
? Object.entries(response).map(([characterName, stateChanges]: [string, any]) => ({
characterName,
currentState: stateChanges[stateChanges.length - 1]?.state || '',
history: stateChanges
}))
: []
set({ characterStates: characterStatesArray, loading: false })
} catch (error) { } catch (error) {
set({ error: (error as Error).message, loading: false }) set({ error: (error as Error).message, loading: false })
} }
@ -129,8 +254,19 @@ export const useMemoryStore = create<MemoryStore>((set, get) => ({
fetchForeshadowing: async (projectId) => { fetchForeshadowing: async (projectId) => {
set({ loading: true, error: null }) set({ loading: true, error: null })
try { try {
const foreshadowing = await memoryService.getForeshadowing(projectId) const response = await memoryService.getForeshadowing(projectId)
set({ foreshadowing, loading: false }) // Transform to match frontend format
const foreshadowingArray: Foreshadowing[] = (response || []).map((item: any) => ({
id: item.id,
description: item.description,
importance: item.importance,
payoffEventId: item.related_events?.[0],
payoffEpisode: item.payoff_at,
status: (item.is_payed_off ? 'paid_off' : 'planted') as 'paid_off' | 'planted',
plantedEpisode: item.introduced_at,
createdAt: item.createdAt || new Date().toISOString()
}))
set({ foreshadowing: foreshadowingArray, loading: false })
} catch (error) { } catch (error) {
set({ error: (error as Error).message, loading: false }) set({ error: (error as Error).message, loading: false })
} }
@ -139,8 +275,21 @@ export const useMemoryStore = create<MemoryStore>((set, get) => ({
fetchRelationships: async (projectId) => { fetchRelationships: async (projectId) => {
set({ loading: true, error: null }) set({ loading: true, error: null })
try { try {
const relationships = await memoryService.getRelationships(projectId) const response = await memoryService.getRelationships(projectId)
set({ relationships, loading: false }) // Transform from nested dict to array format
const relationshipsArray: Relationship[] = response
? Object.entries(response).flatMap(([sourceChar, targets]: [string, any]) =>
Object.entries(targets).map(([targetChar, relationData]: [string, any]) => ({
sourceCharacter: sourceChar,
targetCharacter: targetChar,
relationType: relationData.type || 'neutral',
strength: relationData.strength || 50,
description: relationData.description,
evolutionHistory: relationData.history || []
}))
)
: []
set({ relationships: relationshipsArray, loading: false })
} catch (error) { } catch (error) {
set({ error: (error as Error).message, loading: false }) set({ error: (error as Error).message, loading: false })
} }
@ -150,7 +299,7 @@ export const useMemoryStore = create<MemoryStore>((set, get) => ({
set({ loading: true, error: null }) set({ loading: true, error: null })
try { try {
const statistics = await memoryService.getStatistics(projectId) const statistics = await memoryService.getStatistics(projectId)
set({ statistics, loading: false }) set({ statistics: statistics as MemoryStatistics, loading: false })
} catch (error) { } catch (error) {
set({ error: (error as Error).message, loading: false }) set({ error: (error as Error).message, loading: false })
} }

View File

@ -0,0 +1,184 @@
/**
* Review Card Store
*
*
*/
import { create } from 'zustand'
import { reviewCardService, ReviewCard, ReviewCardStats, ReviewCardStatus, ReviewCardType } from '@/services/reviewCardService'
interface ReviewCardState {
// 数据
cards: ReviewCard[]
currentCard: ReviewCard | null
stats: ReviewCardStats | null
// 筛选状态
statusFilter: ReviewCardStatus | 'all'
typeFilter: ReviewCardType | 'all'
searchQuery: string
// UI 状态
loading: boolean
error: string | null
// 操作
setProjectId: (projectId: string) => void
setStatusFilter: (status: ReviewCardStatus | 'all') => void
setTypeFilter: (type: ReviewCardType | 'all') => void
setSearchQuery: (query: string) => void
// 数据获取
fetchCards: (projectId: string, status?: ReviewCardStatus) => Promise<void>
fetchStats: (projectId: string) => Promise<void>
fetchCard: (cardId: string) => Promise<void>
// 操作
approveCard: (cardId: string, userComment?: string) => Promise<void>
rejectCard: (cardId: string, userComment?: string, modifiedContent?: string) => Promise<void>
modifyCard: (cardId: string, userComment?: string, modifiedContent?: string) => Promise<void>
deleteCard: (cardId: string) => Promise<void>
// 清理
clearCards: () => void
clearCurrentCard: () => void
}
export const useReviewCardStore = create<ReviewCardState>((set, get) => ({
// 初始状态
cards: [],
currentCard: null,
stats: null,
statusFilter: 'all',
typeFilter: 'all',
searchQuery: '',
loading: false,
error: null,
// 设置项目ID预留
setProjectId: (projectId: string) => {
// 可以在这里设置当前项目ID
},
// 设置筛选状态
setStatusFilter: (status) => set({ statusFilter: status }),
setTypeFilter: (type) => set({ typeFilter: type }),
setSearchQuery: (query) => set({ searchQuery: query }),
// 获取卡片列表
fetchCards: async (projectId, status) => {
set({ loading: true, error: null })
try {
const cards = await reviewCardService.listCards(projectId, status)
set({ cards, loading: false })
} catch (error: any) {
set({ error: error?.message || '获取卡片列表失败', loading: false })
}
},
// 获取统计信息
fetchStats: async (projectId) => {
try {
const stats = await reviewCardService.getStats(projectId)
set({ stats })
} catch (error: any) {
console.error('获取统计信息失败:', error)
}
},
// 获取卡片详情
fetchCard: async (cardId) => {
set({ loading: true, error: null })
try {
const card = await reviewCardService.getCard(cardId)
set({ currentCard: card, loading: false })
} catch (error: any) {
set({ error: error?.message || '获取卡片详情失败', loading: false })
}
},
// 通过审核
approveCard: async (cardId, userComment) => {
set({ loading: true, error: null })
try {
const result = await reviewCardService.approveCard(cardId, userComment)
// 更新列表中的卡片
set((state) => ({
cards: state.cards.map(c => c.id === cardId ? result.card : c),
currentCard: state.currentCard?.id === cardId ? result.card : state.currentCard,
loading: false
}))
// 重新获取统计信息
const projectId = get().currentCard?.project_id
if (projectId) {
get().fetchStats(projectId)
}
} catch (error: any) {
set({ error: error?.message || '操作失败', loading: false })
throw error
}
},
// 驳回审核
rejectCard: async (cardId, userComment, modifiedContent) => {
set({ loading: true, error: null })
try {
const result = await reviewCardService.rejectCard(cardId, userComment, modifiedContent)
set((state) => ({
cards: state.cards.map(c => c.id === cardId ? result.card : c),
currentCard: state.currentCard?.id === cardId ? result.card : state.currentCard,
loading: false
}))
const projectId = get().currentCard?.project_id
if (projectId) {
get().fetchStats(projectId)
}
} catch (error: any) {
set({ error: error?.message || '操作失败', loading: false })
throw error
}
},
// 修改审核
modifyCard: async (cardId, userComment, modifiedContent) => {
set({ loading: true, error: null })
try {
const result = await reviewCardService.modifyCard(cardId, userComment, modifiedContent)
set((state) => ({
cards: state.cards.map(c => c.id === cardId ? result.card : c),
currentCard: state.currentCard?.id === cardId ? result.card : state.currentCard,
loading: false
}))
const projectId = get().currentCard?.project_id
if (projectId) {
get().fetchStats(projectId)
}
} catch (error: any) {
set({ error: error?.message || '操作失败', loading: false })
throw error
}
},
// 删除卡片
deleteCard: async (cardId) => {
set({ loading: true, error: null })
try {
await reviewCardService.deleteCard(cardId)
set((state) => ({
cards: state.cards.filter(c => c.id !== cardId),
currentCard: state.currentCard?.id === cardId ? null : state.currentCard,
loading: false
}))
const projectId = get().cards.find(c => c.id !== cardId)?.project_id
if (projectId) {
get().fetchStats(projectId)
}
} catch (error: any) {
set({ error: error?.message || '删除失败', loading: false })
throw error
}
},
// 清理数据
clearCards: () => set({ cards: [], stats: null, error: null }),
clearCurrentCard: () => set({ currentCard: null, error: null })
}))

View File

@ -2,7 +2,7 @@
* Review System State Management (Zustand) * Review System State Management (Zustand)
*/ */
import { create } from 'zustand' import { create } from 'zustand'
import { reviewService, ReviewConfiguration, ReviewResult, ReviewRule, CreateRuleRequest, UpdateRuleRequest, SeverityLevel, ReviewPreset, Issue } from '@/services/reviewService' import { reviewService, ReviewConfiguration, ReviewResult, ReviewRule, CreateRuleRequest, UpdateRuleRequest, ReviewPreset } from '@/services/reviewService'
interface ReviewStore { interface ReviewStore {
// State // State
@ -49,9 +49,22 @@ interface ReviewStore {
} }
export const useReviewStore = create<ReviewStore>((set, get) => ({ export const useReviewStore = create<ReviewStore>((set, get) => ({
// Initial State // Initial State with default configuration
projectId: null, projectId: null,
configuration: null, configuration: {
preset: 'standard',
dimensions: {
character_consistency: { name: '角色一致性', enabled: true, strictness: 50, weight: 0.8 },
plot_coherence: { name: '剧情连贯性', enabled: true, strictness: 50, weight: 0.9 },
dialogue_quality: { name: '对话质量', enabled: true, strictness: 50, weight: 0.7 },
pacing: { name: '节奏控制', enabled: true, strictness: 50, weight: 0.6 },
emotional_depth: { name: '情感深度', enabled: true, strictness: 50, weight: 0.7 },
thematic_strength: { name: '主题强度', enabled: true, strictness: 50, weight: 0.8 }
},
customRules: [],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString()
},
currentResult: null, currentResult: null,
ruleTemplates: [], ruleTemplates: [],
testResult: null, testResult: null,
@ -107,12 +120,14 @@ export const useReviewStore = create<ReviewStore>((set, get) => ({
}, },
fetchRuleTemplates: async () => { fetchRuleTemplates: async () => {
set({ loading: true, error: null }) // Don't set loading state for rule templates as it's not critical
try { try {
const ruleTemplates = await reviewService.getRuleTemplates() const ruleTemplates = await reviewService.getRuleTemplates()
set({ ruleTemplates, loading: false }) set({ ruleTemplates })
} catch (error) { } catch (error) {
set({ error: (error as Error).message, loading: false }) // Silently fail for rule templates - not critical for core functionality
console.warn('Failed to fetch rule templates:', error)
set({ ruleTemplates: [] })
} }
}, },
@ -176,6 +191,8 @@ export const useReviewStore = create<ReviewStore>((set, get) => ({
} }
}, },
clearTestResult: () => set({ testResult: null }),
// Review Execution // Review Execution
executeReview: async (projectId, episodeNumber) => { executeReview: async (projectId, episodeNumber) => {
set({ loading: true, error: null }) set({ loading: true, error: null })

View File

@ -33,10 +33,9 @@ echo [2/2] 启动开发服务器...
echo. echo.
echo ============================================ echo ============================================
echo 前端地址: http://localhost:5173 echo 前端地址: http://localhost:5173
echo API 文档: http://localhost:8000/docs
echo ============================================ echo ============================================
echo. echo.
call npm run dev call npm run dev
call npm run dev
pause pause

BIN
img.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

42
test.md
View File

@ -1,17 +1,29 @@
# 我的python 环境是"C:\ProgramData\Anaconda3\envs\creative_studio\python.exe" # 我的python 环境是"C:\ProgramData\Anaconda3\envs\creative_studio\python.exe"
## 1 开发前后端,完成以下任务:
使用wedsocket 实现实时通信时,用户需要可以中途去别的地方查看和修改,而不是等待生成完成。 ## 检查项目有没有实现一下功能,如果没有需要进行开发实现:
剧集开始创作之后的质量和问题需要有真正的分析和显示可以制作审核卡片推送到审核系统中http://localhost:5173/projects/8f969272-4ece-49e7-8ca1-4877cc62c57c/review/confighttp://localhost:5173/projects/8f969272-4ece-49e7-8ca1-4877cc62c57c/memory都是空白 1. **关于整个项目的ai生成和skills部分**创建skills时可以用ai辅助系统默认使用skill-creator这个skill可以用ai辅助修改可以上传references同时需要可以查看、管理所有用户创建的skills查看skill时需要不仅能看he skill的定义还需要能看he skill的references详细内容。每个skill不管是系统还是用户都能被ai自动选取调用合适的skills参考skillsagent项目所有地方的ai生成都需要考虑到调用skill的问题
## 2 2. **关于全局设定部分**,是不是按照世界观-人物-整体大纲的顺序生成每一个生成之后注入后面的生成中来保证生成的内容符合预期。同时每块生成同样如1.中所述需要能配置skills也可以自动调用合适的skills参考skillsagent项目的逻辑
首先理清楚一个概念虽然现在创作平台是按照画布实现的但是其实是一个编辑台不是出图的而是一个文本编辑台用户在这个台子里创作故事然后通过agent自动更新记忆库和故事上下文。现在的画布只有点击编辑时候才会显示内容需要一直显示用户在创作过程中可以随时查看和修改。现在都显示的空白检查修改一下 3. **关于剧集创作部分**首先agent需要支持**用户在对话框输入任何命令或者需求的解决能力**能在对话框显示用户的任何请求的输出但需要能在支持的场景下能执行特定的动作就是如果用户请求生成某一集或几集的大纲、开始创作某一集或某几集、怎么怎么调整某几集的大纲然后重新生成某一集某几集的剧集内容之类agent需要同时能自动调用/填充/更新/修改剧集大纲和剧集内容的草稿区(交互效果和点击这一集或几集的开始创作和开始生成过程一样,且这个过程同样支持异步,中途同样可以处理其他项目中的各种生成任务),同时对话框要有上下文记忆功能,以及刷新重建对话功能(手动后提示用户手动)(防止对话长度太长)。另外,故事上下文部分的记忆库要进行实现。
## 3 4. **关于剧集创作agent**最开始有没有把全局设定和项目设置的信息作为上下文注入到agent中来确保生成的内容符合预期就算用户重建对话也同样需要保持这个注入头。在生成每个剧集大纲时agent能否规划先调取记忆系统中的数据来确保生成的内容符合预期、在生成剧集内容时agent能否结合记忆库和剧集大纲来确保生成的内容符合预期。在剧集大纲和剧集创作内容使用ai调整或在对话框中输入调整xx内容/大纲的需求时agent能否在需要的时候注入草稿台的内容来让agent对其中的内容进行调整/更新/修改等等。以及和记忆系统进行联动调用,用来进行随时的审查。
关于故事大纲生成和开始创作,现在生成大纲确实在对话框里展示了大纲,但是是这样的: 5. **关于记忆系统和审核系统**都需要进行全方位实现用户可配置同时agent要能用上这两个系统的功能这里要进行打通。agents需要能自动规划和这两个系统的交互比如在生成剧集大纲、在生成剧集内容时agent是否能根据记忆库中的数据来规划是否需要调用审核系统来审核内容、是否需要在记忆库中更新某些数据、是否需要在审核系统推送审核任务等等。不管是配置tools还是甚么方法请你将agent与系统融合。
agent: 2. ✨ 建立了楚云飞的侠义形象,暗示其真实身份 3. ✨ 埋下苏瑾月与楚云飞情感发展的种子 4. ✨ 强化林墨轩的反派色彩 **已提交审阅:** 我已将大纲提交到您的收件箱,请您审阅并反馈是否需要调整。 您可以: - 批准大纲 → 我将开始创作正式剧本 - 要求修改 → 告诉我具体的修改意见 - 重新创作 → 我会从新的角度重新设计 期待您的反馈! 6. **关于审核系统**
用户:批准大纲 1. 审核报告其实应该是审核平台需要有一个审核卡片的概念用来存放agent生成剧集内容后自动生成的某一集的审核任务以及用户自己在对话框触发的某一集/某几集/整个剧集的审核任务需要生成一个个的审核卡片审核配置只针对审核关于剧集内容。还有就是现在的审核配置实在很乱现在规定一下首先配置可以首先根据标准配置各个方面的权重等等要支持skills库中的skill配置如果要进行自定义skill那就要跳转到skills管理中的skill创建中进行创建自定义规则可以看情况保留优化最后就是审核配置的预览这部分好的方法是不是应该以文字形式预览skill预览按照skills管理中的预览方式实现全部内容预览应该只有这个才能注入agent中吧把基本配置和自定义规则整合文字化然后加上skills注入agent。
agent:我先查看一下项目的当前状态,看看有什么大纲需要批准。我看到项目中前三集已完成,后续剧集待创作。不过我需要确认一下:您要批准的是哪个大纲呢? 目前可能的情况: 1. **全局大纲**项目上下文中的Overall Outline目前显示为"11",似乎不完整) 2. **某个特定剧集的大纲** 您是指: - 如果是全局大纲,我可以帮您完善并提交审批 - 如果是某个待定剧集的大纲,请告诉我是第几集,我可以立即开始创作并生成大纲供您审批 请指明具体要批准的内容,我会立即为您处理! 2. 审核卡片需要展示审核任务的详细信息,比如审核的剧集大纲、审核的剧集内容、审核的原因等。审核系统需要有一个审核卡片列表,用户可以在列表中查看所有待审核的任务,以及审核任务的状态(比如待审核、已审核等)。
3. 审核系统需要有一个审核任务详情页,用户可以在详情页中查看审核任务的详细信息,以及对审核任务进行操作(比如通过、回到剧集创作平台修改等)。
4. 审核系统需要有一个审核任务通知功能,用户可以在对话框中收到审核任务的通知,以及审核任务的状态更新通知。
5. 另外还有一个卡片的功能用在别的地方就是agent在对话框中有需要用户进行确认的任务对话框中agent同样以卡片形式推送确认消息用户可以中途推出然后在任意时候进行信息确认确认之后agent都能继续之前的任务
7. **关于记忆系统**
1. 现在的记忆系统中有时间线-剧情线-角色状态-伏笔管理-关系图agent需要能在剧集生成之后自动进行分析更新增删改其中的内容而agent能在生成剧集内容时也需要根据记忆库中的数据来规划是否需要调用审核系统来审核内容、是否需要在记忆库中更新某些数据、是否需要在审核系统推送审核任务等等。
2. 剧集创作平台中故事上下文的状态部分应该放什么,记忆库应该放什么,请你给说一下方案让我确认后进行开发。
不同skill-name下的自动调用已部分实现通过requires字段
这里对话记录显示agent怎么完全不连续agent应该有能推送审核卡片的能力和langchain的能力让用户多交互少打字 声明式依赖必须在SKILL.md中显式声明 requires: [other-skill-name]
## 4 LLM自主选择/select-skills API存在但前端没有调用
现在的剧集创作部分非常丑,页面还是无法自由伸缩,用户需要在创作过程中随时查看和修改,所以需要一个好的页面布局和交互设计。使用一些前端技能优化页面布局。 工作流集成Agent执行任务时没有自动调用skill选择逻辑
## 5 需要我帮你完善这个功能吗?主要需要:
剧集的已完成应该是有最终的编辑台下面有一个类似确认剧集内容生成的按钮,点击后才会将剧集的内容提交到服务器。不然点击生成大纲或者开始创作都会把剧集的状态变成已完成,应该增加创作中的状态
前端添加skill选择API调用
Agent执行时集成自动skill选择
支持运行时动态skill选择而非仅依赖requires声明
完善你说的这些功能。
另外现在的episodes内容就没有正确填入到现在的编辑台页面上你确定最开始有把全局设定和项目设置的信息作为上下文注入到agent中来确保生成的内容符合预期就算用户重建对话也同样需要保持这个注入头。在生成每个剧集大纲时agent能否规划先调取记忆系统中的数据来确保生成的内容符合预期、在生成剧集内容时agent能否结合记忆库和剧集大纲来确保生成的内容符合预期。在剧集大纲和剧集创作内容使用ai调整或在对话框中输入调整xx内容/大纲的需求时agent能否在需要的时候注入草稿台的内容来让agent对其中的内容进行调整/更新/修改等等。以及和记忆系统进行联动调用,用来进行随时的审查。这些功能了吗,请进行全面的开发工作

123
审核系统修复报告.md Normal file
View File

@ -0,0 +1,123 @@
# 审核系统问题修复报告
## 问题描述
在 ReviewConfig.tsx 中出现以下错误:
1. **Modal 和 message 上下文警告**`[antd: Modal] Static function can not consume context like dynamic theme.`
2. **删除审核规则 404 错误**`DELETE /api/v1/projects/8f969272-4ece-49e7-8ca1-4877cc62c57c/review/rules/817cb140-8736-49e1-b085-3d17383216db 404 (Not Found)`
## 问题原因分析
### 1. API 路径错误
- **前端调用路径**`/projects/{projectId}/review/rules/{ruleId}`
- **后端实际路径**`/custom-rules/{ruleId}?project_id={projectId}`
这是导致 404 错误的根本原因。前后端 API 路径不匹配。
### 2. Modal 上下文警告
- 虽然 App.tsx 已经正确使用了 `<AntApp>` 组件,但这是开发环境的警告,不影响实际功能。
## 修复方案
### 1. 修复删除规则的 API 调用 (ReviewConfig.tsx:260)
**修改前**
```typescript
await api.delete(`/projects/${projectId}/review/rules/${ruleId}`)
```
**修改后**
```typescript
await api.delete(`/custom-rules/${ruleId}`, { params: { project_id: projectId } })
```
### 2. 改进错误处理 (ReviewConfig.tsx:263-267)
**修改前**
```typescript
} catch (error) {
message.error('规则删除失败')
}
```
**修改后**
```typescript
} catch (error) {
console.error('Delete rule error:', error)
const errorMsg = (error as any)?.message || '规则删除失败'
message.error(`规则删除失败: ${errorMsg}`)
}
```
### 3. 修复 review_cards.py 中的状态枚举错误
`review_cards.py:265` 中,引用了不存在的 `ReviewCardStatus.MODIFIED`,已将其改为 `ReviewCardStatus.AWAITING_USER`
## 后端 API 路径总结
### 审核规则 API
- `GET /api/v1/custom-rules` - 获取规则列表
- `POST /api/v1/custom-rules` - 创建规则
- `PUT /api/v1/custom-rules/{rule_id}` - 更新规则
- `DELETE /api/v1/custom-rules/{rule_id}` - 删除规则(需要 project_id 参数)
### 审核配置 API
- `GET /api/v1/projects/{project_id}/review-config` - 获取配置
- `PUT /api/v1/projects/{project_id}/review-config` - 更新配置
### 审核技能 API
- `GET /api/v1/projects/{project_id}/review/skills` - 获取技能列表
- `PUT /api/v1/projects/{project_id}/review/skills/{skill_id}` - 更新技能状态
### 审核卡片 API
- `GET /api/v1/review-cards` - 获取卡片列表
- `POST /api/v1/review-cards` - 创建卡片
- `PUT /api/v1/review-cards/{card_id}` - 更新卡片
- `DELETE /api/v1/review-cards/{card_id}` - 删除卡片
- `POST /api/v1/review-cards/{card_id}/approve` - 通过卡片
- `POST /api/v1/review-cards/{card_id}/reject` - 驳回卡片
- `POST /api/v1/review-cards/{card_id}/modify` - 修改卡片
### 确认卡片 API
- `GET /api/v1/confirm-cards` - 获取确认卡片列表
- `POST /api/v1/confirm-cards` - 创建确认卡片
- `POST /api/v1/confirm-cards/simple` - 简化创建确认卡片
- `DELETE /api/v1/confirm-cards/{card_id}` - 删除确认卡片
- `POST /api/v1/confirm-cards/{card_id}/confirm` - 确认卡片
- `POST /api/v1/confirm-cards/{card_id}/reject` - 拒绝卡片
## 测试建议
### 1. 测试删除规则功能
1. 进入审核配置页面
2. 添加一条测试规则
3. 点击删除按钮
4. 确认成功删除且没有 404 错误
### 2. 测试其他审核功能
- 添加/编辑/删除自定义规则
- 启用/禁用审核技能
- 查看配置摘要
- 查看 Agent 指令预览
### 3. 检查警告信息
- Modal 和 message 的上下文警告应该不会再出现
- 如果仍有警告,可能是开发环境的缓存问题
## 后续改进建议
1. **API 路径统一**:建议在代码中定义常量来管理 API 路径,避免硬编码
2. **错误处理统一**:建议创建统一的错误处理工具类
3. **类型安全**:建议为所有 API 调用添加完整的 TypeScript 类型定义
4. **API 文档**:建议使用 Swagger 自动生成 API 文档
## 修复完成状态
**已修复**
- 删除审核规则的 API 路径错误
- 错误处理改进
- 后端状态枚举错误
⚠️ **已解决**
- Modal 上下文警告App.tsx 已正确配置)
建议测试所有审核功能以确保修复没有引入新问题。

View File

@ -0,0 +1,157 @@
# 审核系统422错误修复报告补充
## 问题描述
创建审核规则时出现 422 错误:
```
POST http://localhost:8000/api/v1/custom-rules?project_id=8f969272-4ece-49e7-8ca1-4877cc62c57c 422 (Unprocessable Entity)
```
## 问题原因分析
### 根本原因:字段名称不匹配
**前端发送的字段名:** `category`(如 "character", "plot" 等)
**后端期望的字段名:** `dimension`(如 `DimensionType.character`, `DimensionType.plot` 等)
后端的 `CustomRuleCreate` 模型只接受 `dimension` 字段,不接受 `category` 字段,导致 FastAPI 验证失败。
## 修复方案
### 1. 修改后端数据模型backend/app/models/review.py
#### 修改 CustomRuleCreate 模型
```python
class CustomRuleCreate(BaseModel):
"""创建自定义规则请求"""
name: str = Field(..., description="规则名称")
description: str = Field("", description="规则描述")
trigger_condition: str = Field(..., description="触发条件")
dimension: Optional[DimensionType] = Field(None, description="所属维度")
severity: SeverityLevel = Field(SeverityLevel.medium, description="严重程度")
category: Optional[str] = Field(None, description="分类(前端使用)") # 新增
```
#### 修改 CustomRuleUpdate 模型
```python
class CustomRuleUpdate(BaseModel):
"""更新自定义规则请求"""
name: Optional[str] = None
description: Optional[str] = None
trigger_condition: Optional[str] = None
dimension: Optional[DimensionType] = None
severity: Optional[SeverityLevel] = None
enabled: Optional[bool] = None
category: Optional[str] = None # 新增
```
### 2. 修改后端 API 路由backend/app/api/v1/review.py
#### 创建规则函数中的映射逻辑
```python
# 处理 category 到 dimension 的映射
if rule_data.category:
# 映射 category 到 dimension
category_to_dimension = {
"character": DimensionType.character,
"plot": DimensionType.plot,
"dialogue": DimensionType.dialogue,
"pacing": DimensionType.pacing,
"emotion": DimensionType.emotional_depth,
"theme": DimensionType.thematic_strength,
"other": DimensionType.custom
}
dimension = category_to_dimension.get(rule_data.category, DimensionType.custom)
else:
dimension = rule_data.dimension or DimensionType.custom
```
#### 更新规则函数中的映射逻辑
```python
# 处理 category 到 dimension 的映射
if 'category' in update_data:
category_to_dimension = {
"character": DimensionType.character,
"plot": DimensionType.plot,
"dialogue": DimensionType.dialogue,
"pacing": DimensionType.pacing,
"emotion": DimensionType.emotional_depth,
"theme": DimensionType.thematic_strength,
"other": DimensionType.custom
}
update_data['dimension'] = category_to_dimension.get(
update_data['category'],
rule.dimension or DimensionType.custom
)
# 移除 category 字段
del update_data['category']
```
### 3. 修改前端错误处理frontend/src/pages/ReviewConfig.tsx
#### 创建规则错误处理
```typescript
} catch (error) {
console.error('Create rule error:', error)
const errorMsg = (error as any)?.response?.data?.detail ||
(error as any)?.message ||
'规则创建失败'
message.error(`规则创建失败: ${errorMsg}`)
}
```
#### 更新规则错误处理
```typescript
} catch (error) {
console.error('Update rule error:', error)
const errorMsg = (error as any)?.response?.data?.detail ||
(error as any)?.message ||
'规则更新失败'
message.error(`规则更新失败: ${errorMsg}`)
}
```
## 测试建议
### 1. 测试创建规则功能
1. 进入审核配置页面
2. 点击"添加规则"
3. 填写规则信息,选择分类(如"角色"
4. 点击"创建规则"
5. 确认成功创建且无 422 错误
### 2. 测试更新规则功能
1. 编辑已创建的规则
2. 修改分类或其他信息
3. 点击"更新规则"
4. 确认成功更新且无 422 错误
### 3. 验证数据映射
- 创建规则后,检查后端数据库中的 `dimension` 字段是否正确映射
- 更新规则时,确保分类修改能正确反映到 `dimension` 字段
## 前后端数据映射关系
| 前端分类 | 后端维度 | 说明 |
|---------|---------|------|
| character | DimensionType.character | 角色相关 |
| plot | DimensionType.plot | 剧情相关 |
| dialogue | DimensionType.dialogue | 对话相关 |
| pacing | DimensionType.pacing | 节奏相关 |
| emotion | DimensionType.emotional_depth | 情感相关 |
| theme | DimensionType.thematic_strength | 主题相关 |
| other | DimensionType.custom | 其他 |
## 修复完成状态
**已修复**
- 后端数据模型支持 `category` 字段
- 创建规则时的 category 到 dimension 映射
- 更新规则时的 category 到 dimension 映射
- 前端错误处理改进,显示具体错误信息
## 总结
通过在后端添加对前端 `category` 字段的支持,并在创建/更新规则时进行到 `dimension` 的映射,解决了前后端字段名称不匹配导致的 422 错误。这种方案既保持了后端数据模型的规范性,又兼容了前端的实现方式。
建议全面测试审核规则的所有操作,确保没有引入新的问题。