feat:优化skills创建异步任务
This commit is contained in:
parent
5487450f34
commit
7c007a69a6
@ -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\")"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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())
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
169
backend/app/core/task_persistence.py
Normal file
169
backend/app/core/task_persistence.py
Normal 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
|
||||
@ -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()
|
||||
|
||||
@ -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
|
||||
|
||||
188
backend/data/tasks/tasks.json
Normal file
188
backend/data/tasks/tasks.json
Normal 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"
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,121 @@
|
||||
---
|
||||
name: modern-dialogue-writer
|
||||
description: 创建自然、流畅的现代风格对话内容,适用于小说、剧本、游戏等场景。支持当代语言习惯、网络用语、俚语等现代表达方式。当用户需要:编写人物对话、润色对话使其更自然、创作现代场景对话、调整对话语气和风格、处理多角色互动对话时使用此Skill。
|
||||
---
|
||||
|
||||
# 现代对话创作
|
||||
|
||||
## 核心原则
|
||||
|
||||
### 自然流畅
|
||||
- 使用口语化表达,避免过于书面化
|
||||
- 加入语气词(嘛、呢、呗、呗、啊)增强真实感
|
||||
- 适当使用省略号、破折号表现说话节奏
|
||||
- 允许不完整句子、重复、打断等真实对话特征
|
||||
|
||||
### 现代语言特征
|
||||
- 融入网络用语和流行梗(适度)
|
||||
- 使用缩略语和简化表达(如"啥"代替"什么")
|
||||
- 体现时代感的词汇和表达方式
|
||||
- 根据角色年龄、身份调整语言风格
|
||||
|
||||
### 对话节奏
|
||||
- 长短句交替,避免单调
|
||||
- 控制信息密度,不要一次性输出过多
|
||||
- 使用沉默、动作描写调节节奏
|
||||
- 根据紧张程度调整对话速度
|
||||
|
||||
## 对话模式
|
||||
|
||||
### 日常对话
|
||||
- 轻松随意,多用省略和简化
|
||||
- 关注生活细节和琐事
|
||||
- 话题跳跃性强
|
||||
- 示例:
|
||||
```
|
||||
"哎,你看了没?"
|
||||
"啥?"
|
||||
"就那个热搜啊,炸了。"
|
||||
"哦,那个啊,早刷到了。"
|
||||
```
|
||||
|
||||
### 职场对话
|
||||
- 相对正式但不过于生硬
|
||||
- 使用专业术语但要自然
|
||||
- 体现层级关系和职场文化
|
||||
- 示例:
|
||||
```
|
||||
"李总,方案我发你邮箱了。"
|
||||
"收到,我看看。"
|
||||
"有问题随时滴滴我。"
|
||||
```
|
||||
|
||||
### 网络聊天
|
||||
- 使用表情符号和颜文字
|
||||
- 大量使用网络缩写和梗
|
||||
- 语气夸张,情绪化明显
|
||||
- 示例:
|
||||
```
|
||||
"哈哈哈哈笑死我了"
|
||||
"真的吗????"
|
||||
"绝了家人们"
|
||||
```
|
||||
|
||||
## 角色声音塑造
|
||||
|
||||
### 区分角色
|
||||
- 每个角色有独特的说话方式
|
||||
- 常用词汇、句式结构、语气词
|
||||
- 语言习惯(如口头禅、语速快慢)
|
||||
|
||||
### 年龄差异
|
||||
- 青年:网络用语多,表达直接
|
||||
- 中年:相对稳重,但不过时
|
||||
- 老年:传统表达,可能跟不上网络梗
|
||||
|
||||
### 地域特色
|
||||
- 适度融入方言词汇
|
||||
- 注意不要过度使用影响理解
|
||||
- 体现文化背景
|
||||
|
||||
## 润色技巧
|
||||
|
||||
### 去除生硬感
|
||||
- 删除过于正式的词汇
|
||||
- 增加语气词和口语化表达
|
||||
- 打破完整句子结构
|
||||
|
||||
### 增强画面感
|
||||
- 加入动作描写
|
||||
- 描写表情和神态
|
||||
- 通过对话展现环境
|
||||
|
||||
### 潜台词
|
||||
- 话中有话,不直接说破
|
||||
- 通过语气和措辞暗示真实意图
|
||||
- 留白让读者自己体会
|
||||
|
||||
## 常见问题
|
||||
|
||||
### 避免过度网络化
|
||||
- 不要滥用网络用语
|
||||
- 考虑受众和场景
|
||||
- 保持可读性
|
||||
|
||||
### 平衡真实感和文学性
|
||||
- 真实对话不等于完全照搬
|
||||
- 需要艺术加工和提炼
|
||||
- 服务于故事和人物塑造
|
||||
|
||||
### 对话推动情节
|
||||
- 每句对话都应该有目的
|
||||
- 揭示信息、展现关系、推动剧情
|
||||
- 避免无效对话
|
||||
|
||||
## 工作流程
|
||||
|
||||
1. **理解需求**:明确场景、角色、目的
|
||||
2. **确定风格**:根据角色和场景选择对话模式
|
||||
3. **初稿创作**:快速写出对话框架
|
||||
4. **润色打磨**:加入细节、调整节奏、增强真实感
|
||||
5. **检查验证**:朗读检查流畅度,确保角色声音一致
|
||||
65
backend/skills_storage/user_skills/script-adapter/SKILL.md
Normal file
65
backend/skills_storage/user_skills/script-adapter/SKILL.md
Normal 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.
|
||||
1496
frontend/package-lock.json
generated
1496
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,48 +281,96 @@ 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,如果需要的话
|
||||
console.log('Task progress:', progress)
|
||||
})
|
||||
// 保存任务信息到 localStorage,以便关闭弹窗后恢复
|
||||
localStorage.setItem(SKILL_TASK_STORAGE_KEY, JSON.stringify({
|
||||
taskId,
|
||||
description: aiDescription,
|
||||
timestamp: Date.now()
|
||||
}))
|
||||
|
||||
// 处理任务结果
|
||||
if (result.success && result.result) {
|
||||
const skillData = result.result
|
||||
message.success('任务已创建,正在后台生成中...您可以关闭此弹窗,生成完成后会自动保存')
|
||||
|
||||
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
|
||||
})
|
||||
// 启动后台轮询,但不阻塞 UI
|
||||
const pollTaskInBackground = async () => {
|
||||
try {
|
||||
const result = await taskService.pollTask(taskId, (progress) => {
|
||||
console.log('Task progress:', progress)
|
||||
})
|
||||
|
||||
form.setFieldsValue({
|
||||
content: skillData.skill_content
|
||||
})
|
||||
// 任务完成后,如果弹窗还开着,显示结果
|
||||
if (result.success && result.result) {
|
||||
const skillData = result.result
|
||||
console.log('Task completed, skill data:', skillData)
|
||||
|
||||
setStep('preview')
|
||||
message.success('AI 生成成功!您可以预览和编辑内容')
|
||||
} else {
|
||||
throw new Error(result.error || '生成失败')
|
||||
// 清除 localStorage 中的任务信息
|
||||
localStorage.removeItem(SKILL_TASK_STORAGE_KEY)
|
||||
|
||||
// 检查弹窗是否还打开(使用 ref 避免闭包问题)
|
||||
if (dialogVisibleRef.current) {
|
||||
// 弹窗打开:显示结果供用户查看
|
||||
// 注意:后端已经自动保存了,这里只是显示结果
|
||||
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')
|
||||
|
||||
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(`AI 生成失败: ${(error as Error).message}`)
|
||||
setStep('input')
|
||||
} finally {
|
||||
message.error(`任务创建失败: ${(error as Error).message}`)
|
||||
setGenerating(false)
|
||||
setCurrentTaskId(null)
|
||||
setStep('input')
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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(() => {
|
||||
|
||||
@ -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
|
||||
|
||||
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} 生成完成!`)
|
||||
}
|
||||
}
|
||||
|
||||
// 等待 3 秒后再次轮询
|
||||
if (isMounted) {
|
||||
setTimeout(pollTasks, 3000)
|
||||
}
|
||||
}
|
||||
|
||||
// 启动轮询
|
||||
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) => (
|
||||
<Space>
|
||||
<span>{name}</span>
|
||||
{record.type === 'builtin' && <Badge count="系统" style={{ backgroundColor: '#1890ff' }} />}
|
||||
</Space>
|
||||
)
|
||||
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)
|
||||
|
||||
237
frontend/src/services/taskPollingService.ts
Normal file
237
frontend/src/services/taskPollingService.ts
Normal 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 }
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
|
||||
|
||||
123
审核系统修复报告.md
123
审核系统修复报告.md
@ -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 已正确配置)
|
||||
|
||||
建议测试所有审核功能以确保修复没有引入新问题。
|
||||
157
审核系统修复报告_补充.md
157
审核系统修复报告_补充.md
@ -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 错误。这种方案既保持了后端数据模型的规范性,又兼容了前端的实现方式。
|
||||
|
||||
建议全面测试审核规则的所有操作,确保没有引入新的问题。
|
||||
Loading…
x
Reference in New Issue
Block a user