diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 9fc5e37..99e8df4 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -6,7 +6,9 @@ "Bash(python:*)", "Bash(npm run build)", "Bash(npx tsc:*)", - "Bash(findstr:*)" + "Bash(findstr:*)", + "Bash(tree:*)", + "Bash(npx eslint:*)" ] } } diff --git a/.gitignore b/.gitignore index c818851..5996de3 100644 --- a/.gitignore +++ b/.gitignore @@ -5,4 +5,4 @@ __pycache__/ .env .claude/ .venv/ - +.settings.local.json diff --git a/backend/.gitignore b/backend/.gitignore index c23c0a7..1c1b522 100644 --- a/backend/.gitignore +++ b/backend/.gitignore @@ -109,3 +109,4 @@ dmypy.json logs/ storage/ *.db +.settings.local.json \ No newline at end of file diff --git a/backend/app/api/v1/ai_assistant.py b/backend/app/api/v1/ai_assistant.py index 58ef528..72d67e8 100644 --- a/backend/app/api/v1/ai_assistant.py +++ b/backend/app/api/v1/ai_assistant.py @@ -3,13 +3,16 @@ AI 辅助生成 API 提供 AI 辅助生成人物、大纲、解析剧本等功能 支持 Skills 融入:将选定的 Skills 行为指导融入 LLM 调用 +支持长剧本分段分析:自动切分并合并结果 """ from fastapi import APIRouter, HTTPException from typing import Dict, Any, List, Optional from pydantic import BaseModel +import asyncio from app.core.llm.glm_client import get_glm_client from app.core.skills.skill_manager import get_skill_manager from app.utils.logger import get_logger +from app.utils.script_splitter import split_script, ScriptSplitter logger = get_logger(__name__) @@ -29,6 +32,7 @@ class GenerateCharactersRequest(BaseModel): projectName: Optional[str] = None totalEpisodes: Optional[int] = None skills: Optional[List[SkillInfo]] = None # 新增:Skills 列表 + customPrompt: Optional[str] = None # 自定义提示词 class GenerateOutlineRequest(BaseModel): @@ -38,6 +42,7 @@ class GenerateOutlineRequest(BaseModel): genre: str = "古风" projectName: Optional[str] = None skills: Optional[List[SkillInfo]] = None # 新增:Skills 列表 + customPrompt: Optional[str] = None # 自定义提示词 class ParseScriptRequest(BaseModel): @@ -47,6 +52,24 @@ class ParseScriptRequest(BaseModel): extractOutline: bool = True skills: Optional[List[SkillInfo]] = None # 新增:Skills 列表 use_llm: bool = True # 新增:是否使用 LLM 分析(默认 True) + customPrompt: Optional[str] = None # 新增:自定义提示词 + + +class GenerateWorldRequest(BaseModel): + """生成世界观请求""" + idea: str + projectName: Optional[str] = None + genre: Optional[str] = "古风" + skills: Optional[List[SkillInfo]] = None # Skills 列表 + customPrompt: Optional[str] = None # 自定义提示词 + + +class GenerateWorldFromScriptRequest(BaseModel): + """从剧本分析世界观请求""" + script: str + projectName: Optional[str] = None + skills: Optional[List[SkillInfo]] = None # Skills 列表 + customPrompt: Optional[str] = None # 自定义提示词 # ============================================================================ @@ -126,9 +149,14 @@ async def generate_characters(request: GenerateCharactersRequest): if request.totalEpisodes: extra_info += f"\n总集数:{request.totalEpisodes}" + # 自定义提示词 + custom_requirements = "" + if request.customPrompt: + custom_requirements = f"\n【用户自定义要求】\n{request.customPrompt}\n" + prompt = f"""请根据以下想法生成 3-5 个主要人物设定: -用户想法:{request.idea}{extra_info} +用户想法:{request.idea}{extra_info}{custom_requirements} 要求: 1. 每个人物包含:姓名、身份、性格、说话风格、背景故事 @@ -192,12 +220,17 @@ async def generate_outline(request: GenerateOutlineRequest): ) # 构建用户提示 + # 自定义提示词 + custom_requirements = "" + if request.customPrompt: + custom_requirements = f"\n【用户自定义要求】\n{request.customPrompt}\n" + prompt = f"""请根据以下想法生成完整的剧集大纲: 用户想法:{request.idea} 总集数:{request.totalEpisodes} 类型:{request.genre} -{f'项目名称:{request.projectName}' if request.projectName else ''} +{f'项目名称:{request.projectName}' if request.projectName else ''}{custom_requirements} 要求: 1. 将故事分为 4-5 个阶段 @@ -237,6 +270,141 @@ async def generate_outline(request: GenerateOutlineRequest): raise HTTPException(status_code=500, detail=f"生成失败: {str(e)}") +@router.post("/generate/world") +async def generate_world(request: GenerateWorldRequest): + """ + AI 辅助生成世界观设定 + + 根据用户想法生成世界观设定 + 支持融入 Skills 的行为指导 + 支持自定义提示词 + """ + try: + glm_client = get_glm_client() + skill_manager = get_skill_manager() + + # 构建增强的 System Prompt(融入 Skills) + base_role = "你是专业的世界观设定专家,擅长构建架空世界的背景设定。" + system_prompt = await build_enhanced_system_prompt( + base_role=base_role, + skills=request.skills, + skill_manager=skill_manager + ) + + # 构建用户提示 + # 自定义提示词 + custom_requirements = "" + if request.customPrompt: + custom_requirements = f"\n【用户自定义要求】\n{request.customPrompt}\n" + + prompt = f"""请根据以下想法生成世界观设定: + +用户想法:{request.idea} +类型:{request.genre} +{f'项目名称:{request.projectName}' if request.projectName else ''}{custom_requirements} + +要求: +1. 描述时代背景(朝代、架空世界等) +2. 描述地理环境和主要场景 +3. 描述社会结构(权力体系、阶级关系) +4. 描述文化特色(习俗、服饰、语言等) +5. 字数 200-500 字 +6. 严格遵守上面【应用技能指导】中的要求 + +请输出详细的世界观设定: +""" + + logger.info(f"生成世界观设定,使用 {len(request.skills) if request.skills else 0} 个 Skills") + + response = await glm_client.chat( + messages=[ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": prompt} + ], + temperature=0.7 + ) + + content = response["choices"][0]["message"]["content"] + + return { + "success": True, + "worldSetting": content, + "usage": response.get("usage") + } + + except Exception as e: + logger.error(f"生成世界观设定失败: {str(e)}") + raise HTTPException(status_code=500, detail=f"生成失败: {str(e)}") + + +@router.post("/generate/world-from-script") +async def generate_world_from_script(request: GenerateWorldFromScriptRequest): + """ + AI 辅助从剧本分析世界观 + + 从剧本中提取和分析世界观设定 + 支持融入 Skills 的行为指导 + 支持自定义提示词 + """ + try: + glm_client = get_glm_client() + skill_manager = get_skill_manager() + + # 构建增强的 System Prompt(融入 Skills) + base_role = """你是专业的世界观分析专家,擅长从剧本中提取和分析世界观设定。 +你能识别人物关系、时代背景、社会结构、地理环境等深层信息。""" + system_prompt = await build_enhanced_system_prompt( + base_role=base_role, + skills=request.skills, + skill_manager=skill_manager + ) + + # 构建用户提示 + # 自定义提示词 + custom_requirements = "" + if request.customPrompt: + custom_requirements = f"\n【用户自定义要求】\n{request.customPrompt}\n" + + prompt = f"""请分析以下剧本,提取世界观设定: + +{f'项目名称:{request.projectName}' if request.projectName else ''}{custom_requirements} + +剧本内容: +{request.script[:5000]} + +要求: +1. 识别时代背景(朝代、架空世界等) +2. 提取地理环境和主要场景信息 +3. 分析社会结构(权力体系、阶级关系) +4. 识别文化特色(习俗、服饰、语言等) +5. 输出结构化的世界观分析 + +请输出世界观分析: +""" + + logger.info(f"从剧本分析世界观,使用 {len(request.skills) if request.skills else 0} 个 Skills") + + response = await glm_client.chat( + messages=[ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": prompt} + ], + temperature=0.3 + ) + + content = response["choices"][0]["message"]["content"] + + return { + "success": True, + "worldSetting": content, + "usage": response.get("usage") + } + + except Exception as e: + logger.error(f"从剧本分析世界观失败: {str(e)}") + raise HTTPException(status_code=500, detail=f"分析失败: {str(e)}") + + @router.post("/parse/script") async def parse_script(request: ParseScriptRequest): """ @@ -265,10 +433,44 @@ async def parse_script(request: ParseScriptRequest): async def _parse_script_with_llm(request: ParseScriptRequest) -> Dict[str, Any]: """ 使用 LLM 智能分析剧本,支持 Skills 融入 + 支持长剧本分段分析和结果合并 """ glm_client = get_glm_client() skill_manager = get_skill_manager() + # 检查内容长度,决定是否需要分段 + content = request.content + content_length = len(content) + + # 阈值:超过8000字符时使用分段分析 + SPLIT_THRESHOLD = 8000 + + if content_length <= SPLIT_THRESHOLD: + # 内容较短,直接分析 + return await _analyze_single_segment( + content=content, + request=request, + glm_client=glm_client, + skill_manager=skill_manager + ) + else: + # 内容较长,使用分段分析 + logger.info(f"剧本较长 ({content_length} 字符),启用分段分析模式") + return await _analyze_with_splitting( + content=content, + request=request, + glm_client=glm_client, + skill_manager=skill_manager + ) + + +async def _analyze_single_segment( + content: str, + request: ParseScriptRequest, + glm_client, + skill_manager +) -> Dict[str, Any]: + """分析单个片段(原有逻辑)""" # 构建增强的 System Prompt(融入 Skills) base_role = """你是专业的剧本分析专家,擅长从剧本中提取人物关系、剧情结构、对话风格等关键信息。 你能识别人物的出场频率、人物关系、情感走向、对话特点等深层次信息。""" @@ -278,12 +480,6 @@ async def _parse_script_with_llm(request: ParseScriptRequest) -> Dict[str, Any]: skill_manager=skill_manager ) - # 截取剧本内容(避免过长) - content = request.content - max_length = 8000 # 约 3000-4000 字 - if len(content) > max_length: - content = content[:max_length] + "\n\n...(剧本过长,已截断前部分进行分析)" - # 构建分析提示 analysis_requirements = [] if request.extractCharacters: @@ -303,18 +499,23 @@ async def _parse_script_with_llm(request: ParseScriptRequest) -> Dict[str, Any]: - 分析故事结构(起承转合) - 总结核心冲突""") + # 自定义提示词 + custom_requirements = "" + if request.customPrompt: + custom_requirements = f"\n【用户自定义要求】\n{request.customPrompt}\n" + prompt = f"""请分析以下剧本内容,提取关键信息: {content} 要求: {''.join(analysis_requirements)} -3. 严格遵守上面【应用技能指导】中的分析要求 +3. 严格遵守上面【应用技能指导】中的分析要求{custom_requirements} 请以结构化的格式输出,便于后续处理。 """ - logger.info(f"使用 LLM 分析剧本,使用 {len(request.skills) if request.skills else 0} 个 Skills") + logger.info(f"使用 LLM 分析剧本 ({len(content)} 字符),使用 {len(request.skills) if request.skills else 0} 个 Skills") response = await glm_client.chat( messages=[ @@ -329,14 +530,274 @@ async def _parse_script_with_llm(request: ParseScriptRequest) -> Dict[str, Any]: return { "success": True, "method": "llm", - "analysis": analysis_result, # LLM 的完整分析结果 - "characters": [], # 可选:从分析结果中解析 - "outline": "", # 可选:从分析结果中解析 - "summary": f"LLM 智能分析完成,使用 {len(request.skills) if request.skills else 0} 个 Skills", + "analysis": analysis_result, + "characters": [], + "outline": "", + "summary": f"LLM 智能分析完成 ({len(content)} 字符),使用 {len(request.skills) if request.skills else 0} 个 Skills", "usage": response.get("usage") } +async def _analyze_with_splitting( + content: str, + request: ParseScriptRequest, + glm_client, + skill_manager +) -> Dict[str, Any]: + """分段分析长剧本并合并结果""" + # 1. 切分剧本 + split_result = split_script(content) + segments = split_result["segments"] + summary = split_result["summary"] + + logger.info(f"剧本已切分为 {len(segments)} 个片段: {summary}") + + # 2. 并行分析各片段 + segment_analyses = await _analyze_segments_parallel( + segments=segments, + request=request, + glm_client=glm_client, + skill_manager=skill_manager + ) + + # 3. 合并分析结果 + merged_result = await _merge_analysis_results( + segment_analyses=segment_analyses, + request=request, + glm_client=glm_client, + skill_manager=skill_manager, + split_summary=summary + ) + + return merged_result + + +async def _analyze_segments_parallel( + segments: List[Dict[str, Any]], + request: ParseScriptRequest, + glm_client, + skill_manager +) -> List[Dict[str, Any]]: + """并行分析多个片段""" + # 构建基础系统提示词 + base_role = """你是专业的剧本分析专家,擅长从剧本中提取人物关系、剧情结构、对话风格等关键信息。 +这是长剧本的分段分析,请专注于当前片段的内容。""" + system_prompt = await build_enhanced_system_prompt( + base_role=base_role, + skills=request.skills, + skill_manager=skill_manager + ) + + # 构建分析要求 + analysis_requirements = [] + if request.extractCharacters: + analysis_requirements.append(""" +1. 人物分析: + - 识别当前片段中的所有出场人物 + - 统计每个人物的出场次数/对话次数 + - 分析人物关系(上下级、敌对、盟友等) + - 提取人物性格特点和说话风格 + - 按重要性排序输出""") + if request.extractOutline: + analysis_requirements.append(""" +2. 剧情大纲: + - 识别当前片段的剧情阶段 + - 提取关键转折点 + - 分析情节进展""") + + # 为每个片段创建分析任务 + # 自定义提示词 + custom_requirements = "" + if request.customPrompt: + custom_requirements = f"\n【用户自定义要求】\n{request.customPrompt}\n" + + async def analyze_segment(segment: Dict[str, Any]) -> Dict[str, Any]: + segment_index = segment["index"] + segment_content = segment["content"] + scene_marker = segment.get("scene_marker", "") + + prompt = f"""请分析以下剧本片段(片段 {segment_index + 1}/{len(segments)}),提取关键信息: + +{f"场景标记:{scene_marker}" if scene_marker else ""} +片段内容: +{segment_content} + +要求: +{''.join(analysis_requirements)} +3. 严格遵守上面【应用技能指导】中的分析要求 +4. 输出结构化的分析结果,便于后续合并{custom_requirements} + +请以结构化的格式输出。 +""" + + try: + response = await glm_client.chat( + messages=[ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": prompt} + ], + temperature=0.3 + ) + + return { + "segment_index": segment_index, + "scene_marker": scene_marker, + "analysis": response["choices"][0]["message"]["content"], + "usage": response.get("usage"), + "success": True + } + except Exception as e: + logger.error(f"分析片段 {segment_index} 失败: {str(e)}") + return { + "segment_index": segment_index, + "scene_marker": scene_marker, + "analysis": f"分析失败: {str(e)}", + "success": False + } + + # 并行执行所有片段分析(限制并发数为3) + semaphore = asyncio.Semaphore(3) + + async def analyze_with_semaphore(segment): + async with semaphore: + return await analyze_segment(segment) + + results = await asyncio.gather( + *[analyze_with_semaphore(seg) for seg in segments], + return_exceptions=True + ) + + # 过滤异常结果 + valid_results = [r for r in results if isinstance(r, dict)] + + logger.info(f"并行分析完成:{len(valid_results)}/{len(segments)} 个片段成功") + + return valid_results + + +async def _merge_analysis_results( + segment_analyses: List[Dict[str, Any]], + request: ParseScriptRequest, + glm_client, + skill_manager, + split_summary: Dict[str, Any] +) -> Dict[str, Any]: + """合并多个片段的分析结果""" + # 收集所有片段的分析 + all_analyses = [] + total_usage = {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0} + + for analysis in segment_analyses: + if analysis.get("success"): + all_analyses.append(f""" +--- +片段 {analysis['segment_index'] + 1}{f"({analysis['scene_marker']})" if analysis['scene_marker'] else ""}: +{analysis['analysis']} +--- +""") + + # 累计 token 使用量 + if analysis.get("usage"): + for key in ["prompt_tokens", "completion_tokens", "total_tokens"]: + total_usage[key] += analysis["usage"].get(key, 0) + + if not all_analyses: + return { + "success": False, + "method": "llm_batch", + "error": "所有片段分析均失败", + "summary": "分段分析失败" + } + + # 使用 LLM 合并和去重 + base_role = """你是专业的剧本分析专家,擅长整合和总结多源信息。 +你的任务是将多个片段的分析结果合并成一个完整的、去重的、结构化的分析报告。""" + system_prompt = await build_enhanced_system_prompt( + base_role=base_role, + skills=request.skills, + skill_manager=skill_manager + ) + + merge_requirements = [] + if request.extractCharacters: + merge_requirements.append(""" +1. 人物合并: + - 将各片段中的人物列表合并去重 + - 累加每个人物的出场次数 + - 整合人物关系分析 + - 输出完整的人物列表""") + if request.extractOutline: + merge_requirements.append(""" +2. 大纲合并: + - 将各片段的剧情大纲按时间/逻辑顺序整合 + - 形成完整的剧情结构 + - 标注关键转折点""") + + # 自定义提示词 + custom_requirements = "" + if request.customPrompt: + custom_requirements = f"\n【用户自定义要求】\n{request.customPrompt}\n" + + merge_prompt = f"""以下是将长剧本切分后各片段的分析结果: + +{''.join(all_analyses)} + +请合并以上分析,输出一个完整的、去重的分析报告。 + +要求: +{''.join(merge_requirements)} +3. 保持结构化输出格式 +4. 严格遵守上面【应用技能指导】中的分析要求{custom_requirements} + +请输出合并后的完整分析报告。 +""" + + logger.info("开始合并各片段分析结果...") + + try: + response = await glm_client.chat( + messages=[ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": merge_prompt} + ], + temperature=0.3 + ) + + merged_analysis = response["choices"][0]["message"]["content"] + + # 加上合并步骤的 token 使用量 + if response.get("usage"): + for key in ["prompt_tokens", "completion_tokens", "total_tokens"]: + total_usage[key] += response["usage"].get(key, 0) + + return { + "success": True, + "method": "llm_batch", + "analysis": merged_analysis, + "characters": [], + "outline": "", + "summary": f"分段分析完成:共 {len(segment_analyses)} 个片段,{split_summary.get('split_methods', {})}", + "split_info": split_summary, + "segment_count": len(segment_analyses), + "usage": total_usage + } + except Exception as e: + logger.error(f"合并分析结果失败: {str(e)}") + # 如果合并失败,返回原始的片段分析 + return { + "success": True, + "method": "llm_batch", + "analysis": "\n\n".join(all_analyses), + "characters": [], + "outline": "", + "summary": f"分段分析完成(合并失败,返回原始分析):共 {len(segment_analyses)} 个片段", + "split_info": split_summary, + "segment_count": len(segment_analyses), + "usage": total_usage, + "merge_error": str(e) + } + + async def _parse_script_with_regex(request: ParseScriptRequest) -> Dict[str, Any]: """ 使用正则表达式快速提取剧本信息(不使用 LLM,不消耗 token) diff --git a/backend/app/api/v1/ai_async.py b/backend/app/api/v1/ai_async.py new file mode 100644 index 0000000..8b1d45b --- /dev/null +++ b/backend/app/api/v1/ai_async.py @@ -0,0 +1,429 @@ +""" +异步 AI 辅助生成 API + +将原有的同步 AI 生成改为异步任务模式 +""" +from fastapi import APIRouter, HTTPException, Depends, BackgroundTasks +from typing import Dict, Any, Optional, List +from pydantic import BaseModel +import asyncio + +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.api.v1.ai_assistant import build_enhanced_system_prompt +from app.utils.logger import get_logger + +logger = get_logger(__name__) + +router = APIRouter(prefix="/ai-assistant/async", tags=["AI 异步生成"]) + + +# ============================================================================ +# 请求模型 +# ============================================================================ + +class SkillInfo(BaseModel): + """Skill 信息(由前端传递)""" + id: str + name: str + behavior: str # behavior_guide 内容 + + +# ============================================================================ +# 任务执行器 +# ============================================================================ + +async def execute_generate_characters( + task_manager: TaskManager, + task_id: str, + params: Dict[str, Any] +) -> Dict[str, Any]: + """执行人物生成任务""" + try: + glm_client = get_glm_client() + skill_manager = get_skill_manager() + + # 更新进度 + task_manager.update_task_progress( + task_id, 10, 100, + "正在构建提示词..." + ) + + # 构建系统提示词 + base_role = "你是专业的剧集创作专家,擅长创作丰富立体的人物角色。" + skills = params.get("skills", []) + system_prompt = await build_enhanced_system_prompt( + base_role=base_role, + skills=skills, + skill_manager=skill_manager + ) + + # 构建用户提示 + extra_info = "" + if params.get("projectName"): + extra_info += f"\n项目名称:{params['projectName']}" + if params.get("totalEpisodes"): + extra_info += f"\n总集数:{params['totalEpisodes']}" + + custom_requirements = "" + if params.get("customPrompt"): + custom_requirements = f"\n【用户自定义要求】\n{params['customPrompt']}\n" + + prompt = f"""请根据以下想法生成 3-5 个主要人物设定: + +用户想法:{params['idea']}{extra_info}{custom_requirements} + +要求: +1. 每个人物包含:姓名、身份、性格、说话风格、背景故事 +2. 人物之间要有关系冲突 +3. 每个人物 50-100 字 +4. 格式:姓名:身份 - 性格 - 说话风格 - 背景故事 +5. 严格遵守上面【应用技能指导】中的要求 + +请按以下格式输出: +【人物1】 +姓名:xxx +身份:xxx +性格:xxx +说话风格:xxx +背景故事:xxx +【人物2】 +... +""" + + task_manager.update_task_progress( + task_id, 30, 100, + "正在调用 AI 生成..." + ) + + response = await glm_client.chat( + messages=[ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": prompt} + ], + temperature=0.7 + ) + + task_manager.update_task_progress( + task_id, 90, 100, + "正在处理结果..." + ) + + content = response["choices"][0]["message"]["content"] + + task_manager.update_task_progress( + task_id, 100, 100, + "生成完成!" + ) + + return { + "success": True, + "characters": content, + "usage": response.get("usage") + } + + except Exception as e: + logger.error(f"人物生成失败: {str(e)}") + raise + + +async def execute_generate_outline( + task_manager: TaskManager, + task_id: str, + params: Dict[str, Any] +) -> Dict[str, Any]: + """执行大纲生成任务""" + try: + glm_client = get_glm_client() + skill_manager = get_skill_manager() + + task_manager.update_task_progress( + task_id, 10, 100, + "正在构建提示词..." + ) + + base_role = "你是专业的剧集创作专家,擅长构建引人入胜的剧情结构和故事节奏。" + skills = params.get("skills", []) + system_prompt = await build_enhanced_system_prompt( + base_role=base_role, + skills=skills, + skill_manager=skill_manager + ) + + custom_requirements = "" + if params.get("customPrompt"): + custom_requirements = f"\n【用户自定义要求】\n{params['customPrompt']}\n" + + prompt = f"""请根据以下想法生成完整的剧集大纲: + +用户想法:{params['idea']} +总集数:{params['totalEpisodes']} +类型:{params['genre']} +{f"项目名称:{params['projectName']}" if params.get('projectName') else ''}{custom_requirements} + +要求: +1. 将故事分为 4-5 个阶段 +2. 每个阶段包含具体的集数范围 +3. 标注每个阶段的关键事件和转折点 +4. 字数 200-400 字 +5. 严格遵守上面【应用技能指导】中的要求 + +请按以下格式输出: +【阶段1】EPxx-EPxx:阶段名称 +内容概要... + +【阶段2】EPxx-EPxx:阶段名称 +... +""" + + task_manager.update_task_progress( + task_id, 30, 100, + "正在调用 AI 生成..." + ) + + response = await glm_client.chat( + messages=[ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": prompt} + ], + temperature=0.7 + ) + + task_manager.update_task_progress( + task_id, 90, 100, + "正在处理结果..." + ) + + content = response["choices"][0]["message"]["content"] + + task_manager.update_task_progress( + task_id, 100, 100, + "生成完成!" + ) + + return { + "success": True, + "outline": content, + "usage": response.get("usage") + } + + except Exception as e: + logger.error(f"大纲生成失败: {str(e)}") + raise + + +async def execute_generate_world( + task_manager: TaskManager, + task_id: str, + params: Dict[str, Any] +) -> Dict[str, Any]: + """执行世界观生成任务""" + try: + glm_client = get_glm_client() + skill_manager = get_skill_manager() + + task_manager.update_task_progress( + task_id, 10, 100, + "正在构建提示词..." + ) + + base_role = "你是专业的世界观设定专家,擅长构建架空世界的背景设定。" + skills = params.get("skills", []) + system_prompt = await build_enhanced_system_prompt( + base_role=base_role, + skills=skills, + skill_manager=skill_manager + ) + + custom_requirements = "" + if params.get("customPrompt"): + custom_requirements = f"\n【用户自定义要求】\n{params['customPrompt']}\n" + + prompt = f"""请根据以下想法生成世界观设定: + +用户想法:{params['idea']} +类型:{params['genre']} +{f"项目名称:{params['projectName']}" if params.get('projectName') else ''}{custom_requirements} + +要求: +1. 描述时代背景(朝代、架空世界等) +2. 描述地理环境和主要场景 +3. 描述社会结构(权力体系、阶级关系) +4. 描述文化特色(习俗、服饰、语言等) +5. 字数 200-500 字 +6. 严格遵守上面【应用技能指导】中的要求 + +请输出详细的世界观设定: +""" + + task_manager.update_task_progress( + task_id, 30, 100, + "正在调用 AI 生成..." + ) + + response = await glm_client.chat( + messages=[ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": prompt} + ], + temperature=0.7 + ) + + task_manager.update_task_progress( + task_id, 90, 100, + "正在处理结果..." + ) + + content = response["choices"][0]["message"]["content"] + + task_manager.update_task_progress( + task_id, 100, 100, + "生成完成!" + ) + + return { + "success": True, + "worldSetting": content, + "usage": response.get("usage") + } + + except Exception as e: + logger.error(f"世界观生成失败: {str(e)}") + raise + + +# ============================================================================ +# 异步任务创建端点 +# ============================================================================ + +class GenerateCharactersRequest(BaseModel): + """生成人物设定请求(异步)""" + idea: str + projectName: Optional[str] = None + totalEpisodes: Optional[int] = None + skills: Optional[List[SkillInfo]] = None + customPrompt: Optional[str] = None + projectId: Optional[str] = None # 关联项目ID + + +class GenerateOutlineRequest(BaseModel): + """生成大纲请求(异步)""" + idea: str + totalEpisodes: int = 30 + genre: str = "古风" + projectName: Optional[str] = None + skills: Optional[List[SkillInfo]] = None + customPrompt: Optional[str] = None + projectId: Optional[str] = None + + +class GenerateWorldRequest(BaseModel): + """生成世界观请求(异步)""" + idea: str + projectName: Optional[str] = None + genre: Optional[str] = "古风" + skills: Optional[List[SkillInfo]] = None + customPrompt: Optional[str] = None + projectId: Optional[str] = None + + +@router.post("/generate/characters") +async def generate_characters_async( + request: GenerateCharactersRequest, + background_tasks: BackgroundTasks, + task_manager: TaskManager = Depends(get_task_manager) +): + """ + 异步生成人物设定 + + 返回任务ID,需要通过轮询 /tasks/{task_id} 获取结果 + """ + # 创建任务 + task = task_manager.create_task( + task_type=TaskType.GENERATE_CHARACTERS, + params=request.dict(exclude={"projectId"}), + project_id=request.projectId + ) + + # 在后台执行 + async def run_task(): + await task_manager.execute_task_async( + task.id, + lambda p: execute_generate_characters(task_manager, task.id, p) + ) + + asyncio.create_task(run_task()) + + return { + "success": True, + "taskId": task.id, + "message": "任务已创建,正在后台执行" + } + + +@router.post("/generate/outline") +async def generate_outline_async( + request: GenerateOutlineRequest, + task_manager: TaskManager = Depends(get_task_manager) +): + """ + 异步生成大纲 + + 返回任务ID,需要通过轮询 /tasks/{task_id} 获取结果 + """ + # 创建任务 + task = task_manager.create_task( + task_type=TaskType.GENERATE_OUTLINE, + params=request.dict(exclude={"projectId"}), + project_id=request.projectId + ) + + # 在后台执行 + async def run_task(): + await task_manager.execute_task_async( + task.id, + lambda p: execute_generate_outline(task_manager, task.id, p) + ) + + asyncio.create_task(run_task()) + + return { + "success": True, + "taskId": task.id, + "message": "任务已创建,正在后台执行" + } + + +@router.post("/generate/world") +async def generate_world_async( + request: GenerateWorldRequest, + task_manager: TaskManager = Depends(get_task_manager) +): + """ + 异步生成世界观设定 + + 返回任务ID,需要通过轮询 /tasks/{task_id} 获取结果 + """ + # 创建任务 + task = task_manager.create_task( + task_type=TaskType.GENERATE_WORLD, + params=request.dict(exclude={"projectId"}), + project_id=request.projectId + ) + + # 在后台执行 + async def run_task(): + await task_manager.execute_task_async( + task.id, + lambda p: execute_generate_world(task_manager, task.id, p) + ) + + asyncio.create_task(run_task()) + + return { + "success": True, + "taskId": task.id, + "message": "任务已创建,正在后台执行" + } diff --git a/backend/app/api/v1/async_tasks.py b/backend/app/api/v1/async_tasks.py new file mode 100644 index 0000000..2fed20b --- /dev/null +++ b/backend/app/api/v1/async_tasks.py @@ -0,0 +1,185 @@ +""" +异步任务 API 路由 + +提供异步任务的创建、查询、取消等操作 +""" +from fastapi import APIRouter, Depends, HTTPException, status, BackgroundTasks +from typing import List, Optional, Dict, Any + +from app.models.task import ( + AsyncTask, + TaskCreateRequest, + TaskResponse, + TaskType, + TaskStatus +) +from app.core.task_manager import get_task_manager, TaskManager +from app.utils.logger import get_logger + +logger = get_logger(__name__) + +router = APIRouter(prefix="/tasks", tags=["异步任务"]) + + +@router.post("/", response_model=TaskResponse, status_code=status.HTTP_201_CREATED) +async def create_task( + request: TaskCreateRequest, + task_manager: TaskManager = Depends(get_task_manager) +): + """ + 创建新的异步任务 + + 创建后任务会立即返回任务ID,需要通过轮询 /tasks/{task_id} 来获取任务状态和结果 + """ + task = task_manager.create_task( + task_type=request.type, + params=request.params, + project_id=request.project_id + ) + + return TaskResponse( + id=task.id, + type=task.type, + status=task.status, + progress=task.progress, + result=task.result, + error=task.error, + project_id=task.project_id, + created_at=task.created_at, + updated_at=task.updated_at + ) + + +@router.get("/{task_id}", response_model=TaskResponse) +async def get_task( + task_id: str, + task_manager: TaskManager = Depends(get_task_manager) +): + """获取任务详情""" + task = task_manager.get_task(task_id) + if not task: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"任务不存在: {task_id}" + ) + + return TaskResponse( + id=task.id, + type=task.type, + status=task.status, + progress=task.progress, + result=task.result, + error=task.error, + project_id=task.project_id, + created_at=task.created_at, + updated_at=task.updated_at + ) + + +@router.get("/", response_model=List[TaskResponse]) +async def list_tasks( + task_type: Optional[TaskType] = None, + project_id: Optional[str] = None, + status: Optional[TaskStatus] = None, + task_manager: TaskManager = Depends(get_task_manager) +): + """ + 列出任务 + + 支持按类型、项目ID、状态筛选 + """ + tasks = [] + + if task_type: + tasks = task_manager.get_tasks_by_type(task_type) + elif project_id: + tasks = task_manager.get_tasks_by_project(project_id) + else: + # 返回所有任务(最多100个) + tasks = list(task_manager._tasks.values())[:100] + + # 状态筛选 + if status: + tasks = [t for t in tasks if t.status == status] + + return [ + TaskResponse( + id=t.id, + type=t.type, + status=t.status, + progress=t.progress, + result=t.result, + error=t.error, + project_id=t.project_id, + created_at=t.created_at, + updated_at=t.updated_at + ) + for t in tasks + ] + + +@router.post("/{task_id}/cancel", response_model=TaskResponse) +async def cancel_task( + task_id: str, + task_manager: TaskManager = Depends(get_task_manager) +): + """取消任务""" + success = task_manager.cancel_task(task_id) + if not success: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"任务不存在: {task_id}" + ) + + task = task_manager.get_task(task_id) + return TaskResponse( + id=task.id, + type=task.type, + status=task.status, + progress=task.progress, + result=task.result, + error=task.error, + project_id=task.project_id, + created_at=task.created_at, + updated_at=task.updated_at + ) + + +@router.delete("/{task_id}", status_code=status.HTTP_204_NO_CONTENT) +async def delete_task( + task_id: str, + task_manager: TaskManager = Depends(get_task_manager) +): + """删除任务""" + success = task_manager.delete_task(task_id) + if not success: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"任务不存在: {task_id}" + ) + + return None + + +@router.get("/project/{project_id}", response_model=List[TaskResponse]) +async def get_project_tasks( + project_id: str, + task_manager: TaskManager = Depends(get_task_manager) +): + """获取项目的所有任务""" + tasks = task_manager.get_tasks_by_project(project_id) + + return [ + TaskResponse( + id=t.id, + type=t.type, + status=t.status, + progress=t.progress, + result=t.result, + error=t.error, + project_id=t.project_id, + created_at=t.created_at, + updated_at=t.updated_at + ) + for t in tasks + ] diff --git a/backend/app/api/v1/skills.py b/backend/app/api/v1/skills.py index 5f26a8a..2fd5419 100644 --- a/backend/app/api/v1/skills.py +++ b/backend/app/api/v1/skills.py @@ -7,7 +7,9 @@ Skill 管理 API 路由 3. AI 辅助 Skill 创建(完整流程) 4. Skill 选择和路由(LLM 与 Skills 结合) 5. Agent 工作流配置 +6. 文档驱动的 Skill 生成(新增) """ +from datetime import datetime from fastapi import APIRouter, Depends, HTTPException, status from typing import List, Optional, Dict, Any from pydantic import BaseModel @@ -21,6 +23,16 @@ from app.models.skill import ( SkillTestResponse, SkillGenerateRequest, SkillGenerateResponse, + # 新增模型 + DocFetchRequest, + GitHubDocFetchRequest, + SkillGenerateFromDocsRequest, + SkillGenerateFromDocsResponse, + SkillPreviewRequest, + SkillPreviewResponse, + SkillSaveFromPreviewRequest, + SkillRefineRequest, + SkillRefineResponse, ) from app.models.skill_integration import ( SkillMetadata, @@ -89,9 +101,40 @@ async def create_skill( skill_data: SkillCreate, skill_manager: SkillManager = Depends(get_skill_manager) ): - """创建新的用户 Skill""" + """ + 创建新的用户 Skill + + 如果不提供 ID,系统将自动生成: + - 使用 UUID 作为唯一标识 + - 基于名称生成可读 ID(kebab-case) + """ try: - skill = await skill_manager.create_user_skill(skill_data) + # 如果没有提供 ID,自动生成 + if not skill_data.id: + import uuid + import re + + # 生成基于名称的 kebab-case ID + base_id = skill_data.name.lower() + base_id = re.sub(r'[^\w\s-]', '', base_id) # 移除特殊字符 + base_id = re.sub(r'\s+', '-', base_id) # 空格替换为连字符 + base_id = re.sub(r'-{2,}', '-', base_id) # 多个连字符合并为一个 + + # 如果生成结果为空,使用名称的拼音或默认前缀 + if not base_id or len(base_id) < 3: + base_id = f"skill-{uuid.uuid4().hex[:8]}" + + # 确保 ID 唯一(添加随机后缀) + unique_id = f"{base_id}-{uuid.uuid4().hex[:6]}" + + # 创建新的 SkillCreate 对象,使用自动生成的 ID + skill_data_with_id = skill_data.copy() + skill_data_with_id.id = unique_id + + skill = await skill_manager.create_user_skill(skill_data_with_id) + else: + skill = await skill_manager.create_user_skill(skill_data) + return skill except Exception as e: logger.error(f"创建 Skill 失败: {str(e)}") @@ -829,3 +872,614 @@ async def execute_tool( """ result = await skill_manager.route_and_execute_tool(tool_name, parameters) return result + + +# ============================================================================ +# 增强的 Skill 生成工作流(文档驱动) +# ============================================================================ + +import uuid +from collections import defaultdict +from app.core.documentation_fetcher import get_documentation_fetcher, DocumentationFetcher + +# 预览存储(生产环境应使用 Redis 或数据库) +_preview_storage: Dict[str, Dict[str, Any]] = defaultdict(dict) +_preview_references: Dict[str, Dict[str, str]] = defaultdict(dict) + + +@router.post("/fetch-doc", response_model=dict) +async def fetch_documentation( + request: DocFetchRequest, + doc_fetcher: DocumentationFetcher = Depends(get_documentation_fetcher) +): + """ + 获取文档内容(用于预览或生成 Skill) + + Args: + request: 包含文档 URL 和可选的 CSS 选择器 + + Returns: + 获取的文档内容 + """ + try: + result = await doc_fetcher.fetch_from_url(request.url, request.selector) + + if result.get("success"): + return { + "success": True, + "title": result.get("title"), + "content": result.get("content"), + "word_count": result.get("word_count"), + "char_count": result.get("char_count"), + "url": result.get("url") + } + else: + return { + "success": False, + "error": result.get("error") + } + + except Exception as e: + logger.error(f"获取文档失败: {str(e)}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"获取文档失败: {str(e)}" + ) + + +@router.post("/fetch-github-doc", response_model=dict) +async def fetch_github_documentation( + request: GitHubDocFetchRequest, + doc_fetcher: DocumentationFetcher = Depends(get_documentation_fetcher) +): + """ + 获取 GitHub 文档内容 + + Args: + request: GitHub 仓库和文档路径 + + Returns: + 获取的文档内容 + """ + try: + result = await doc_fetcher.fetch_from_github( + repo_url=request.repo_url, + docs_path=request.docs_path + ) + + if result.get("success"): + return { + "success": True, + "repo_url": result.get("repo_url"), + "docs_path": result.get("docs_path"), + "content": result.get("content"), + "word_count": result.get("word_count"), + "char_count": result.get("char_count") + } + else: + return { + "success": False, + "error": result.get("error") + } + + except Exception as e: + logger.error(f"获取 GitHub 文档失败: {str(e)}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"获取 GitHub 文档失败: {str(e)}" + ) + + +@router.post("/generate-from-docs", response_model=SkillGenerateFromDocsResponse) +async def generate_skill_from_documentation( + request: SkillGenerateFromDocsRequest, + skill_manager: SkillManager = Depends(get_skill_manager), + glm_client: GLMClient = Depends(get_glm_client), + doc_fetcher: DocumentationFetcher = Depends(get_documentation_fetcher) +): + """ + 基于文档生成 Skill(新工作流) + + 完整流程: + 1. 获取文档内容(URL/GitHub/上传) + 2. 提取关键信息 + 3. 使用 LLM 生成 SKILL.md + 4. 生成预览 ID + 5. 返回可预览的内容 + + 用户可以: + - 预览生成的内容 + - 手动编辑 + - 用 AI 调整 + - 确认后保存到系统 + """ + try: + logger.info(f"开始基于文档生成 Skill: {request.skill_name}") + + # 1. 收集所有文档内容 + all_docs = [] + doc_summary_parts = [] + + # 从 URL 获取 + if request.doc_urls: + for url in request.doc_urls: + result = await doc_fetcher.fetch_from_url(url) + if result.get("success"): + all_docs.append({ + "source": f"URL: {url}", + "title": result.get("title"), + "content": result.get("content") + }) + doc_summary_parts.append(f"- {result.get('title')} ({url})") + + # 从 GitHub 获取 + if request.github_repos: + for github_req in request.github_repos: + result = await doc_fetcher.fetch_from_github( + repo_url=github_req.repo_url, + docs_path=github_req.docs_path + ) + if result.get("success"): + all_docs.append({ + "source": f"GitHub: {github_req.repo_url}/{github_req.docs_path}", + "title": github_req.docs_path, + "content": result.get("content") + }) + doc_summary_parts.append(f"- {github_req.repo_url}/{github_req.docs_path}") + + # 用户上传的 references + if request.uploaded_references: + for filename, content in request.uploaded_references.items(): + all_docs.append({ + "source": f"上传: {filename}", + "title": filename, + "content": content + }) + doc_summary_parts.append(f"- {filename} (用户上传)") + + # 2. 构建 LLM 提示词 + doc_context = "" + if all_docs: + # 取文档摘要(前 500 字) + doc_summary = "\n".join(doc_summary_parts) + + # 构建文档上下文(避免超过上下文窗口) + doc_context_parts = [] + for doc in all_docs[:3]: # 最多 3 个文档 + content_preview = doc["content"][:500].replace("\n", " ") + doc_context_parts.append(f"#### {doc['title']}\n{content_preview}...") + + doc_context = f""" +**文档来源**: +{doc_summary} + +**文档内容摘要**: +{''.join(doc_context_parts)} +""" + + # 3. 加载 skill-creator 标准 + skill_creator = await skill_manager.load_skill("skill-creator") + creator_guide = skill_creator.behavior_guide if skill_creator else "" + + # 4. 构建 LLM 提示词 + system_prompt = f"""你是一个专业的 Skill 创建专家。 + +{creator_guide if creator_guide else ''} + +你的任务是基于提供的文档内容,创建一个高质量的 Skill。 + +**重要要求**: +1. SKILL.md 必须以 YAML frontmatter 开始 +2. YAML 必须包含:name, description, category, tags +3. description 应该清晰说明此 Skill 的用途和使用场景 +4. 行为指导部分应该简洁、具体,从文档中提取关键信息 +5. 如果文档包含代码示例、API、配置等,应该在行为指导中体现 +6. 使用 markdown 格式 + +请以 JSON 格式返回结果,包含以下字段: +- skill_content: 完整的 SKILL.md 内容(以 --- 开头的 YAML frontmatter + markdown 内容) +- explanation: 对生成的 Skill 的简要说明(中文) +- suggested_category: 建议的分类 +- suggested_tags: 建议的标签数组""" + + user_prompt = f"""请基于以下文档创建一个 Skill: + +**Skill 名称**:{request.skill_name} +**描述**:{request.description or f'基于文档生成的 Skill:{request.skill_name}'} +**指定分类**:{request.category or '自动推断'} +**指定标签**:{', '.join(request.tags) if request.tags else '自动推断'} + +{doc_context if request.include_doc_summary else ''} + +请生成完整的 SKILL.md:""" + + # 5. 调用 LLM 生成 + response = await glm_client.chat( + messages=[ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": user_prompt} + ], + temperature=request.temperature + ) + + response_text = response["choices"][0]["message"]["content"] + + # 6. 解析 JSON 响应 + import json + import re + + json_match = re.search(r'\{[\s\S]*\}', response_text) + if json_match: + try: + result = json.loads(json_match.group()) + skill_content = result.get("skill_content", response_text) + explanation = result.get("explanation", "AI 生成的 Skill") + suggested_category = result.get("suggested_category", request.category or "通用") + suggested_tags = result.get("suggested_tags", request.tags or ["自定义"]) + except json.JSONDecodeError: + skill_content = response_text + explanation = "AI 生成的 Skill" + suggested_category = request.category or "通用" + suggested_tags = request.tags or ["自定义"] + else: + skill_content = response_text + explanation = "AI 生成的 Skill" + suggested_category = request.category or "通用" + suggested_tags = request.tags or ["自定义"] + + # 7. 生成预览 ID + preview_id = str(uuid.uuid4()) + + # 8. 存储预览数据 + _preview_storage[preview_id] = { + "skill_name": request.skill_name, + "skill_content": skill_content, + "category": suggested_category, + "tags": suggested_tags, + "doc_urls": request.doc_urls, + "github_repos": [r.model_dump() for r in request.github_repos], + "uploaded_references": request.uploaded_references, + "explanation": explanation, + "created_at": str(datetime.now()) + } + + # 9. 存储 references(后续保存时使用) + references = {} + for i, doc in enumerate(all_docs): + filename = f"{doc['title'].replace('/', '-').replace(' ', '_')}.md" + # 清理文件名 + filename = ''.join(c for c in filename if c.isalnum() or c in '._-') + references[filename] = doc["content"] + + _preview_references[preview_id] = references + + # 10. 生成文档摘要 + doc_summary = "\n".join(doc_summary_parts) if doc_summary_parts else "无文档来源" + + return SkillGenerateFromDocsResponse( + success=True, + preview_id=preview_id, + skill_content=skill_content, + skill_name=request.skill_name, + suggested_id=request.skill_name.lower().replace(" ", "-").replace("_", "-"), + category=suggested_category, + tags=suggested_tags, + doc_summary=doc_summary, + references_count=len(references), + explanation=explanation + ) + + except HTTPException: + raise + except Exception as e: + logger.error(f"基于文档生成 Skill 失败: {str(e)}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"生成失败: {str(e)}" + ) + + +@router.post("/preview", response_model=SkillPreviewResponse) +async def preview_skill( + request: SkillPreviewRequest +): + """ + 预览 Skill(验证和解析) + + 返回: + - 解析后的元数据 + - 验证警告 + - 字数统计 + - 预估 token 数 + """ + try: + # 解析 YAML frontmatter + metadata = {} + content = request.skill_content + + import yaml + yaml_match = re.match(r'^---\n(.*?)\n---', content, re.DOTALL) + if yaml_match: + try: + metadata = yaml.safe_load(yaml_match.group(1)) or {} + except: + pass + + # 验证警告 + warnings = [] + + if not metadata.get("name"): + warnings.append("缺少 name 字段") + + if not metadata.get("description"): + warnings.append("缺少 description 字段") + + if len(content) < 100: + warnings.append("内容过短,可能不够详细") + + if len(content) > 50000: + warnings.append("内容过长,可能占用过多上下文") + + # 统计 + word_count = len(content.split()) + estimated_tokens = int(len(content) * 1.3) # 粗略估计 + + # 生成预览 ID + preview_id = str(uuid.uuid4()) + _preview_storage[preview_id] = { + "skill_name": request.skill_name, + "skill_content": content, + "category": request.category, + "tags": request.tags, + "metadata": metadata, + "warnings": warnings, + "created_at": str(datetime.now()) + } + + return SkillPreviewResponse( + preview_id=preview_id, + skill_content=content, + parsed_metadata=metadata, + validation_warnings=warnings, + word_count=word_count, + estimated_tokens=estimated_tokens + ) + + except Exception as e: + logger.error(f"预览 Skill 失败: {str(e)}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"预览失败: {str(e)}" + ) + + +@router.post("/refine", response_model=SkillRefineResponse) +async def refine_skill_with_ai( + request: SkillRefineRequest, + glm_client: GLMClient = Depends(get_glm_client) +): + """ + 使用 AI 调整/优化 Skill + + 用户可以输入自然语言提示词,让 AI 修改 Skill 内容。 + + 示例提示词: + - "把行为指导改得更简洁" + - "增加一个快速开始部分" + - "添加更多代码示例" + - "优化描述,让它更清晰" + """ + try: + logger.info(f"AI 调整 Skill,提示词: {request.refinement_prompt[:50]}...") + + prompt = f"""请根据以下要求调整 SKILL.md 内容: + +**调整要求**: +{request.refinement_prompt} + +**原始 SKILL.md**: +{request.skill_content} + +请返回完整的调整后的 SKILL.md 内容(不要包含其他解释):""" + + response = await glm_client.chat( + messages=[{"role": "user", "content": prompt}], + temperature=request.temperature + ) + + refined_content = response["choices"][0]["message"]["content"].strip() + + # 清理可能的 markdown 代码块标记 + if refined_content.startswith("```"): + lines = refined_content.split("\n") + refined_content = "\n".join(lines[1:]) + if refined_content.endswith("```"): + refined_content = "\n".join(refined_content.split("\n")[:-1]) + + # 生成变更摘要 + changes_summary = f"根据提示词「{request.refinement_prompt}」进行了调整" + + return SkillRefineResponse( + success=True, + refined_content=refined_content.strip(), + changes_summary=changes_summary, + original_length=len(request.skill_content), + new_length=len(refined_content.strip()) + ) + + except Exception as e: + logger.error(f"AI 调整失败: {str(e)}") + return SkillRefineResponse( + success=False, + refined_content=request.skill_content, + changes_summary=f"调整失败: {str(e)}", + original_length=len(request.skill_content), + new_length=len(request.skill_content) + ) + + +@router.post("/save-from-preview", response_model=Skill) +async def save_skill_from_preview( + request: SkillSaveFromPreviewRequest, + skill_manager: SkillManager = Depends(get_skill_manager) +): + """ + 从预览保存 Skill 到系统 + + 保存流程: + 1. 创建 Skill 目录结构 + 2. 保存 SKILL.md + 3. 保存 references(如果有) + 4. 注册到 SkillManager + """ + try: + logger.info(f"从预览保存 Skill: {request.skill_id}") + + # 1. 创建 Skill + skill_data = SkillCreate( + id=request.skill_id, + name=request.skill_id.replace("-", " ").title(), + content=request.skill_content, + category="通用", # 从 YAML frontmatter 解析 + tags=[] # 从 YAML frontmatter 解析 + ) + + skill = await skill_manager.create_user_skill(skill_data) + + # 2. 保存 references(如果有) + if request.references: + skill_path = skill_manager._find_skill_path(request.skill_id) + if skill_path: + refs_dir = skill_path.parent / "references" + refs_dir.mkdir(exist_ok=True) + + for filename, content in request.references.items(): + # 确保文件名安全 + safe_filename = filename.replace("..", "").replace("/", "").replace("\\", "") + if not safe_filename.endswith(".md"): + safe_filename += ".md" + + ref_file = refs_dir / safe_filename + ref_file.write_text(content, encoding="utf-8") + logger.info(f"保存 reference: {request.skill_id}/references/{safe_filename}") + + # 3. 清理预览数据 + if request.preview_id in _preview_storage: + del _preview_storage[request.preview_id] + if request.preview_id in _preview_references: + del _preview_references[request.preview_id] + + return skill + + except Exception as e: + logger.error(f"保存 Skill 失败: {str(e)}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"保存失败: {str(e)}" + ) + + +@router.get("/preview/{preview_id}", response_model=dict) +async def get_preview(preview_id: str): + """ + 获取预览数据 + """ + if preview_id not in _preview_storage: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"预览不存在: {preview_id}" + ) + + preview_data = _preview_storage[preview_id] + references = _preview_references.get(preview_id, {}) + + return { + "preview": preview_data, + "references": references, + "references_count": len(references) + } + + +@router.delete("/preview/{preview_id}", status_code=status.HTTP_204_NO_CONTENT) +async def delete_preview(preview_id: str): + """ + 删除预览数据 + """ + if preview_id in _preview_storage: + del _preview_storage[preview_id] + if preview_id in _preview_references: + del _preview_references[preview_id] + + return None + + +@router.put("/{skill_id}/with-references", response_model=Skill) +async def update_skill_with_references( + skill_id: str, + request: SkillSaveFromPreviewRequest, + skill_manager: SkillManager = Depends(get_skill_manager) +): + """ + 更新 Skill 及其 References + + 用于编辑模式,同时更新 SKILL.md 和 references/ 目录中的文件 + """ + try: + logger.info(f"更新 Skill 及 references: {skill_id}") + + # 1. 更新 SKILL.md + skill_update = SkillUpdate(content=request.skill_content) + skill = await skill_manager.update_user_skill(skill_id, skill_update) + + if not skill: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Skill 不存在: {skill_id}" + ) + + # 2. 更新 references(如果有) + if request.references is not None: # 允许传入空字典清空references + skill_path = skill_manager._find_skill_path(skill_id) + if skill_path: + refs_dir = skill_path.parent / "references" + + # 如果传入空字典,删除整个references目录 + if len(request.references) == 0: + if refs_dir.exists(): + import shutil + shutil.rmtree(refs_dir) + logger.info(f"清空 references: {skill_id}/references/") + else: + # 确保目录存在 + refs_dir.mkdir(exist_ok=True) + + # 清空现有文件 + for existing_file in refs_dir.iterdir(): + if existing_file.is_file(): + existing_file.unlink() + + # 写入新文件 + for filename, content in request.references.items(): + safe_filename = filename.replace("..", "").replace("/", "").replace("\\", "") + if not safe_filename.endswith(".md"): + safe_filename += ".md" + + ref_file = refs_dir / safe_filename + ref_file.write_text(content, encoding="utf-8") + logger.info(f"更新 reference: {skill_id}/references/{safe_filename}") + + # 清除缓存 + if skill_id in skill_manager._cache: + del skill_manager._cache[skill_id] + + # 重新加载并返回 + return await skill_manager.load_skill(skill_id) + + except HTTPException: + raise + except Exception as e: + logger.error(f"更新 Skill 失败: {str(e)}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"更新失败: {str(e)}" + ) diff --git a/backend/app/api/v1/skills_async.py b/backend/app/api/v1/skills_async.py new file mode 100644 index 0000000..f027cec --- /dev/null +++ b/backend/app/api/v1/skills_async.py @@ -0,0 +1,226 @@ +""" +异步 Skill 生成 API + +将同步的 Skill 生成改为异步任务模式 +""" +from fastapi import APIRouter, HTTPException, Depends, BackgroundTasks +from typing import Dict, Any, Optional, List +from pydantic import BaseModel +import asyncio +import json +import re + +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.utils.logger import get_logger + +logger = get_logger(__name__) + +router = APIRouter(prefix="/skills/async", tags=["Skill 异步生成"]) + + +# ============================================================================ +# 请求模型 +# ============================================================================ + +class GenerateSkillRequest(BaseModel): + """生成 Skill 请求(异步)""" + description: str # 用户需求描述 + category: Optional[str] = None + tags: Optional[List[str]] = None + temperature: float = 0.7 + + +# ============================================================================ +# 任务执行器 +# ============================================================================ + +async def execute_generate_skill( + task_manager: TaskManager, + task_id: str, + params: Dict[str, Any] +) -> Dict[str, Any]: + """执行 Skill 生成任务""" + try: + glm_client = get_glm_client() + skill_manager = get_skill_manager() + + # 更新进度 + task_manager.update_task_progress( + task_id, 10, 100, + "正在加载 skill-creator 标准..." + ) + + # 1. 加载 skill-creator 的行为指导 + skill_creator = await skill_manager.load_skill("skill-creator") + if not skill_creator: + raise Exception("skill-creator 未找到,请确保内置 Skills 正确安装") + + task_manager.update_task_progress( + task_id, 30, 100, + "正在构建提示词..." + ) + + # 2. 构建提示词 + user_requirements = f"""用户想要创建的 Skill 描述: +{params['description']} +""" + if params.get('category'): + user_requirements += f"\n指定分类:{params['category']}" + + system_prompt = f"""你是一个专业的 Skill 创建专家。 + +以下是 skill-creator 的行为指导(关于如何创建有效 Skill 的指南): +{'━' * 60} +{skill_creator.behavior_guide} +{'━' * 60} + +你的任务是根据用户的需求,创建一个符合上述标准的 Skill。 + +**重要要求**: +1. SKILL.md 必须以 YAML frontmatter 开始,包含 name 和 description 字段 +2. description 应该清晰说明此 Skill 的用途和使用场景 +3. 行为指导部分应该简洁、具体,避免冗余的解释 +4. 使用 markdown 格式 +5. 返回完整的 SKILL.md 内容 + +请以 JSON 格式返回结果,包含以下字段: +- suggested_id: 建议 Skill ID(kebab-case,如 dialogue-writer-ancient) +- suggested_name: 建议 Skill 名称(简短中文) +- skill_content: 完整的 SKILL.md 内容 +- category: 分类(如"编剧"、"审核"、"通用"等) +- suggested_tags: 建议标签数组 +- explanation: 对生成的 Skill 的简要说明(中文) +""" + + task_manager.update_task_progress( + task_id, 50, 100, + "正在调用 AI 生成..." + ) + + # 3. 调用 GLM 生成 + response = await glm_client.chat( + messages=[ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": user_requirements} + ], + temperature=params.get('temperature', 0.7) + ) + + task_manager.update_task_progress( + task_id, 90, 100, + "正在处理结果..." + ) + + # 4. 解析响应 + import json + import re + + response_text = response["choices"][0]["message"]["content"] + json_match = re.search(r'\{[\s\S]*\}', response_text) + + if json_match: + result = json.loads(json_match.group()) + else: + result = { + "suggested_id": "custom-skill", + "suggested_name": "自定义 Skill", + "skill_content": response_text, + "category": params.get('category', '通用'), + "suggested_tags": ["自定义"], + "explanation": "AI 生成的 Skill 内容" + } + + task_manager.update_task_progress( + task_id, 100, 100, + "生成完成!" + ) + + return result + + except Exception as e: + logger.error(f"Skill 生成失败: {str(e)}") + raise + + +# ============================================================================ +# 异步任务创建端点 +# ============================================================================ + +@router.post("/generate") +async def generate_skill_async( + request: GenerateSkillRequest, + task_manager: TaskManager = Depends(get_task_manager) +): + """ + 异步生成 Skill + + 返回任务ID,需要通过轮询 /tasks/{task_id} 获取结果 + """ + # 创建任务 + task = task_manager.create_task( + task_type=TaskType.GENERATE_SKILL, + params=request.dict(), + project_id=None + ) + + # 在后台执行 + async def run_task(): + await task_manager.execute_task_async( + task.id, + lambda p: execute_generate_skill(task_manager, task.id, p) + ) + + asyncio.create_task(run_task()) + + return { + "success": True, + "taskId": task.id, + "message": "任务已创建,正在后台执行" + } + + +@router.get("/task/{task_id}") +async def get_skill_generation_task( + task_id: str, + task_manager: TaskManager = Depends(get_task_manager) +): + """ + 获取技能生成任务状态 + + Args: + task_id: 任务ID + + Returns: + 任务详细信息 + """ + task = task_manager.get_task(task_id) + if not task: + raise HTTPException( + status_code=404, + detail=f"任务不存在: {task_id}" + ) + + return task.model_dump() + + +@router.get("/tasks/running") +async def get_running_skill_tasks( + task_manager: TaskManager = Depends(get_task_manager) +): + """ + 获取所有正在运行的技能生成任务 + + Returns: + 正在运行的任务列表 + """ + tasks = task_manager.get_tasks_by_type(TaskType.GENERATE_SKILL) + running_tasks = [task for task in tasks if task.status == TaskStatus.RUNNING] + + return { + "success": True, + "tasks": [task.model_dump() for task in running_tasks], + "count": len(running_tasks) + } \ No newline at end of file diff --git a/backend/app/core/documentation_fetcher.py b/backend/app/core/documentation_fetcher.py new file mode 100644 index 0000000..529975d --- /dev/null +++ b/backend/app/core/documentation_fetcher.py @@ -0,0 +1,355 @@ +""" +文档内容获取服务(轻量版) + +只做一件事:从 URL/GitHub 获取文档内容,转换成 Markdown reference 文件。 +不涉及复杂的爬取、代码分析、脚本执行等功能。 + +适用于: +- 用户输入文档 URL,自动获取内容作为 references +- 简单的文档解析和清理 +- 与现有 LLM 生成流程配合 +""" + +import asyncio +import re +from typing import Optional, List, Dict, Any, TYPE_CHECKING +from pathlib import Path +import logging + +try: + import httpx + from bs4 import BeautifulSoup + HTTPX_AVAILABLE = True +except ImportError: + HTTPX_AVAILABLE = False + # TYPE_CHECKING is always False at runtime, so BeautifulSoup won't be imported + if TYPE_CHECKING: + from bs4 import BeautifulSoup + +from app.utils.logger import get_logger + +logger = get_logger(__name__) + + +class DocumentationFetcher: + """ + 轻量级文档获取器 + + 功能: + 1. 从 URL 获取网页内容 + 2. 提取主要内容(去除导航、广告等) + 3. 转换为 Markdown 格式 + 4. 清理和格式化 + + 不做: + - 复杂的爬虫(不递归抓取) + - 代码分析 + - 脚本执行 + """ + + def __init__(self, timeout: int = 30): + """ + 初始化获取器 + + Args: + timeout: 请求超时时间(秒) + """ + self.timeout = timeout + + if not HTTPX_AVAILABLE: + logger.warning("httpx 或 beautifulsoup4 未安装,部分功能不可用") + logger.warning("请安装: pip install httpx beautifulsoup4") + + async def fetch_from_url( + self, + url: str, + selector: Optional[str] = None + ) -> Dict[str, Any]: + """ + 从 URL 获取文档内容 + + Args: + url: 文档 URL + selector: CSS 选择器(可选,用于定位主内容) + + Returns: + 包含获取结果的字典 + """ + if not HTTPX_AVAILABLE: + return { + "success": False, + "error": "httpx 未安装,请运行: pip install httpx" + } + + logger.info(f"获取文档: {url}") + + try: + async with httpx.AsyncClient(timeout=self.timeout) as client: + response = await client.get(url, follow_redirects=True) + response.raise_for_status() + + # 解析 HTML + soup = BeautifulSoup(response.text, "html.parser") + + # 提取内容 + title = self._extract_title(soup) + content = self._extract_main_content(soup, selector) + + # 转换为 Markdown + markdown = self._html_to_markdown(title, content, url) + + # 清理 + markdown = self._clean_markdown(markdown) + + return { + "success": True, + "url": url, + "title": title, + "content": markdown, + "word_count": len(markdown.split()), + "char_count": len(markdown) + } + + except httpx.TimeoutException: + return { + "success": False, + "error": f"请求超时: {url}" + } + except httpx.HTTPStatusError as e: + return { + "success": False, + "error": f"HTTP 错误: {e.response.status_code}" + } + except Exception as e: + logger.error(f"获取文档失败: {str(e)}") + return { + "success": False, + "error": str(e) + } + + async def fetch_from_github( + self, + repo_url: str, + docs_path: str = "README.md" + ) -> Dict[str, Any]: + """ + 从 GitHub 获取文档 + + Args: + repo_url: 仓库 URL (如 https://github.com/owner/repo) + docs_path: 文档路径 (如 README.md, docs/README.md) + + Returns: + 包含获取结果的字典 + """ + if not HTTPX_AVAILABLE: + return { + "success": False, + "error": "httpx 未安装" + } + + logger.info(f"获取 GitHub 文档: {repo_url}/{docs_path}") + + try: + # 解析 repo 信息 + match = re.match(r'https://github\.com/([^/]+)/([^/]+)', repo_url) + if not match: + return { + "success": False, + "error": "无效的 GitHub URL" + } + + owner, repo = match.groups() + + # 使用 raw.githubusercontent.com 获取文件 + raw_url = f"https://raw.githubusercontent.com/{owner}/{repo}/main/{docs_path}" + + async with httpx.AsyncClient(timeout=self.timeout) as client: + response = await client.get(raw_url, follow_redirects=True) + + # 如果 main 分支不存在,尝试 master + if response.status_code == 404: + raw_url = raw_url.replace("/main/", "/master/") + response = await client.get(raw_url, follow_redirects=True) + + response.raise_for_status() + + content = response.text + + return { + "success": True, + "repo_url": repo_url, + "docs_path": docs_path, + "content": content, + "word_count": len(content.split()), + "char_count": len(content) + } + + except Exception as e: + logger.error(f"获取 GitHub 文档失败: {str(e)}") + return { + "success": False, + "error": str(e) + } + + def _extract_title(self, soup: Any) -> str: + """提取页面标题""" + # 尝试从 h1 获取 + h1 = soup.find("h1") + if h1: + return h1.get_text().strip() + + # 尝试从 title 标签获取 + title_tag = soup.find("title") + if title_tag: + return title_tag.get_text().strip() + + return "Untitled" + + def _extract_main_content( + self, + soup: Any, + selector: Optional[str] + ) -> Any: + """提取主要内容""" + + # 如果指定了选择器,使用它 + if selector: + main = soup.select_one(selector) + if main: + return main + + # 尝试常见的内容区域选择器 + content_selectors = [ + "article", + "main", + '[role="main"]', + ".content", + "#content", + ".documentation", + ".docs-content", + "main .content" + ] + + for sel in content_selectors: + main = soup.select_one(sel) + if main: + return main + + # 如果都找不到,返回 body + body = soup.find("body") + return body if body else soup + + def _html_to_markdown( + self, + title: str, + content: Any, + url: str + ) -> str: + """将 HTML 转换为 Markdown""" + + lines = [f"# {title}\n"] + lines.append(f"Source: {url}\n") + + # 提取标题 + for i, heading in enumerate(content.find_all(["h1", "h2", "h3", "h4", "h5", "h6"])): + if heading.name == "h1": + continue # 跳过第一个 h1(已经用作标题) + level = int(heading.name[1]) + text = heading.get_text().strip() + lines.append(f"\n{'#' * level} {text}\n") + + # 提取段落 + paragraphs = [] + for p in content.find_all("p"): + text = p.get_text().strip() + if len(text) > 20: # 只保留有意义的段落 + paragraphs.append(text) + + if paragraphs: + lines.append("\n## Content\n") + lines.extend(paragraphs) + + # 提取代码块 + code_blocks = [] + for pre in content.find_all("pre"): + code = pre.get_text() + if len(code) > 10: + code_blocks.append(f"```\n{code}\n```") + + if code_blocks: + lines.append("\n## Code Examples\n") + lines.extend(code_blocks[:5]) # 最多 5 个代码块 + + # 提取列表 + for ul in content.find_all(["ul", "ol"]): + items = [f"- {li.get_text().strip()}" for li in ul.find_all("li")] + if items: + lines.append("\n") + lines.extend(items) + + return "\n".join(lines) + + def _clean_markdown(self, markdown: str) -> str: + """清理 Markdown 内容""" + + # 移除过多的空行 + markdown = re.sub(r'\n{3,}', '\n\n', markdown) + + # 移除导航类文本 + noise_patterns = [ + r'Table of Contents.*?(?=\n##)', + r'Navigation.*?(?=\n##)', + r'Menu.*?(?=\n##)', + r'Skip to content', + r'© \d{4}.*', + ] + + for pattern in noise_patterns: + markdown = re.sub(pattern, '', markdown, flags=re.IGNORECASE | re.DOTALL) + + return markdown.strip() + + async def fetch_multiple_urls( + self, + urls: List[str], + selector: Optional[str] = None + ) -> List[Dict[str, Any]]: + """ + 批量获取多个 URL 的内容 + + Args: + urls: URL 列表 + selector: CSS 选择器 + + Returns: + 获取结果列表 + """ + tasks = [self.fetch_from_url(url, selector) for url in urls] + results = await asyncio.gather(*tasks, return_exceptions=True) + + # 处理异常 + processed_results = [] + for i, result in enumerate(results): + if isinstance(result, Exception): + processed_results.append({ + "success": False, + "url": urls[i], + "error": str(result) + }) + else: + processed_results.append(result) + + return processed_results + + +# 全局单例 +_doc_fetcher: Optional[DocumentationFetcher] = None + + +def get_documentation_fetcher() -> DocumentationFetcher: + """获取文档获取器单例""" + global _doc_fetcher + if _doc_fetcher is None: + _doc_fetcher = DocumentationFetcher() + return _doc_fetcher diff --git a/backend/app/core/skills/skill_manager.py b/backend/app/core/skills/skill_manager.py index 138997e..48aa43a 100644 --- a/backend/app/core/skills/skill_manager.py +++ b/backend/app/core/skills/skill_manager.py @@ -296,6 +296,9 @@ class SkillManager: 提取行为指导部分(核心功能) 查找 ## 行为指导 或 ## Behavior Guide 部分 + + 如果找到专门的章节,返回该章节内容; + 如果没有找到,返回完整内容(整个SKILL.md就是行为指导) """ lines = content.split('\n') start_idx = -1 @@ -306,8 +309,9 @@ class SkillManager: break if start_idx == -1: - # 如果没有专门的章节,尝试从整体提取 - return content[:500] # 返回前500字符作为默认 + # 如果没有专门的章节,返回完整内容 + # 整个SKILL.md就是行为指导,不应该截断 + return content # 提取到下一个 ## 之前的内容 guide_lines = [] diff --git a/backend/app/core/task_manager.py b/backend/app/core/task_manager.py new file mode 100644 index 0000000..95ec034 --- /dev/null +++ b/backend/app/core/task_manager.py @@ -0,0 +1,258 @@ +""" +异步任务管理器 + +负责管理异步 AI 生成任务的创建、执行和状态跟踪 +""" +import asyncio +import uuid +from typing import Dict, Optional, List, Callable, Any +from datetime import datetime +from collections import defaultdict + +from app.models.task import AsyncTask, TaskStatus, TaskType, TaskProgress +from app.utils.logger import get_logger + +logger = get_logger(__name__) + + +class TaskManager: + """ + 异步任务管理器 + + 功能: + 1. 创建异步任务 + 2. 执行任务(在后台) + 3. 跟踪任务状态和进度 + 4. 提供任务查询接口 + """ + + def __init__(self): + # 内存存储(生产环境应使用 Redis 或数据库) + self._tasks: Dict[str, AsyncTask] = {} + # 按项目ID索引的任务 + self._project_tasks: Dict[str, List[str]] = defaultdict(list) + # 按类型索引的任务 + self._type_tasks: Dict[TaskType, List[str]] = defaultdict(list) + + def create_task( + self, + task_type: TaskType, + params: Dict[str, Any], + project_id: Optional[str] = None + ) -> AsyncTask: + """ + 创建新任务 + + Args: + task_type: 任务类型 + params: 任务参数 + project_id: 关联的项目ID + + Returns: + 创建的任务 + """ + task_id = str(uuid.uuid4()) + + task = AsyncTask( + id=task_id, + type=task_type, + params=params, + project_id=project_id, + status=TaskStatus.PENDING + ) + + self._tasks[task_id] = task + + if project_id: + self._project_tasks[project_id].append(task_id) + + self._type_tasks[task_type].append(task_id) + + logger.info(f"创建任务: {task_id} ({task_type.value})") + + return task + + def get_task(self, task_id: str) -> Optional[AsyncTask]: + """获取任务""" + return self._tasks.get(task_id) + + def get_tasks_by_project(self, project_id: str) -> List[AsyncTask]: + """获取项目的所有任务""" + task_ids = self._project_tasks.get(project_id, []) + return [self._tasks[tid] for tid in task_ids if tid in self._tasks] + + def get_tasks_by_type(self, task_type: TaskType) -> List[AsyncTask]: + """获取指定类型的所有任务""" + task_ids = self._type_tasks.get(task_type, []) + return [self._tasks[tid] for tid in task_ids if tid in self._tasks] + + def update_task_progress( + self, + task_id: str, + current: int, + total: int = 100, + message: str = "", + stage: Optional[str] = None + ) -> bool: + """ + 更新任务进度 + + Args: + task_id: 任务ID + current: 当前进度 + total: 总进度 + message: 进度消息 + stage: 当前阶段 + + Returns: + 是否更新成功 + """ + task = self._tasks.get(task_id) + if not task: + return False + + task.progress = TaskProgress( + current=current, + total=total, + message=message, + stage=stage + ) + task.updated_at = datetime.now() + + logger.debug(f"任务进度更新: {task_id} - {current}/{total} - {message}") + + return True + + def update_task_status( + self, + task_id: str, + status: TaskStatus, + result: Optional[Dict[str, Any]] = None, + error: Optional[str] = None + ) -> bool: + """ + 更新任务状态 + + Args: + task_id: 任务ID + status: 新状态 + result: 任务结果 + error: 错误信息 + + Returns: + 是否更新成功 + """ + task = self._tasks.get(task_id) + if not task: + return False + + old_status = task.status + task.status = status + task.updated_at = datetime.now() + + if result: + task.result = result + + if error: + task.error = error + + # 状态变更时间戳 + if status == TaskStatus.RUNNING and old_status == TaskStatus.PENDING: + task.started_at = datetime.now() + elif status in [TaskStatus.COMPLETED, TaskStatus.FAILED, TaskStatus.CANCELLED]: + task.completed_at = datetime.now() + + logger.info(f"任务状态更新: {task_id} - {old_status.value} -> {status.value}") + + return True + + def cancel_task(self, task_id: str) -> bool: + """取消任务""" + return self.update_task_status(task_id, TaskStatus.CANCELLED) + + def delete_task(self, task_id: str) -> bool: + """删除任务""" + task = self._tasks.get(task_id) + if not task: + return False + + # 从索引中移除 + if task.project_id: + self._project_tasks[task.project_id] = [ + tid for tid in self._project_tasks[task.project_id] if tid != task_id + ] + + self._type_tasks[task.type] = [ + tid for tid in self._type_tasks[task.type] if tid != task_id + ] + + del self._tasks[task_id] + + logger.info(f"删除任务: {task_id}") + + return True + + async def execute_task_async( + self, + task_id: str, + executor: Callable + ) -> Dict[str, Any]: + """ + 异步执行任务 + + Args: + task_id: 任务ID + executor: 执行器函数,接收 params,返回 result + + Returns: + 任务执行结果 + """ + task = self._tasks.get(task_id) + if not task: + raise ValueError(f"任务不存在: {task_id}") + + # 更新状态为运行中 + self.update_task_status(task_id, TaskStatus.RUNNING) + + try: + # 执行任务 + result = await executor(task.params) + + # 更新状态为完成 + self.update_task_status(task_id, TaskStatus.COMPLETED, result=result) + + return result + + except Exception as e: + logger.error(f"任务执行失败: {task_id} - {str(e)}") + self.update_task_status(task_id, TaskStatus.FAILED, error=str(e)) + raise + + def execute_task_in_background( + self, + task_id: str, + executor: Callable + ) -> asyncio.Task: + """ + 在后台执行任务 + + Args: + task_id: 任务ID + executor: 执行器函数 + + Returns: + asyncio Task 对象 + """ + return asyncio.create_task(self.execute_task_async(task_id, executor)) + + +# 全局单例 +_task_manager: Optional[TaskManager] = None + + +def get_task_manager() -> TaskManager: + """获取任务管理器单例""" + global _task_manager + if _task_manager is None: + _task_manager = TaskManager() + return _task_manager diff --git a/backend/app/main.py b/backend/app/main.py index 399990d..10826ef 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -108,12 +108,15 @@ async def health_check(): # ============================================ # API 路由注册 # ============================================ -from app.api.v1 import skills, projects, ai_assistant, memory, review, websocket, uploads, episodes +from app.api.v1 import skills, projects, ai_assistant, memory, review, websocket, uploads, episodes, async_tasks, ai_async, skills_async app.include_router(skills.router, prefix="/api/v1") +app.include_router(skills_async.router, prefix="/api/v1") app.include_router(projects.router, prefix="/api/v1") app.include_router(episodes.router, prefix="/api/v1") app.include_router(ai_assistant.router, prefix="/api/v1") +app.include_router(async_tasks.router, prefix="/api/v1") +app.include_router(ai_async.router, prefix="/api/v1") app.include_router(memory.router, prefix="/api/v1") app.include_router(review.router, prefix="/api/v1") app.include_router(uploads.router, prefix="/api/v1") diff --git a/backend/app/models/skill.py b/backend/app/models/skill.py index 0684f93..34fa9f2 100644 --- a/backend/app/models/skill.py +++ b/backend/app/models/skill.py @@ -65,7 +65,7 @@ class Skill(BaseModel): class SkillCreate(BaseModel): """创建 Skill 请求""" - id: str + id: Optional[str] = None # ID 可选,不传则自动生成 name: str content: str # Markdown 格式的完整 Skill 内容 category: str = "通用" @@ -118,3 +118,90 @@ class SkillGenerateResponse(BaseModel): category: str suggested_tags: List[str] explanation: str # AI 对生成的 Skill 的说明 + + +# ============================================================================ +# 新增:文档驱动的 Skill 生成模型 +# ============================================================================ + +class DocFetchRequest(BaseModel): + """文档获取请求""" + url: str = Field(..., description="文档 URL") + selector: Optional[str] = Field(None, description="CSS 选择器(可选)") + + +class GitHubDocFetchRequest(BaseModel): + """GitHub 文档获取请求""" + repo_url: str = Field(..., description="GitHub 仓库 URL") + docs_path: str = Field("README.md", description="文档路径") + + +class SkillGenerateFromDocsRequest(BaseModel): + """基于文档生成 Skill 请求""" + skill_name: str = Field(..., description="Skill 名称") + description: Optional[str] = Field(None, description="Skill 描述(可选)") + category: Optional[str] = Field(None, description="分类") + tags: Optional[List[str]] = Field(None, description="标签") + # 文档来源 + doc_urls: List[str] = Field(default_factory=list, description="文档 URL 列表") + github_repos: List[GitHubDocFetchRequest] = Field(default_factory=list, description="GitHub 仓库列表") + uploaded_references: Dict[str, str] = Field(default_factory=dict, description="用户上传的 references {文件名: 内容}") + # 生成选项 + temperature: float = Field(0.7, ge=0, le=1, description="LLM 温度") + include_doc_summary: bool = Field(True, description="是否包含文档摘要") + + +class SkillGenerateFromDocsResponse(BaseModel): + """基于文档生成 Skill 响应""" + success: bool + preview_id: str = Field(..., description="预览 ID(用于后续保存)") + skill_content: str = Field(..., description="生成的 SKILL.md 内容") + skill_name: str + suggested_id: str + category: str + tags: List[str] + doc_summary: Optional[str] = Field(None, description="文档摘要") + references_count: int = Field(0, description="生成的 reference 文件数") + explanation: str + + +class SkillPreviewRequest(BaseModel): + """Skill 预览请求""" + skill_content: str + skill_name: str + category: Optional[str] = None + tags: Optional[List[str]] = None + + +class SkillPreviewResponse(BaseModel): + """Skill 预览响应""" + preview_id: str + skill_content: str + parsed_metadata: Dict[str, Any] + validation_warnings: List[str] = Field(default_factory=list) + word_count: int + estimated_tokens: int + + +class SkillSaveFromPreviewRequest(BaseModel): + """从预览保存 Skill 请求""" + preview_id: str + skill_id: str = Field(..., description="要保存的 Skill ID") + skill_content: str + references: Optional[Dict[str, str]] = Field(None, description="要保存的 references") + + +class SkillRefineRequest(BaseModel): + """AI 调整 Skill 请求""" + skill_content: str + refinement_prompt: str = Field(..., description="调整提示词") + temperature: float = Field(0.7, ge=0, le=1) + + +class SkillRefineResponse(BaseModel): + """AI 调整 Skill 响应""" + success: bool + refined_content: str + changes_summary: str + original_length: int + new_length: int diff --git a/backend/app/models/task.py b/backend/app/models/task.py new file mode 100644 index 0000000..9378e3f --- /dev/null +++ b/backend/app/models/task.py @@ -0,0 +1,82 @@ +""" +异步任务模型 + +用于管理 AI 生成等耗时任务的进度和状态 +""" +from pydantic import BaseModel, Field +from typing import Optional, Dict, Any, List +from datetime import datetime +from enum import Enum + + +class TaskStatus(str, Enum): + """任务状态枚举""" + PENDING = "pending" # 等待执行 + RUNNING = "running" # 执行中 + COMPLETED = "completed" # 完成 + FAILED = "failed" # 失败 + CANCELLED = "cancelled" # 已取消 + + +class TaskType(str, Enum): + """任务类型枚举""" + GENERATE_CHARACTERS = "generate_characters" + GENERATE_OUTLINE = "generate_outline" + GENERATE_WORLD = "generate_world" + ANALYZE_SCRIPT = "analyze_script" + WORLD_FROM_SCRIPT = "world_from_script" + GENERATE_ALL = "generate_all" + EPISODE_CREATE = "episode_create" + GENERATE_SKILL = "generate_skill" + + +class TaskProgress(BaseModel): + """任务进度""" + current: int = Field(0, description="当前进度") + total: int = Field(100, description="总进度") + message: str = Field("", description="进度消息") + stage: Optional[str] = Field(None, description="当前阶段") + + +class AsyncTask(BaseModel): + """异步任务""" + id: str + type: TaskType + status: TaskStatus = TaskStatus.PENDING + progress: TaskProgress = Field(default_factory=TaskProgress) + + # 任务参数 + params: Dict[str, Any] = Field(default_factory=dict) + + # 任务结果 + result: Optional[Dict[str, Any]] = None + error: Optional[str] = None + + # 关联信息 + project_id: Optional[str] = None + + # 时间戳 + created_at: datetime = Field(default_factory=datetime.now) + started_at: Optional[datetime] = None + completed_at: Optional[datetime] = None + updated_at: datetime = Field(default_factory=datetime.now) + + +class TaskCreateRequest(BaseModel): + """创建任务请求""" + type: TaskType + params: Dict[str, Any] = Field(default_factory=dict) + project_id: Optional[str] = None + + +class TaskResponse(BaseModel): + """任务响应""" + id: str + type: TaskType + status: TaskStatus + progress: TaskProgress + result: Optional[Dict[str, Any]] = None + error: Optional[str] = None + project_id: Optional[str] = None + created_at: datetime + updated_at: datetime diff --git a/backend/app/utils/script_splitter.py b/backend/app/utils/script_splitter.py new file mode 100644 index 0000000..b24ab67 --- /dev/null +++ b/backend/app/utils/script_splitter.py @@ -0,0 +1,324 @@ +""" +剧本切分器 + +支持混合切分策略: +1. 优先按场景标记切分(第X场、Scene X、【场景X】等) +2. 单场超过阈值时按固定长度切分(5000字符,保留500字符重叠) +""" +import re +from typing import List, Dict, Any +from dataclasses import dataclass +from app.utils.logger import get_logger + +logger = get_logger(__name__) + + +@dataclass +class ScriptSegment: + """剧本片段""" + index: int # 片段索引(从0开始) + content: str # 片段内容 + start_pos: int # 在原文中的起始位置 + end_pos: int # 在原文中的结束位置 + scene_marker: str = "" # 场景标记(如果有) + metadata: Dict[str, Any] = None # 额外元数据 + + def __post_init__(self): + if self.metadata is None: + self.metadata = {} + + +class ScriptSplitter: + """剧本切分器 - 混合切分策略""" + + # 场景标记的正则表达式模式(支持多种格式) + SCENE_PATTERNS = [ + r'第[一二三四五六七八九十百千0-9]+[集场幕]', # 第X场/集/幕 + r'[第第]?[0-9]+[集场幕]', # 第1场/集/幕 或 1场/集/幕 + r'Scene\s*\d+', # Scene 1, Scene 2, ... + r'【场景[^\n]*】', # 【场景xxx】 + r'【[场景场][^\n]*】', # 【场xxx】 + r'\[场景[^\n]*\]', # [场景xxx] + r'场景[::][^\n]*', # 场景:xxx + r'[0-9]+\.[0-9]+\s*[场景场外]', # 1.1 场景/场/外 + ] + + # 切分阈值 + MAX_SEGMENT_LENGTH = 5000 # 单片段最大长度 + OVERLAP_LENGTH = 500 # 切分时的重叠长度 + CHUNK_THRESHOLD = 8000 # 超过此长度开始切分 + + def __init__( + self, + max_segment_length: int = MAX_SEGMENT_LENGTH, + overlap_length: int = OVERLAP_LENGTH, + chunk_threshold: int = CHUNK_THRESHOLD + ): + """ + 初始化切分器 + + Args: + max_segment_length: 单片段最大长度 + overlap_length: 切分时的重叠长度(保持上下文) + chunk_threshold: 超过此长度开始切分 + """ + self.max_segment_length = max_segment_length + self.overlap_length = overlap_length + self.chunk_threshold = chunk_threshold + + def split(self, content: str) -> List[ScriptSegment]: + """ + 切分剧本内容 + + Args: + content: 剧本内容 + + Returns: + 切分后的片段列表 + """ + if not content or len(content) <= self.chunk_threshold: + # 内容较短,不需要切分 + return [ScriptSegment( + index=0, + content=content, + start_pos=0, + end_pos=len(content), + metadata={"split_method": "none", "reason": "content_too_short"} + )] + + # 尝试按场景切分 + segments = self._split_by_scenes(content) + + # 检查是否有片段过长 + oversized_segments = [ + s for s in segments + if len(s.content) > self.max_segment_length + ] + + if oversized_segments: + # 对过长片段进行二次切分 + segments = self._split_oversized_segments(segments) + + logger.info(f"剧本切分完成:共 {len(segments)} 个片段") + return segments + + def _split_by_scenes(self, content: str) -> List[ScriptSegment]: + """ + 按场景标记切分 + + Args: + content: 剧本内容 + + Returns: + 切分后的片段列表 + """ + # 编译所有场景模式 + patterns = [re.compile(pattern, re.MULTILINE) for pattern in self.SCENE_PATTERNS] + + # 查找所有场景标记位置 + scene_positions = [] + for pattern in patterns: + for match in pattern.finditer(content): + scene_positions.append({ + 'pos': match.start(), + 'marker': match.group(0), + 'pattern': pattern.pattern + }) + + # 按位置排序并去重 + scene_positions = sorted( + list({(p['pos'], p['marker']): p for p in scene_positions}.values()), + key=lambda x: x['pos'] + ) + + if not scene_positions: + # 没有找到场景标记,使用固定长度切分 + return self._split_by_length(content) + + # 按场景位置切分 + segments = [] + for i, scene_info in enumerate(scene_positions): + start_pos = scene_info['pos'] + end_pos = scene_positions[i + 1]['pos'] if i + 1 < len(scene_positions) else len(content) + + segment_content = content[start_pos:end_pos].strip() + + if segment_content: + segments.append(ScriptSegment( + index=i, + content=segment_content, + start_pos=start_pos, + end_pos=end_pos, + scene_marker=scene_info['marker'], + metadata={ + "split_method": "scene", + "scene_marker": scene_info['marker'] + } + )) + + return segments + + def _split_by_length(self, content: str) -> List[ScriptSegment]: + """ + 按固定长度切分(带重叠) + + Args: + content: 剧本内容 + + Returns: + 切分后的片段列表 + """ + segments = [] + content_length = len(content) + pos = 0 + index = 0 + + while pos < content_length: + end_pos = min(pos + self.max_segment_length, content_length) + segment_content = content[pos:end_pos] + + # 如果不是最后一段,添加重叠 + if end_pos < content_length: + overlap_content = content[end_pos:end_pos + self.overlap_length] + segment_content += overlap_content + + segments.append(ScriptSegment( + index=index, + content=segment_content, + start_pos=pos, + end_pos=end_pos, + metadata={ + "split_method": "length", + "has_overlap": end_pos < content_length + } + )) + + # 移动到下一个片段 + pos += self.max_segment_length + index += 1 + + return segments + + def _split_oversized_segments( + self, + segments: List[ScriptSegment] + ) -> List[ScriptSegment]: + """ + 对过长的片段进行二次切分 + + Args: + segments: 原始片段列表 + + Returns: + 处理后的片段列表 + """ + result = [] + + for segment in segments: + if len(segment.content) <= self.max_segment_length: + # 片段长度合适,直接添加 + result.append(segment) + else: + # 片段过长,进行二次切分 + logger.info(f"片段 {segment.index} 过长 ({len(segment.content)} 字符),进行二次切分") + + sub_segments = self._split_by_length(segment.content) + + # 重新编号并保留原始元数据 + for i, sub_seg in enumerate(sub_segments): + result.append(ScriptSegment( + index=len(result), + content=sub_seg.content, + start_pos=segment.start_pos + sub_seg.start_pos, + end_pos=segment.start_pos + sub_seg.end_pos, + scene_marker=segment.scene_marker if i == 0 else f"{segment.scene_marker}(续)", + metadata={ + **segment.metadata, + "split_method": f"{segment.metadata.get('split_method', '')}_then_length", + "parent_scene": segment.scene_marker, + "sub_index": i + } + )) + + return result + + def get_split_summary(self, segments: List[ScriptSegment]) -> Dict[str, Any]: + """ + 获取切分摘要信息 + + Args: + segments: 片段列表 + + Returns: + 摘要信息字典 + """ + if not segments: + return { + "total_segments": 0, + "total_length": 0, + "split_methods": {} + } + + total_length = sum(len(s.content) for s in segments) + split_methods = {} + + for segment in segments: + method = segment.metadata.get("split_method", "unknown") + split_methods[method] = split_methods.get(method, 0) + 1 + + return { + "total_segments": len(segments), + "total_length": total_length, + "average_length": total_length // len(segments), + "min_length": min(len(s.content) for s in segments), + "max_length": max(len(s.content) for s in segments), + "split_methods": split_methods, + "has_scenes": any(s.scene_marker for s in segments) + } + + +def split_script( + content: str, + max_segment_length: int = ScriptSplitter.MAX_SEGMENT_LENGTH, + overlap_length: int = ScriptSplitter.OVERLAP_LENGTH, + chunk_threshold: int = ScriptSplitter.CHUNK_THRESHOLD +) -> Dict[str, Any]: + """ + 便捷函数:切分剧本内容 + + Args: + content: 剧本内容 + max_segment_length: 单片段最大长度 + overlap_length: 切分时的重叠长度 + chunk_threshold: 超过此长度开始切分 + + Returns: + 包含片段列表和摘要的字典 + """ + splitter = ScriptSplitter( + max_segment_length=max_segment_length, + overlap_length=overlap_length, + chunk_threshold=chunk_threshold + ) + + segments = splitter.split(content) + summary = splitter.get_split_summary(segments) + + # 转换为可序列化的格式 + segments_data = [ + { + "index": s.index, + "content": s.content, + "start_pos": s.start_pos, + "end_pos": s.end_pos, + "scene_marker": s.scene_marker, + "length": len(s.content), + "metadata": s.metadata + } + for s in segments + ] + + return { + "segments": segments_data, + "summary": summary + } diff --git a/backend/docs/SKILL_GENERATION_GUIDE.md b/backend/docs/SKILL_GENERATION_GUIDE.md new file mode 100644 index 0000000..19d5808 --- /dev/null +++ b/backend/docs/SKILL_GENERATION_GUIDE.md @@ -0,0 +1,406 @@ +# 增强的 Skill 生成功能 - 使用指南 + +## 📋 概述 + +本次更新为 Creative Studio 添加了**文档驱动的 Skill 生成工作流**,完全集成到现有架构中。 + +--- + +## ✨ 新功能 + +### 1. 文档获取 API + +| 端点 | 功能 | +|-----|------| +| `POST /api/v1/skills/fetch-doc` | 从 URL 获取文档内容 | +| `POST /api/v1/skills/fetch-github-doc` | 从 GitHub 获取 README/docs | + +**请求示例:** +```json +// 获取网页文档 +{ + "url": "https://docs.example.com", + "selector": "article" // 可选 CSS 选择器 +} + +// 获取 GitHub 文档 +{ + "repo_url": "https://github.com/owner/repo", + "docs_path": "README.md" +} +``` + +--- + +### 2. 文档驱动的 Skill 生成 + +**端点:** `POST /api/v1/skills/generate-from-docs` + +**完整工作流:** +``` +用户输入 → 文档获取 → LLM 生成 → 预览 → 调整 → 保存 + ↓ ↓ ↓ ↓ ↓ ↓ + 名称/URL 自动爬取 AI生成 即时预览 AI/手动 统一管理 +``` + +**请求示例:** +```json +{ + "skill_name": "React 开发助手", + "description": "帮助开发者使用 React 框架", + "category": "development", + "tags": ["react", "frontend", "javascript"], + "doc_urls": [ + "https://react.dev/learn", + "https://react.dev/reference/react" + ], + "github_repos": [ + { + "repo_url": "https://github.com/facebook/react", + "docs_path": "README.md" + } + ], + "uploaded_references": { + "custom_notes.md": "# 自定义笔记\n..." + }, + "temperature": 0.7, + "include_doc_summary": true +} +``` + +**响应示例:** +```json +{ + "success": true, + "preview_id": "550e8400-e29b-41d4-a716-446655440000", + "skill_content": "---\nname: react-assistant\ndescription: ...\n\n# React 开发助手\n...", + "skill_name": "React 开发助手", + "suggested_id": "react-assistant", + "category": "development", + "tags": ["react", "frontend"], + "doc_summary": "- React Learn (https://react.dev/learn)\n- README.md", + "references_count": 5, + "explanation": "已基于 React 官方文档生成 Skill" +} +``` + +--- + +### 3. Skill 预览 + +**端点:** `POST /api/v1/skills/preview` + +在保存前预览和验证 Skill 内容。 + +**请求示例:** +```json +{ + "skill_content": "---\nname: my-skill\n...\n", + "skill_name": "My Skill", + "category": "通用", + "tags": ["custom"] +} +``` + +**响应示例:** +```json +{ + "preview_id": "...", + "skill_content": "...", + "parsed_metadata": { + "name": "my-skill", + "description": "..." + }, + "validation_warnings": [ + "缺少 description 字段" + ], + "word_count": 1250, + "estimated_tokens": 1625 +} +``` + +--- + +### 4. AI 调整/优化 + +**端点:** `POST /api/v1/skills/refine` + +使用自然语言提示词让 AI 调整 Skill。 + +**请求示例:** +```json +{ + "skill_content": "---\nname: my-skill\n...", + "refinement_prompt": "把行为指导改得更简洁,增加代码示例", + "temperature": 0.5 +} +``` + +**提示词示例:** +- "把行为指导改得更简洁" +- "增加一个快速开始部分" +- "添加更多代码示例" +- "优化描述,让它更清晰" +- "用更专业的语气重写" + +**响应示例:** +```json +{ + "success": true, + "refined_content": "---\nname: my-skill\n...", + "changes_summary": "根据提示词「把行为指导改得更简洁,增加代码示例」进行了调整", + "original_length": 2500, + "new_length": 3200 +} +``` + +--- + +### 5. 保存到系统 + +**端点:** `POST /api/v1/skills/save-from-preview` + +将预览的 Skill 保存到系统(统一后端管理)。 + +**请求示例:** +```json +{ + "preview_id": "550e8400-e29b-41d4-a716-446655440000", + "skill_id": "my-skill", + "skill_content": "---\nname: my-skill\n...", + "references": { + "api_reference.md": "# API 参考\n...", + "examples.md": "# 示例\n..." + } +} +``` + +**保存位置:** +``` +backend/skills_storage/user_skills/my-skill/ +├── SKILL.md +└── references/ + ├── api_reference.md + └── examples.md +``` + +--- + +## 🔄 完整工作流示例 + +### 前端流程 + +```javascript +// 步骤 1: 用户输入并生成 +const response = await fetch('/api/v1/skills/generate-from-docs', { + method: 'POST', + body: JSON.stringify({ + skill_name: 'React 助手', + description: 'React 开发辅助', + doc_urls: ['https://react.dev/learn'], + temperature: 0.7 + }) +}); +const { preview_id, skill_content } = await response.json(); + +// 步骤 2: 显示预览 +// 显示 skill_content,让用户查看 + +// 步骤 3: 用户调整(可选) +// 方式 A: 手动编辑 +let editedContent = skill_content; + +// 方式 B: AI 调整 +const refineResponse = await fetch('/api/v1/skills/refine', { + method: 'POST', + body: JSON.stringify({ + skill_content: editedContent, + refinement_prompt: '增加代码示例', + temperature: 0.5 + }) +}); +const { refined_content } = await refineResponse.json(); + +// 步骤 4: 保存到系统 +await fetch('/api/v1/skills/save-from-preview', { + method: 'POST', + body: JSON.stringify({ + preview_id: preview_id, + skill_id: 'react-assistant', + skill_content: refined_content + }) +}); +``` + +--- + +## 🎨 前端页面建议 + +### Skill 创建页面布局 + +``` +┌─────────────────────────────────────────────────────────────┐ +│ 创建新 Skill │ +├─────────────────────────────────────────────────────────────┤ +│ │ +│ 基础信息 │ +│ ┌─────────────────────────────────────────────────────┐ │ +│ │ Skill 名称: [React 开发助手 ] │ │ +│ │ 描述: [帮助开发者使用 React 框架 ] │ │ +│ │ 分类: [development ▼] 标签: [+ Add tag] │ │ +│ └─────────────────────────────────────────────────────┘ │ +│ │ +│ 文档来源 │ +│ ┌─────────────────────────────────────────────────────┐ │ +│ │ URLs: │ │ +│ │ [+ Add URL] │ │ +│ │ • https://react.dev/learn [×] │ │ +│ │ • https://react.dev/reference/react [×] │ │ +│ │ │ │ +│ │ GitHub: │ │ +│ │ [+ Add GitHub Repository] │ │ +│ │ • facebook/react (README.md) [×] │ │ +│ │ │ │ +│ │ 上传 References: │ │ +│ │ [Drag & drop files or click to upload] │ │ +│ └─────────────────────────────────────────────────────┘ │ +│ │ +│ 选项 │ +│ ┌─────────────────────────────────────────────────────┐ │ +│ │ Temperature: [━━●━━] 0.7 │ │ +│ │ ☑ 包含文档摘要 │ │ +│ └─────────────────────────────────────────────────────┘ │ +│ │ +│ [生成 Skill] │ +│ │ +└─────────────────────────────────────────────────────────────┘ +``` + +### 预览/编辑页面 + +``` +┌─────────────────────────────────────────────────────────────┐ +│ 预览 Skill: React 开发助手 │ +├─────────────────────────────────────────────────────────────┤ +│ │ +│ 元数据 │ +│ ┌─────────────────────────────────────────────────────┐ │ +│ │ ✅ name: react-assistant │ │ +│ │ ✅ description: 帮助开发者使用 React 框架 │ │ +│ │ ✅ category: development │ │ +│ │ ✅ tags: react, frontend, javascript │ │ +│ │ ⚠️ 内容较长 (3200 tokens) │ │ +│ └─────────────────────────────────────────────────────┘ │ +│ │ +│ 内容编辑器 │ +│ ┌─────────────────────────────────────────────────────┐ │ +│ │ --- │ │ +│ │ name: react-assistant │ │ +│ │ description: 帮助开发者使用 React 框架 │ │ +│ │ --- │ │ +│ │ │ │ +│ │ # React 开发助手 │ │ +│ │ │ │ +│ │ ## 使用场景 │ │ +│ │ ... │ │ +│ │ [Markdown 编辑器] │ │ +│ └─────────────────────────────────────────────────────┘ │ +│ │ +│ AI 调整 │ +│ ┌─────────────────────────────────────────────────────┐ │ +│ │ 提示词: [让内容更简洁 ] │ │ +│ │ [AI 调整] │ │ +│ │ │ │ +│ │ 快捷提示词: │ │ +│ │ • [增加代码示例] • [添加快速开始] • [优化描述] │ │ +│ └─────────────────────────────────────────────────────┘ │ +│ │ +│ References (5 个文件) │ +│ ┌─────────────────────────────────────────────────────┐ │ +│ │ • react_learn.md (1250 词) [查看] │ │ +│ │ • react_reference.md (800 词) [查看] │ │ +│ │ • github_readme.md (450 词) [查看] │ │ +│ └─────────────────────────────────────────────────────┘ │ +│ │ +│ [取消] [保存 Skill] │ +│ │ +└─────────────────────────────────────────────────────────────┘ +``` + +--- + +## 🔧 依赖安装 + +确保安装以下依赖: + +```bash +cd backend +pip install httpx beautifulsoup4 +``` + +更新 `requirements.txt`: +``` +httpx>=0.27.0 +beautifulsoup4>=4.12.0 +``` + +--- + +## 📝 API 端点总览 + +| 端点 | 方法 | 功能 | +|-----|------|------| +| `/api/v1/skills/fetch-doc` | POST | 获取网页文档 | +| `/api/v1/skills/fetch-github-doc` | POST | 获取 GitHub 文档 | +| `/api/v1/skills/generate-from-docs` | POST | 基于文档生成 Skill | +| `/api/v1/skills/preview` | POST | 预览 Skill | +| `/api/v1/skills/refine` | POST | AI 调整 Skill | +| `/api/v1/skills/save-from-preview` | POST | 保存 Skill | +| `/api/v1/skills/preview/{preview_id}` | GET | 获取预览数据 | +| `/api/v1/skills/preview/{preview_id}` | DELETE | 删除预览 | + +--- + +## 🚀 后续优化建议 + +1. **预览存储优化** + - 当前使用内存存储(`_preview_storage`) + - 生产环境建议使用 Redis 或数据库 + +2. **异步处理** + - 文档获取可以异步进行 + - 大文档处理可以返回任务 ID + +3. **缓存机制** + - 缓存已获取的文档 + - 避免重复获取相同 URL + +4. **批量处理** + - 支持批量生成 Skills + - 批量导入文档 + +--- + +## 🐛 故障排除 + +### httpx 未安装 + +```bash +pip install httpx beautifulsoup4 +``` + +### 文档获取失败 + +- 检查 URL 是否正确 +- 确认目标网站是否允许爬取 +- 尝试使用 CSS 选择器指定主内容区域 + +### LLM 生成质量不佳 + +- 调整 `temperature` 参数 +- 提供更详细的描述 +- 添加更多文档来源 + +--- + +生成时间:2025-01-26 diff --git a/backend/skills_storage/user_skills/古风对话创作-856130/SKILL.md b/backend/skills_storage/user_skills/古风对话创作-856130/SKILL.md new file mode 100644 index 0000000..fdb1d83 --- /dev/null +++ b/backend/skills_storage/user_skills/古风对话创作-856130/SKILL.md @@ -0,0 +1,31 @@ +--- +name: ancient-style-dialogue-writer +description: 专门用于创作符合中国古代背景、角色身份地位及性格特征的对话内容。 +--- + +# 古风对话创作 + +此 Skill 用于指导生成符合中国古代语境的角色对话,确保语言风格贴合人物身份与时代背景。 + +## 角色分析 + +在创作对话前,需明确以下要素: +- **身份地位**:皇室、官宦、平民、江湖人士、僧道等。 +- **性格特征**:傲慢、谦卑、儒雅、粗犷、狡诈等。 +- **时代背景**:先秦、汉唐、宋明或架空朝代,不同时代用词习惯略有差异。 +- **对话对象**:君臣、父子、挚友、仇敌等关系决定语气。 + +## 语言风格指南 + +### 1. 代词与称呼 +- **自称**: + - 皇室:朕、孤、本宫。 + - 官员/文人:下官、学生、在下、愚兄。 + - 普通百姓/江湖:老朽、洒家、奴家、妾身、某。 + - 通称:吾、余、我。 +- **对称**: + - 尊称:陛下、殿下、大人、阁下、足下、先生、姑娘。 + - 谦称/贬称:竖子、匹夫、老贼、尔、汝。 + +### 2. 语气与句式 +- **文雅含 \ No newline at end of file diff --git a/frontend/package-lock.json b/frontend/package-lock.json index aec7225..ce4b06a 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -181,7 +181,6 @@ "integrity": "sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/generator": "^7.28.6", @@ -1818,7 +1817,6 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.27.tgz", "integrity": "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==", "license": "MIT", - "peer": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.2.2" @@ -1889,7 +1887,6 @@ "integrity": "sha512-tbsV1jPne5CkFQCgPBcDOt30ItF7aJoZL997JSF7MhGQqOeT3svWRYxiqlfA5RUdlHN6Fi+EI9bxqbdyAUZjYQ==", "dev": true, "license": "BSD-2-Clause", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "6.21.0", "@typescript-eslint/types": "6.21.0", @@ -2079,7 +2076,6 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -2387,7 +2383,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -2816,8 +2811,7 @@ "version": "1.11.19", "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.19.tgz", "integrity": "sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/debug": { "version": "4.4.3", @@ -3100,7 +3094,6 @@ "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -4114,7 +4107,6 @@ "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", "dev": true, "license": "MIT", - "peer": true, "bin": { "jiti": "bin/jiti.js" } @@ -5242,8 +5234,7 @@ "version": "0.45.0", "resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.45.0.tgz", "integrity": "sha512-mjv1G1ZzfEE3k9HZN0dQ2olMdwIfaeAAjFiwNprLfYNRSz7ctv9XuCT7gPtBGrMUeV1/iZzYKj17Khu1hxoHOA==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/ms": { "version": "2.1.3", @@ -5530,7 +5521,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -6368,7 +6358,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "license": "MIT", - "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -6381,7 +6370,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", "license": "MIT", - "peer": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" @@ -7245,7 +7233,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -7344,7 +7331,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -7553,7 +7539,6 @@ "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 84d3f02..f179820 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,10 +1,11 @@ import React from 'react' import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom' import { ConfigProvider, Layout, Menu, theme } from 'antd' -import { DashboardOutlined, PlusOutlined, BookOutlined, SettingOutlined } from '@ant-design/icons' +import { DashboardOutlined, BookOutlined, SettingOutlined } from '@ant-design/icons' import { ProjectList } from './pages/ProjectList' import { ProjectCreateEnhanced } from './pages/ProjectCreateEnhanced' +import { ProjectCreateProgressive } from './pages/ProjectCreateProgressive' import { ProjectDetail } from './pages/ProjectDetail' import { ProjectWorkspace } from './pages/ProjectWorkspace' import { SkillManagement } from './pages/SkillManagement' @@ -21,7 +22,6 @@ function App() { const menuItems = [ { key: '/projects', icon: , label: '项目列表' }, - { key: '/projects/new', icon: , label: '创建项目' }, { key: '/skills', icon: , label: 'Skills 管理' }, { key: '/agents', icon: , label: 'Agents 管理' }, ] @@ -69,6 +69,7 @@ function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/frontend/src/components/ContentEditor.tsx b/frontend/src/components/ContentEditor.tsx index 7277c98..06c95ec 100644 --- a/frontend/src/components/ContentEditor.tsx +++ b/frontend/src/components/ContentEditor.tsx @@ -386,7 +386,7 @@ export const ContentEditor: React.FC = ({ /> ) : (
diff --git a/frontend/src/components/SkillCreate.tsx b/frontend/src/components/SkillCreate.tsx new file mode 100644 index 0000000..7e750bf --- /dev/null +++ b/frontend/src/components/SkillCreate.tsx @@ -0,0 +1,940 @@ +/** + * Skill 创建组件 - 统一流程 + * + * **正确的创建流程**(遵循 skill-creator 标准): + * 1. 用户输入需求 + * 2. AI 使用 skill-creator 标准生成基础 SKILL.md + * 3. 用户预览完整内容(可手动编辑) + * 4. 可选择上传 references(文档/GitHub/PDF) + * 5. 保存为完整 Skill(包含 SKILL.md + references/) + * + * **关键修复**: + * - AI 生成和 References 上传是同一流程的不同步骤,而非分离的模式 + * - 预览内容完整显示,无高度限制 + * - 支持在编辑时随意增删 references + */ +import { useState, useRef, useEffect } from 'react' +import { + Modal, + Form, + Input, + Select, + Button, + Space, + Card, + Alert, + message, + Spin, + Typography, + Tag, + Upload, + Divider, + Steps, + Tabs, + Row, + Col, + List, + Popconfirm, + Tooltip +} from 'antd' +import { + RobotOutlined, + CheckCircleOutlined, + FileTextOutlined, + UploadOutlined, + LinkOutlined, + GithubOutlined, + FilePdfOutlined, + PlusOutlined, + DeleteOutlined, + EditOutlined, + SaveOutlined, + ArrowLeftOutlined, + EyeOutlined, + InboxOutlined, + CopyOutlined +} from '@ant-design/icons' +import { skillService } from '@/services' +import { taskService } from '@/services/taskService' +import TaskProgressTracker from '@/components/TaskProgressTracker' + +const { TextArea } = Input +const { Text, Title, Paragraph } = Typography +const { Step } = Steps +const { TabPane } = Tabs +const { Option } = Select + +interface SkillCreateProps { + visible: boolean + onClose: () => void + onSuccess: () => void + editingSkillId?: string // 如果传入,表示编辑模式 +} + +interface ReferenceFile { + id: string + type: 'url' | 'github' | 'upload' | 'manual' + filename: string + url?: string + content?: string + size?: number + uploadedAt: Date +} + +interface GeneratedSkill { + suggested_id: string + suggested_name: string + skill_content: string + category: string + suggested_tags: string[] + explanation: string +} + +type CreateStep = 'input' | 'generating' | 'preview' | 'references' | 'saving' + +export const SkillCreate: React.FC = ({ + visible, + onClose, + onSuccess, + editingSkillId +}) => { + const [form] = Form.useForm() + + // 步骤状态 + const [step, setStep] = useState('input') + const [aiDescription, setAiDescription] = useState('') + const [generating, setGenerating] = useState(false) + const [currentTaskId, setCurrentTaskId] = useState(null) + + // 生成的Skill内容 + const [generatedSkill, setGeneratedSkill] = useState(null) + const [editingContent, setEditingContent] = useState(false) + const [saving, setSaving] = useState(false) + + // References管理 + const [references, setReferences] = useState([]) + const [activeReferenceTab, setActiveReferenceTab] = useState<'url' | 'github' | 'upload' | 'manual'>('url') + const [urlInput, setUrlInput] = useState('') + const [githubInput, setGithubInput] = useState('') + const [manualFileName, setManualFileName] = useState('') + const [manualContent, setManualContent] = useState('') + const [uploadedFiles, setUploadedFiles] = useState([]) + + // 预览ID(用于保存) + const [previewId, setPreviewId] = useState(null) + + const contentPreviewRef = useRef(null) + + // 重置表单 + const resetForm = () => { + setStep('input') + setAiDescription('') + setGeneratedSkill(null) + setEditingContent(false) + setReferences([]) + setUrlInput('') + setGithubInput('') + setManualFileName('') + setManualContent('') + setUploadedFiles([]) + setPreviewId(null) + setCurrentTaskId(null) + form.resetFields() + } + + // 加载编辑中的Skill + useEffect(() => { + if (editingSkillId && visible) { + loadSkillForEdit(editingSkillId) + } else if (visible) { + resetForm() + setStep('input') + } + }, [visible, editingSkillId]) + + const loadSkillForEdit = async (skillId: string) => { + try { + const result = await skillService.getSkillWithReferences(skillId, true) + if (result) { + const { skill, references: refs } = result + + // 设置生成的内容 + setGeneratedSkill({ + suggested_id: skill.id, + suggested_name: skill.name, + skill_content: skill.behavior_guide, + category: skill.category, + suggested_tags: skill.tags || [], + explanation: `正在编辑 Skill: ${skill.name}` + }) + + form.setFieldsValue({ + content: skill.behavior_guide + }) + + // 加载references + if (refs) { + const refFiles: ReferenceFile[] = Object.entries(refs).map(([filename, content], idx) => ({ + id: `ref-${idx}`, + type: 'manual', + filename, + content: content as string, + uploadedAt: new Date() + })) + setReferences(refFiles) + } + + setStep('preview') + } + } catch (error) { + message.error(`加载Skill失败: ${(error as Error).message}`) + } + } + + const handleClose = () => { + resetForm() + onClose() + } + + // ========== 步骤1: 输入需求 ========== + const handleGenerate = async () => { + if (!aiDescription.trim()) { + message.warning('请描述你想要创建的 Skill') + return + } + + setGenerating(true) + setStep('generating') + + try { + // 创建异步任务 + const taskResult = await taskService.generateSkill({ + description: aiDescription, + temperature: 0.7 + }) + + setCurrentTaskId(taskResult.taskId) + message.success('任务已创建,正在生成中...') + + // 轮询任务直到完成 + const result = await taskService.pollTask(taskResult.taskId, (progress) => { + // 可以在这里更新进度UI,如果需要的话 + 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') + message.success('AI 生成成功!您可以预览和编辑内容') + } else { + throw new Error(result.error || '生成失败') + } + } catch (error) { + message.error(`AI 生成失败: ${(error as Error).message}`) + setStep('input') + } finally { + setGenerating(false) + setCurrentTaskId(null) + } + } + + // ========== 步骤2: 预览和编辑 ========== + const handleStartEditing = () => { + setEditingContent(true) + } + + const handleCancelEdit = () => { + if (generatedSkill) { + form.setFieldsValue({ content: generatedSkill.skill_content }) + } + setEditingContent(false) + } + + const handleConfirmEdit = () => { + const newContent = form.getFieldValue('content') + if (newContent && generatedSkill) { + setGeneratedSkill({ + ...generatedSkill, + skill_content: newContent + }) + message.success('内容已更新') + } + setEditingContent(false) + } + + // 使用AI调整内容 + const handleRefineWithAI = async () => { + const currentContent = form.getFieldValue('content') + if (!currentContent) { + message.warning('没有可调整的内容') + return + } + + const refinePrompt = await new Promise((resolve) => { + Modal.input({ + title: '输入调整需求', + placeholder: '例如:把内容改得更简洁、增加代码示例、优化描述...', + onOk: (value) => resolve(value || '') + }) + }) + + if (!refinePrompt) return + + setGenerating(true) + try { + const result = await skillService.refineSkill(currentContent, refinePrompt, 0.7) + + if (result.success) { + form.setFieldsValue({ content: result.refined_content }) + setGeneratedSkill(prev => prev ? { ...prev, skill_content: result.refined_content } : null) + + Modal.info({ + title: '调整完成', + content: ( +
+

变更说明:{result.changes_summary}

+

原始长度: {result.original_length} 字符

+

新长度: {result.new_length} 字符

+
+ ) + }) + } + } catch (error) { + message.error(`调整失败: ${(error as Error).message}`) + } finally { + setGenerating(false) + } + } + + // 复制内容到剪贴板 + const handleCopyContent = () => { + const content = form.getFieldValue('content') + if (content) { + navigator.clipboard.writeText(content) + message.success('已复制到剪贴板') + } + } + + // ========== 步骤3: References管理 ========== + const handleAddUrlReference = async () => { + if (!urlInput.trim()) { + message.warning('请输入文档URL') + return + } + + try { + const result = await skillService.fetchDoc(urlInput) + if (result.success) { + const ref: ReferenceFile = { + id: `ref-${Date.now()}`, + type: 'url', + filename: result.title || 'untitled', + url: urlInput, + content: result.content, + uploadedAt: new Date() + } + setReferences([...references, ref]) + setUrlInput('') + message.success('文档获取成功') + } else { + message.error(`获取文档失败: ${result.error}`) + } + } catch (error) { + message.error(`获取文档失败: ${(error as Error).message}`) + } + } + + const handleAddGithubReference = async () => { + if (!githubInput.trim()) { + message.warning('请输入GitHub仓库URL') + return + } + + // 解析GitHub URL + const match = githubInput.match(/github\.com\/([^/]+)\/([^/]+)/) + if (!match) { + message.warning('GitHub URL格式不正确,应为: https://github.com/owner/repo') + return + } + + const repoUrl = `https://github.com/${match[1]}/${match[2]}` + const docsPath = 'README.md' // 默认读取README + + try { + const result = await skillService.fetchGithubDoc(repoUrl, docsPath) + if (result.success) { + const ref: ReferenceFile = { + id: `ref-${Date.now()}`, + type: 'github', + filename: `${match[2]}-${docsPath}`, + url: githubInput, + content: result.content, + uploadedAt: new Date() + } + setReferences([...references, ref]) + setGithubInput('') + message.success('GitHub文档获取成功') + } else { + message.error(`获取GitHub文档失败: ${result.error}`) + } + } catch (error) { + message.error(`获取GitHub文档失败: ${(error as Error).message}`) + } + } + + const handleAddManualReference = () => { + if (!manualFileName.trim() || !manualContent.trim()) { + message.warning('请输入文件名和内容') + return + } + + const ref: ReferenceFile = { + id: `ref-${Date.now()}`, + type: 'manual', + filename: manualFileName.endsWith('.md') ? manualFileName : `${manualFileName}.md`, + content: manualContent, + uploadedAt: new Date() + } + + setReferences([...references, ref]) + setManualFileName('') + setManualContent('') + message.success('参考文档添加成功') + } + + const handleUploadFiles = (file: File) => { + // 文件上传处理(TODO: 实际上传到服务器) + const reader = new FileReader() + reader.onload = (e) => { + const ref: ReferenceFile = { + id: `ref-${Date.now()}`, + type: 'upload', + filename: file.name, + content: e.target?.result as string, + size: file.size, + uploadedAt: new Date() + } + setReferences([...references, ref]) + } + reader.readAsText(file) + return false // 阻止自动上传 + } + + const handleDeleteReference = (refId: string) => { + setReferences(references.filter(r => r.id !== refId)) + message.success('参考文档已删除') + } + + const handleViewReference = (ref: ReferenceFile) => { + Modal.info({ + title: ref.filename, + width: 900, + content: ( +
+ {ref.content} +
+ ) + }) + } + + // ========== 步骤4: 保存 ========== + const handleSaveSkill = async () => { + if (!generatedSkill) return + + setSaving(true) + try { + const skillContent = form.getFieldValue('content') + + // 构建references对象 + const referencesObj: Record = {} + references.forEach(ref => { + referencesObj[ref.filename] = ref.content || '' + }) + + // 判断是新建还是编辑 + if (editingSkillId) { + // 编辑模式:更新现有Skill + await skillService.updateSkillWithReferences( + editingSkillId, + skillContent, + referencesObj + ) + message.success('Skill 更新成功!') + } else { + // 新建模式:创建新Skill + // 如果没有previewId,先生成一个 + let currentPreviewId = previewId + if (!currentPreviewId) { + const previewResult = await skillService.previewSkill({ + skill_content: skillContent, + skill_name: generatedSkill.suggested_name, + category: generatedSkill.category, + tags: generatedSkill.suggested_tags + }) + currentPreviewId = previewResult.preview_id + setPreviewId(currentPreviewId) + } + + // 保存Skill + await skillService.saveSkillFromPreview( + currentPreviewId, + generatedSkill.suggested_id, + skillContent, + referencesObj + ) + message.success('Skill 创建成功!') + } + + onSuccess() + handleClose() + } catch (error) { + message.error(`保存失败: ${(error as Error).message}`) + } finally { + setSaving(false) + } + } + + // 计算步骤进度 + const getStepProgress = () => { + switch (step) { + case 'input': return 0 + case 'generating': return 1 + case 'preview': return 2 + case 'references': return 2 + case 'saving': return 3 + default: return 0 + } + } + + return ( + + + {editingSkillId ? '编辑 Skill' : '创建 Skill'} + + } + open={visible} + onCancel={handleClose} + footer={null} + width={1000} + destroyOnClose + > + + + {/* 步骤指示器 */} + + + + + + + + + {/* ========== 步骤1: 输入需求 ========== */} + {step === 'input' && ( + + + +
+ +