feat:项目初步开发和debug
This commit is contained in:
parent
d0e11aef1b
commit
1f10bb4406
@ -6,7 +6,9 @@
|
|||||||
"Bash(python:*)",
|
"Bash(python:*)",
|
||||||
"Bash(npm run build)",
|
"Bash(npm run build)",
|
||||||
"Bash(npx tsc:*)",
|
"Bash(npx tsc:*)",
|
||||||
"Bash(findstr:*)"
|
"Bash(findstr:*)",
|
||||||
|
"Bash(tree:*)",
|
||||||
|
"Bash(npx eslint:*)"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
2
.gitignore
vendored
2
.gitignore
vendored
@ -5,4 +5,4 @@ __pycache__/
|
|||||||
.env
|
.env
|
||||||
.claude/
|
.claude/
|
||||||
.venv/
|
.venv/
|
||||||
|
.settings.local.json
|
||||||
|
|||||||
1
backend/.gitignore
vendored
1
backend/.gitignore
vendored
@ -109,3 +109,4 @@ dmypy.json
|
|||||||
logs/
|
logs/
|
||||||
storage/
|
storage/
|
||||||
*.db
|
*.db
|
||||||
|
.settings.local.json
|
||||||
@ -3,13 +3,16 @@ AI 辅助生成 API
|
|||||||
|
|
||||||
提供 AI 辅助生成人物、大纲、解析剧本等功能
|
提供 AI 辅助生成人物、大纲、解析剧本等功能
|
||||||
支持 Skills 融入:将选定的 Skills 行为指导融入 LLM 调用
|
支持 Skills 融入:将选定的 Skills 行为指导融入 LLM 调用
|
||||||
|
支持长剧本分段分析:自动切分并合并结果
|
||||||
"""
|
"""
|
||||||
from fastapi import APIRouter, HTTPException
|
from fastapi import APIRouter, HTTPException
|
||||||
from typing import Dict, Any, List, Optional
|
from typing import Dict, Any, List, Optional
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
import asyncio
|
||||||
from app.core.llm.glm_client import get_glm_client
|
from app.core.llm.glm_client import get_glm_client
|
||||||
from app.core.skills.skill_manager import get_skill_manager
|
from app.core.skills.skill_manager import get_skill_manager
|
||||||
from app.utils.logger import get_logger
|
from app.utils.logger import get_logger
|
||||||
|
from app.utils.script_splitter import split_script, ScriptSplitter
|
||||||
|
|
||||||
logger = get_logger(__name__)
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
@ -29,6 +32,7 @@ class GenerateCharactersRequest(BaseModel):
|
|||||||
projectName: Optional[str] = None
|
projectName: Optional[str] = None
|
||||||
totalEpisodes: Optional[int] = None
|
totalEpisodes: Optional[int] = None
|
||||||
skills: Optional[List[SkillInfo]] = None # 新增:Skills 列表
|
skills: Optional[List[SkillInfo]] = None # 新增:Skills 列表
|
||||||
|
customPrompt: Optional[str] = None # 自定义提示词
|
||||||
|
|
||||||
|
|
||||||
class GenerateOutlineRequest(BaseModel):
|
class GenerateOutlineRequest(BaseModel):
|
||||||
@ -38,6 +42,7 @@ class GenerateOutlineRequest(BaseModel):
|
|||||||
genre: str = "古风"
|
genre: str = "古风"
|
||||||
projectName: Optional[str] = None
|
projectName: Optional[str] = None
|
||||||
skills: Optional[List[SkillInfo]] = None # 新增:Skills 列表
|
skills: Optional[List[SkillInfo]] = None # 新增:Skills 列表
|
||||||
|
customPrompt: Optional[str] = None # 自定义提示词
|
||||||
|
|
||||||
|
|
||||||
class ParseScriptRequest(BaseModel):
|
class ParseScriptRequest(BaseModel):
|
||||||
@ -47,6 +52,24 @@ class ParseScriptRequest(BaseModel):
|
|||||||
extractOutline: bool = True
|
extractOutline: bool = True
|
||||||
skills: Optional[List[SkillInfo]] = None # 新增:Skills 列表
|
skills: Optional[List[SkillInfo]] = None # 新增:Skills 列表
|
||||||
use_llm: bool = True # 新增:是否使用 LLM 分析(默认 True)
|
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:
|
if request.totalEpisodes:
|
||||||
extra_info += f"\n总集数:{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 个主要人物设定:
|
prompt = f"""请根据以下想法生成 3-5 个主要人物设定:
|
||||||
|
|
||||||
用户想法:{request.idea}{extra_info}
|
用户想法:{request.idea}{extra_info}{custom_requirements}
|
||||||
|
|
||||||
要求:
|
要求:
|
||||||
1. 每个人物包含:姓名、身份、性格、说话风格、背景故事
|
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"""请根据以下想法生成完整的剧集大纲:
|
prompt = f"""请根据以下想法生成完整的剧集大纲:
|
||||||
|
|
||||||
用户想法:{request.idea}
|
用户想法:{request.idea}
|
||||||
总集数:{request.totalEpisodes}
|
总集数:{request.totalEpisodes}
|
||||||
类型:{request.genre}
|
类型:{request.genre}
|
||||||
{f'项目名称:{request.projectName}' if request.projectName else ''}
|
{f'项目名称:{request.projectName}' if request.projectName else ''}{custom_requirements}
|
||||||
|
|
||||||
要求:
|
要求:
|
||||||
1. 将故事分为 4-5 个阶段
|
1. 将故事分为 4-5 个阶段
|
||||||
@ -237,6 +270,141 @@ async def generate_outline(request: GenerateOutlineRequest):
|
|||||||
raise HTTPException(status_code=500, detail=f"生成失败: {str(e)}")
|
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")
|
@router.post("/parse/script")
|
||||||
async def parse_script(request: ParseScriptRequest):
|
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]:
|
async def _parse_script_with_llm(request: ParseScriptRequest) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
使用 LLM 智能分析剧本,支持 Skills 融入
|
使用 LLM 智能分析剧本,支持 Skills 融入
|
||||||
|
支持长剧本分段分析和结果合并
|
||||||
"""
|
"""
|
||||||
glm_client = get_glm_client()
|
glm_client = get_glm_client()
|
||||||
skill_manager = get_skill_manager()
|
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)
|
# 构建增强的 System Prompt(融入 Skills)
|
||||||
base_role = """你是专业的剧本分析专家,擅长从剧本中提取人物关系、剧情结构、对话风格等关键信息。
|
base_role = """你是专业的剧本分析专家,擅长从剧本中提取人物关系、剧情结构、对话风格等关键信息。
|
||||||
你能识别人物的出场频率、人物关系、情感走向、对话特点等深层次信息。"""
|
你能识别人物的出场频率、人物关系、情感走向、对话特点等深层次信息。"""
|
||||||
@ -278,12 +480,6 @@ async def _parse_script_with_llm(request: ParseScriptRequest) -> Dict[str, Any]:
|
|||||||
skill_manager=skill_manager
|
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 = []
|
analysis_requirements = []
|
||||||
if request.extractCharacters:
|
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"""请分析以下剧本内容,提取关键信息:
|
prompt = f"""请分析以下剧本内容,提取关键信息:
|
||||||
|
|
||||||
{content}
|
{content}
|
||||||
|
|
||||||
要求:
|
要求:
|
||||||
{''.join(analysis_requirements)}
|
{''.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(
|
response = await glm_client.chat(
|
||||||
messages=[
|
messages=[
|
||||||
@ -329,14 +530,274 @@ async def _parse_script_with_llm(request: ParseScriptRequest) -> Dict[str, Any]:
|
|||||||
return {
|
return {
|
||||||
"success": True,
|
"success": True,
|
||||||
"method": "llm",
|
"method": "llm",
|
||||||
"analysis": analysis_result, # LLM 的完整分析结果
|
"analysis": analysis_result,
|
||||||
"characters": [], # 可选:从分析结果中解析
|
"characters": [],
|
||||||
"outline": "", # 可选:从分析结果中解析
|
"outline": "",
|
||||||
"summary": f"LLM 智能分析完成,使用 {len(request.skills) if request.skills else 0} 个 Skills",
|
"summary": f"LLM 智能分析完成 ({len(content)} 字符),使用 {len(request.skills) if request.skills else 0} 个 Skills",
|
||||||
"usage": response.get("usage")
|
"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]:
|
async def _parse_script_with_regex(request: ParseScriptRequest) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
使用正则表达式快速提取剧本信息(不使用 LLM,不消耗 token)
|
使用正则表达式快速提取剧本信息(不使用 LLM,不消耗 token)
|
||||||
|
|||||||
429
backend/app/api/v1/ai_async.py
Normal file
429
backend/app/api/v1/ai_async.py
Normal file
@ -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": "任务已创建,正在后台执行"
|
||||||
|
}
|
||||||
185
backend/app/api/v1/async_tasks.py
Normal file
185
backend/app/api/v1/async_tasks.py
Normal file
@ -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
|
||||||
|
]
|
||||||
@ -7,7 +7,9 @@ Skill 管理 API 路由
|
|||||||
3. AI 辅助 Skill 创建(完整流程)
|
3. AI 辅助 Skill 创建(完整流程)
|
||||||
4. Skill 选择和路由(LLM 与 Skills 结合)
|
4. Skill 选择和路由(LLM 与 Skills 结合)
|
||||||
5. Agent 工作流配置
|
5. Agent 工作流配置
|
||||||
|
6. 文档驱动的 Skill 生成(新增)
|
||||||
"""
|
"""
|
||||||
|
from datetime import datetime
|
||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
from typing import List, Optional, Dict, Any
|
from typing import List, Optional, Dict, Any
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
@ -21,6 +23,16 @@ from app.models.skill import (
|
|||||||
SkillTestResponse,
|
SkillTestResponse,
|
||||||
SkillGenerateRequest,
|
SkillGenerateRequest,
|
||||||
SkillGenerateResponse,
|
SkillGenerateResponse,
|
||||||
|
# 新增模型
|
||||||
|
DocFetchRequest,
|
||||||
|
GitHubDocFetchRequest,
|
||||||
|
SkillGenerateFromDocsRequest,
|
||||||
|
SkillGenerateFromDocsResponse,
|
||||||
|
SkillPreviewRequest,
|
||||||
|
SkillPreviewResponse,
|
||||||
|
SkillSaveFromPreviewRequest,
|
||||||
|
SkillRefineRequest,
|
||||||
|
SkillRefineResponse,
|
||||||
)
|
)
|
||||||
from app.models.skill_integration import (
|
from app.models.skill_integration import (
|
||||||
SkillMetadata,
|
SkillMetadata,
|
||||||
@ -89,9 +101,40 @@ async def create_skill(
|
|||||||
skill_data: SkillCreate,
|
skill_data: SkillCreate,
|
||||||
skill_manager: SkillManager = Depends(get_skill_manager)
|
skill_manager: SkillManager = Depends(get_skill_manager)
|
||||||
):
|
):
|
||||||
"""创建新的用户 Skill"""
|
"""
|
||||||
|
创建新的用户 Skill
|
||||||
|
|
||||||
|
如果不提供 ID,系统将自动生成:
|
||||||
|
- 使用 UUID 作为唯一标识
|
||||||
|
- 基于名称生成可读 ID(kebab-case)
|
||||||
|
"""
|
||||||
try:
|
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
|
return skill
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"创建 Skill 失败: {str(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)
|
result = await skill_manager.route_and_execute_tool(tool_name, parameters)
|
||||||
return result
|
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)}"
|
||||||
|
)
|
||||||
|
|||||||
226
backend/app/api/v1/skills_async.py
Normal file
226
backend/app/api/v1/skills_async.py
Normal file
@ -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)
|
||||||
|
}
|
||||||
355
backend/app/core/documentation_fetcher.py
Normal file
355
backend/app/core/documentation_fetcher.py
Normal file
@ -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
|
||||||
@ -296,6 +296,9 @@ class SkillManager:
|
|||||||
提取行为指导部分(核心功能)
|
提取行为指导部分(核心功能)
|
||||||
|
|
||||||
查找 ## 行为指导 或 ## Behavior Guide 部分
|
查找 ## 行为指导 或 ## Behavior Guide 部分
|
||||||
|
|
||||||
|
如果找到专门的章节,返回该章节内容;
|
||||||
|
如果没有找到,返回完整内容(整个SKILL.md就是行为指导)
|
||||||
"""
|
"""
|
||||||
lines = content.split('\n')
|
lines = content.split('\n')
|
||||||
start_idx = -1
|
start_idx = -1
|
||||||
@ -306,8 +309,9 @@ class SkillManager:
|
|||||||
break
|
break
|
||||||
|
|
||||||
if start_idx == -1:
|
if start_idx == -1:
|
||||||
# 如果没有专门的章节,尝试从整体提取
|
# 如果没有专门的章节,返回完整内容
|
||||||
return content[:500] # 返回前500字符作为默认
|
# 整个SKILL.md就是行为指导,不应该截断
|
||||||
|
return content
|
||||||
|
|
||||||
# 提取到下一个 ## 之前的内容
|
# 提取到下一个 ## 之前的内容
|
||||||
guide_lines = []
|
guide_lines = []
|
||||||
|
|||||||
258
backend/app/core/task_manager.py
Normal file
258
backend/app/core/task_manager.py
Normal file
@ -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
|
||||||
@ -108,12 +108,15 @@ async def health_check():
|
|||||||
# ============================================
|
# ============================================
|
||||||
# API 路由注册
|
# 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.router, prefix="/api/v1")
|
||||||
|
app.include_router(skills_async.router, prefix="/api/v1")
|
||||||
app.include_router(projects.router, prefix="/api/v1")
|
app.include_router(projects.router, prefix="/api/v1")
|
||||||
app.include_router(episodes.router, prefix="/api/v1")
|
app.include_router(episodes.router, prefix="/api/v1")
|
||||||
app.include_router(ai_assistant.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(memory.router, prefix="/api/v1")
|
||||||
app.include_router(review.router, prefix="/api/v1")
|
app.include_router(review.router, prefix="/api/v1")
|
||||||
app.include_router(uploads.router, prefix="/api/v1")
|
app.include_router(uploads.router, prefix="/api/v1")
|
||||||
|
|||||||
@ -65,7 +65,7 @@ class Skill(BaseModel):
|
|||||||
|
|
||||||
class SkillCreate(BaseModel):
|
class SkillCreate(BaseModel):
|
||||||
"""创建 Skill 请求"""
|
"""创建 Skill 请求"""
|
||||||
id: str
|
id: Optional[str] = None # ID 可选,不传则自动生成
|
||||||
name: str
|
name: str
|
||||||
content: str # Markdown 格式的完整 Skill 内容
|
content: str # Markdown 格式的完整 Skill 内容
|
||||||
category: str = "通用"
|
category: str = "通用"
|
||||||
@ -118,3 +118,90 @@ class SkillGenerateResponse(BaseModel):
|
|||||||
category: str
|
category: str
|
||||||
suggested_tags: List[str]
|
suggested_tags: List[str]
|
||||||
explanation: str # AI 对生成的 Skill 的说明
|
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
|
||||||
|
|||||||
82
backend/app/models/task.py
Normal file
82
backend/app/models/task.py
Normal file
@ -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
|
||||||
324
backend/app/utils/script_splitter.py
Normal file
324
backend/app/utils/script_splitter.py
Normal file
@ -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
|
||||||
|
}
|
||||||
406
backend/docs/SKILL_GENERATION_GUIDE.md
Normal file
406
backend/docs/SKILL_GENERATION_GUIDE.md
Normal file
@ -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
|
||||||
31
backend/skills_storage/user_skills/古风对话创作-856130/SKILL.md
Normal file
31
backend/skills_storage/user_skills/古风对话创作-856130/SKILL.md
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
---
|
||||||
|
name: ancient-style-dialogue-writer
|
||||||
|
description: 专门用于创作符合中国古代背景、角色身份地位及性格特征的对话内容。
|
||||||
|
---
|
||||||
|
|
||||||
|
# 古风对话创作
|
||||||
|
|
||||||
|
此 Skill 用于指导生成符合中国古代语境的角色对话,确保语言风格贴合人物身份与时代背景。
|
||||||
|
|
||||||
|
## 角色分析
|
||||||
|
|
||||||
|
在创作对话前,需明确以下要素:
|
||||||
|
- **身份地位**:皇室、官宦、平民、江湖人士、僧道等。
|
||||||
|
- **性格特征**:傲慢、谦卑、儒雅、粗犷、狡诈等。
|
||||||
|
- **时代背景**:先秦、汉唐、宋明或架空朝代,不同时代用词习惯略有差异。
|
||||||
|
- **对话对象**:君臣、父子、挚友、仇敌等关系决定语气。
|
||||||
|
|
||||||
|
## 语言风格指南
|
||||||
|
|
||||||
|
### 1. 代词与称呼
|
||||||
|
- **自称**:
|
||||||
|
- 皇室:朕、孤、本宫。
|
||||||
|
- 官员/文人:下官、学生、在下、愚兄。
|
||||||
|
- 普通百姓/江湖:老朽、洒家、奴家、妾身、某。
|
||||||
|
- 通称:吾、余、我。
|
||||||
|
- **对称**:
|
||||||
|
- 尊称:陛下、殿下、大人、阁下、足下、先生、姑娘。
|
||||||
|
- 谦称/贬称:竖子、匹夫、老贼、尔、汝。
|
||||||
|
|
||||||
|
### 2. 语气与句式
|
||||||
|
- **文雅含
|
||||||
19
frontend/package-lock.json
generated
19
frontend/package-lock.json
generated
@ -181,7 +181,6 @@
|
|||||||
"integrity": "sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw==",
|
"integrity": "sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/code-frame": "^7.28.6",
|
"@babel/code-frame": "^7.28.6",
|
||||||
"@babel/generator": "^7.28.6",
|
"@babel/generator": "^7.28.6",
|
||||||
@ -1818,7 +1817,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.27.tgz",
|
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.27.tgz",
|
||||||
"integrity": "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==",
|
"integrity": "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/prop-types": "*",
|
"@types/prop-types": "*",
|
||||||
"csstype": "^3.2.2"
|
"csstype": "^3.2.2"
|
||||||
@ -1889,7 +1887,6 @@
|
|||||||
"integrity": "sha512-tbsV1jPne5CkFQCgPBcDOt30ItF7aJoZL997JSF7MhGQqOeT3svWRYxiqlfA5RUdlHN6Fi+EI9bxqbdyAUZjYQ==",
|
"integrity": "sha512-tbsV1jPne5CkFQCgPBcDOt30ItF7aJoZL997JSF7MhGQqOeT3svWRYxiqlfA5RUdlHN6Fi+EI9bxqbdyAUZjYQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "BSD-2-Clause",
|
"license": "BSD-2-Clause",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@typescript-eslint/scope-manager": "6.21.0",
|
"@typescript-eslint/scope-manager": "6.21.0",
|
||||||
"@typescript-eslint/types": "6.21.0",
|
"@typescript-eslint/types": "6.21.0",
|
||||||
@ -2079,7 +2076,6 @@
|
|||||||
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"bin": {
|
"bin": {
|
||||||
"acorn": "bin/acorn"
|
"acorn": "bin/acorn"
|
||||||
},
|
},
|
||||||
@ -2387,7 +2383,6 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"baseline-browser-mapping": "^2.9.0",
|
"baseline-browser-mapping": "^2.9.0",
|
||||||
"caniuse-lite": "^1.0.30001759",
|
"caniuse-lite": "^1.0.30001759",
|
||||||
@ -2816,8 +2811,7 @@
|
|||||||
"version": "1.11.19",
|
"version": "1.11.19",
|
||||||
"resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.19.tgz",
|
"resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.19.tgz",
|
||||||
"integrity": "sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw==",
|
"integrity": "sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw==",
|
||||||
"license": "MIT",
|
"license": "MIT"
|
||||||
"peer": true
|
|
||||||
},
|
},
|
||||||
"node_modules/debug": {
|
"node_modules/debug": {
|
||||||
"version": "4.4.3",
|
"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.",
|
"deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@eslint-community/eslint-utils": "^4.2.0",
|
"@eslint-community/eslint-utils": "^4.2.0",
|
||||||
"@eslint-community/regexpp": "^4.6.1",
|
"@eslint-community/regexpp": "^4.6.1",
|
||||||
@ -4114,7 +4107,6 @@
|
|||||||
"integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==",
|
"integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"bin": {
|
"bin": {
|
||||||
"jiti": "bin/jiti.js"
|
"jiti": "bin/jiti.js"
|
||||||
}
|
}
|
||||||
@ -5242,8 +5234,7 @@
|
|||||||
"version": "0.45.0",
|
"version": "0.45.0",
|
||||||
"resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.45.0.tgz",
|
"resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.45.0.tgz",
|
||||||
"integrity": "sha512-mjv1G1ZzfEE3k9HZN0dQ2olMdwIfaeAAjFiwNprLfYNRSz7ctv9XuCT7gPtBGrMUeV1/iZzYKj17Khu1hxoHOA==",
|
"integrity": "sha512-mjv1G1ZzfEE3k9HZN0dQ2olMdwIfaeAAjFiwNprLfYNRSz7ctv9XuCT7gPtBGrMUeV1/iZzYKj17Khu1hxoHOA==",
|
||||||
"license": "MIT",
|
"license": "MIT"
|
||||||
"peer": true
|
|
||||||
},
|
},
|
||||||
"node_modules/ms": {
|
"node_modules/ms": {
|
||||||
"version": "2.1.3",
|
"version": "2.1.3",
|
||||||
@ -5530,7 +5521,6 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"nanoid": "^3.3.11",
|
"nanoid": "^3.3.11",
|
||||||
"picocolors": "^1.1.1",
|
"picocolors": "^1.1.1",
|
||||||
@ -6368,7 +6358,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
|
||||||
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
|
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"loose-envify": "^1.1.0"
|
"loose-envify": "^1.1.0"
|
||||||
},
|
},
|
||||||
@ -6381,7 +6370,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
|
||||||
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
|
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"loose-envify": "^1.1.0",
|
"loose-envify": "^1.1.0",
|
||||||
"scheduler": "^0.23.2"
|
"scheduler": "^0.23.2"
|
||||||
@ -7245,7 +7233,6 @@
|
|||||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
},
|
},
|
||||||
@ -7344,7 +7331,6 @@
|
|||||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"peer": true,
|
|
||||||
"bin": {
|
"bin": {
|
||||||
"tsc": "bin/tsc",
|
"tsc": "bin/tsc",
|
||||||
"tsserver": "bin/tsserver"
|
"tsserver": "bin/tsserver"
|
||||||
@ -7553,7 +7539,6 @@
|
|||||||
"integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==",
|
"integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"esbuild": "^0.21.3",
|
"esbuild": "^0.21.3",
|
||||||
"postcss": "^8.4.43",
|
"postcss": "^8.4.43",
|
||||||
|
|||||||
@ -1,10 +1,11 @@
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'
|
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'
|
||||||
import { ConfigProvider, Layout, Menu, theme } from 'antd'
|
import { 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 { ProjectList } from './pages/ProjectList'
|
||||||
import { ProjectCreateEnhanced } from './pages/ProjectCreateEnhanced'
|
import { ProjectCreateEnhanced } from './pages/ProjectCreateEnhanced'
|
||||||
|
import { ProjectCreateProgressive } from './pages/ProjectCreateProgressive'
|
||||||
import { ProjectDetail } from './pages/ProjectDetail'
|
import { ProjectDetail } from './pages/ProjectDetail'
|
||||||
import { ProjectWorkspace } from './pages/ProjectWorkspace'
|
import { ProjectWorkspace } from './pages/ProjectWorkspace'
|
||||||
import { SkillManagement } from './pages/SkillManagement'
|
import { SkillManagement } from './pages/SkillManagement'
|
||||||
@ -21,7 +22,6 @@ function App() {
|
|||||||
|
|
||||||
const menuItems = [
|
const menuItems = [
|
||||||
{ key: '/projects', icon: <DashboardOutlined />, label: '项目列表' },
|
{ key: '/projects', icon: <DashboardOutlined />, label: '项目列表' },
|
||||||
{ key: '/projects/new', icon: <PlusOutlined />, label: '创建项目' },
|
|
||||||
{ key: '/skills', icon: <BookOutlined />, label: 'Skills 管理' },
|
{ key: '/skills', icon: <BookOutlined />, label: 'Skills 管理' },
|
||||||
{ key: '/agents', icon: <SettingOutlined />, label: 'Agents 管理' },
|
{ key: '/agents', icon: <SettingOutlined />, label: 'Agents 管理' },
|
||||||
]
|
]
|
||||||
@ -69,6 +69,7 @@ function App() {
|
|||||||
<Route path="/" element={<Navigate to="/projects" replace />} />
|
<Route path="/" element={<Navigate to="/projects" replace />} />
|
||||||
<Route path="/projects" element={<ProjectList />} />
|
<Route path="/projects" element={<ProjectList />} />
|
||||||
<Route path="/projects/new" element={<ProjectCreateEnhanced />} />
|
<Route path="/projects/new" element={<ProjectCreateEnhanced />} />
|
||||||
|
<Route path="/projects/progressive" element={<ProjectCreateProgressive />} />
|
||||||
<Route path="/projects/:id" element={<ProjectDetail />} />
|
<Route path="/projects/:id" element={<ProjectDetail />} />
|
||||||
<Route path="/projects/:id/workspace" element={<ProjectWorkspace />} />
|
<Route path="/projects/:id/workspace" element={<ProjectWorkspace />} />
|
||||||
<Route path="/projects/:id/execute" element={<ExecutionMonitor />} />
|
<Route path="/projects/:id/execute" element={<ExecutionMonitor />} />
|
||||||
|
|||||||
@ -386,7 +386,7 @@ export const ContentEditor: React.FC<ContentEditorProps> = ({
|
|||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div style={{
|
<div style={{
|
||||||
maxHeight: isFullscreen ? 'calc(100vh - 300px)' : '500px',
|
maxHeight: isFullscreen ? 'calc(100vh - 200px)' : '65vh',
|
||||||
overflowY: 'auto',
|
overflowY: 'auto',
|
||||||
borderRadius: '4px'
|
borderRadius: '4px'
|
||||||
}}>
|
}}>
|
||||||
|
|||||||
940
frontend/src/components/SkillCreate.tsx
Normal file
940
frontend/src/components/SkillCreate.tsx
Normal file
@ -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<SkillCreateProps> = ({
|
||||||
|
visible,
|
||||||
|
onClose,
|
||||||
|
onSuccess,
|
||||||
|
editingSkillId
|
||||||
|
}) => {
|
||||||
|
const [form] = Form.useForm()
|
||||||
|
|
||||||
|
// 步骤状态
|
||||||
|
const [step, setStep] = useState<CreateStep>('input')
|
||||||
|
const [aiDescription, setAiDescription] = useState('')
|
||||||
|
const [generating, setGenerating] = useState(false)
|
||||||
|
const [currentTaskId, setCurrentTaskId] = useState<string | null>(null)
|
||||||
|
|
||||||
|
// 生成的Skill内容
|
||||||
|
const [generatedSkill, setGeneratedSkill] = useState<GeneratedSkill | null>(null)
|
||||||
|
const [editingContent, setEditingContent] = useState(false)
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
|
||||||
|
// References管理
|
||||||
|
const [references, setReferences] = useState<ReferenceFile[]>([])
|
||||||
|
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<any[]>([])
|
||||||
|
|
||||||
|
// 预览ID(用于保存)
|
||||||
|
const [previewId, setPreviewId] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const contentPreviewRef = useRef<HTMLDivElement>(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<string>((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: (
|
||||||
|
<div>
|
||||||
|
<p><strong>变更说明:</strong>{result.changes_summary}</p>
|
||||||
|
<p>原始长度: {result.original_length} 字符</p>
|
||||||
|
<p>新长度: {result.new_length} 字符</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} 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: (
|
||||||
|
<div style={{
|
||||||
|
maxHeight: '70vh',
|
||||||
|
overflowY: 'auto',
|
||||||
|
background: '#f5f5f5',
|
||||||
|
padding: '16px',
|
||||||
|
borderRadius: '4px',
|
||||||
|
whiteSpace: 'pre-wrap',
|
||||||
|
wordBreak: 'break-word',
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
fontSize: '13px',
|
||||||
|
lineHeight: '1.6'
|
||||||
|
}}>
|
||||||
|
{ref.content}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== 步骤4: 保存 ==========
|
||||||
|
const handleSaveSkill = async () => {
|
||||||
|
if (!generatedSkill) return
|
||||||
|
|
||||||
|
setSaving(true)
|
||||||
|
try {
|
||||||
|
const skillContent = form.getFieldValue('content')
|
||||||
|
|
||||||
|
// 构建references对象
|
||||||
|
const referencesObj: Record<string, string> = {}
|
||||||
|
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 (
|
||||||
|
<Modal
|
||||||
|
title={
|
||||||
|
<Space>
|
||||||
|
<RobotOutlined />
|
||||||
|
<span>{editingSkillId ? '编辑 Skill' : '创建 Skill'}</span>
|
||||||
|
</Space>
|
||||||
|
}
|
||||||
|
open={visible}
|
||||||
|
onCancel={handleClose}
|
||||||
|
footer={null}
|
||||||
|
width={1000}
|
||||||
|
destroyOnClose
|
||||||
|
>
|
||||||
|
<Space direction="vertical" style={{ width: '100%' }} size="large">
|
||||||
|
|
||||||
|
{/* 步骤指示器 */}
|
||||||
|
<Steps current={getStepProgress()} size="small">
|
||||||
|
<Step title="输入需求" />
|
||||||
|
<Step title="AI生成" />
|
||||||
|
<Step title="预览编辑" />
|
||||||
|
<Step title="添加References" />
|
||||||
|
<Step title="完成" />
|
||||||
|
</Steps>
|
||||||
|
|
||||||
|
{/* ========== 步骤1: 输入需求 ========== */}
|
||||||
|
{step === 'input' && (
|
||||||
|
<Card>
|
||||||
|
<Alert
|
||||||
|
message="描述你想要的 Skill"
|
||||||
|
description="AI 会根据你的描述,结合 skill-creator 标准,自动生成完整的 SKILL.md。之后你可以预览、编辑,并选择性地添加参考文档(references)。"
|
||||||
|
type="info"
|
||||||
|
showIcon
|
||||||
|
style={{ marginBottom: '16px' }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Form layout="vertical">
|
||||||
|
<Form.Item label="需求描述">
|
||||||
|
<TextArea
|
||||||
|
value={aiDescription}
|
||||||
|
onChange={(e) => setAiDescription(e.target.value)}
|
||||||
|
placeholder="描述你想要创建的 Skill 功能和要求... 例如:我想创建一个古风对话创作的 Skill,让角色说话符合古代身份和性格"
|
||||||
|
rows={6}
|
||||||
|
showCount
|
||||||
|
maxLength={1000}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
size="large"
|
||||||
|
block
|
||||||
|
icon={<RobotOutlined />}
|
||||||
|
disabled={!aiDescription.trim()}
|
||||||
|
onClick={handleGenerate}
|
||||||
|
>
|
||||||
|
AI 生成 Skill
|
||||||
|
</Button>
|
||||||
|
</Form>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ========== 生成中 ========== */}
|
||||||
|
{step === 'generating' && currentTaskId && (
|
||||||
|
<Card title="AI 正在生成 Skill..." style={{ marginBottom: 16 }}>
|
||||||
|
<TaskProgressTracker
|
||||||
|
taskId={currentTaskId}
|
||||||
|
onComplete={(result) => {
|
||||||
|
// 任务完成后的处理逻辑已经在 handleGenerate 中了
|
||||||
|
}}
|
||||||
|
onError={(error) => {
|
||||||
|
message.error(`生成失败: ${error}`)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ========== 步骤2&3: 预览和编辑 / 添加References ========== */}
|
||||||
|
{(step === 'preview' || step === 'references') && generatedSkill && (
|
||||||
|
<Space direction="vertical" style={{ width: '100%' }} size="large">
|
||||||
|
|
||||||
|
{/* 基本信息卡片 */}
|
||||||
|
<Alert
|
||||||
|
message="生成成功"
|
||||||
|
description={
|
||||||
|
<Space direction="vertical" size="small">
|
||||||
|
<Text><strong>名称:</strong>{generatedSkill.suggested_name}</Text>
|
||||||
|
<Text><strong>分类:</strong><Tag color="blue">{generatedSkill.category}</Tag></Text>
|
||||||
|
<Text><strong>说明:</strong>{generatedSkill.explanation}</Text>
|
||||||
|
<Text><strong>References:</strong>{references.length} 个文件</Text>
|
||||||
|
</Space>
|
||||||
|
}
|
||||||
|
type="success"
|
||||||
|
showIcon
|
||||||
|
action={
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
icon={<EditOutlined />}
|
||||||
|
onClick={() => setStep(step === 'preview' ? 'references' : 'preview')}
|
||||||
|
>
|
||||||
|
{step === 'preview' ? '添加References' : '返回预览'}
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 预览/编辑 SKILL.md */}
|
||||||
|
{step === 'preview' && (
|
||||||
|
<Card
|
||||||
|
title="SKILL.md 内容"
|
||||||
|
extra={
|
||||||
|
<Space>
|
||||||
|
{!editingContent && (
|
||||||
|
<>
|
||||||
|
<Button size="small" icon={<EditOutlined />} onClick={handleStartEditing}>
|
||||||
|
编辑
|
||||||
|
</Button>
|
||||||
|
<Button size="small" icon={<CopyOutlined />} onClick={handleCopyContent}>
|
||||||
|
复制
|
||||||
|
</Button>
|
||||||
|
<Button size="small" onClick={handleRefineWithAI}>
|
||||||
|
AI调整
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{editingContent && (
|
||||||
|
<>
|
||||||
|
<Button size="small" onClick={handleCancelEdit}>
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
<Button size="small" type="primary" onClick={handleConfirmEdit}>
|
||||||
|
确认
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Space>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Form form={form} layout="vertical">
|
||||||
|
<Form.Item name="content">
|
||||||
|
{editingContent ? (
|
||||||
|
<TextArea
|
||||||
|
rows={25}
|
||||||
|
style={{
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
fontSize: '13px',
|
||||||
|
lineHeight: '1.6'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
ref={contentPreviewRef}
|
||||||
|
style={{
|
||||||
|
background: '#f5f5f5',
|
||||||
|
padding: '16px',
|
||||||
|
borderRadius: '4px',
|
||||||
|
// 关键修复:移除高度限制,让内容完整显示
|
||||||
|
maxHeight: '70vh',
|
||||||
|
overflowY: 'auto',
|
||||||
|
whiteSpace: 'pre-wrap',
|
||||||
|
wordBreak: 'break-word',
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
fontSize: '13px',
|
||||||
|
lineHeight: '1.6'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{form.getFieldValue('content')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
|
||||||
|
{/* 标签显示 */}
|
||||||
|
<Row gutter={16} style={{ marginTop: '16px' }}>
|
||||||
|
<Col span={12}>
|
||||||
|
<Text type="secondary">分类:</Text>
|
||||||
|
<Tag color="blue">{generatedSkill.category}</Tag>
|
||||||
|
</Col>
|
||||||
|
<Col span={12}>
|
||||||
|
<Text type="secondary">标签:</Text>
|
||||||
|
<Space size={[4, 4]} wrap>
|
||||||
|
{generatedSkill.suggested_tags.map((tag) => (
|
||||||
|
<Tag key={tag}>{tag}</Tag>
|
||||||
|
))}
|
||||||
|
</Space>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* References管理 */}
|
||||||
|
{step === 'references' && (
|
||||||
|
<Card
|
||||||
|
title={
|
||||||
|
<Space>
|
||||||
|
<FileTextOutlined />
|
||||||
|
<span>参考文档 (References)</span>
|
||||||
|
<Tag color="green">{references.length}</Tag>
|
||||||
|
</Space>
|
||||||
|
}
|
||||||
|
extra={
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
icon={<ArrowLeftOutlined />}
|
||||||
|
onClick={() => setStep('preview')}
|
||||||
|
>
|
||||||
|
返回预览
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Space direction="vertical" style={{ width: '100%' }} size="large">
|
||||||
|
|
||||||
|
{/* 添加References的选项卡 */}
|
||||||
|
<Tabs activeKey={activeReferenceTab} onChange={(key) => setActiveReferenceTab(key as any)}>
|
||||||
|
{/* URL获取 */}
|
||||||
|
<TabPane tab="文档URL" key="url">
|
||||||
|
<Space direction="vertical" style={{ width: '100%' }}>
|
||||||
|
<Input
|
||||||
|
placeholder="输入文档URL(支持HTML/Markdown页面)"
|
||||||
|
prefix={<LinkOutlined />}
|
||||||
|
value={urlInput}
|
||||||
|
onChange={(e) => setUrlInput(e.target.value)}
|
||||||
|
onPressEnter={handleAddUrlReference}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
icon={<PlusOutlined />}
|
||||||
|
onClick={handleAddUrlReference}
|
||||||
|
disabled={!urlInput.trim()}
|
||||||
|
>
|
||||||
|
获取文档
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
</TabPane>
|
||||||
|
|
||||||
|
{/* GitHub获取 */}
|
||||||
|
<TabPane tab="GitHub" key="github">
|
||||||
|
<Space direction="vertical" style={{ width: '100%' }}>
|
||||||
|
<Input
|
||||||
|
placeholder="GitHub仓库URL,如: https://github.com/owner/repo"
|
||||||
|
prefix={<GithubOutlined />}
|
||||||
|
value={githubInput}
|
||||||
|
onChange={(e) => setGithubInput(e.target.value)}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
icon={<PlusOutlined />}
|
||||||
|
onClick={handleAddGithubReference}
|
||||||
|
disabled={!githubInput.trim()}
|
||||||
|
>
|
||||||
|
获取README
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
</TabPane>
|
||||||
|
|
||||||
|
{/* 文件上传 */}
|
||||||
|
<TabPane tab="上传文件" key="upload">
|
||||||
|
<Upload
|
||||||
|
accept=".md,.txt,.json,.yaml,.yml"
|
||||||
|
showUploadList={false}
|
||||||
|
beforeUpload={handleUploadFiles}
|
||||||
|
>
|
||||||
|
<Button icon={<UploadOutlined />}>选择文件</Button>
|
||||||
|
</Upload>
|
||||||
|
<Text type="secondary" style={{ marginLeft: '8px' }}>
|
||||||
|
支持 .md, .txt, .json, .yaml, .yml
|
||||||
|
</Text>
|
||||||
|
</TabPane>
|
||||||
|
|
||||||
|
{/* 手动输入 */}
|
||||||
|
<TabPane tab="手动输入" key="manual">
|
||||||
|
<Space direction="vertical" style={{ width: '100%' }}>
|
||||||
|
<Input
|
||||||
|
placeholder="文件名 (如: api-reference.md)"
|
||||||
|
value={manualFileName}
|
||||||
|
onChange={(e) => setManualFileName(e.target.value)}
|
||||||
|
/>
|
||||||
|
<TextArea
|
||||||
|
placeholder="输入文档内容..."
|
||||||
|
rows={4}
|
||||||
|
value={manualContent}
|
||||||
|
onChange={(e) => setManualContent(e.target.value)}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
icon={<PlusOutlined />}
|
||||||
|
onClick={handleAddManualReference}
|
||||||
|
disabled={!manualFileName.trim() || !manualContent.trim()}
|
||||||
|
>
|
||||||
|
添加
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
</TabPane>
|
||||||
|
</Tabs>
|
||||||
|
|
||||||
|
{/* References列表 */}
|
||||||
|
<Divider orientation="left">已添加的 References ({references.length})</Divider>
|
||||||
|
|
||||||
|
{references.length === 0 ? (
|
||||||
|
<div style={{ textAlign: 'center', padding: '40px', background: '#fafafa' }}>
|
||||||
|
<InboxOutlined style={{ fontSize: '48px', color: '#ccc' }} />
|
||||||
|
<div style={{ marginTop: '16px', color: '#999' }}>
|
||||||
|
暂无参考文档,可以选择性添加
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<List
|
||||||
|
dataSource={references}
|
||||||
|
renderItem={(ref) => (
|
||||||
|
<List.Item
|
||||||
|
actions={[
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
type="link"
|
||||||
|
icon={<EyeOutlined />}
|
||||||
|
onClick={() => handleViewReference(ref)}
|
||||||
|
>
|
||||||
|
查看
|
||||||
|
</Button>,
|
||||||
|
<Popconfirm
|
||||||
|
title="确定删除这个参考文档吗?"
|
||||||
|
onConfirm={() => handleDeleteReference(ref.id)}
|
||||||
|
okText="确定"
|
||||||
|
cancelText="取消"
|
||||||
|
>
|
||||||
|
<Button size="small" type="link" danger icon={<DeleteOutlined />}>
|
||||||
|
删除
|
||||||
|
</Button>
|
||||||
|
</Popconfirm>
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<List.Item.Meta
|
||||||
|
avatar={
|
||||||
|
ref.type === 'url' ? <LinkOutlined /> :
|
||||||
|
ref.type === 'github' ? <GithubOutlined /> :
|
||||||
|
ref.type === 'upload' ? <UploadOutlined /> :
|
||||||
|
<FileTextOutlined />
|
||||||
|
}
|
||||||
|
title={
|
||||||
|
<Space>
|
||||||
|
<span>{ref.filename}</span>
|
||||||
|
<Tag color={ref.type === 'url' ? 'blue' : ref.type === 'github' ? 'purple' : 'default'}>
|
||||||
|
{ref.type}
|
||||||
|
</Tag>
|
||||||
|
{ref.size && <Text type="secondary">({(ref.size / 1024).toFixed(1)}KB)</Text>}
|
||||||
|
</Space>
|
||||||
|
}
|
||||||
|
description={
|
||||||
|
ref.url ? (
|
||||||
|
<Text copyable={{ text: ref.url }} style={{ fontSize: '12px' }}>
|
||||||
|
{ref.url}
|
||||||
|
</Text>
|
||||||
|
) : (
|
||||||
|
<Text type="secondary" style={{ fontSize: '12px' }}>
|
||||||
|
{ref.content?.substring(0, 100)}...
|
||||||
|
</Text>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</List.Item>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 说明 */}
|
||||||
|
<Alert
|
||||||
|
message="关于 References"
|
||||||
|
description="References 是可选的参考文档,会被存储在 Skill 的 references/ 目录中。按照 skill-creator 标准,这些文档应该在需要时被加载到上下文中,用于提供更详细的参考信息。"
|
||||||
|
type="info"
|
||||||
|
showIcon
|
||||||
|
/>
|
||||||
|
</Space>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 保存按钮区域 */}
|
||||||
|
<Card>
|
||||||
|
<Space style={{ width: '100%', justifyContent: 'space-between' }}>
|
||||||
|
<Button onClick={handleClose}>
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
<Space>
|
||||||
|
{step === 'references' && (
|
||||||
|
<Button onClick={() => setStep('preview')}>
|
||||||
|
返回预览
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
size="large"
|
||||||
|
icon={<SaveOutlined />}
|
||||||
|
loading={saving}
|
||||||
|
onClick={handleSaveSkill}
|
||||||
|
>
|
||||||
|
保存 Skill
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
</Space>
|
||||||
|
</Card>
|
||||||
|
</Space>
|
||||||
|
)}
|
||||||
|
</Space>
|
||||||
|
</Modal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default SkillCreate
|
||||||
470
frontend/src/components/TaskProgressTracker.tsx
Normal file
470
frontend/src/components/TaskProgressTracker.tsx
Normal file
@ -0,0 +1,470 @@
|
|||||||
|
/**
|
||||||
|
* 任务进度追踪器组件
|
||||||
|
*
|
||||||
|
* 用于显示异步任务的执行进度
|
||||||
|
* 支持:
|
||||||
|
* - 多阶段进度显示
|
||||||
|
* - 实时进度更新
|
||||||
|
* - 错误显示
|
||||||
|
* - 完成状态
|
||||||
|
*/
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { Card, Steps, Progress, Alert, Space, Tag, Button } from 'antd'
|
||||||
|
import {
|
||||||
|
CheckCircleOutlined,
|
||||||
|
SyncOutlined,
|
||||||
|
CloseCircleOutlined,
|
||||||
|
ClockCircleOutlined,
|
||||||
|
ReloadOutlined
|
||||||
|
} from '@ant-design/icons'
|
||||||
|
import api from '@/services/api'
|
||||||
|
|
||||||
|
const { Step } = Steps
|
||||||
|
|
||||||
|
export interface TaskStage {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
status: 'pending' | 'running' | 'completed' | 'failed'
|
||||||
|
progress?: number
|
||||||
|
error?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TaskProgress {
|
||||||
|
taskId: string
|
||||||
|
type: string
|
||||||
|
status: 'pending' | 'running' | 'completed' | 'failed'
|
||||||
|
stages?: TaskStage[]
|
||||||
|
currentStage?: string
|
||||||
|
progress: {
|
||||||
|
current: number
|
||||||
|
total: number
|
||||||
|
message: string
|
||||||
|
stage?: string
|
||||||
|
}
|
||||||
|
result?: any
|
||||||
|
error?: string
|
||||||
|
createdAt: string
|
||||||
|
updatedAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TaskProgressTrackerProps {
|
||||||
|
taskId: string
|
||||||
|
onComplete?: (result: any) => void
|
||||||
|
onError?: (error: string) => void
|
||||||
|
stages?: Array<{ id: string; name: string; description?: string }>
|
||||||
|
pollInterval?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TaskProgressTracker = ({
|
||||||
|
taskId,
|
||||||
|
onComplete,
|
||||||
|
onError,
|
||||||
|
stages,
|
||||||
|
pollInterval = 1000
|
||||||
|
}: TaskProgressTrackerProps) => {
|
||||||
|
const [taskProgress, setTaskProgress] = useState<TaskProgress | null>(null)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
|
// 轮询任务进度
|
||||||
|
useEffect(() => {
|
||||||
|
let mounted = true
|
||||||
|
let pollTimer: NodeJS.Timeout
|
||||||
|
|
||||||
|
const fetchProgress = async () => {
|
||||||
|
try {
|
||||||
|
const data = await api.get<TaskProgress>(`/tasks/${taskId}`)
|
||||||
|
if (!mounted) return
|
||||||
|
|
||||||
|
setTaskProgress(data)
|
||||||
|
setLoading(false)
|
||||||
|
|
||||||
|
// 任务完成或失败时停止轮询
|
||||||
|
if (data.status === 'completed') {
|
||||||
|
onComplete?.(data.result)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (data.status === 'failed') {
|
||||||
|
onError?.(data.error || '任务执行失败')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 继续轮询
|
||||||
|
pollTimer = setTimeout(fetchProgress, pollInterval)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('获取任务进度失败:', err)
|
||||||
|
if (mounted) {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchProgress()
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
mounted = false
|
||||||
|
if (pollTimer) {
|
||||||
|
clearTimeout(pollTimer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [taskId, pollInterval, onComplete, onError])
|
||||||
|
|
||||||
|
// 获取状态图标
|
||||||
|
const getStatusIcon = (status: string) => {
|
||||||
|
switch (status) {
|
||||||
|
case 'completed':
|
||||||
|
return <CheckCircleOutlined style={{ color: '#52c41a' }} />
|
||||||
|
case 'running':
|
||||||
|
return <SyncOutlined spin style={{ color: '#1677ff' }} />
|
||||||
|
case 'failed':
|
||||||
|
return <CloseCircleOutlined style={{ color: '#ff4d4f' }} />
|
||||||
|
default:
|
||||||
|
return <ClockCircleOutlined style={{ color: '#d9d9d9' }} />
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取状态颜色
|
||||||
|
const getStatusColor = (status: string) => {
|
||||||
|
switch (status) {
|
||||||
|
case 'completed':
|
||||||
|
return '#52c41a'
|
||||||
|
case 'running':
|
||||||
|
return '#1677ff'
|
||||||
|
case 'failed':
|
||||||
|
return '#ff4d4f'
|
||||||
|
default:
|
||||||
|
return '#d9d9d9'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 计算进度百分比
|
||||||
|
const getProgressPercent = () => {
|
||||||
|
if (!taskProgress) return 0
|
||||||
|
const { current, total } = taskProgress.progress
|
||||||
|
return total > 0 ? Math.round((current / total) * 100) : 0
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<Card loading bordered={false}>
|
||||||
|
<div style={{ padding: '20px 0' }}>加载任务进度...</div>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!taskProgress) {
|
||||||
|
return (
|
||||||
|
<Alert
|
||||||
|
message="任务不存在"
|
||||||
|
description="无法找到任务信息"
|
||||||
|
type="error"
|
||||||
|
showIcon
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const { status, progress, error } = taskProgress
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
title="任务执行进度"
|
||||||
|
extra={
|
||||||
|
<Tag color={getStatusColor(status)}>
|
||||||
|
{status === 'completed' && '已完成'}
|
||||||
|
{status === 'running' && '执行中'}
|
||||||
|
{status === 'failed' && '失败'}
|
||||||
|
{status === 'pending' && '等待中'}
|
||||||
|
</Tag>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Space direction="vertical" style={{ width: '100%' }} size="large">
|
||||||
|
{/* 进度条 */}
|
||||||
|
<div>
|
||||||
|
<div style={{ marginBottom: '8px', display: 'flex', justifyContent: 'space-between' }}>
|
||||||
|
<span>{progress.message || '正在处理...'}</span>
|
||||||
|
<span>{getProgressPercent()}%</span>
|
||||||
|
</div>
|
||||||
|
<Progress
|
||||||
|
percent={getProgressPercent()}
|
||||||
|
status={
|
||||||
|
status === 'failed' ? 'exception' :
|
||||||
|
status === 'completed' ? 'success' :
|
||||||
|
'active'
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 错误信息 */}
|
||||||
|
{status === 'failed' && error && (
|
||||||
|
<Alert
|
||||||
|
message="执行失败"
|
||||||
|
description={error}
|
||||||
|
type="error"
|
||||||
|
showIcon
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 完成信息 */}
|
||||||
|
{status === 'completed' && (
|
||||||
|
<Alert
|
||||||
|
message="执行完成"
|
||||||
|
description="任务已成功完成"
|
||||||
|
type="success"
|
||||||
|
showIcon
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 任务详情 */}
|
||||||
|
<div style={{ fontSize: '12px', color: '#666' }}>
|
||||||
|
<div>任务ID: {taskProgress.taskId}</div>
|
||||||
|
<div>任务类型: {taskProgress.type}</div>
|
||||||
|
<div>创建时间: {new Date(taskProgress.createdAt).toLocaleString()}</div>
|
||||||
|
</div>
|
||||||
|
</Space>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 多阶段进度追踪器
|
||||||
|
*/
|
||||||
|
interface StageProgressTrackerProps {
|
||||||
|
taskId: string
|
||||||
|
stages: Array<{ id: string; name: string; description?: string }>
|
||||||
|
onComplete?: (result: any) => void
|
||||||
|
onError?: (error: string) => void
|
||||||
|
pollInterval?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export const StageProgressTracker = ({
|
||||||
|
taskId,
|
||||||
|
stages,
|
||||||
|
onComplete,
|
||||||
|
onError,
|
||||||
|
pollInterval = 1000
|
||||||
|
}: StageProgressTrackerProps) => {
|
||||||
|
const [taskProgress, setTaskProgress] = useState<TaskProgress | null>(null)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let mounted = true
|
||||||
|
let pollTimer: NodeJS.Timeout
|
||||||
|
|
||||||
|
const fetchProgress = async () => {
|
||||||
|
try {
|
||||||
|
const data = await api.get<TaskProgress>(`/tasks/${taskId}`)
|
||||||
|
if (!mounted) return
|
||||||
|
|
||||||
|
setTaskProgress(data)
|
||||||
|
setLoading(false)
|
||||||
|
|
||||||
|
if (data.status === 'completed') {
|
||||||
|
onComplete?.(data.result)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (data.status === 'failed') {
|
||||||
|
onError?.(data.error || '任务执行失败')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
pollTimer = setTimeout(fetchProgress, pollInterval)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('获取任务进度失败:', err)
|
||||||
|
if (mounted) {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchProgress()
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
mounted = false
|
||||||
|
if (pollTimer) {
|
||||||
|
clearTimeout(pollTimer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [taskId, pollInterval, onComplete, onError])
|
||||||
|
|
||||||
|
// 获取当前阶段索引
|
||||||
|
const getCurrentStageIndex = () => {
|
||||||
|
if (!taskProgress || !taskProgress.currentStage) return -1
|
||||||
|
return stages.findIndex(s => s.id === taskProgress.currentStage)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取阶段状态
|
||||||
|
const getStageStatus = (stageId: string): 'wait' | 'process' | 'finish' | 'error' => {
|
||||||
|
if (!taskProgress) return 'wait'
|
||||||
|
|
||||||
|
if (taskProgress.status === 'failed') {
|
||||||
|
return 'error'
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentIndex = getCurrentStageIndex()
|
||||||
|
const stageIndex = stages.findIndex(s => s.id === stageId)
|
||||||
|
|
||||||
|
if (stageIndex < currentIndex) return 'finish'
|
||||||
|
if (stageIndex === currentIndex) return 'process'
|
||||||
|
return 'wait'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<Card loading bordered={false}>
|
||||||
|
<div style={{ padding: '20px 0' }}>加载进度...</div>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!taskProgress) {
|
||||||
|
return (
|
||||||
|
<Alert
|
||||||
|
message="任务不存在"
|
||||||
|
type="error"
|
||||||
|
showIcon
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card title="任务执行进度">
|
||||||
|
<Steps
|
||||||
|
current={getCurrentStageIndex()}
|
||||||
|
status={taskProgress.status === 'failed' ? 'error' : 'process'}
|
||||||
|
>
|
||||||
|
{stages.map((stage, index) => (
|
||||||
|
<Step
|
||||||
|
key={stage.id}
|
||||||
|
title={stage.name}
|
||||||
|
description={stage.description}
|
||||||
|
status={getStageStatus(stage.id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Steps>
|
||||||
|
|
||||||
|
{/* 进度条 */}
|
||||||
|
<div style={{ marginTop: '24px' }}>
|
||||||
|
<Progress
|
||||||
|
percent={Math.round(
|
||||||
|
(taskProgress.progress.current / taskProgress.progress.total) * 100
|
||||||
|
)}
|
||||||
|
status={
|
||||||
|
taskProgress.status === 'failed' ? 'exception' :
|
||||||
|
taskProgress.status === 'completed' ? 'success' :
|
||||||
|
'active'
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<div style={{ marginTop: '8px', textAlign: 'center', color: '#666' }}>
|
||||||
|
{taskProgress.progress.message}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 错误信息 */}
|
||||||
|
{taskProgress.status === 'failed' && taskProgress.error && (
|
||||||
|
<Alert
|
||||||
|
message="执行失败"
|
||||||
|
description={taskProgress.error}
|
||||||
|
type="error"
|
||||||
|
showIcon
|
||||||
|
style={{ marginTop: '16px' }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 任务结果展示组件
|
||||||
|
*/
|
||||||
|
interface TaskResultProps {
|
||||||
|
result: any
|
||||||
|
taskType: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TaskResultDisplay = ({ result, taskType }: TaskResultProps) => {
|
||||||
|
const getResultContent = () => {
|
||||||
|
switch (taskType) {
|
||||||
|
case 'generate_characters':
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div style={{ marginBottom: '8px', fontWeight: 'bold' }}>人物设定</div>
|
||||||
|
<div style={{
|
||||||
|
whiteSpace: 'pre-wrap',
|
||||||
|
background: '#f5f5f5',
|
||||||
|
padding: '12px',
|
||||||
|
borderRadius: '4px',
|
||||||
|
maxHeight: '60vh',
|
||||||
|
overflowY: 'auto',
|
||||||
|
wordBreak: 'break-word',
|
||||||
|
lineHeight: '1.6'
|
||||||
|
}}>
|
||||||
|
{result?.characters || result}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
case 'generate_outline':
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div style={{ marginBottom: '8px', fontWeight: 'bold' }}>剧情大纲</div>
|
||||||
|
<div style={{
|
||||||
|
whiteSpace: 'pre-wrap',
|
||||||
|
background: '#f5f5f5',
|
||||||
|
padding: '12px',
|
||||||
|
borderRadius: '4px',
|
||||||
|
maxHeight: '60vh',
|
||||||
|
overflowY: 'auto',
|
||||||
|
wordBreak: 'break-word',
|
||||||
|
lineHeight: '1.6'
|
||||||
|
}}>
|
||||||
|
{result?.outline || result}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
case 'generate_world':
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div style={{ marginBottom: '8px', fontWeight: 'bold' }}>世界观设定</div>
|
||||||
|
<div style={{
|
||||||
|
whiteSpace: 'pre-wrap',
|
||||||
|
background: '#f5f5f5',
|
||||||
|
padding: '12px',
|
||||||
|
borderRadius: '4px',
|
||||||
|
maxHeight: '60vh',
|
||||||
|
overflowY: 'auto',
|
||||||
|
wordBreak: 'break-word',
|
||||||
|
lineHeight: '1.6'
|
||||||
|
}}>
|
||||||
|
{result?.worldSetting || result}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
default:
|
||||||
|
return (
|
||||||
|
<pre style={{
|
||||||
|
background: '#f5f5f5',
|
||||||
|
padding: '12px',
|
||||||
|
borderRadius: '4px',
|
||||||
|
maxHeight: '60vh',
|
||||||
|
overflowY: 'auto',
|
||||||
|
wordBreak: 'break-word',
|
||||||
|
fontSize: '13px',
|
||||||
|
lineHeight: '1.6'
|
||||||
|
}}>
|
||||||
|
{JSON.stringify(result, null, 2)}
|
||||||
|
</pre>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card title="生成结果">
|
||||||
|
{getResultContent()}
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default TaskProgressTracker
|
||||||
@ -1156,9 +1156,11 @@ export const AgentManagement = () => {
|
|||||||
background: '#f5f5f5',
|
background: '#f5f5f5',
|
||||||
padding: '16px',
|
padding: '16px',
|
||||||
borderRadius: '4px',
|
borderRadius: '4px',
|
||||||
maxHeight: '400px',
|
maxHeight: '65vh',
|
||||||
overflow: 'auto',
|
overflowY: 'auto',
|
||||||
fontSize: '12px'
|
fontSize: '12px',
|
||||||
|
wordBreak: 'break-word',
|
||||||
|
lineHeight: '1.5'
|
||||||
}}>
|
}}>
|
||||||
{JSON.stringify(selectedAgent, null, 2)}
|
{JSON.stringify(selectedAgent, null, 2)}
|
||||||
</pre>
|
</pre>
|
||||||
|
|||||||
@ -270,9 +270,11 @@ export const ExecutionMonitor = () => {
|
|||||||
<div>
|
<div>
|
||||||
<Title level={5}>剧本内容</Title>
|
<Title level={5}>剧本内容</Title>
|
||||||
<div style={{
|
<div style={{
|
||||||
maxHeight: '400px',
|
maxHeight: '65vh',
|
||||||
overflowY: 'auto',
|
overflowY: 'auto',
|
||||||
borderRadius: '4px'
|
borderRadius: '4px',
|
||||||
|
background: '#fafafa',
|
||||||
|
padding: '12px'
|
||||||
}}>
|
}}>
|
||||||
<MarkdownRenderer content={selectedEpisode.content} />
|
<MarkdownRenderer content={selectedEpisode.content} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
279
frontend/src/pages/ProjectCreateProgressive.tsx
Normal file
279
frontend/src/pages/ProjectCreateProgressive.tsx
Normal file
@ -0,0 +1,279 @@
|
|||||||
|
/**
|
||||||
|
* 简化项目创建页面
|
||||||
|
*
|
||||||
|
* 设计理念:
|
||||||
|
* 1. 收集项目基本信息(名称、集数)
|
||||||
|
* 2. 支持直接输入创作文字或上传灵感文件
|
||||||
|
* 3. 立即创建项目
|
||||||
|
* 4. 其他内容(AI辅助生成)在项目详情页完成
|
||||||
|
*/
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
import { Form, Input, InputNumber, Button, Card, message, Space, Alert, Radio, Upload, Select } from 'antd'
|
||||||
|
import { FileTextOutlined, PlusOutlined, UploadOutlined, BulbOutlined, EditOutlined } from '@ant-design/icons'
|
||||||
|
import { useProjectStore } from '@/stores/projectStore'
|
||||||
|
import { ProjectCreateRequest } from '@/services/projectService'
|
||||||
|
import type { UploadFile } from 'antd'
|
||||||
|
|
||||||
|
const { TextArea } = Input
|
||||||
|
const { Option } = Select
|
||||||
|
|
||||||
|
export const ProjectCreateProgressive = () => {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const createProject = useProjectStore(state => state.createProject)
|
||||||
|
|
||||||
|
// 项目状态
|
||||||
|
const [creating, setCreating] = useState(false)
|
||||||
|
const [creationMode, setCreationMode] = useState<'blank' | 'text' | 'file'>('blank')
|
||||||
|
const [textContent, setTextContent] = useState('')
|
||||||
|
const [uploadedFile, setUploadedFile] = useState<UploadFile | null>(null)
|
||||||
|
const [inspirationType, setInspirationType] = useState<'story' | 'character' | 'scene' | 'dialogue'>('story')
|
||||||
|
|
||||||
|
const [form] = Form.useForm()
|
||||||
|
|
||||||
|
// 处理创作方式切换
|
||||||
|
const handleModeChange = (newMode: 'blank' | 'text' | 'file') => {
|
||||||
|
// 如果有内容且切换到不同模式,提示用户内容已保留
|
||||||
|
if (creationMode !== newMode && (textContent || uploadedFile)) {
|
||||||
|
const keptContent = creationMode === 'text' ? '文字内容' : '文件'
|
||||||
|
message.info(`切换到${newMode === 'blank' ? '从头创作' : newMode === 'text' ? '直接输入' : '文件上传'},${keptContent}已保留`)
|
||||||
|
}
|
||||||
|
setCreationMode(newMode)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建项目
|
||||||
|
const handleCreateProject = async () => {
|
||||||
|
try {
|
||||||
|
await form.validateFields()
|
||||||
|
const name = form.getFieldValue('name')
|
||||||
|
const totalEpisodes = form.getFieldValue('totalEpisodes') || 30
|
||||||
|
|
||||||
|
// 验证创作内容
|
||||||
|
if (creationMode === 'text' && !textContent.trim()) {
|
||||||
|
message.error('请输入创作文字内容')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (creationMode === 'file' && !uploadedFile) {
|
||||||
|
message.error('请上传灵感文件')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setCreating(true)
|
||||||
|
|
||||||
|
// 读取文件内容
|
||||||
|
let fileContent = ''
|
||||||
|
if (creationMode === 'file' && uploadedFile?.originFileObj) {
|
||||||
|
fileContent = await uploadedFile.originFileObj.text()
|
||||||
|
}
|
||||||
|
|
||||||
|
const projectData: ProjectCreateRequest = {
|
||||||
|
name: name.trim(),
|
||||||
|
totalEpisodes,
|
||||||
|
globalContext: {
|
||||||
|
worldSetting: '',
|
||||||
|
overallOutline: '',
|
||||||
|
characterProfiles: {},
|
||||||
|
sceneSettings: {},
|
||||||
|
// 根据创作模式保存内容
|
||||||
|
uploadedScript: creationMode === 'text' ? textContent : fileContent,
|
||||||
|
inspiration: creationMode === 'file' ? inspirationType : undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const project = await createProject(projectData)
|
||||||
|
message.success('项目创建成功!')
|
||||||
|
|
||||||
|
// 跳转到项目详情页
|
||||||
|
navigate(`/projects/${project.id}`)
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error.errorFields) {
|
||||||
|
// 表单验证错误
|
||||||
|
return
|
||||||
|
}
|
||||||
|
console.error('创建项目失败:', error)
|
||||||
|
message.error(`创建失败: ${error.message || '未知错误'}`)
|
||||||
|
} finally {
|
||||||
|
setCreating(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ padding: '24px', maxWidth: '800px', margin: '0 auto' }}>
|
||||||
|
{/* 头部 */}
|
||||||
|
<Card style={{ marginBottom: '24px' }}>
|
||||||
|
<Space direction="vertical" style={{ width: '100%' }} size="small">
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||||
|
<FileTextOutlined style={{ fontSize: '20px', color: '#1677ff' }} />
|
||||||
|
<h1 style={{ margin: 0, fontSize: '20px', fontWeight: 600 }}>创建新项目</h1>
|
||||||
|
</div>
|
||||||
|
<Alert
|
||||||
|
message="填写基本信息后即可创建项目,后续内容可在项目详情页中完善"
|
||||||
|
type="info"
|
||||||
|
showIcon={false}
|
||||||
|
/>
|
||||||
|
</Space>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 创建项目表单 */}
|
||||||
|
<Card>
|
||||||
|
<Form form={form} layout="vertical">
|
||||||
|
<Form.Item
|
||||||
|
label="项目名称"
|
||||||
|
name="name"
|
||||||
|
rules={[{ required: true, message: '请填写项目名称' }]}
|
||||||
|
>
|
||||||
|
<Input placeholder="例如:宫廷风云" size="large" />
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item
|
||||||
|
label="总集数"
|
||||||
|
name="totalEpisodes"
|
||||||
|
initialValue={30}
|
||||||
|
rules={[{ required: true, message: '请填写总集数' }]}
|
||||||
|
>
|
||||||
|
<InputNumber min={1} max={500} style={{ width: '100%' }} size="large" />
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
{/* 创作方式选择 */}
|
||||||
|
<Form.Item label="创作方式">
|
||||||
|
<Radio.Group
|
||||||
|
value={creationMode}
|
||||||
|
onChange={(e) => handleModeChange(e.target.value)}
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
>
|
||||||
|
<Space direction="vertical" style={{ width: '100%' }}>
|
||||||
|
<Radio value="blank">
|
||||||
|
<Space>
|
||||||
|
<FileTextOutlined />
|
||||||
|
<span>从头创作(稍后在详情页补充内容)</span>
|
||||||
|
</Space>
|
||||||
|
</Radio>
|
||||||
|
<Radio value="text">
|
||||||
|
<Space>
|
||||||
|
<EditOutlined />
|
||||||
|
<span>直接输入创作文字</span>
|
||||||
|
</Space>
|
||||||
|
</Radio>
|
||||||
|
<Radio value="file">
|
||||||
|
<Space>
|
||||||
|
<UploadOutlined />
|
||||||
|
<span>上传灵感文件</span>
|
||||||
|
</Space>
|
||||||
|
</Radio>
|
||||||
|
</Space>
|
||||||
|
</Radio.Group>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
{/* 直接输入文字 */}
|
||||||
|
{creationMode === 'text' && (
|
||||||
|
<Form.Item label="创作文字内容">
|
||||||
|
<TextArea
|
||||||
|
value={textContent}
|
||||||
|
onChange={(e) => setTextContent(e.target.value)}
|
||||||
|
placeholder="请输入您的创作文字、故事大纲、角色设定等内容..."
|
||||||
|
rows={8}
|
||||||
|
maxLength={10000}
|
||||||
|
showCount
|
||||||
|
/>
|
||||||
|
<Alert
|
||||||
|
message="提示"
|
||||||
|
description="输入的内容将作为创作基础,AI 将根据您的内容进行分析和扩展。"
|
||||||
|
type="info"
|
||||||
|
showIcon
|
||||||
|
style={{ marginTop: '8px' }}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 上传灵感文件 */}
|
||||||
|
{creationMode === 'file' && (
|
||||||
|
<>
|
||||||
|
<Form.Item label="灵感类型">
|
||||||
|
<Select
|
||||||
|
value={inspirationType}
|
||||||
|
onChange={setInspirationType}
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
>
|
||||||
|
<Option value="story">
|
||||||
|
<Space>
|
||||||
|
<BulbOutlined />
|
||||||
|
故事灵感
|
||||||
|
</Space>
|
||||||
|
</Option>
|
||||||
|
<Option value="character">角色设定</Option>
|
||||||
|
<Option value="scene">场景描述</Option>
|
||||||
|
<Option value="dialogue">对话片段</Option>
|
||||||
|
</Select>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item label="上传文件">
|
||||||
|
<Upload
|
||||||
|
accept=".txt,.md,.doc,.docx"
|
||||||
|
maxCount={1}
|
||||||
|
fileList={uploadedFile ? [uploadedFile] : []}
|
||||||
|
beforeUpload={(file) => {
|
||||||
|
setUploadedFile(file)
|
||||||
|
return false // 阻止自动上传
|
||||||
|
}}
|
||||||
|
onRemove={() => setUploadedFile(null)}
|
||||||
|
>
|
||||||
|
<Button icon={<UploadOutlined />} disabled={uploadedFile !== null}>
|
||||||
|
{uploadedFile ? '已选择文件' : '选择文件(支持 .txt, .md, .doc, .docx)'}
|
||||||
|
</Button>
|
||||||
|
</Upload>
|
||||||
|
{uploadedFile && (
|
||||||
|
<div style={{ marginTop: '8px', fontSize: '12px', color: '#52c41a' }}>
|
||||||
|
✓ 已选择: {uploadedFile.name}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<Alert
|
||||||
|
message="提示"
|
||||||
|
description="上传的文件内容将被读取并作为创作灵感。支持文本文件和 Word 文档。"
|
||||||
|
type="info"
|
||||||
|
showIcon
|
||||||
|
style={{ marginTop: '8px' }}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 显示已保存内容的提示 */}
|
||||||
|
{creationMode !== 'blank' && (textContent || uploadedFile) && (
|
||||||
|
<Alert
|
||||||
|
message="已准备就绪"
|
||||||
|
description={
|
||||||
|
creationMode === 'text'
|
||||||
|
? `已输入 ${textContent.length} 个字符的创作内容`
|
||||||
|
: uploadedFile
|
||||||
|
? `已上传文件: ${uploadedFile.name}`
|
||||||
|
: ''
|
||||||
|
}
|
||||||
|
type="success"
|
||||||
|
showIcon
|
||||||
|
style={{ marginBottom: '16px' }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Form.Item>
|
||||||
|
<Space style={{ width: '100%', justifyContent: 'space-between' }}>
|
||||||
|
<Button onClick={() => navigate('/projects')}>
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
size="large"
|
||||||
|
icon={<PlusOutlined />}
|
||||||
|
loading={creating}
|
||||||
|
onClick={handleCreateProject}
|
||||||
|
>
|
||||||
|
创建项目
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ProjectCreateProgressive
|
||||||
File diff suppressed because it is too large
Load Diff
@ -1,18 +1,223 @@
|
|||||||
/**
|
/**
|
||||||
* 项目列表页面
|
* 项目列表页面 - 采用卡片网格布局
|
||||||
|
*
|
||||||
|
* 设计理念:
|
||||||
|
* - 不使用传统列表,而是卡片网格展示
|
||||||
|
* - 每个项目卡片显示项目状态和进度
|
||||||
|
* - 支持快速操作:继续编辑、查看详情、删除
|
||||||
*/
|
*/
|
||||||
import { useEffect } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
import { Table, Button, Space, Tag, Card, message } from 'antd'
|
import { Button, Space, Tag, Card, message, Empty, Row, Col, Progress, Tooltip, Badge } from 'antd'
|
||||||
import { PlusOutlined } from '@ant-design/icons'
|
import {
|
||||||
|
PlusOutlined, EditOutlined, DeleteOutlined, EyeOutlined,
|
||||||
|
ClockCircleOutlined, CheckCircleOutlined, LoadingOutlined,
|
||||||
|
FileTextOutlined, SettingOutlined
|
||||||
|
} from '@ant-design/icons'
|
||||||
import { useProjectStore } from '@/stores/projectStore'
|
import { useProjectStore } from '@/stores/projectStore'
|
||||||
import { type ColumnsType } from 'antd/es/table'
|
|
||||||
import { SeriesProject } from '@/services/projectService'
|
import { SeriesProject } from '@/services/projectService'
|
||||||
import dayjs from 'dayjs'
|
import dayjs from 'dayjs'
|
||||||
|
import relativeTime from 'dayjs/plugin/relativeTime'
|
||||||
|
import 'dayjs/locale/zh-cn'
|
||||||
|
|
||||||
|
dayjs.extend(relativeTime)
|
||||||
|
dayjs.locale('zh-cn')
|
||||||
|
|
||||||
|
type ProjectStatus = 'draft' | 'in_progress' | 'completed'
|
||||||
|
|
||||||
|
// 项目状态配置
|
||||||
|
const STATUS_CONFIG: Record<ProjectStatus, {
|
||||||
|
text: string
|
||||||
|
color: string
|
||||||
|
icon: React.ReactNode
|
||||||
|
}> = {
|
||||||
|
draft: {
|
||||||
|
text: '草稿',
|
||||||
|
color: 'default',
|
||||||
|
icon: <FileTextOutlined />
|
||||||
|
},
|
||||||
|
in_progress: {
|
||||||
|
text: '创作中',
|
||||||
|
color: 'processing',
|
||||||
|
icon: <LoadingOutlined />
|
||||||
|
},
|
||||||
|
completed: {
|
||||||
|
text: '已完成',
|
||||||
|
color: 'success',
|
||||||
|
icon: <CheckCircleOutlined />
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 计算项目完成度
|
||||||
|
const calculateCompletion = (project: SeriesProject): number => {
|
||||||
|
let completed = 0
|
||||||
|
let total = 3
|
||||||
|
|
||||||
|
// 检查世界观设定
|
||||||
|
if (project.globalContext?.worldSetting) completed += 1
|
||||||
|
|
||||||
|
// 检查人物设定
|
||||||
|
if (project.globalContext?.characterProfiles &&
|
||||||
|
Object.keys(project.globalContext.characterProfiles).length > 0) {
|
||||||
|
completed += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查大纲
|
||||||
|
if (project.globalContext?.overallOutline) completed += 1
|
||||||
|
|
||||||
|
// 检查剧集完成度
|
||||||
|
if (project.totalEpisodes && project.episodes) {
|
||||||
|
total = 3 + project.totalEpisodes
|
||||||
|
completed += project.episodes.length
|
||||||
|
}
|
||||||
|
|
||||||
|
return Math.min(100, Math.round((completed / total) * 100))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取项目状态
|
||||||
|
const getProjectStatus = (project: SeriesProject): ProjectStatus => {
|
||||||
|
const completion = calculateCompletion(project)
|
||||||
|
|
||||||
|
if (completion === 0) return 'draft'
|
||||||
|
if (completion >= 100) return 'completed'
|
||||||
|
return 'in_progress'
|
||||||
|
}
|
||||||
|
|
||||||
|
// 项目卡片组件
|
||||||
|
const ProjectCard = ({ project, onEdit, onDelete, onView }: {
|
||||||
|
project: SeriesProject
|
||||||
|
onEdit: (id: string) => void
|
||||||
|
onDelete: (id: string) => void
|
||||||
|
onView: (id: string) => void
|
||||||
|
}) => {
|
||||||
|
const status = getProjectStatus(project)
|
||||||
|
const statusConfig = STATUS_CONFIG[status]
|
||||||
|
const completion = calculateCompletion(project)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Badge.Ribbon
|
||||||
|
text={statusConfig.text}
|
||||||
|
color={statusConfig.color}
|
||||||
|
>
|
||||||
|
<Card
|
||||||
|
hoverable
|
||||||
|
style={{ height: '100%' }}
|
||||||
|
bodyStyle={{ display: 'flex', flexDirection: 'column', height: '100%' }}
|
||||||
|
>
|
||||||
|
{/* 项目标题 */}
|
||||||
|
<div style={{ marginBottom: '12px' }}>
|
||||||
|
<h3 style={{ margin: 0, fontSize: '16px', fontWeight: 600 }}>
|
||||||
|
{project.name}
|
||||||
|
</h3>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', marginTop: '4px', gap: '4px' }}>
|
||||||
|
<Tag color="blue" icon={<FileTextOutlined />}>
|
||||||
|
{project.totalEpisodes || 0} 集
|
||||||
|
</Tag>
|
||||||
|
<Tag color={statusConfig.color} icon={statusConfig.icon}>
|
||||||
|
{statusConfig.text}
|
||||||
|
</Tag>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 项目描述/内容预览 */}
|
||||||
|
<div style={{ flex: 1, marginBottom: '16px' }}>
|
||||||
|
{project.globalContext?.overallOutline ? (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: '12px',
|
||||||
|
color: '#666',
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
display: '-webkit-box',
|
||||||
|
WebkitLineClamp: 3,
|
||||||
|
WebkitBoxOrient: 'vertical'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{project.globalContext.overallOutline}
|
||||||
|
</div>
|
||||||
|
) : project.globalContext?.worldSetting ? (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: '12px',
|
||||||
|
color: '#666',
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
display: '-webkit-box',
|
||||||
|
WebkitLineClamp: 3,
|
||||||
|
WebkitBoxOrient: 'vertical'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{project.globalContext.worldSetting}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div style={{ fontSize: '12px', color: '#999' }}>
|
||||||
|
暂无内容描述
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 进度条 */}
|
||||||
|
<div style={{ marginBottom: '16px' }}>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '4px' }}>
|
||||||
|
<span style={{ fontSize: '12px', color: '#666' }}>完成度</span>
|
||||||
|
<span style={{ fontSize: '12px', color: '#1677ff', fontWeight: 600 }}>
|
||||||
|
{completion}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Progress
|
||||||
|
percent={completion}
|
||||||
|
size="small"
|
||||||
|
status={completion === 100 ? 'success' : 'active'}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 时间信息 */}
|
||||||
|
<div style={{ fontSize: '12px', color: '#999', marginBottom: '16px' }}>
|
||||||
|
<ClockCircleOutlined style={{ marginRight: '4px' }} />
|
||||||
|
{dayjs(project.createdAt).fromNow()}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 操作按钮 */}
|
||||||
|
<div style={{ display: 'flex', gap: '8px' }}>
|
||||||
|
{status === 'draft' || status === 'in_progress' ? (
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
size="small"
|
||||||
|
icon={<EditOutlined />}
|
||||||
|
onClick={() => onEdit(project.id)}
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
>
|
||||||
|
继续编辑
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
type="default"
|
||||||
|
size="small"
|
||||||
|
icon={<EyeOutlined />}
|
||||||
|
onClick={() => onView(project.id)}
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
>
|
||||||
|
查看详情
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Tooltip title="删除">
|
||||||
|
<Button
|
||||||
|
danger
|
||||||
|
size="small"
|
||||||
|
icon={<DeleteOutlined />}
|
||||||
|
onClick={() => onDelete(project.id)}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</Badge.Ribbon>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export const ProjectList = () => {
|
export const ProjectList = () => {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const { projects, loading, fetchProjects, deleteProject } = useProjectStore()
|
const { projects, loading, fetchProjects, deleteProject } = useProjectStore()
|
||||||
|
const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid')
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchProjects()
|
fetchProjects()
|
||||||
@ -27,79 +232,141 @@ export const ProjectList = () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const columns: ColumnsType<SeriesProject> = [
|
const handleContinueEdit = (id: string) => {
|
||||||
{
|
// 跳转到项目详情页继续编辑
|
||||||
title: '项目名称',
|
navigate(`/projects/${id}`)
|
||||||
dataIndex: 'name',
|
}
|
||||||
key: 'name',
|
|
||||||
},
|
const handleView = (id: string) => {
|
||||||
{
|
navigate(`/projects/${id}`)
|
||||||
title: '总集数',
|
}
|
||||||
dataIndex: 'totalEpisodes',
|
|
||||||
key: 'totalEpisodes',
|
// 按状态分组项目
|
||||||
width: 100,
|
const draftProjects = projects.filter(p => getProjectStatus(p) === 'draft')
|
||||||
},
|
const inProgressProjects = projects.filter(p => getProjectStatus(p) === 'in_progress')
|
||||||
{
|
const completedProjects = projects.filter(p => getProjectStatus(p) === 'completed')
|
||||||
title: '模式',
|
|
||||||
dataIndex: 'mode',
|
|
||||||
key: 'mode',
|
|
||||||
width: 100,
|
|
||||||
render: (mode: string) => {
|
|
||||||
const modeMap: Record<string, { text: string; color: string }> = {
|
|
||||||
'batch': { text: '分批次', color: 'blue' },
|
|
||||||
'auto': { text: '全自动', color: 'green' },
|
|
||||||
'step': { text: '逐步审核', color: 'orange' }
|
|
||||||
}
|
|
||||||
const config = modeMap[mode] || { text: mode, color: 'default' }
|
|
||||||
return <Tag color={config.color}>{config.text}</Tag>
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '创建时间',
|
|
||||||
dataIndex: 'createdAt',
|
|
||||||
key: 'createdAt',
|
|
||||||
width: 180,
|
|
||||||
render: (date: string) => dayjs(date).format('YYYY-MM-DD HH:mm')
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '操作',
|
|
||||||
key: 'actions',
|
|
||||||
width: 200,
|
|
||||||
render: (_, record) => (
|
|
||||||
<Space>
|
|
||||||
<Button type="link" onClick={() => navigate(`/projects/${record.id}`)}>
|
|
||||||
查看详情
|
|
||||||
</Button>
|
|
||||||
<Button type="link" danger onClick={() => handleDelete(record.id)}>
|
|
||||||
删除
|
|
||||||
</Button>
|
|
||||||
</Space>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ padding: '24px' }}>
|
<div style={{ padding: '24px' }}>
|
||||||
<Card
|
{/* 头部 */}
|
||||||
title="我的项目"
|
<div style={{ marginBottom: '24px', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||||
extra={
|
<div>
|
||||||
|
<h1 style={{ margin: 0, fontSize: '24px', fontWeight: 600 }}>我的项目</h1>
|
||||||
|
<p style={{ margin: '4px 0 0 0', color: '#666' }}>
|
||||||
|
共 {projects.length} 个项目
|
||||||
|
{inProgressProjects.length > 0 && ` · ${inProgressProjects.length} 个创作中`}
|
||||||
|
{completedProjects.length > 0 && ` · ${completedProjects.length} 个已完成`}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Space>
|
||||||
|
<Button
|
||||||
|
icon={<SettingOutlined />}
|
||||||
|
onClick={() => setViewMode(viewMode === 'grid' ? 'list' : 'grid')}
|
||||||
|
>
|
||||||
|
{viewMode === 'grid' ? '列表视图' : '网格视图'}
|
||||||
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
type="primary"
|
type="primary"
|
||||||
|
size="large"
|
||||||
icon={<PlusOutlined />}
|
icon={<PlusOutlined />}
|
||||||
onClick={() => navigate('/projects/new')}
|
onClick={() => navigate('/projects/progressive')}
|
||||||
>
|
>
|
||||||
创建新项目
|
创建新项目
|
||||||
</Button>
|
</Button>
|
||||||
}
|
</Space>
|
||||||
>
|
</div>
|
||||||
<Table
|
|
||||||
columns={columns}
|
{/* 项目列表 */}
|
||||||
dataSource={projects}
|
{loading ? (
|
||||||
rowKey="id"
|
<div style={{ textAlign: 'center', padding: '100px 0' }}>
|
||||||
loading={loading}
|
<LoadingOutlined style={{ fontSize: '48px', color: '#1677ff' }} />
|
||||||
locale={{ emptyText: '暂无项目,点击上方按钮创建' }}
|
<p style={{ marginTop: '16px', color: '#666' }}>加载中...</p>
|
||||||
/>
|
</div>
|
||||||
</Card>
|
) : projects.length === 0 ? (
|
||||||
|
<Empty
|
||||||
|
style={{ padding: '100px 0' }}
|
||||||
|
description={
|
||||||
|
<div>
|
||||||
|
<p style={{ fontSize: '16px', marginBottom: '8px' }}>暂无项目</p>
|
||||||
|
<p style={{ color: '#999' }}>创建您的第一个项目开始创作</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
size="large"
|
||||||
|
icon={<PlusOutlined />}
|
||||||
|
onClick={() => navigate('/projects/progressive')}
|
||||||
|
>
|
||||||
|
创建项目
|
||||||
|
</Button>
|
||||||
|
</Empty>
|
||||||
|
) : (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '32px' }}>
|
||||||
|
{/* 创作中的项目 */}
|
||||||
|
{inProgressProjects.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<h2 style={{ marginBottom: '16px', fontSize: '18px', fontWeight: 600 }}>
|
||||||
|
创作中 ({inProgressProjects.length})
|
||||||
|
</h2>
|
||||||
|
<Row gutter={[16, 16]}>
|
||||||
|
{inProgressProjects.map(project => (
|
||||||
|
<Col key={project.id} xs={24} sm={12} md={8} lg={6}>
|
||||||
|
<ProjectCard
|
||||||
|
project={project}
|
||||||
|
onEdit={handleContinueEdit}
|
||||||
|
onView={handleView}
|
||||||
|
onDelete={handleDelete}
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
))}
|
||||||
|
</Row>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 草稿 */}
|
||||||
|
{draftProjects.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<h2 style={{ marginBottom: '16px', fontSize: '18px', fontWeight: 600 }}>
|
||||||
|
草稿 ({draftProjects.length})
|
||||||
|
</h2>
|
||||||
|
<Row gutter={[16, 16]}>
|
||||||
|
{draftProjects.map(project => (
|
||||||
|
<Col key={project.id} xs={24} sm={12} md={8} lg={6}>
|
||||||
|
<ProjectCard
|
||||||
|
project={project}
|
||||||
|
onEdit={handleContinueEdit}
|
||||||
|
onView={handleView}
|
||||||
|
onDelete={handleDelete}
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
))}
|
||||||
|
</Row>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 已完成的项目 */}
|
||||||
|
{completedProjects.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<h2 style={{ marginBottom: '16px', fontSize: '18px', fontWeight: 600 }}>
|
||||||
|
已完成 ({completedProjects.length})
|
||||||
|
</h2>
|
||||||
|
<Row gutter={[16, 16]}>
|
||||||
|
{completedProjects.map(project => (
|
||||||
|
<Col key={project.id} xs={24} sm={12} md={8} lg={6}>
|
||||||
|
<ProjectCard
|
||||||
|
project={project}
|
||||||
|
onEdit={handleContinueEdit}
|
||||||
|
onView={handleView}
|
||||||
|
onDelete={handleDelete}
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
))}
|
||||||
|
</Row>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -22,9 +22,12 @@ import {
|
|||||||
Switch,
|
Switch,
|
||||||
Empty,
|
Empty,
|
||||||
Spin,
|
Spin,
|
||||||
Alert
|
Alert,
|
||||||
|
Progress,
|
||||||
|
List,
|
||||||
|
Typography
|
||||||
} from 'antd'
|
} from 'antd'
|
||||||
import { SkillCreateWizard } from '@/components/SkillCreateWizard'
|
import { SkillCreate } from '@/components/SkillCreate'
|
||||||
import {
|
import {
|
||||||
PlusOutlined,
|
PlusOutlined,
|
||||||
EyeOutlined,
|
EyeOutlined,
|
||||||
@ -39,11 +42,15 @@ import {
|
|||||||
LockOutlined,
|
LockOutlined,
|
||||||
RobotOutlined,
|
RobotOutlined,
|
||||||
EditFilled,
|
EditFilled,
|
||||||
ReloadOutlined
|
ReloadOutlined,
|
||||||
|
ClockCircleOutlined,
|
||||||
|
CloseCircleOutlined,
|
||||||
|
CheckOutlined
|
||||||
} from '@ant-design/icons'
|
} from '@ant-design/icons'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
import { useSkillStore, Skill } from '@/stores/skillStore'
|
import { useSkillStore, Skill } from '@/stores/skillStore'
|
||||||
import { skillService, SkillDraft } from '@/services/skillService'
|
import { skillService, SkillDraft } from '@/services/skillService'
|
||||||
|
import { taskService } from '@/services/taskService'
|
||||||
import { type ColumnsType, TablePaginationConfig } from 'antd/es/table'
|
import { type ColumnsType, TablePaginationConfig } from 'antd/es/table'
|
||||||
import dayjs from 'dayjs'
|
import dayjs from 'dayjs'
|
||||||
|
|
||||||
@ -99,6 +106,10 @@ export const SkillManagement = () => {
|
|||||||
const [aiCategory, setAiCategory] = useState<string | undefined>()
|
const [aiCategory, setAiCategory] = useState<string | undefined>()
|
||||||
const [generating, setGenerating] = useState(false)
|
const [generating, setGenerating] = useState(false)
|
||||||
const [generatedDraft, setGeneratedDraft] = useState<SkillDraft | null>(null)
|
const [generatedDraft, setGeneratedDraft] = useState<SkillDraft | null>(null)
|
||||||
|
|
||||||
|
// 技能生成任务状态
|
||||||
|
const [skillGenerationTasks, setSkillGenerationTasks] = useState<any[]>([])
|
||||||
|
const [loadingTasks, setLoadingTasks] = useState(false)
|
||||||
const [optimizing, setOptimizing] = useState(false)
|
const [optimizing, setOptimizing] = useState(false)
|
||||||
const [optimizeRequirements, setOptimizeRequirements] = useState('')
|
const [optimizeRequirements, setOptimizeRequirements] = useState('')
|
||||||
|
|
||||||
@ -113,8 +124,34 @@ export const SkillManagement = () => {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchSkills()
|
fetchSkills()
|
||||||
|
|
||||||
|
// 轮询技能生成任务
|
||||||
|
const interval = setInterval(async () => {
|
||||||
|
await fetchSkillGenerationTasks()
|
||||||
|
}, 3000) // 每3秒检查一次
|
||||||
|
|
||||||
|
return () => clearInterval(interval)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
// 获取技能生成任务
|
||||||
|
const fetchSkillGenerationTasks = async () => {
|
||||||
|
try {
|
||||||
|
const tasks = await taskService.listTasks({
|
||||||
|
type: 'generate_skill',
|
||||||
|
status: 'running'
|
||||||
|
})
|
||||||
|
setSkillGenerationTasks(tasks)
|
||||||
|
|
||||||
|
// 如果有任务完成,刷新技能列表
|
||||||
|
const completedTasks = tasks.filter(task => task.status === 'completed')
|
||||||
|
if (completedTasks.length > 0) {
|
||||||
|
fetchSkills()
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch skill generation tasks:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Get unique categories
|
// Get unique categories
|
||||||
const categories = Array.from(new Set(skills.map(s => s.category)))
|
const categories = Array.from(new Set(skills.map(s => s.category)))
|
||||||
|
|
||||||
@ -170,15 +207,9 @@ export const SkillManagement = () => {
|
|||||||
message.warning('内置 Skill 不允许编辑')
|
message.warning('内置 Skill 不允许编辑')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// 使用新的 SkillCreate 组件进行编辑
|
||||||
setEditingSkill(skill)
|
setEditingSkill(skill)
|
||||||
form.setFieldsValue({
|
setWizardVisible(true)
|
||||||
id: skill.id,
|
|
||||||
name: skill.name,
|
|
||||||
category: skill.category,
|
|
||||||
behavior_guide: skill.behavior_guide,
|
|
||||||
tags: skill.tags || []
|
|
||||||
})
|
|
||||||
setEditModalOpen(true)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleCopy = (skill: Skill) => {
|
const handleCopy = (skill: Skill) => {
|
||||||
@ -553,6 +584,25 @@ export const SkillManagement = () => {
|
|||||||
</Space>
|
</Space>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
{/* 技能生成任务状态 */}
|
||||||
|
{skillGenerationTasks.length > 0 && (
|
||||||
|
<Alert
|
||||||
|
type="info"
|
||||||
|
showIcon
|
||||||
|
style={{ marginBottom: 16 }}
|
||||||
|
action={
|
||||||
|
<Button size="small" onClick={fetchSkillGenerationTasks}>
|
||||||
|
刷新
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Space>
|
||||||
|
<ClockCircleOutlined />
|
||||||
|
<span>当前有 {skillGenerationTasks.length} 个 Skill 正在生成中</span>
|
||||||
|
</Space>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 搜索和筛选 */}
|
{/* 搜索和筛选 */}
|
||||||
<div style={{ marginBottom: '16px' }}>
|
<div style={{ marginBottom: '16px' }}>
|
||||||
<Space size="large" wrap>
|
<Space size="large" wrap>
|
||||||
@ -725,7 +775,7 @@ export const SkillManagement = () => {
|
|||||||
background: '#f5f5f5',
|
background: '#f5f5f5',
|
||||||
padding: '16px',
|
padding: '16px',
|
||||||
borderRadius: '4px',
|
borderRadius: '4px',
|
||||||
maxHeight: '500px',
|
maxHeight: '70vh',
|
||||||
overflowY: 'auto',
|
overflowY: 'auto',
|
||||||
whiteSpace: 'pre-wrap',
|
whiteSpace: 'pre-wrap',
|
||||||
wordBreak: 'break-word',
|
wordBreak: 'break-word',
|
||||||
@ -744,7 +794,7 @@ export const SkillManagement = () => {
|
|||||||
background: '#f5f5f5',
|
background: '#f5f5f5',
|
||||||
padding: '16px',
|
padding: '16px',
|
||||||
borderRadius: '4px',
|
borderRadius: '4px',
|
||||||
maxHeight: '500px',
|
maxHeight: '70vh',
|
||||||
overflowY: 'auto'
|
overflowY: 'auto'
|
||||||
}}>
|
}}>
|
||||||
{JSON.stringify(selectedSkill, null, 2)}
|
{JSON.stringify(selectedSkill, null, 2)}
|
||||||
@ -829,7 +879,7 @@ export const SkillManagement = () => {
|
|||||||
background: '#f5f5f5',
|
background: '#f5f5f5',
|
||||||
padding: '12px',
|
padding: '12px',
|
||||||
borderRadius: '4px',
|
borderRadius: '4px',
|
||||||
maxHeight: '400px',
|
maxHeight: '60vh',
|
||||||
overflowY: 'auto',
|
overflowY: 'auto',
|
||||||
fontSize: '13px',
|
fontSize: '13px',
|
||||||
lineHeight: '1.6'
|
lineHeight: '1.6'
|
||||||
@ -1415,14 +1465,19 @@ export const SkillManagement = () => {
|
|||||||
</Form>
|
</Form>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
{/* Skill 创建向导 - 新的多步骤创建流程 */}
|
{/* Skill 创建 - AI 自动生成 */}
|
||||||
<SkillCreateWizard
|
<SkillCreate
|
||||||
visible={wizardVisible}
|
visible={wizardVisible}
|
||||||
onClose={() => setWizardVisible(false)}
|
onClose={() => {
|
||||||
|
setWizardVisible(false)
|
||||||
|
setEditingSkill(null)
|
||||||
|
}}
|
||||||
onSuccess={() => {
|
onSuccess={() => {
|
||||||
fetchSkills()
|
fetchSkills()
|
||||||
setWizardVisible(false)
|
setWizardVisible(false)
|
||||||
|
setEditingSkill(null)
|
||||||
}}
|
}}
|
||||||
|
editingSkillId={editingSkill?.id}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@ -36,7 +36,9 @@ api.interceptors.response.use(
|
|||||||
(response) => response.data,
|
(response) => response.data,
|
||||||
(error) => {
|
(error) => {
|
||||||
const message = error.response?.data?.detail || error.message || '请求失败'
|
const message = error.response?.data?.detail || error.message || '请求失败'
|
||||||
console.error('API Error:', message)
|
console.error('API Error:', message, error)
|
||||||
|
console.error('API Error URL:', error.config?.url)
|
||||||
|
console.error('API Error Status:', error.response?.status)
|
||||||
return Promise.reject(new Error(message))
|
return Promise.reject(new Error(message))
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
@ -24,6 +24,8 @@ export interface GlobalContext {
|
|||||||
sceneSettings?: Record<string, SceneSetting>
|
sceneSettings?: Record<string, SceneSetting>
|
||||||
overallOutline?: string
|
overallOutline?: string
|
||||||
styleGuide?: string
|
styleGuide?: string
|
||||||
|
uploadedScript?: string
|
||||||
|
inspiration?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
// Skills 配置相关类型
|
// Skills 配置相关类型
|
||||||
|
|||||||
@ -83,32 +83,209 @@ export const skillService = {
|
|||||||
return await api.post<SkillGenerateResponse>('/skills/generate', request)
|
return await api.post<SkillGenerateResponse>('/skills/generate', request)
|
||||||
},
|
},
|
||||||
|
|
||||||
// 列出所有 Skills
|
// ========================================
|
||||||
|
// 基于文档生成 Skill(skill-creator 工作流)
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取文档内容(用于预览或生成 Skill)
|
||||||
|
* @param url 文档 URL
|
||||||
|
* @param selector 可选的 CSS 选择器
|
||||||
|
*/
|
||||||
|
fetchDoc: async (url: string, selector?: string): Promise<{ success: boolean; title?: string; content?: string; word_count?: number; char_count?: number; url?: string; error?: string }> => {
|
||||||
|
return await api.post('/skills/fetch-doc', { url, selector })
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取 GitHub 文档内容
|
||||||
|
* @param repoUrl GitHub 仓库 URL
|
||||||
|
* @param docsPath 文档路径
|
||||||
|
*/
|
||||||
|
fetchGithubDoc: async (repoUrl: string, docsPath: string): Promise<{ success: boolean; repo_url?: string; docs_path?: string; content?: string; word_count?: number; char_count?: number; error?: string }> => {
|
||||||
|
return await api.post('/skills/fetch-github-doc', { repo_url: repoUrl, docs_path: docsPath })
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 基于文档生成 Skill(完整工作流)
|
||||||
|
* @param request 包含技能名称、文档来源等
|
||||||
|
* @returns 生成的 Skill 内容和预览 ID
|
||||||
|
*/
|
||||||
|
generateSkillFromDocs: async (request: {
|
||||||
|
skill_name: string
|
||||||
|
description?: string
|
||||||
|
category?: string
|
||||||
|
tags?: string[]
|
||||||
|
doc_urls?: string[]
|
||||||
|
github_repos?: Array<{ repo_url: string; docs_path: string }>
|
||||||
|
uploaded_references?: Record<string, string>
|
||||||
|
include_doc_summary?: boolean
|
||||||
|
temperature?: number
|
||||||
|
}): Promise<{
|
||||||
|
success: boolean
|
||||||
|
preview_id: string
|
||||||
|
skill_content: string
|
||||||
|
skill_name: string
|
||||||
|
suggested_id: string
|
||||||
|
category: string
|
||||||
|
tags: string[]
|
||||||
|
doc_summary: string
|
||||||
|
references_count: number
|
||||||
|
explanation: string
|
||||||
|
}> => {
|
||||||
|
return await api.post('/skills/generate-from-docs', request)
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 预览 Skill(验证和解析)
|
||||||
|
* @param request 包含 SKILL.md 内容
|
||||||
|
* @returns 预览 ID 和解析结果
|
||||||
|
*/
|
||||||
|
previewSkill: async (request: {
|
||||||
|
skill_content: string
|
||||||
|
skill_name?: string
|
||||||
|
category?: string
|
||||||
|
tags?: string[]
|
||||||
|
}): Promise<{
|
||||||
|
preview_id: string
|
||||||
|
skill_content: string
|
||||||
|
parsed_metadata: Record<string, any>
|
||||||
|
validation_warnings: string[]
|
||||||
|
word_count: number
|
||||||
|
estimated_tokens: number
|
||||||
|
}> => {
|
||||||
|
return await api.post('/skills/preview', request)
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 使用 AI 调整/优化 Skill
|
||||||
|
* @param skillContent 原始 SKILL.md 内容
|
||||||
|
* @param refinementPrompt 调整需求
|
||||||
|
* @param temperature 温度
|
||||||
|
* @returns 调整后的内容
|
||||||
|
*/
|
||||||
|
refineSkill: async (skillContent: string, refinementPrompt: string, temperature?: number): Promise<{
|
||||||
|
success: boolean
|
||||||
|
refined_content: string
|
||||||
|
changes_summary: string
|
||||||
|
original_length: number
|
||||||
|
new_length: number
|
||||||
|
}> => {
|
||||||
|
return await api.post('/skills/refine', {
|
||||||
|
skill_content: skillContent,
|
||||||
|
refinement_prompt: refinementPrompt,
|
||||||
|
temperature: temperature || 0.7
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从预览保存 Skill 到系统
|
||||||
|
* @param previewId 预览 ID
|
||||||
|
* @param skillId Skill ID
|
||||||
|
* @param skillContent SKILL.md 内容
|
||||||
|
* @param references 可选的 references 文件
|
||||||
|
*/
|
||||||
|
saveSkillFromPreview: async (previewId: string, skillId: string, skillContent: string, references?: Record<string, string>): Promise<Skill> => {
|
||||||
|
return await api.post<Skill>('/skills/save-from-preview', {
|
||||||
|
preview_id: previewId,
|
||||||
|
skill_id: skillId,
|
||||||
|
skill_content: skillContent,
|
||||||
|
references: references
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取预览数据
|
||||||
|
* @param previewId 预览 ID
|
||||||
|
*/
|
||||||
|
getPreview: async (previewId: string): Promise<{
|
||||||
|
preview: Record<string, any>
|
||||||
|
references: Record<string, string>
|
||||||
|
references_count: number
|
||||||
|
}> => {
|
||||||
|
return await api.get(`/skills/preview/${previewId}`)
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除预览数据
|
||||||
|
* @param previewId 预览 ID
|
||||||
|
*/
|
||||||
|
deletePreview: async (previewId: string): Promise<void> => {
|
||||||
|
return await api.delete(`/skills/preview/${previewId}`)
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取 Skill 及其参考文件
|
||||||
|
* @param skillId Skill ID
|
||||||
|
* @param includeReferences 是否包含参考文件内容
|
||||||
|
*/
|
||||||
|
getSkillWithReferences: async (skillId: string, includeReferences = true): Promise<{
|
||||||
|
skill: Skill
|
||||||
|
references?: Record<string, string>
|
||||||
|
}> => {
|
||||||
|
return await api.get(`/skills/${skillId}/with-references`, { include_references: includeReferences })
|
||||||
|
},
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// 基础 CRUD 方法
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 列出所有 Skills
|
||||||
|
*/
|
||||||
listSkills: async (params?: { skill_type?: string; category?: string }) => {
|
listSkills: async (params?: { skill_type?: string; category?: string }) => {
|
||||||
return await api.get<Skill[]>('/skills', { params })
|
return await api.get<Skill[]>('/skills', { params })
|
||||||
},
|
},
|
||||||
|
|
||||||
// 获取 Skill 详情
|
/**
|
||||||
|
* 获取 Skill 详情
|
||||||
|
*/
|
||||||
getSkill: async (skillId: string) => {
|
getSkill: async (skillId: string) => {
|
||||||
return await api.get<Skill>(`/skills/${skillId}`)
|
return await api.get<Skill>(`/skills/${skillId}`)
|
||||||
},
|
},
|
||||||
|
|
||||||
// 测试 Skill
|
/**
|
||||||
|
* 测试 Skill
|
||||||
|
*/
|
||||||
testSkill: async (skillId: string, data: SkillTestRequest) => {
|
testSkill: async (skillId: string, data: SkillTestRequest) => {
|
||||||
return await api.post<SkillTestResponse>(`/skills/${skillId}/test`, data)
|
return await api.post<SkillTestResponse>(`/skills/${skillId}/test`, data)
|
||||||
},
|
},
|
||||||
|
|
||||||
// 创建 Skill
|
/**
|
||||||
createSkill: async (data: { id: string; name: string; content: string; category?: string }) => {
|
* 创建 Skill (ID 由后端自动生成)
|
||||||
|
*/
|
||||||
|
createSkill: async (data: { name: string; content: string; category?: string }) => {
|
||||||
return await api.post<Skill>('/skills', data)
|
return await api.post<Skill>('/skills', data)
|
||||||
},
|
},
|
||||||
|
|
||||||
// 更新 Skill
|
/**
|
||||||
|
* 更新 Skill
|
||||||
|
*/
|
||||||
updateSkill: async (skillId: string, data: { name?: string; content?: string }) => {
|
updateSkill: async (skillId: string, data: { name?: string; content?: string }) => {
|
||||||
return await api.put<Skill>(`/skills/${skillId}`, data)
|
return await api.put<Skill>(`/skills/${skillId}`, data)
|
||||||
},
|
},
|
||||||
|
|
||||||
// 删除 Skill
|
/**
|
||||||
|
* 更新 Skill 及其 References
|
||||||
|
* @param skillId Skill ID
|
||||||
|
* @param skillContent SKILL.md 内容
|
||||||
|
* @param references 参考文件对象 (可选)
|
||||||
|
*/
|
||||||
|
updateSkillWithReferences: async (
|
||||||
|
skillId: string,
|
||||||
|
skillContent: string,
|
||||||
|
references?: Record<string, string>
|
||||||
|
): Promise<Skill> => {
|
||||||
|
return await api.put<Skill>(`/skills/${skillId}/with-references`, {
|
||||||
|
preview_id: '', // 不需要preview_id,编辑模式
|
||||||
|
skill_id: skillId,
|
||||||
|
skill_content: skillContent,
|
||||||
|
references: references || {}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除 Skill
|
||||||
|
*/
|
||||||
deleteSkill: async (skillId: string) => {
|
deleteSkill: async (skillId: string) => {
|
||||||
return await api.delete(`/skills/${skillId}`)
|
return await api.delete(`/skills/${skillId}`)
|
||||||
},
|
},
|
||||||
|
|||||||
163
frontend/src/services/taskService.ts
Normal file
163
frontend/src/services/taskService.ts
Normal file
@ -0,0 +1,163 @@
|
|||||||
|
/**
|
||||||
|
* 异步任务服务
|
||||||
|
*/
|
||||||
|
import api from './api'
|
||||||
|
import type { TaskProgress } from '@/components/TaskProgressTracker'
|
||||||
|
|
||||||
|
export interface CreateTaskRequest {
|
||||||
|
type: string
|
||||||
|
params: Record<string, any>
|
||||||
|
projectId?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateTaskResponse {
|
||||||
|
success: boolean
|
||||||
|
taskId: string
|
||||||
|
message: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const taskService = {
|
||||||
|
/**
|
||||||
|
* 创建异步任务
|
||||||
|
*/
|
||||||
|
createTask: async (request: CreateTaskRequest) => {
|
||||||
|
return await api.post<CreateTaskResponse>('/tasks', request)
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取任务详情
|
||||||
|
*/
|
||||||
|
getTask: async (taskId: string) => {
|
||||||
|
return await api.get<TaskProgress>(`/tasks/${taskId}`)
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 列出任务
|
||||||
|
*/
|
||||||
|
listTasks: async (params?: {
|
||||||
|
type?: string
|
||||||
|
projectId?: string
|
||||||
|
status?: string
|
||||||
|
}) => {
|
||||||
|
return await api.get<TaskProgress[]>('/tasks', { params })
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 取消任务
|
||||||
|
*/
|
||||||
|
cancelTask: async (taskId: string) => {
|
||||||
|
return await api.post(`/tasks/${taskId}/cancel`)
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除任务
|
||||||
|
*/
|
||||||
|
deleteTask: async (taskId: string) => {
|
||||||
|
return await api.delete(`/tasks/${taskId}`)
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取项目的所有任务
|
||||||
|
*/
|
||||||
|
getProjectTasks: async (projectId: string) => {
|
||||||
|
return await api.get<TaskProgress[]>(`/tasks/project/${projectId}`)
|
||||||
|
},
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// AI 生成任务快捷方法
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 异步生成人物设定
|
||||||
|
*/
|
||||||
|
generateCharacters: async (params: {
|
||||||
|
idea: string
|
||||||
|
projectName?: string
|
||||||
|
totalEpisodes?: number
|
||||||
|
skills?: Array<{ id: string; name: string; behavior: string }>
|
||||||
|
customPrompt?: string
|
||||||
|
projectId?: string
|
||||||
|
}) => {
|
||||||
|
return await api.post<CreateTaskResponse>('/ai-assistant/async/generate/characters', params)
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 异步生成大纲
|
||||||
|
*/
|
||||||
|
generateOutline: async (params: {
|
||||||
|
idea: string
|
||||||
|
totalEpisodes?: number
|
||||||
|
genre?: string
|
||||||
|
projectName?: string
|
||||||
|
skills?: Array<{ id: string; name: string; behavior: string }>
|
||||||
|
customPrompt?: string
|
||||||
|
projectId?: string
|
||||||
|
}) => {
|
||||||
|
return await api.post<CreateTaskResponse>('/ai-assistant/async/generate/outline', params)
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 异步生成世界观
|
||||||
|
*/
|
||||||
|
generateWorld: async (params: {
|
||||||
|
idea: string
|
||||||
|
projectName?: string
|
||||||
|
genre?: string
|
||||||
|
skills?: Array<{ id: string; name: string; behavior: string }>
|
||||||
|
customPrompt?: string
|
||||||
|
projectId?: string
|
||||||
|
}) => {
|
||||||
|
return await api.post<CreateTaskResponse>('/ai-assistant/async/generate/world', params)
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 异步生成技能
|
||||||
|
*/
|
||||||
|
generateSkill: async (params: {
|
||||||
|
description: string
|
||||||
|
category?: string
|
||||||
|
tags?: string[]
|
||||||
|
temperature?: number
|
||||||
|
}) => {
|
||||||
|
return await api.post<CreateTaskResponse>('/skills/async/generate', params)
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 轮询任务直到完成
|
||||||
|
* @param taskId 任务ID
|
||||||
|
* @param onUpdate 进度更新回调
|
||||||
|
* @param interval 轮询间隔(毫秒)
|
||||||
|
* @returns Promise<任务结果>
|
||||||
|
*/
|
||||||
|
pollTask: async (
|
||||||
|
taskId: string,
|
||||||
|
onUpdate?: (progress: TaskProgress) => void,
|
||||||
|
interval = 1000
|
||||||
|
): Promise<any> => {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const poll = async () => {
|
||||||
|
try {
|
||||||
|
const progress = await api.get<TaskProgress>(`/tasks/${taskId}`)
|
||||||
|
onUpdate?.(progress)
|
||||||
|
|
||||||
|
if (progress.status === 'completed') {
|
||||||
|
resolve(progress.result)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (progress.status === 'failed') {
|
||||||
|
reject(new Error(progress.error || '任务执行失败'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 继续轮询
|
||||||
|
setTimeout(poll, interval)
|
||||||
|
} catch (err) {
|
||||||
|
reject(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
poll()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user