feat:优化skills创建异步任务

This commit is contained in:
thejoeymills 2026-02-03 01:12:39 +08:00
parent 5487450f34
commit 7c007a69a6
21 changed files with 2275 additions and 1244 deletions

View File

@ -16,16 +16,8 @@
"Bash(tasklist:*)",
"Bash(taskkill:*)",
"Bash(where:*)",
"Bash(\"C:/ProgramData/Anaconda3/envs/creative_studio/python.exe\" -m uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload)",
"Bash(\"C:/ProgramData/Anaconda3/envs/creative_studio/python.exe\" -m pip install langchain langchain-core langgraph)",
"Bash(npm run dev:*)",
"Bash(\"C:\\\\ProgramData\\\\Anaconda3\\\\envs\\\\creative_studio\\\\python.exe\" --version)",
"Bash(\"C:\\\\ProgramData\\\\Anaconda3\\\\envs\\\\creative_studio\\\\python.exe\":*)",
"Bash(\"C:\\\\ProgramData\\\\Anaconda3\\\\envs\\\\creative_studio\\\\python.exe\" -c \"import langchain; print\\(''langchain version:'', langchain.__version__\\)\")",
"Bash(\"C:\\\\ProgramData\\\\Anaconda3\\\\envs\\\\creative_studio\\\\python.exe\" -c \"from langchain.agents import create_agent; print\\(''create_agent OK''\\)\")",
"Bash(\"C:\\\\ProgramData\\\\Anaconda3\\\\envs\\\\creative_studio\\\\python.exe\" -c \"from app.core.agent_runtime.agent import LangChainSkillsAgent; print\\(''Import OK''\\)\")",
"Bash(powershell -Command \"Get-Process | Where-Object {$_ProcessName -like ''*python*'' -or $_ProcessName -like ''*uvicorn*''}\")",
"Bash(\"C:\\\\ProgramData\\\\Anaconda3\\\\envs\\\\creative_studio\\\\python.exe\" -c \"import sys; sys.path.insert\\(0, r''d:\\\\platform\\\\creative_studio\\\\backend''\\); from app.api.v1.websocket import app; print\\(''WebSocket module import successful''\\)\")"
"Bash(cmd:*)",
"Bash(powershell \"Stop-Process -Id 365652 -Force; Stop-Process -Id 366104 -Force\")"
]
}
}

View File

@ -42,6 +42,7 @@ async def create_task(
type=task.type,
status=task.status,
progress=task.progress,
params=task.params,
result=task.result,
error=task.error,
project_id=task.project_id,
@ -68,6 +69,7 @@ async def get_task(
type=task.type,
status=task.status,
progress=task.progress,
params=task.params,
result=task.result,
error=task.error,
project_id=task.project_id,
@ -102,12 +104,15 @@ async def list_tasks(
if status:
tasks = [t for t in tasks if t.status == status]
return [
logger.info(f"list_tasks: task_type={task_type}, status={status}, found={len(tasks)} tasks")
result = [
TaskResponse(
id=t.id,
type=t.type,
status=t.status,
progress=t.progress,
params=t.params,
result=t.result,
error=t.error,
project_id=t.project_id,
@ -117,6 +122,12 @@ async def list_tasks(
for t in tasks
]
# Log first task for debugging
if result:
logger.info(f"list_tasks: first task id={result[0].id}, status={result[0].status}, params keys={list(result[0].params.keys()) if result[0].params else 'none'}")
return result
@router.post("/{task_id}/cancel", response_model=TaskResponse)
async def cancel_task(
@ -137,6 +148,7 @@ async def cancel_task(
type=task.type,
status=task.status,
progress=task.progress,
params=task.params,
result=task.result,
error=task.error,
project_id=task.project_id,
@ -175,6 +187,7 @@ async def get_project_tasks(
type=t.type,
status=t.status,
progress=t.progress,
params=t.params,
result=t.result,
error=t.error,
project_id=t.project_id,

View File

@ -9,6 +9,7 @@ Skill 管理 API 路由
5. Agent 工作流配置
6. 文档驱动的 Skill 生成新增
"""
import re
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, status
from typing import List, Optional, Dict, Any

View File

@ -2,6 +2,12 @@
异步 Skill 生成 API
将同步的 Skill 生成改为异步任务模式
特性
1. 异步生成后台执行
2. 生成完成后自动保存到 skill-storage
3. 支持关闭弹窗后继续生成
4. 任务持久化重启后可恢复
"""
from fastapi import APIRouter, HTTPException, Depends, BackgroundTasks
from typing import Dict, Any, Optional, List
@ -9,11 +15,13 @@ from pydantic import BaseModel
import asyncio
import json
import re
import uuid
from app.core.llm.glm_client import get_glm_client
from app.core.skills.skill_manager import get_skill_manager
from app.core.task_manager import get_task_manager, TaskManager
from app.models.task import TaskType, TaskStatus
from app.models.skill import SkillCreate
from app.utils.logger import get_logger
logger = get_logger(__name__)
@ -37,13 +45,21 @@ class GenerateSkillRequest(BaseModel):
# 任务执行器
# ============================================================================
async def execute_generate_skill(
task_manager: TaskManager,
async def execute_generate_skill_with_id(
task_id: str,
params: Dict[str, Any]
) -> Dict[str, Any]:
"""执行 Skill 生成任务"""
"""执行 Skill 生成任务(生成完成后自动保存)
Args:
task_id: 任务ID
params: 任务参数包含 description, category, tags, temperature
Returns:
生成的 Skill 数据
"""
try:
task_manager = get_task_manager()
glm_client = get_glm_client()
skill_manager = get_skill_manager()
@ -110,14 +126,11 @@ async def execute_generate_skill(
)
task_manager.update_task_progress(
task_id, 90, 100,
"正在处理结果..."
task_id, 75, 100,
"正在解析结果..."
)
# 4. 解析响应
import json
import re
response_text = response["choices"][0]["message"]["content"]
json_match = re.search(r'\{[\s\S]*\}', response_text)
@ -133,9 +146,62 @@ async def execute_generate_skill(
"explanation": "AI 生成的 Skill 内容"
}
skill_content = result.get("skill_content", "")
suggested_id = result.get("suggested_id", "custom-skill")
suggested_name = result.get("suggested_name", "自定义 Skill")
category = result.get("category", params.get('category', '通用'))
suggested_tags = result.get("suggested_tags", [])
task_manager.update_task_progress(
task_id, 90, 100,
"正在自动保存 Skill..."
)
# 5. 自动保存到 storage
# 确保 ID 唯一(如果已存在,添加随机后缀)
final_skill_id = suggested_id
counter = 1
while True:
try:
# 检查 skill 是否已存在
existing_skill = await skill_manager.load_skill(final_skill_id)
if existing_skill:
# ID 已存在,添加后缀
final_skill_id = f"{suggested_id}-{counter}"
counter += 1
else:
break
except:
# 加载失败说明不存在,可以使用这个 ID
break
# 创建并保存 skill
skill_data = SkillCreate(
id=final_skill_id,
name=suggested_name,
content=skill_content,
category=category,
tags=suggested_tags
)
try:
saved_skill = await skill_manager.create_user_skill(skill_data)
logger.info(f"自动保存 Skill 成功: {final_skill_id}")
# 更新结果中的 ID 为实际保存的 ID
result["suggested_id"] = final_skill_id
result["saved_skill_id"] = final_skill_id
result["auto_saved"] = True
except Exception as save_error:
logger.error(f"自动保存 Skill 失败: {str(save_error)}")
result["auto_saved"] = False
result["save_error"] = str(save_error)
# 即使保存失败,也返回生成的结果
result["suggested_id"] = final_skill_id
task_manager.update_task_progress(
task_id, 100, 100,
"生成完成!"
f"生成完成!Skill 已保存为: {final_skill_id}"
)
return result
@ -145,6 +211,24 @@ async def execute_generate_skill(
raise
async def execute_generate_skill(params: Dict[str, Any]) -> Dict[str, Any]:
"""兼容旧版本的执行函数通过查找当前任务获取task_id"""
task_manager = get_task_manager()
# 获取当前正在运行的任务
running_tasks = task_manager.get_tasks_by_type(TaskType.GENERATE_SKILL)
current_task = None
for task in running_tasks:
if task.status == TaskStatus.RUNNING:
current_task = task
break
if not current_task:
raise Exception("找不到正在运行的任务")
return await execute_generate_skill_with_id(current_task.id, params)
# ============================================================================
# 异步任务创建端点
# ============================================================================
@ -170,7 +254,7 @@ async def generate_skill_async(
async def run_task():
await task_manager.execute_task_async(
task.id,
lambda p: execute_generate_skill(task_manager, task.id, p)
execute_generate_skill
)
asyncio.create_task(run_task())

View File

@ -17,6 +17,7 @@ import json
import sys
from datetime import datetime
import yaml
import asyncio
from app.models.skill import Skill, SkillCreate, SkillUpdate
from app.config import settings
@ -157,6 +158,18 @@ class SkillManager:
logger.info(f"Skill 管理器初始化完成,目录: {self.skills_dir}")
# 技能列表缓存(用于快速返回列表,不需要每次都扫描目录)
self._skills_list_cache: Optional[List[Skill]] = None
self._cache_dirty = True # 标记缓存需要更新
async def warmup_cache(self):
"""预热缓存 - 在应用启动时调用,预加载所有 skills"""
logger.info("开始预热 Skill 缓存...")
skills = await self.list_skills()
self._skills_list_cache = skills
self._cache_dirty = False
logger.info(f"Skill 缓存预热完成,共加载 {len(skills)} 个 skills")
async def load_skill(self, skill_id: str) -> Optional[Skill]:
"""
加载 Skill
@ -179,7 +192,7 @@ class SkillManager:
return None
try:
# 解析 SKILL.md
# 解析 SKILL.md(异步读取文件)
skill = await self._parse_skill_file(skill_path, skill_id)
# 缓存
@ -220,7 +233,8 @@ class SkillManager:
Returns:
Skill 对象
"""
content = skill_path.read_text(encoding='utf-8')
# 使用 asyncio.to_thread 在单独的线程中读取文件,避免阻塞事件循环
content = await asyncio.to_thread(skill_path.read_text, encoding='utf-8')
# 确定类型
skill_type = "builtin" if "builtin_skills" in str(skill_path) else "user"
@ -235,7 +249,7 @@ class SkillManager:
has_scripts = (skill_path.parent / "scripts").exists()
tools = []
if has_scripts:
tools = self._load_tools(skill_path.parent / "scripts")
tools = await asyncio.to_thread(self._load_tools, skill_path.parent / "scripts")
return Skill(
id=skill_id,
@ -375,6 +389,10 @@ class SkillManager:
Returns:
Skill 列表
"""
# 如果不需要筛选且有缓存,直接返回缓存
if not skill_type and not category and self._skills_list_cache is not None:
return self._skills_list_cache
skills = []
# 扫描内置 Skills
@ -393,6 +411,10 @@ class SkillManager:
if skill and (category is None or skill.category == category):
skills.append(skill)
# 如果没有筛选条件,更新缓存
if not skill_type and not category:
self._skills_list_cache = skills
return skills
async def create_user_skill(self, skill_data: SkillCreate) -> Skill:
@ -413,6 +435,9 @@ class SkillManager:
skill_file = skill_dir / "SKILL.md"
skill_file.write_text(skill_data.content, encoding='utf-8')
# 清除列表缓存
self._skills_list_cache = None
logger.info(f"创建用户 Skill: {skill_data.id}")
# 加载并返回
@ -455,6 +480,9 @@ class SkillManager:
# 写入更新后的内容
skill_path.write_text(content, encoding='utf-8')
# 清除缓存
self._skills_list_cache = None
# 重新加载
return await self.reload_skill(skill_id)
@ -484,6 +512,7 @@ class SkillManager:
# 清除缓存
if skill_id in self._cache:
del self._cache[skill_id]
self._skills_list_cache = None
logger.info(f"删除用户 Skill: {skill_id}")
return True

View File

@ -11,6 +11,7 @@ from collections import defaultdict
from app.models.task import AsyncTask, TaskStatus, TaskType, TaskProgress
from app.utils.logger import get_logger
from app.core.task_persistence import get_task_persistence
logger = get_logger(__name__)
@ -33,6 +34,11 @@ class TaskManager:
self._project_tasks: Dict[str, List[str]] = defaultdict(list)
# 按类型索引的任务
self._type_tasks: Dict[TaskType, List[str]] = defaultdict(list)
# 持久化存储
self._persistence = get_task_persistence()
# 启动时恢复持久化的任务
self._restore_tasks()
def create_task(
self,
@ -68,6 +74,9 @@ class TaskManager:
self._type_tasks[task_type].append(task_id)
# 保存到持久化存储
self._persistence.save_task(task)
logger.info(f"创建任务: {task_id} ({task_type.value})")
return task
@ -162,6 +171,9 @@ class TaskManager:
elif status in [TaskStatus.COMPLETED, TaskStatus.FAILED, TaskStatus.CANCELLED]:
task.completed_at = datetime.now()
# 更新持久化存储
self._persistence.update_task_status(task_id, status, result, error)
logger.info(f"任务状态更新: {task_id} - {old_status.value} -> {status.value}")
return True
@ -188,6 +200,9 @@ class TaskManager:
del self._tasks[task_id]
# 从持久化存储中删除
self._persistence.delete_task(task_id)
logger.info(f"删除任务: {task_id}")
return True
@ -245,6 +260,40 @@ class TaskManager:
"""
return asyncio.create_task(self.execute_task_async(task_id, executor))
def _restore_tasks(self) -> None:
"""从持久化存储恢复任务"""
try:
saved_tasks = self._persistence.get_all_tasks()
restored_count = 0
for task_id, task_data in saved_tasks.items():
# 跳过已完成、失败或取消的任务(太久远的不需要恢复)
if task_data.get('status') in ['completed', 'failed', 'cancelled']:
continue
# 恢复任务到内存
try:
task = AsyncTask(**task_data)
self._tasks[task_id] = task
# 恢复索引
if task.project_id:
self._project_tasks[task.project_id].append(task_id)
self._type_tasks[task.type].append(task_id)
restored_count += 1
except Exception as e:
logger.warning(f"恢复任务失败: {task_id} - {e}")
if restored_count > 0:
logger.info(f"从持久化存储恢复了 {restored_count} 个任务")
# 清理旧任务超过24小时的已完成任务
self._persistence.cleanup_old_tasks(max_age_hours=24)
except Exception as e:
logger.error(f"恢复任务失败: {e}")
# 全局单例
_task_manager: Optional[TaskManager] = None

View File

@ -0,0 +1,169 @@
"""
任务持久化存储
使用文件系统持久化任务状态支持
1. 服务重启后恢复任务
2. 前端刷新后可继续查看任务
"""
import json
import os
from datetime import datetime
from typing import Dict, List, Optional
from pathlib import Path
from app.models.task import AsyncTask, TaskStatus
from app.utils.logger import get_logger
logger = get_logger(__name__)
class TaskPersistence:
"""任务持久化管理器"""
def __init__(self, storage_dir: str = "data/tasks"):
self.storage_dir = Path(storage_dir)
self.storage_dir.mkdir(parents=True, exist_ok=True)
self.tasks_file = self.storage_dir / "tasks.json"
def save_task(self, task: AsyncTask) -> bool:
"""保存单个任务"""
try:
tasks = self._load_all_tasks()
tasks[task.id] = task.model_dump(mode='json')
self._save_all_tasks(tasks)
return True
except Exception as e:
logger.error(f"保存任务失败: {task.id} - {e}")
return False
def get_task(self, task_id: str) -> Optional[Dict]:
"""获取任务"""
try:
tasks = self._load_all_tasks()
return tasks.get(task_id)
except Exception as e:
logger.error(f"获取任务失败: {task_id} - {e}")
return None
def get_all_tasks(self) -> Dict[str, Dict]:
"""获取所有任务"""
try:
return self._load_all_tasks()
except Exception as e:
logger.error(f"获取所有任务失败: {e}")
return {}
def delete_task(self, task_id: str) -> bool:
"""删除任务"""
try:
tasks = self._load_all_tasks()
if task_id in tasks:
del tasks[task_id]
self._save_all_tasks(tasks)
return True
except Exception as e:
logger.error(f"删除任务失败: {task_id} - {e}")
return False
def update_task_status(
self,
task_id: str,
status: TaskStatus,
result: Optional[Dict] = None,
error: Optional[str] = None
) -> bool:
"""更新任务状态"""
try:
tasks = self._load_all_tasks()
if task_id not in tasks:
return False
task_data = tasks[task_id]
task_data['status'] = status.value
task_data['updated_at'] = datetime.now().isoformat()
if result is not None:
task_data['result'] = result
if error is not None:
task_data['error'] = error
self._save_all_tasks(tasks)
return True
except Exception as e:
logger.error(f"更新任务状态失败: {task_id} - {e}")
return False
def cleanup_old_tasks(self, max_age_hours: int = 24) -> int:
"""清理旧任务(已完成/失败/取消超过指定时间的任务)"""
try:
tasks = self._load_all_tasks()
now = datetime.now()
to_delete = []
for task_id, task_data in tasks.items():
# 跳过运行中的任务
if task_data.get('status') in ['running', 'pending']:
continue
# 检查任务年龄
updated_at = task_data.get('updated_at')
if updated_at:
try:
update_time = datetime.fromisoformat(updated_at)
age_hours = (now - update_time).total_seconds() / 3600
if age_hours > max_age_hours:
to_delete.append(task_id)
except:
pass
# 删除旧任务
for task_id in to_delete:
del tasks[task_id]
if to_delete:
self._save_all_tasks(tasks)
logger.info(f"清理了 {len(to_delete)} 个旧任务")
return len(to_delete)
except Exception as e:
logger.error(f"清理旧任务失败: {e}")
return 0
def _load_all_tasks(self) -> Dict[str, Dict]:
"""加载所有任务"""
if not self.tasks_file.exists():
return {}
try:
with open(self.tasks_file, 'r', encoding='utf-8') as f:
return json.load(f)
except Exception as e:
logger.error(f"加载任务文件失败: {e}")
return {}
def _save_all_tasks(self, tasks: Dict[str, Dict]) -> None:
"""保存所有任务"""
try:
# 先写入临时文件,然后重命名,避免写入失败导致数据丢失
temp_file = self.tasks_file.with_suffix('.tmp')
with open(temp_file, 'w', encoding='utf-8') as f:
json.dump(tasks, f, ensure_ascii=False, indent=2)
# 重命名
temp_file.replace(self.tasks_file)
except Exception as e:
logger.error(f"保存任务文件失败: {e}")
raise
# 全局单例
_persistence: Optional[TaskPersistence] = None
def get_task_persistence() -> TaskPersistence:
"""获取任务持久化单例"""
global _persistence
if _persistence is None:
_persistence = TaskPersistence()
return _persistence

View File

@ -23,7 +23,8 @@ async def lifespan(app: FastAPI):
# 初始化 Skill 管理器
from app.core.skills.skill_manager import get_skill_manager
skill_manager = get_skill_manager()
logger.info(f"✅ Skill 管理器初始化完成,已加载 {len(await skill_manager.list_skills())} 个 Skills")
# 预热 Skill 缓存(异步加载所有 skills避免首次请求阻塞
await skill_manager.warmup_cache()
# 注册 Builtin Skills 的工具Agent-Skill 生命周期:注册阶段)
await skill_manager.register_builtin_tools()

View File

@ -75,6 +75,7 @@ class TaskResponse(BaseModel):
type: TaskType
status: TaskStatus
progress: TaskProgress
params: Dict[str, Any] = Field(default_factory=dict)
result: Optional[Dict[str, Any]] = None
error: Optional[str] = None
project_id: Optional[str] = None

View File

@ -0,0 +1,188 @@
{
"bba4efe7-127f-4a90-bee3-0dbac02e0fbb": {
"id": "bba4efe7-127f-4a90-bee3-0dbac02e0fbb",
"type": "generate_skill",
"status": "completed",
"progress": {
"current": 0,
"total": 100,
"message": "",
"stage": null
},
"params": {
"description": "生成一个剧本改编skill",
"category": null,
"tags": null,
"temperature": 0.7
},
"result": {
"suggested_id": "screenplay-adaptation",
"suggested_name": "剧本改编专家",
"skill_content": "---\nname: screenplay-adaptation\ndescription: 将小说、故事或叙事文本转换为专业剧本格式。适用于将文字描述转化为场景标题、动作描述和对白。专注于将内心独白外化、压缩时间线、视觉化叙事以及保持标准剧本格式。\n---\n\n# 剧本改编流程\n\n## 核心工作流\n\n遵循以下步骤将文本改编为剧本\n\n1. **分析源材料**\n * 识别核心冲突、关键情节转折和主要角色。\n * 标记必须保留的视觉化场景。\n\n2. **场景拆解**\n * 将叙事文本分解为独立的场景。\n * 确定每个场景的地点(内/外)和时间(日/夜)。\n * 列出每个场景中的出场角色。\n\n3. **起草剧本**\n * **场景标题 (Slugline)**:使用标准格式(例如:`内景. 咖啡馆 - 日`)。\n * **动作描述 (Action)**:只描述观众能看到和听到的东西。避免使用“他想”、“她觉得”。\n * **对白 (Dialogue)**:将叙述性解释转化为角色之间的对话。使用潜台词。\n\n4. **润色与精简**\n * 删除无法拍摄的内容。\n * 检查对白是否自然。\n * 确保格式统一。\n\n## 改编关键原则\n\n### 展示而非讲述\n* **错误**:约翰感到很生气,因为他输了比赛。\n* **正确**:约翰狠狠地摔下球拍,拳头砸在墙上。\n\n### 内心独白外化\n* 将角色的内心想法转化为视觉动作或对白。\n* 如果必须表达想法,考虑使用画外音 (V.O.),但尽量优先使用视觉表达。\n\n### 压缩时间与空间\n* 合并发生在同一地点但不同时间的场景。\n* 删除对情节推进无用的过渡场景。\n\n## 标准剧本格式参考\n\n使用以下标准结构\n\n```text\n场景标题 (Slugline)\n内景. 地点 - 时间\n\n动作描述 (Action)\n描述场景中发生的视觉和听觉事件。\n\n角色名 (Character)\n居中对齐的角色名称。\n\n对白 (Dialogue)\n角色说的话。\n\n(括号说明)\n语气或动作的简短提示。\n```\n\n## 注意事项\n\n* **格式一致性**:确保全篇剧本的缩进和标点符号一致。\n* **一页一分钟**:通常剧本的一页对应屏幕时间的一分钟。注意控制场景长度。",
"category": "编剧",
"suggested_tags": [
"剧本",
"改编",
"编剧",
"格式化"
],
"explanation": "这个 Skill 专注于将叙事文本(如小说)转换为剧本格式。它提供了结构化的工作流(分析、拆解、起草、润色)和具体的改编原则(如“展示而非讲述”),帮助 Claude 生成符合行业标准的剧本。"
},
"error": null,
"project_id": null,
"created_at": "2026-02-03T00:13:07.204960",
"started_at": null,
"completed_at": null,
"updated_at": "2026-02-03T00:13:43.809136"
},
"5d86feb1-31c9-4760-952e-1960234a813c": {
"id": "5d86feb1-31c9-4760-952e-1960234a813c",
"type": "generate_skill",
"status": "completed",
"progress": {
"current": 0,
"total": 100,
"message": "",
"stage": null
},
"params": {
"description": "创建一个剧本改编skill",
"category": null,
"tags": null,
"temperature": 0.7
},
"result": {
"suggested_id": "script-adapter",
"suggested_name": "剧本改编专家",
"skill_content": "---\nname: script-adapter\ndescription: 将小说、故事或其他文学作品改编为标准影视或舞台剧本。用于将文本转化为剧本格式、优化对白、构建分场大纲,以及处理“展示而非讲述”的改编需求。当用户要求“把小说改成剧本”、“写个剧本”、“格式化剧本”或进行剧本创作时使用。\n---\n\n# 剧本改编指南\n\n本 Skill 提供将叙事文本转化为标准剧本格式的指导原则和工作流。\n\n## 核心工作流\n\n1. **素材分析**:提取核心冲突、人物动机、关键情节点和主题。\n2. **结构提炼**:将故事拆解为场景列表,确定叙事节奏(如三幕式结构)。\n3. **场景转化**:将描述性文字转化为动作描述和对白,遵循“展示而非讲述”原则。\n4. **格式规范**:应用标准剧本格式(场景标题、动作、角色名、对白、括号备注)。\n\n## 标准剧本格式\n\n严格遵循以下格式规范\n\n- **场景标题**[内/外景] 地点 - [日/夜]\n - 示例:`内景. 咖啡馆 - 日`\n- **动作描述**:现在时态,只描述可见和可听的内容。\n - 错误:`约翰感到很悲伤。`\n - 正确:`约翰低下头,双手紧握咖啡杯。`\n- **角色名**:居中,位于对白上方。\n- **对白**:角色口中说的内容。\n- **括号备注**:位于角色名下方,指示语气或微小动作(慎用)。\n\n## 改编关键原则\n\n### 展示而非讲述\n- **内心独白外化**:将角色的心理活动转化为具体的动作、表情或对白。\n- **视觉化叙事**:优先使用视觉元素传达信息,减少依赖旁白。\n\n### 节奏与压缩\n- **删减冗余**:去除不推动剧情发展的支线情节。\n- **合并场景**:将发生在不同时间但功能相似的场景合并,以保持紧凑感。\n\n### 对白优化\n- **潜台词**:让角色说出的话与其真实意图之间存在差距,增加戏剧张力。\n- **声音区分**:确保每个角色的说话方式、词汇选择符合其性格和背景。\n\n## 参考资源\n\n- **结构模板**:参见 `references/structure.md` 了解经典叙事结构(如救猫咪、英雄之旅)。\n- **格式示例**:参见 `references/formatting-examples.md` 查看具体格式范例。\n",
"category": "编剧",
"suggested_tags": [
"剧本",
"改编",
"影视",
"写作",
"格式化"
],
"explanation": "该 Skill 专注于将小说、故事等叙事文本转化为标准的影视/舞台剧本。它定义了清晰的四步工作流(分析、提炼、转化、格式化),并强调了剧本改编的核心原则——如“展示而非讲述”和“视觉化叙事”。同时,它提供了严格的格式规范指导,确保输出符合行业标准。通过引用 `references/` 目录下的结构模板和格式示例,该 Skill 实现了核心指令与详细参考资料的分离,既保证了指令的简洁性,又支持了深度创作需求。"
},
"error": null,
"project_id": null,
"created_at": "2026-02-03T00:19:06.464946",
"started_at": null,
"completed_at": null,
"updated_at": "2026-02-03T00:20:00.347558"
},
"6f721ad2-370f-4550-b5b7-c2c300ea262b": {
"id": "6f721ad2-370f-4550-b5b7-c2c300ea262b",
"type": "generate_skill",
"status": "completed",
"progress": {
"current": 0,
"total": 100,
"message": "",
"stage": null
},
"params": {
"description": "创建一个剧本改编skill",
"category": null,
"tags": null,
"temperature": 0.7
},
"result": {
"suggested_id": "script-adapter",
"suggested_name": "剧本改编专家",
"skill_content": "---\nname: script-adapter\ndescription: 专注于将小说、故事、真实事件或原始素材改编为标准剧本格式。适用于将叙事文本转化为视觉脚本、将故事结构拆解为场景、提炼对白以符合影视/舞台要求,以及进行跨媒介的文本改编工作。\n---\n\n# 剧本改编专家\n\n本 Skill 提供将叙事文本转换为标准剧本格式的流程、原则和规范。\n\n## 核心工作流程\n\n遵循以下步骤进行改编确保从叙事到视觉的平滑过渡\n\n1. **素材分析**\n * 识别核心冲突、主要人物弧光和关键情节点。\n * 区分必须保留的情节和可以删减的支线。\n\n2. **结构拆解**\n * 将故事分解为场景列表。\n * 确定每个场景的叙事目标(推进情节、揭示人物、营造氛围)。\n\n3. **场景写作**\n * 将小说中的描写转化为动作描述。\n * 将内心独白转化为视觉动作或对白。\n\n4. **格式规范**\n * 应用标准剧本格式(见下文“格式标准”)。\n\n## 改编核心原则\n\n### 视觉化表达\n* **展示而非讲述**:不要写“他感到很悲伤”,而要写“他盯着空荡荡的椅子,久久没有说话”。\n* **外化内心活动**:将心理描写转化为具体的动作、表情或环境反应。\n\n### 压缩与提炼\n* **场景合并**:将发生在同一地点、时间的多个段落合并为一个场景。\n* **对白精炼**:删除日常寒暄,确保每句对白都有潜台词或推动剧情。\n* **删除说明性文字**:避免让角色说出观众已经知道的信息。\n\n### 冲突外化\n* 确保每个场景都有明确的冲突或转折。\n* 通过角色之间的互动来展现矛盾,而不是通过旁白。\n\n## 标准剧本格式\n\n严格遵循以下格式规范\n\n### 1. 场景标题\n* **格式**[内/外] 地点 - [日/夜]\n* **示例**`内. 咖啡馆 - 日`\n* **规则**:每个新场景以此开始,首次出现的地点需大写。\n\n### 2. 动作描述\n* **格式**:现在时态,左对齐。\n* **内容**:描述场景中发生的事情、角色的动作和环境细节。\n* **规则**:只描述观众能看到和听到的东西。\n\n### 3. 角色名\n* **格式**:全部大写,居中(或距离左边距一定距离)。\n* **示例**`李明`\n* **规则**:角色首次出现时需在动作描述中标注年龄和特征。\n\n### 4. 对白\n* **格式**:位于角色名下方,居中。\n* **规则**:口语化,符合角色性格。\n\n### 5. 副语言说明\n* **格式**:括号内,位于角色名和对白之间。\n* **示例**`(低声)`、`(愤怒地)`\n* **规则**:仅在语气无法通过对白本身传达时使用,避免滥用。\n\n## 输出示例\n\n```markdown\n内. 旧公寓 - 夜\n\n窗外霓虹灯闪烁。李明30岁面容憔悴坐在沙发上手里紧紧攥着一张照片。\n\n门锁转动。张华推门进来手里提着便利店的袋子。\n\n张华\n还没睡\n\n李明没有抬头只是把照片反扣在桌面上。\n\n李明\n我们需要谈谈。\n```\n\n## 常见改编陷阱\n\n* **过度依赖旁白**:尽量用画面讲故事,旁白是最后手段。\n* **保留过多次要角色**:合并功能相似的角色,简化人物关系。\n* **忽视节奏**:通过场景的长短和交替来控制叙事节奏(如动作戏场景短,情感戏场景长)。\n",
"category": "编剧",
"suggested_tags": [
"剧本",
"改编",
"影视",
"写作",
"格式规范"
],
"explanation": "该 Skill 专注于将小说、故事等叙事文本改编为标准影视剧本。它提供了从素材分析到格式化输出的完整工作流程,强调了“视觉化表达”、“压缩提炼”和“冲突外化”等改编核心原则,并定义了标准的剧本格式规范(场景标题、动作、角色、对白),帮助 Claude 高效地完成跨媒介的文本转换工作。"
},
"error": null,
"project_id": null,
"created_at": "2026-02-03T00:31:44.308553",
"started_at": null,
"completed_at": null,
"updated_at": "2026-02-03T00:32:04.210026"
},
"96d9a716-ceff-43b9-952e-57d7b08b28bb": {
"id": "96d9a716-ceff-43b9-952e-57d7b08b28bb",
"type": "generate_skill",
"status": "completed",
"progress": {
"current": 0,
"total": 100,
"message": "",
"stage": null
},
"params": {
"description": "创建一个剧本改编skill",
"category": null,
"tags": null,
"temperature": 0.7
},
"result": {
"suggested_id": "script-adaptation",
"suggested_name": "剧本改编",
"skill_content": "---\nname: script-adaptation\ndescription: 将小说、故事或其他文本内容改编为标准剧本格式。适用于将文学作品转化为影视剧本、舞台剧剧本等场景,包括:提取对话、构建场景、编写动作描述、处理转场、保持原作核心情节和人物性格。当用户需要将文本改编为剧本时使用此技能。\n---\n\n# 剧本改编\n\n## 核心原则\n\n### 视觉化叙事\n- 将文字描述转化为可拍摄的动作和画面\n- 避免内心独白,通过行为和对话展现角色状态\n- 每个场景都应有明确的视觉目标\n\n### 保持原作精髓\n- 保留核心情节和关键转折点\n- 维持人物性格和关系动态\n- 提取并优化原作中的精彩对话\n\n## 剧本格式标准\n\n### 场景标题Slugline\n```\n内景. 咖啡馆 - 日\nEXT. PARK - NIGHT\n```\n- 格式:[内/外景]. [地点] - [时间]\n- 每个新场景开始时使用\n\n### 动作描述Action\n- 使用现在时\n- 简洁有力,突出关键动作\n- 避免过度描述,只保留拍摄必要的信息\n\n### 对话Dialogue\n```\n角色名\n括号内的动作/语气指示)\n对话内容\n```\n- 角色名居中\n- 括号指示可选,用于补充表演提示\n- 对话自然口语化\n\n### 转场Transition\n```\n切至\n淡入\n黑屏\n```\n- 位于场景结尾\n- 简洁明确\n\n## 改编流程\n\n### 1. 分析原作\n- 识别核心冲突和主题\n- 列出主要人物及其关系\n- 标记关键场景和情节节点\n\n### 2. 场景拆分\n- 将原作按场景重新组织\n- 确定每个场景的地点和时间\n- 删除或合并不必要的场景\n\n### 3. 对话提取与改编\n- 提取原作中的直接对话\n- 将叙述性内容转化为对话\n- 精简冗长对话,增强戏剧张力\n\n### 4. 动作描述编写\n- 将环境描写转化为视觉动作\n- 通过动作展现人物心理\n- 保持节奏和张力\n\n### 5. 格式规范化\n- 应用标准剧本格式\n- 检查转场逻辑\n- 确保页码和场景连续性\n\n## 常见挑战处理\n\n### 内心戏外化\n- 通过表情、动作、环境反应展现\n- 使用画外音V.O.或闪回FLASHBACK\n- 创造象征性视觉元素\n\n### 时间压缩\n- 合并相似场景\n- 使用蒙太奇MONTAGE表现时间流逝\n- 删除次要情节线\n\n### 人物精简\n- 合并功能相似的角色\n- 删除对主线无推动作用的角色\n- 保留核心人物弧光\n\n## 质量检查清单\n\n- [ ] 所有场景都有正确的场景标题\n- [ ] 动作描述使用现在时\n- [ ] 对话符合角色性格\n- [ ] 核心情节完整保留\n- [ ] 转场逻辑清晰\n- [ ] 格式统一规范\n\n## 参考资源\n\n- **详细格式示例**:见 [references/format-examples.md](references/format-examples.md)\n- **经典剧本分析**:见 [references/classic-scripts.md](references/classic-scripts.md)\n- **改编案例研究**:见 [references/adaptation-cases.md](references/adaptation-cases.md)\n",
"category": "编剧",
"suggested_tags": [
"剧本",
"改编",
"影视",
"编剧",
"格式"
],
"explanation": "这个 Skill 提供了将文本内容改编为剧本的完整指导。包含了剧本格式标准(场景标题、动作描述、对话、转场)、改编流程(分析原作、场景拆分、对话提取、动作编写、格式规范化)、常见挑战处理(内心戏外化、时间压缩、人物精简)以及质量检查清单。采用了渐进式披露设计,核心流程在 SKILL.md 中,详细示例和案例研究放在 references/ 目录下,保持主文件简洁高效。"
},
"error": null,
"project_id": null,
"created_at": "2026-02-03T00:37:34.960133",
"started_at": null,
"completed_at": null,
"updated_at": "2026-02-03T00:37:57.575299"
},
"de53d476-455b-4324-bf04-1cd3860e3ede": {
"id": "de53d476-455b-4324-bf04-1cd3860e3ede",
"type": "generate_skill",
"status": "completed",
"progress": {
"current": 0,
"total": 100,
"message": "",
"stage": null
},
"params": {
"description": "创建一个剧本改编skill",
"category": null,
"tags": null,
"temperature": 0.7
},
"result": {
"suggested_id": "script-adapter",
"suggested_name": "剧本改编专家",
"skill_content": "---\nname: script-adapter\ndescription: Adapt novels, stories, or concepts into professional screenplay format (film/theater/TV). Use when user needs to transform narrative text into scripts, including scene breakdown, dialogue conversion, visual storytelling, and standard industry formatting.\n---\n\n# Script Adapter\n\nTransform source material into professional screenplays using industry-standard formatting and visual storytelling techniques.\n\n## Core Principles\n\n- **Show, Don't Tell**: Convert internal monologues and descriptions into visible actions and subtextual dialogue.\n- **Visual Narrative**: Prioritize actions that can be seen and heard over abstract descriptions.\n- **Economy of Words**: Keep action lines concise and dialogue sharp.\n\n## Adaptation Workflow\n\n### 1. Analyze Source Material\nIdentify and extract:\n- Core conflict and theme\n- Protagonist's arc and motivation\n- Key plot points (inciting incident, climax, resolution)\n- Essential characters (combine minor characters if necessary)\n\n### 2. Structure the Narrative\nMap the source material to a standard structure:\n- **Three-Act Structure**: Setup, Confrontation, Resolution\n- **Scene List**: Break down the story into specific locations and timeframes\n- **Pacing**: Ensure the flow works for visual media (faster than prose)\n\n### 3. Draft the Scene\nFor each scene:\n1. **Slugline**: INT./EXT. LOCATION - DAY/NIGHT\n2. **Action**: Describe what happens visually (present tense).\n3. **Character Name**: Centered, uppercase.\n4. **Dialogue**: What the character says.\n5. **Parenthetical**: How the character says it (use sparingly).\n\n### 4. Refine and Format\n- Check standard margins and spacing.\n- Ensure all scenes drive the plot or reveal character.\n- Remove exposition-heavy dialogue; replace with action.\n\n## Standard Format Guide\n\n```text\nSLUGLINE: INT. LOCATION - DAY\n\nAction lines describe what the audience sees. Be specific and visual.\n\nCHARACTER NAME\n(parenthetical direction)\nDialogue goes here.\n\nEXT. STREET - NIGHT\n\nMore action.\n```\n\n## Key Adaptation Techniques\n\n- **Internal to External**: Convert \"He felt sad\" to \"He stared at the floor, shoulders slumped.\"\n- **Compression**: Merge multiple conversations from the book into one impactful scene.\n- **Subtext**: Characters should rarely say exactly what they mean. Use silence and action to convey true feelings.\n- **Enter Late, Leave Early**: Start scenes at the last possible moment and end them as soon as the main point is made.",
"category": "编剧",
"suggested_tags": [
"剧本",
"改编",
"编剧",
"电影",
"格式化"
],
"explanation": "该 Skill 专注于将小说、故事或概念转化为专业的剧本格式。它提供了从素材分析、结构搭建到场景起草的完整工作流,强调了“展示不要讲述”的核心原则,并包含了标准的剧本格式指南。适用于需要将文本转化为视觉叙事的场景。",
"saved_skill_id": "script-adapter",
"auto_saved": true
},
"error": null,
"project_id": null,
"created_at": "2026-02-03T00:50:46.553278",
"started_at": null,
"completed_at": null,
"updated_at": "2026-02-03T00:51:21.223438"
}
}

View File

@ -0,0 +1,121 @@
---
name: modern-dialogue-writer
description: 创建自然、流畅的现代风格对话内容适用于小说、剧本、游戏等场景。支持当代语言习惯、网络用语、俚语等现代表达方式。当用户需要编写人物对话、润色对话使其更自然、创作现代场景对话、调整对话语气和风格、处理多角色互动对话时使用此Skill。
---
# 现代对话创作
## 核心原则
### 自然流畅
- 使用口语化表达,避免过于书面化
- 加入语气词(嘛、呢、呗、呗、啊)增强真实感
- 适当使用省略号、破折号表现说话节奏
- 允许不完整句子、重复、打断等真实对话特征
### 现代语言特征
- 融入网络用语和流行梗(适度)
- 使用缩略语和简化表达(如"啥"代替"什么"
- 体现时代感的词汇和表达方式
- 根据角色年龄、身份调整语言风格
### 对话节奏
- 长短句交替,避免单调
- 控制信息密度,不要一次性输出过多
- 使用沉默、动作描写调节节奏
- 根据紧张程度调整对话速度
## 对话模式
### 日常对话
- 轻松随意,多用省略和简化
- 关注生活细节和琐事
- 话题跳跃性强
- 示例:
```
"哎,你看了没?"
"啥?"
"就那个热搜啊,炸了。"
"哦,那个啊,早刷到了。"
```
### 职场对话
- 相对正式但不过于生硬
- 使用专业术语但要自然
- 体现层级关系和职场文化
- 示例:
```
"李总,方案我发你邮箱了。"
"收到,我看看。"
"有问题随时滴滴我。"
```
### 网络聊天
- 使用表情符号和颜文字
- 大量使用网络缩写和梗
- 语气夸张,情绪化明显
- 示例:
```
"哈哈哈哈笑死我了"
"真的吗????"
"绝了家人们"
```
## 角色声音塑造
### 区分角色
- 每个角色有独特的说话方式
- 常用词汇、句式结构、语气词
- 语言习惯(如口头禅、语速快慢)
### 年龄差异
- 青年:网络用语多,表达直接
- 中年:相对稳重,但不过时
- 老年:传统表达,可能跟不上网络梗
### 地域特色
- 适度融入方言词汇
- 注意不要过度使用影响理解
- 体现文化背景
## 润色技巧
### 去除生硬感
- 删除过于正式的词汇
- 增加语气词和口语化表达
- 打破完整句子结构
### 增强画面感
- 加入动作描写
- 描写表情和神态
- 通过对话展现环境
### 潜台词
- 话中有话,不直接说破
- 通过语气和措辞暗示真实意图
- 留白让读者自己体会
## 常见问题
### 避免过度网络化
- 不要滥用网络用语
- 考虑受众和场景
- 保持可读性
### 平衡真实感和文学性
- 真实对话不等于完全照搬
- 需要艺术加工和提炼
- 服务于故事和人物塑造
### 对话推动情节
- 每句对话都应该有目的
- 揭示信息、展现关系、推动剧情
- 避免无效对话
## 工作流程
1. **理解需求**:明确场景、角色、目的
2. **确定风格**:根据角色和场景选择对话模式
3. **初稿创作**:快速写出对话框架
4. **润色打磨**:加入细节、调整节奏、增强真实感
5. **检查验证**:朗读检查流畅度,确保角色声音一致

View File

@ -0,0 +1,65 @@
---
name: script-adapter
description: Adapt novels, stories, or concepts into professional screenplay format (film/theater/TV). Use when user needs to transform narrative text into scripts, including scene breakdown, dialogue conversion, visual storytelling, and standard industry formatting.
---
# Script Adapter
Transform source material into professional screenplays using industry-standard formatting and visual storytelling techniques.
## Core Principles
- **Show, Don't Tell**: Convert internal monologues and descriptions into visible actions and subtextual dialogue.
- **Visual Narrative**: Prioritize actions that can be seen and heard over abstract descriptions.
- **Economy of Words**: Keep action lines concise and dialogue sharp.
## Adaptation Workflow
### 1. Analyze Source Material
Identify and extract:
- Core conflict and theme
- Protagonist's arc and motivation
- Key plot points (inciting incident, climax, resolution)
- Essential characters (combine minor characters if necessary)
### 2. Structure the Narrative
Map the source material to a standard structure:
- **Three-Act Structure**: Setup, Confrontation, Resolution
- **Scene List**: Break down the story into specific locations and timeframes
- **Pacing**: Ensure the flow works for visual media (faster than prose)
### 3. Draft the Scene
For each scene:
1. **Slugline**: INT./EXT. LOCATION - DAY/NIGHT
2. **Action**: Describe what happens visually (present tense).
3. **Character Name**: Centered, uppercase.
4. **Dialogue**: What the character says.
5. **Parenthetical**: How the character says it (use sparingly).
### 4. Refine and Format
- Check standard margins and spacing.
- Ensure all scenes drive the plot or reveal character.
- Remove exposition-heavy dialogue; replace with action.
## Standard Format Guide
```text
SLUGLINE: INT. LOCATION - DAY
Action lines describe what the audience sees. Be specific and visual.
CHARACTER NAME
(parenthetical direction)
Dialogue goes here.
EXT. STREET - NIGHT
More action.
```
## Key Adaptation Techniques
- **Internal to External**: Convert "He felt sad" to "He stared at the floor, shoulders slumped."
- **Compression**: Merge multiple conversations from the book into one impactful scene.
- **Subtext**: Characters should rarely say exactly what they mean. Use silence and action to convey true feelings.
- **Enter Late, Leave Early**: Start scenes at the last possible moment and end them as soon as the main point is made.

File diff suppressed because it is too large Load Diff

View File

@ -10,35 +10,35 @@
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0"
},
"dependencies": {
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-router-dom": "^6.20.0",
"antd": "^5.12.0",
"@ant-design/icons": "^5.2.6",
"zustand": "^4.4.7",
"@monaco-editor/react": "^4.6.0",
"antd": "^5.12.0",
"axios": "^1.6.2",
"socket.io-client": "^4.6.0",
"dayjs": "^1.11.10",
"monaco-editor": "^0.45.0",
"@monaco-editor/react": "^4.6.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-markdown": "^9.0.1",
"react-router-dom": "^6.20.0",
"react-syntax-highlighter": "^16.1.0",
"recharts": "^2.10.3",
"remark-gfm": "^4.0.0",
"react-syntax-highlighter": "^15.5.0",
"recharts": "^2.10.3"
"socket.io-client": "^4.6.0",
"zustand": "^4.4.7"
},
"devDependencies": {
"@types/react": "^18.2.43",
"@types/react-dom": "^18.2.17",
"@typescript-eslint/eslint-plugin": "^6.14.0",
"@typescript-eslint/parser": "^6.14.0",
"@typescript-eslint/eslint-plugin": "^8.54.0",
"@typescript-eslint/parser": "^8.54.0",
"@vitejs/plugin-react": "^4.2.1",
"autoprefixer": "^10.4.16",
"eslint": "^8.55.0",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint": "^9.39.2",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.5",
"postcss": "^8.4.32",
"tailwindcss": "^3.3.6",
"typescript": "^5.2.2",
"vite": "^5.0.8"
"vite": "^7.3.1"
}
}

View File

@ -18,13 +18,11 @@ import {
Modal,
Form,
Input,
Select,
Button,
Space,
Card,
Alert,
message,
Spin,
Typography,
Tag,
Upload,
@ -34,17 +32,14 @@ import {
Row,
Col,
List,
Popconfirm,
Tooltip
Popconfirm
} from 'antd'
import {
RobotOutlined,
CheckCircleOutlined,
FileTextOutlined,
UploadOutlined,
LinkOutlined,
GithubOutlined,
FilePdfOutlined,
PlusOutlined,
DeleteOutlined,
EditOutlined,
@ -59,10 +54,9 @@ import { taskService } from '@/services/taskService'
import TaskProgressTracker from '@/components/TaskProgressTracker'
const { TextArea } = Input
const { Text, Title, Paragraph } = Typography
const { Text } = Typography
const { Step } = Steps
const { TabPane } = Tabs
const { Option } = Select
interface SkillCreateProps {
visible: boolean
@ -92,7 +86,7 @@ interface GeneratedSkill {
type CreateStep = 'input' | 'generating' | 'preview' | 'references' | 'saving'
export const SkillCreate: React.FC<SkillCreateProps> = ({
const SkillCreateComponent: React.FC<SkillCreateProps> = ({
visible,
onClose,
onSuccess,
@ -100,6 +94,14 @@ export const SkillCreate: React.FC<SkillCreateProps> = ({
}) => {
const [form] = Form.useForm()
// 使用 ref 跟踪弹窗实际可见性(避免闭包问题)
const dialogVisibleRef = useRef(visible)
// 当 visible prop 变化时更新 ref
useEffect(() => {
dialogVisibleRef.current = visible
}, [visible])
// 步骤状态
const [step, setStep] = useState<CreateStep>('input')
const [aiDescription, setAiDescription] = useState('')
@ -118,13 +120,15 @@ export const SkillCreate: React.FC<SkillCreateProps> = ({
const [githubInput, setGithubInput] = useState('')
const [manualFileName, setManualFileName] = useState('')
const [manualContent, setManualContent] = useState('')
const [uploadedFiles, setUploadedFiles] = useState<any[]>([])
// 预览ID用于保存
const [previewId, setPreviewId] = useState<string | null>(null)
const contentPreviewRef = useRef<HTMLDivElement>(null)
// LocalStorage key for saving task state
const SKILL_TASK_STORAGE_KEY = 'skill_generation_task'
// 重置表单
const resetForm = () => {
setStep('input')
@ -136,9 +140,8 @@ export const SkillCreate: React.FC<SkillCreateProps> = ({
setGithubInput('')
setManualFileName('')
setManualContent('')
setUploadedFiles([])
setPreviewId(null)
setCurrentTaskId(null)
// 不清除 currentTaskId让用户可以继续跟踪任务
form.resetFields()
}
@ -147,6 +150,61 @@ export const SkillCreate: React.FC<SkillCreateProps> = ({
if (editingSkillId && visible) {
loadSkillForEdit(editingSkillId)
} else if (visible) {
// 检查是否有正在进行的任务
const savedTask = localStorage.getItem(SKILL_TASK_STORAGE_KEY)
if (savedTask) {
try {
const taskData = JSON.parse(savedTask)
// 检查任务是否是最近的10分钟内
const taskAge = Date.now() - taskData.timestamp
if (taskAge < 10 * 60 * 1000 && taskData.taskId && taskData.description) {
setCurrentTaskId(taskData.taskId)
setAiDescription(taskData.description)
setStep('generating')
setGenerating(true)
// 继续轮询任务
const pollSavedTask = async () => {
try {
const result = await taskService.pollTask(taskData.taskId, (progress) => {
console.log('Task progress:', progress)
})
if (result.success && result.result) {
const skillData = result.result
setGeneratedSkill({
suggested_id: skillData.suggested_id,
suggested_name: skillData.suggested_name,
skill_content: skillData.skill_content,
category: skillData.category,
suggested_tags: skillData.suggested_tags,
explanation: skillData.explanation
})
form.setFieldsValue({ content: skillData.skill_content })
setStep('preview')
setGenerating(false)
setCurrentTaskId(null)
localStorage.removeItem(SKILL_TASK_STORAGE_KEY)
message.success('AI 生成成功!您可以预览和编辑内容')
}
} catch (error) {
message.error(`AI 生成失败: ${(error as Error).message}`)
setStep('input')
setGenerating(false)
setCurrentTaskId(null)
localStorage.removeItem(SKILL_TASK_STORAGE_KEY)
}
}
pollSavedTask()
message.info('检测到正在进行的生成任务,正在继续...')
return
}
} catch (error) {
console.error('Failed to parse saved task:', error)
localStorage.removeItem(SKILL_TASK_STORAGE_KEY)
}
}
resetForm()
setStep('input')
}
@ -154,6 +212,15 @@ export const SkillCreate: React.FC<SkillCreateProps> = ({
const loadSkillForEdit = async (skillId: string) => {
try {
// 检查是否是虚拟的 task ID正在生成中的 skill
// 这种 ID 以 "task-" 开头,不是真实的 skill不应该加载
if (skillId.startsWith('task-')) {
console.log('[SkillCreate] Skipping load for virtual task ID:', skillId)
message.info('该 Skill 正在生成中,请等待生成完成后再编辑')
handleClose()
return
}
const result = await skillService.getSkillWithReferences(skillId, true)
if (result) {
const { skill, references: refs } = result
@ -194,6 +261,10 @@ export const SkillCreate: React.FC<SkillCreateProps> = ({
}
const handleClose = () => {
// 如果有后台任务正在运行,可以关闭弹窗让任务继续在后台运行
if (currentTaskId && generating) {
message.info('任务将在后台继续运行,您可以稍后再来查看结果')
}
resetForm()
onClose()
}
@ -210,24 +281,42 @@ export const SkillCreate: React.FC<SkillCreateProps> = ({
try {
// 创建异步任务
const taskResult = await taskService.generateSkill({
const response = await taskService.generateSkill({
description: aiDescription,
temperature: 0.7
})
setCurrentTaskId(taskResult.taskId)
message.success('任务已创建,正在生成中...')
const taskId = response.taskId
setCurrentTaskId(taskId)
// 轮询任务直到完成
const result = await taskService.pollTask(taskResult.taskId, (progress) => {
// 可以在这里更新进度UI如果需要的话
// 保存任务信息到 localStorage以便关闭弹窗后恢复
localStorage.setItem(SKILL_TASK_STORAGE_KEY, JSON.stringify({
taskId,
description: aiDescription,
timestamp: Date.now()
}))
message.success('任务已创建,正在后台生成中...您可以关闭此弹窗,生成完成后会自动保存')
// 启动后台轮询,但不阻塞 UI
const pollTaskInBackground = async () => {
try {
const result = await taskService.pollTask(taskId, (progress) => {
console.log('Task progress:', progress)
})
// 处理任务结果
// 任务完成后,如果弹窗还开着,显示结果
if (result.success && result.result) {
const skillData = result.result
console.log('Task completed, skill data:', skillData)
// 清除 localStorage 中的任务信息
localStorage.removeItem(SKILL_TASK_STORAGE_KEY)
// 检查弹窗是否还打开(使用 ref 避免闭包问题)
if (dialogVisibleRef.current) {
// 弹窗打开:显示结果供用户查看
// 注意:后端已经自动保存了,这里只是显示结果
setGeneratedSkill({
suggested_id: skillData.suggested_id,
suggested_name: skillData.suggested_name,
@ -242,19 +331,49 @@ export const SkillCreate: React.FC<SkillCreateProps> = ({
})
setStep('preview')
message.success('AI 生成成功!您可以预览和编辑内容')
const savedInfo = skillData.auto_saved
? `${skillData.suggested_name} 已自动保存!(ID: ${skillData.saved_skill_id})`
: `${skillData.suggested_name} 生成完成!`
message.success(savedInfo)
// 通知父组件刷新列表
onSuccess()
} else {
// 弹窗已关闭:后端已经自动保存,只需通知用户
console.log('Dialog closed, skill was auto-saved by backend')
if (skillData.auto_saved) {
message.success(`${skillData.suggested_name} 已自动保存到 skills 列表!`)
// 通知父组件刷新列表
onSuccess()
} else if (skillData.save_error) {
message.warning(`生成完成但自动保存失败: ${skillData.save_error}`)
}
}
} else {
throw new Error(result.error || '生成失败')
}
} catch (error) {
message.error(`AI 生成失败: ${(error as Error).message}`)
setStep('input')
// 清除 localStorage 中的任务信息
localStorage.removeItem(SKILL_TASK_STORAGE_KEY)
} finally {
setGenerating(false)
setCurrentTaskId(null)
}
}
// 启动后台轮询
pollTaskInBackground()
} catch (error) {
message.error(`任务创建失败: ${(error as Error).message}`)
setGenerating(false)
setStep('input')
}
}
// ========== 步骤2: 预览和编辑 ==========
const handleStartEditing = () => {
setEditingContent(true)
@ -280,6 +399,9 @@ export const SkillCreate: React.FC<SkillCreateProps> = ({
}
// 使用AI调整内容
const [refineModalVisible, setRefineModalVisible] = useState(false)
const [refinePrompt, setRefinePrompt] = useState('')
const handleRefineWithAI = async () => {
const currentContent = form.getFieldValue('content')
if (!currentContent) {
@ -287,17 +409,20 @@ export const SkillCreate: React.FC<SkillCreateProps> = ({
return
}
const refinePrompt = await new Promise<string>((resolve) => {
Modal.input({
title: '输入调整需求',
placeholder: '例如:把内容改得更简洁、增加代码示例、优化描述...',
onOk: (value) => resolve(value || '')
})
})
setRefinePrompt('')
setRefineModalVisible(true)
}
if (!refinePrompt) return
const confirmRefineWithAI = async () => {
if (!refinePrompt.trim()) {
message.warning('请输入调整需求')
return
}
const currentContent = form.getFieldValue('content')
setRefineModalVisible(false)
setGenerating(true)
try {
const result = await skillService.refineSkill(currentContent, refinePrompt, 0.7)
@ -535,6 +660,7 @@ export const SkillCreate: React.FC<SkillCreateProps> = ({
}
return (
<>
<Modal
title={
<Space>
@ -599,15 +725,28 @@ export const SkillCreate: React.FC<SkillCreateProps> = ({
{/* ========== 生成中 ========== */}
{step === 'generating' && currentTaskId && (
<Card title="AI 正在生成 Skill..." style={{ marginBottom: 16 }}>
<Alert
message="任务已在后台创建"
description="您可以关闭此弹窗,任务将在后台继续运行。稍后重新打开创建弹窗即可查看生成结果。"
type="info"
showIcon
closable
style={{ marginBottom: 16 }}
/>
<TaskProgressTracker
taskId={currentTaskId}
onComplete={(result) => {
onComplete={() => {
// 任务完成后的处理逻辑已经在 handleGenerate 中了
}}
onError={(error) => {
message.error(`生成失败: ${error}`)
}}
/>
<div style={{ marginTop: 16, textAlign: 'center' }}>
<Button onClick={handleClose}>
</Button>
</div>
</Card>
)}
@ -936,7 +1075,29 @@ export const SkillCreate: React.FC<SkillCreateProps> = ({
)}
</Space>
</Modal>
{/* AI 调整弹窗 */}
<Modal
title="输入调整需求"
open={refineModalVisible}
onOk={confirmRefineWithAI}
onCancel={() => setRefineModalVisible(false)}
okText="开始调整"
cancelText="取消"
>
<Space direction="vertical" style={{ width: '100%' }}>
<TextArea
rows={4}
placeholder="例如:把内容改得更简洁、增加代码示例、优化描述..."
value={refinePrompt}
onChange={(e) => setRefinePrompt(e.target.value)}
/>
</Space>
</Modal>
</>
)
}
// 导出组件
const SkillCreate = SkillCreateComponent
export default SkillCreate

View File

@ -62,7 +62,7 @@ export const TaskProgressTracker = ({
stages,
pollInterval = 1000
}: TaskProgressTrackerProps) => {
const [taskProgress, setTaskProgress] = useState<TaskProgress | null>(null)
const [taskProgress, setTaskProgress] = useState<any>(null)
const [loading, setLoading] = useState(true)
// 轮询任务进度
@ -72,7 +72,10 @@ export const TaskProgressTracker = ({
const fetchProgress = async () => {
try {
const data = await api.get<TaskProgress>(`/tasks/${taskId}`)
// 后端返回的是 TaskResponse 对象,包含 status, progress 等字段
const data = await api.get<any>(`/tasks/${taskId}`)
console.log('TaskProgressTracker - fetched data:', data)
if (!mounted) return
setTaskProgress(data)
@ -139,7 +142,10 @@ export const TaskProgressTracker = ({
// 计算进度百分比
const getProgressPercent = () => {
if (!taskProgress) return 0
const { current, total } = taskProgress.progress
// taskProgress 是 TaskResponse 对象,包含 progress 字段
// progress 是 TaskProgress 对象,包含 current 和 total 字段
const progressData = taskProgress.progress || { current: 0, total: 100 }
const { current, total } = progressData
return total > 0 ? Math.round((current / total) * 100) : 0
}
@ -215,9 +221,9 @@ export const TaskProgressTracker = ({
{/* 任务详情 */}
<div style={{ fontSize: '12px', color: '#666' }}>
<div>ID: {taskProgress.taskId}</div>
<div>ID: {taskProgress.taskId || taskProgress.id}</div>
<div>: {taskProgress.type}</div>
<div>: {new Date(taskProgress.createdAt).toLocaleString()}</div>
<div>: {taskProgress.createdAt ? new Date(taskProgress.createdAt).toLocaleString() : new Date(taskProgress.created_at).toLocaleString()}</div>
</div>
</Space>
</Card>
@ -243,7 +249,7 @@ export const StageProgressTracker = ({
onError,
pollInterval = 1000
}: StageProgressTrackerProps) => {
const [taskProgress, setTaskProgress] = useState<TaskProgress | null>(null)
const [taskProgress, setTaskProgress] = useState<any>(null)
const [loading, setLoading] = useState(true)
useEffect(() => {

View File

@ -15,7 +15,6 @@ import {
Modal,
Form,
Select,
Popconfirm,
Badge,
Divider,
Tooltip,
@ -23,11 +22,11 @@ import {
Empty,
Spin,
Alert,
Progress,
List,
Typography
Popconfirm,
Progress
} from 'antd'
import { SkillCreate } from '@/components/SkillCreate'
import SkillCreate from '@/components/SkillCreate'
import {
PlusOutlined,
EyeOutlined,
@ -37,22 +36,21 @@ import {
CopyOutlined,
DeleteOutlined,
AppstoreOutlined,
SettingOutlined,
CheckCircleOutlined,
LockOutlined,
RobotOutlined,
EditFilled,
ReloadOutlined,
ClockCircleOutlined,
CloseCircleOutlined,
CheckOutlined
FileTextOutlined
} from '@ant-design/icons'
import { useNavigate, useSearchParams, useLocation } from 'react-router-dom'
import { useSkillStore, Skill } from '@/stores/skillStore'
import { skillService, SkillDraft } from '@/services/skillService'
import { taskService } from '@/services/taskService'
import { uploadService } from '@/services/uploadService'
import { getTaskPollingService } from '@/services/taskPollingService'
import type { ColumnsType } from 'antd/es/table'
import { TablePaginationConfig } from 'antd/es/table'
import dayjs from 'dayjs'
const { Search, TextArea } = Input
@ -62,13 +60,190 @@ const { Option } = Select
// Keep Input component available
const InputComponent = Input
interface SkillFormData {
id: string
name: string
category: string
behavior_guide: string
tags: string[]
config?: Record<string, any>
// ============================================================================
// Skill References View 组件
// ============================================================================
interface SkillReferencesViewProps {
skillId: string
}
const SkillReferencesView: React.FC<SkillReferencesViewProps> = ({ skillId }) => {
const [files, setFiles] = useState<any[]>([])
const [loading, setLoading] = useState(true)
const [selectedFile, setSelectedFile] = useState<any | null>(null)
const [fileContent, setFileContent] = useState<string>('')
const [contentLoading, setContentLoading] = useState(false)
const [previewVisible, setPreviewVisible] = useState(false)
useEffect(() => {
fetchReferences()
}, [skillId])
const fetchReferences = async () => {
setLoading(true)
try {
const result = await uploadService.listReferenceFiles(skillId)
setFiles(result.files || [])
} catch (error) {
console.error('Failed to fetch references:', error)
message.error('加载 References 失败')
} finally {
setLoading(false)
}
}
const handleViewFile = async (file: any) => {
setSelectedFile(file)
setContentLoading(true)
setPreviewVisible(true)
try {
const result = await uploadService.getReferenceFileContent(skillId, file.name)
if (result.success && result.content) {
setFileContent(result.content)
} else {
message.error(result.message || '无法读取文件内容')
}
} catch (error) {
console.error('Failed to fetch file content:', error)
message.error('读取文件内容失败')
} finally {
setContentLoading(false)
}
}
const handleDeleteFile = async (fileName: string) => {
try {
await uploadService.deleteReferenceFile(skillId, fileName)
message.success('删除成功')
fetchReferences()
} catch (error) {
console.error('Failed to delete file:', error)
message.error('删除失败')
}
}
const formatFileSize = (bytes: number) => {
if (bytes < 1024) return bytes + ' B'
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'
return (bytes / (1024 * 1024)).toFixed(1) + ' MB'
}
if (loading) {
return <Spin />
}
return (
<Space direction="vertical" style={{ width: '100%' }} size="middle">
{files.length === 0 ? (
<Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
description="暂无 References 文件"
/>
) : (
<List
dataSource={files}
renderItem={(file) => (
<List.Item
actions={[
<Button
key="view"
size="small"
type="link"
icon={<EyeOutlined />}
onClick={() => handleViewFile(file)}
>
</Button>,
<Popconfirm
key="delete"
title="确认删除"
description="确定要删除这个文件吗?"
onConfirm={() => handleDeleteFile(file.name)}
okText="确定"
cancelText="取消"
>
<Button size="small" type="link" danger icon={<DeleteOutlined />}>
</Button>
</Popconfirm>
]}
>
<List.Item.Meta
avatar={<FileTextOutlined style={{ fontSize: '20px', color: '#1890ff' }} />}
title={file.name}
description={
<Space size="small">
<span>{formatFileSize(file.size)}</span>
{file.modified && (
<span style={{ color: '#999' }}>
{dayjs(file.modified).format('YYYY-MM-DD HH:mm')}
</span>
)}
</Space>
}
/>
</List.Item>
)}
/>
)}
{/* 文件预览弹窗 */}
<Modal
title={
<Space>
<FileTextOutlined />
<span>{selectedFile?.name}</span>
</Space>
}
open={previewVisible}
onCancel={() => {
setPreviewVisible(false)
setSelectedFile(null)
setFileContent('')
}}
width={900}
footer={[
<Button key="close" onClick={() => setPreviewVisible(false)}>
</Button>,
<Button
key="copy"
type="primary"
icon={<CopyOutlined />}
onClick={() => {
navigator.clipboard.writeText(fileContent)
message.success('已复制到剪贴板')
}}
>
</Button>
]}
>
{contentLoading ? (
<div style={{ textAlign: 'center', padding: '40px' }}>
<Spin />
</div>
) : (
<pre style={{
background: '#f5f5f5',
padding: '16px',
borderRadius: '4px',
maxHeight: '60vh',
overflowY: 'auto',
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
fontFamily: 'monospace',
fontSize: '13px',
lineHeight: '1.6'
}}>
{fileContent}
</pre>
)}
</Modal>
</Space>
)
}
// AI 创建模式
@ -82,7 +257,7 @@ export const SkillManagement = () => {
// List view states
const [searchText, setSearchText] = useState('')
const [activeTab, setActiveTab] = useState('all')
const [activeTab] = useState('all')
const [viewMode, setViewMode] = useState<'list' | 'grid'>('list')
const [selectedCategories, setSelectedCategories] = useState<string[]>([])
@ -112,7 +287,6 @@ export const SkillManagement = () => {
// 技能生成任务状态
const [skillGenerationTasks, setSkillGenerationTasks] = useState<any[]>([])
const [loadingTasks, setLoadingTasks] = useState(false)
const [optimizing, setOptimizing] = useState(false)
const [optimizeRequirements, setOptimizeRequirements] = useState('')
@ -126,34 +300,43 @@ export const SkillManagement = () => {
const [wizardVisible, setWizardVisible] = useState(false)
// 跳转回退信息(从外部跳转过来时使用)
const [redirectState, setRedirectState] = useState<{ path: string; activeTab?: string; reviewSubTab?: string } | null>(null)
// References 数量缓存
const [skillReferencesCount, setSkillReferencesCount] = useState<Record<string, number>>({})
const [loadingReferences, setLoadingReferences] = useState<Record<string, boolean>>({})
useEffect(() => {
fetchSkills()
// 轮询技能生成任务 - 使用递归 setTimeout 而不是 setInterval
// 这样可以确保前一个请求完成后才开始下一个请求
let isMounted = true
// 使用全局轮询服务,避免多个页面重复轮询
const pollingService = getTaskPollingService()
const pollTasks = async () => {
if (!isMounted) return
// 订阅任务更新
const unsubscribe = pollingService.subscribe({
onTasksUpdate: (tasks) => {
setSkillGenerationTasks(tasks)
},
onTaskCompleted: (task) => {
// 任务完成时刷新技能列表
console.log('[SkillManagement] Task completed:', task)
fetchSkills()
try {
await fetchSkillGenerationTasks()
} catch (error) {
console.error('Error polling tasks:', error)
}
// 显示完成通知
const skillName = task.result?.suggested_name || 'Skill'
const savedSkillId = task.result?.saved_skill_id
const autoSaved = task.result?.auto_saved
// 等待 3 秒后再次轮询
if (isMounted) {
setTimeout(pollTasks, 3000)
if (autoSaved && savedSkillId) {
message.success(`${skillName} 已自动保存!(ID: ${savedSkillId})`)
} else if (task.result?.save_error) {
message.warning(`${skillName} 生成完成但保存失败: ${task.result.save_error}`)
} else {
message.success(`${skillName} 生成完成!`)
}
}
// 启动轮询
pollTasks()
})
return () => {
isMounted = false
unsubscribe()
}
}, [])
@ -220,25 +403,6 @@ export const SkillManagement = () => {
}
}, [skills, searchParams, navigate, location])
// 获取技能生成任务
const fetchSkillGenerationTasks = async () => {
try {
const tasks = await taskService.listTasks({
type: 'generate_skill',
status: 'running'
})
setSkillGenerationTasks(tasks)
// 如果有任务完成,刷新技能列表
const completedTasks = tasks.filter(task => task.status === 'completed')
if (completedTasks.length > 0) {
fetchSkills()
}
} catch (error) {
console.error('Failed to fetch skill generation tasks:', error)
}
}
// Get unique categories
const categories = Array.from(new Set(skills.map(s => s.category)))
@ -259,10 +423,54 @@ export const SkillManagement = () => {
return matchSearch && matchTab && matchCategory
})
// 创建正在生成的任务的虚拟 skill 列表
const generatingSkills = skillGenerationTasks.map((task: any) => ({
id: `task-${task.id}`,
name: task.params?.description?.substring(0, 30) + '...' || '正在生成 Skill',
version: '0.1',
type: 'user' as const,
category: task.params?.category || '通用',
behavior_guide: '',
tags: ['生成中'],
created_at: new Date(task.created_at).toISOString(),
// 添加任务相关字段用于显示
_isGenerating: true,
_taskId: task.id,
_taskStatus: task.status,
_taskProgress: task.progress,
_description: task.params?.description || ''
}))
// 合并正常 skills 和正在生成的 skills
const allDisplaySkills = [...generatingSkills, ...filteredSkills]
// 调试日志
console.log('=== Rendering ===')
console.log('skillGenerationTasks count:', skillGenerationTasks.length)
console.log('generatingSkills count:', generatingSkills.length)
console.log('filteredSkills count:', filteredSkills.length)
console.log('allDisplaySkills count:', allDisplaySkills.length)
// 加载单个 skill 的 references 数量
const fetchSkillReferencesCount = async (skillId: string) => {
try {
setLoadingReferences(prev => ({ ...prev, [skillId]: true }))
const result = await uploadService.listReferenceFiles(skillId)
setSkillReferencesCount(prev => ({ ...prev, [skillId]: result.files.length }))
} catch (error) {
console.error(`Failed to fetch references for skill ${skillId}:`, error)
setSkillReferencesCount(prev => ({ ...prev, [skillId]: 0 }))
} finally {
setLoadingReferences(prev => ({ ...prev, [skillId]: false }))
}
}
// Handlers
const handleView = (skill: Skill) => {
setSelectedSkill(skill)
setDetailDrawerOpen(true)
// 加载 references 数量
fetchSkillReferencesCount(skill.id)
}
const handleTest = (skill: Skill) => {
@ -535,12 +743,39 @@ export const SkillManagement = () => {
title: '名称',
dataIndex: 'name',
key: 'name',
render: (name: string, record: Skill) => (
render: (name: string, record: any) => {
const isGenerating = (record as any)._isGenerating
const taskProgress = (record as any)._taskProgress
return (
<Space direction="vertical" size="small">
<Space>
<span>{name}</span>
{record.type === 'builtin' && <Badge count="系统" style={{ backgroundColor: '#1890ff' }} />}
{isGenerating && (
<Badge
count="生成中"
style={{
backgroundColor: '#52c41a',
animation: 'pulse 2s infinite'
}}
/>
)}
</Space>
{isGenerating && taskProgress && (
<Progress
percent={taskProgress.current || 0}
size="small"
status="active"
strokeColor={{
'0%': '#108ee9',
'100%': '#87d068',
}}
/>
)}
</Space>
)
}
},
{
title: '类型',
@ -567,6 +802,32 @@ export const SkillManagement = () => {
onFilter: (value, record) => record.category === value,
render: (category: string) => <Tag>{category}</Tag>
},
{
title: 'References',
key: 'references',
width: 100,
render: (_, record: Skill) => {
const count = skillReferencesCount[record.id] ?? 0
const loading = loadingReferences[record.id]
if (loading) {
return <Spin size="small" />
}
return (
<Tooltip title="点击查看详情中的 References 标签页">
<Tag
icon={<FileTextOutlined />}
color={count > 0 ? 'blue' : 'default'}
style={{ cursor: 'pointer' }}
onClick={() => handleView(record)}
>
{count}
</Tag>
</Tooltip>
)
}
},
{
title: '版本',
dataIndex: 'version',
@ -678,7 +939,7 @@ export const SkillManagement = () => {
showIcon
style={{ marginBottom: 16 }}
action={
<Button size="small" onClick={fetchSkillGenerationTasks}>
<Button size="small" onClick={() => getTaskPollingService().refresh()}>
</Button>
}
@ -734,7 +995,7 @@ export const SkillManagement = () => {
{/* Skills 列表 */}
<Table
columns={columns}
dataSource={filteredSkills}
dataSource={allDisplaySkills}
rowKey="id"
loading={loading}
pagination={{
@ -875,6 +1136,18 @@ export const SkillManagement = () => {
</Card>
</TabPane>
<TabPane
tab={
<Space>
<span>References</span>
<Badge count={skillReferencesCount[selectedSkill.id] ?? 0} style={{ backgroundColor: '#1890ff' }} />
</Space>
}
key="references"
>
<SkillReferencesView skillId={selectedSkill.id} />
</TabPane>
<TabPane tab="配置" key="config">
<Card size="small" title="配置参数">
<pre style={{
@ -1568,6 +1841,7 @@ export const SkillManagement = () => {
}}
onSuccess={() => {
fetchSkills()
// 全局轮询服务会自动更新任务列表,无需手动刷新
// 不自动关闭,让用户查看创建结果后主动关闭
// setWizardVisible(false)
// setEditingSkill(null)

View File

@ -0,0 +1,237 @@
/**
*
/
1. generate_skill
2.
3.
*/
import { taskService } from './taskService'
interface TaskPollingCallbacks {
onTasksUpdate?: (tasks: any[]) => void
onTaskCompleted?: (task: any) => void // 新增:任务完成回调
}
class TaskPollingService {
private pollTimer: any = null
private isPolling = false
private isPageVisible = true
private listeners: Set<TaskPollingCallbacks> = new Set()
private currentTasks: any[] = []
private completedTaskIds: Set<string> = new Set() // 追踪已通知完成的任务
private recentCompletedTasks: any[] = [] // 最近完成的任务5分钟内
constructor() {
// 监听页面可见性
document.addEventListener('visibilitychange', this.handleVisibilityChange)
}
private handleVisibilityChange = () => {
this.isPageVisible = !document.hidden
if (this.isPageVisible) {
// 页面重新可见时,立即刷新并开始轮询
this.fetchTasks()
if (!this.isPolling && this.listeners.size > 0) {
this.startPolling()
}
} else {
// 页面不可见时,停止轮询
this.stopPolling()
}
}
/**
*
*/
subscribe(callbacks: TaskPollingCallbacks): () => void {
this.listeners.add(callbacks)
// 如果有新的订阅者且没有在轮询,开始轮询
if (this.listeners.size === 1 && !this.isPolling) {
this.startPolling()
}
// 立即返回当前的任务状态
if (callbacks.onTasksUpdate && this.currentTasks.length > 0) {
callbacks.onTasksUpdate(this.currentTasks)
}
// 返回取消订阅函数
return () => {
this.listeners.delete(callbacks)
// 如果没有订阅者了,停止轮询
if (this.listeners.size === 0) {
this.stopPolling()
}
}
}
/**
*
*/
private startPolling() {
if (this.isPolling) return
this.isPolling = true
this.poll()
}
/**
*
*/
private stopPolling() {
this.isPolling = false
if (this.pollTimer) {
clearTimeout(this.pollTimer)
this.pollTimer = null
}
}
/**
*
*/
private async poll() {
if (!this.isPolling || !this.isPageVisible) {
return
}
try {
await this.fetchTasks()
} catch (error) {
console.error('Task polling error:', error)
}
// 继续轮询10秒间隔
if (this.isPolling && this.isPageVisible) {
this.pollTimer = setTimeout(() => this.poll(), 10000)
}
}
/**
*
*/
private async fetchTasks() {
try {
// 获取所有 generate_skill 类型的任务
const response = await taskService.listTasks({
type: 'generate_skill'
}) as any
console.log('[TaskPollingService] Fetched tasks:', response)
// 转换数据格式
// 注意:后端返回的 ID 字段名可能是 taskId 或 id需要兼容处理
const tasks = (response || []).map((task: any) => ({
id: task.taskId || task.id, // 兼容两种字段名
type: task.type,
status: task.status,
progress: task.progress || { current: 0, total: 100, message: '', stage: null },
params: task.params || {},
result: task.result,
error: task.error,
project_id: task.projectId || task.project_id,
created_at: task.created_at,
updated_at: task.updated_at
}))
console.log('[TaskPollingService] Mapped tasks:', tasks)
// 分离正在运行和已完成的任务
const runningTasks = tasks.filter((t: any) => t.status === 'running')
const newlyCompletedTasks = tasks.filter((t: any) =>
t.status === 'completed' && !this.completedTaskIds.has(t.id)
)
// 更新已完成任务追踪
if (newlyCompletedTasks.length > 0) {
console.log('[TaskPollingService] Newly completed tasks:', newlyCompletedTasks)
newlyCompletedTasks.forEach((task: any) => {
this.completedTaskIds.add(task.id)
this.recentCompletedTasks.push(task)
// 通知所有订阅者有新任务完成
this.listeners.forEach(callbacks => {
if (callbacks.onTaskCompleted) {
callbacks.onTaskCompleted(task)
}
})
})
// 清理超过5分钟的已完成任务
const fiveMinutesAgo = Date.now() - 5 * 60 * 1000
this.recentCompletedTasks = this.recentCompletedTasks.filter((t: any) => {
const taskTime = new Date(t.updated_at).getTime()
return taskTime > fiveMinutesAgo
})
}
// 返回:正在运行的任务 + 最近完成的任务
this.currentTasks = [...runningTasks, ...this.recentCompletedTasks]
// 通知所有订阅者
this.listeners.forEach(callbacks => {
if (callbacks.onTasksUpdate) {
callbacks.onTasksUpdate(this.currentTasks)
}
})
} catch (error) {
console.error('[TaskPollingService] Failed to fetch tasks:', error)
}
}
/**
*
*/
refresh() {
this.fetchTasks()
}
/**
*
*/
getCurrentTasks(): any[] {
return this.currentTasks
}
/**
*
*/
destroy() {
this.stopPolling()
this.listeners.clear()
document.removeEventListener('visibilitychange', this.handleVisibilityChange)
}
}
// 全局单例
let pollingService: TaskPollingService | null = null
export const getTaskPollingService = () => {
if (!pollingService) {
pollingService = new TaskPollingService()
}
return pollingService
}
/**
* React Hook
*/
export const useTaskPolling = (callback: (tasks: any[]) => void) => {
const service = getTaskPollingService()
// 组件挂载时订阅
const subscribe = () => {
const unsubscribe = service.subscribe({
onTasksUpdate: callback
})
return unsubscribe
}
return { service, subscribe }
}

View File

@ -39,7 +39,18 @@ export const taskService = {
projectId?: string
status?: string
}) => {
return await api.get<TaskProgress[]>('/tasks', { params })
// 后端期望的参数名是 task_type 而不是 type
const apiParams: any = {}
if (params?.type) {
apiParams.task_type = params.type
}
if (params?.projectId) {
apiParams.project_id = params.projectId
}
if (params?.status) {
apiParams.status = params.status
}
return await api.get<TaskProgress[]>('/tasks', { params: apiParams })
},
/**
@ -119,7 +130,7 @@ export const taskService = {
category?: string
tags?: string[]
temperature?: number
}) => {
}): Promise<CreateTaskResponse> => {
return await api.post<CreateTaskResponse>('/skills/async/generate', params)
},
@ -132,22 +143,27 @@ export const taskService = {
*/
pollTask: async (
taskId: string,
onUpdate?: (progress: TaskProgress) => void,
onUpdate?: (progress: any) => void,
interval = 1000
): Promise<any> => {
return new Promise((resolve, reject) => {
const poll = async () => {
try {
const progress = await api.get<TaskProgress>(`/tasks/${taskId}`) as unknown as TaskProgress
onUpdate?.(progress)
// 后端返回的是 TaskResponse包含 taskId, type, status, progress 等字段
const taskResponse = await api.get<any>(`/tasks/${taskId}`)
console.log('Poll task response:', taskResponse)
if (progress.status === 'completed') {
resolve(progress.result)
// 调用进度更新回调,传递完整的任务信息
onUpdate?.(taskResponse)
// 检查任务状态
if (taskResponse.status === 'completed') {
resolve({ success: true, result: taskResponse.result })
return
}
if (progress.status === 'failed') {
reject(new Error(progress.error || '任务执行失败'))
if (taskResponse.status === 'failed') {
reject(new Error(taskResponse.error || '任务执行失败'))
return
}

View File

@ -1,123 +0,0 @@
# 审核系统问题修复报告
## 问题描述
在 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

@ -1,157 +0,0 @@
# 审核系统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 错误。这种方案既保持了后端数据模型的规范性,又兼容了前端的实现方式。
建议全面测试审核规则的所有操作,确保没有引入新的问题。