feat:项目初步开发和debug

This commit is contained in:
hjjjj 2026-01-26 16:45:17 +08:00
parent d0e11aef1b
commit 1f10bb4406
33 changed files with 7694 additions and 375 deletions

View File

@ -6,7 +6,9 @@
"Bash(python:*)",
"Bash(npm run build)",
"Bash(npx tsc:*)",
"Bash(findstr:*)"
"Bash(findstr:*)",
"Bash(tree:*)",
"Bash(npx eslint:*)"
]
}
}

2
.gitignore vendored
View File

@ -5,4 +5,4 @@ __pycache__/
.env
.claude/
.venv/
.settings.local.json

1
backend/.gitignore vendored
View File

@ -109,3 +109,4 @@ dmypy.json
logs/
storage/
*.db
.settings.local.json

View File

@ -3,13 +3,16 @@ AI 辅助生成 API
提供 AI 辅助生成人物大纲解析剧本等功能
支持 Skills 融入将选定的 Skills 行为指导融入 LLM 调用
支持长剧本分段分析自动切分并合并结果
"""
from fastapi import APIRouter, HTTPException
from typing import Dict, Any, List, Optional
from pydantic import BaseModel
import asyncio
from app.core.llm.glm_client import get_glm_client
from app.core.skills.skill_manager import get_skill_manager
from app.utils.logger import get_logger
from app.utils.script_splitter import split_script, ScriptSplitter
logger = get_logger(__name__)
@ -29,6 +32,7 @@ class GenerateCharactersRequest(BaseModel):
projectName: Optional[str] = None
totalEpisodes: Optional[int] = None
skills: Optional[List[SkillInfo]] = None # 新增Skills 列表
customPrompt: Optional[str] = None # 自定义提示词
class GenerateOutlineRequest(BaseModel):
@ -38,6 +42,7 @@ class GenerateOutlineRequest(BaseModel):
genre: str = "古风"
projectName: Optional[str] = None
skills: Optional[List[SkillInfo]] = None # 新增Skills 列表
customPrompt: Optional[str] = None # 自定义提示词
class ParseScriptRequest(BaseModel):
@ -47,6 +52,24 @@ class ParseScriptRequest(BaseModel):
extractOutline: bool = True
skills: Optional[List[SkillInfo]] = None # 新增Skills 列表
use_llm: bool = True # 新增:是否使用 LLM 分析(默认 True
customPrompt: Optional[str] = None # 新增:自定义提示词
class GenerateWorldRequest(BaseModel):
"""生成世界观请求"""
idea: str
projectName: Optional[str] = None
genre: Optional[str] = "古风"
skills: Optional[List[SkillInfo]] = None # Skills 列表
customPrompt: Optional[str] = None # 自定义提示词
class GenerateWorldFromScriptRequest(BaseModel):
"""从剧本分析世界观请求"""
script: str
projectName: Optional[str] = None
skills: Optional[List[SkillInfo]] = None # Skills 列表
customPrompt: Optional[str] = None # 自定义提示词
# ============================================================================
@ -126,9 +149,14 @@ async def generate_characters(request: GenerateCharactersRequest):
if request.totalEpisodes:
extra_info += f"\n总集数:{request.totalEpisodes}"
# 自定义提示词
custom_requirements = ""
if request.customPrompt:
custom_requirements = f"\n【用户自定义要求】\n{request.customPrompt}\n"
prompt = f"""请根据以下想法生成 3-5 个主要人物设定:
用户想法{request.idea}{extra_info}
用户想法{request.idea}{extra_info}{custom_requirements}
要求
1. 每个人物包含姓名身份性格说话风格背景故事
@ -192,12 +220,17 @@ async def generate_outline(request: GenerateOutlineRequest):
)
# 构建用户提示
# 自定义提示词
custom_requirements = ""
if request.customPrompt:
custom_requirements = f"\n【用户自定义要求】\n{request.customPrompt}\n"
prompt = f"""请根据以下想法生成完整的剧集大纲:
用户想法{request.idea}
总集数{request.totalEpisodes}
类型{request.genre}
{f'项目名称:{request.projectName}' if request.projectName else ''}
{f'项目名称:{request.projectName}' if request.projectName else ''}{custom_requirements}
要求
1. 将故事分为 4-5 个阶段
@ -237,6 +270,141 @@ async def generate_outline(request: GenerateOutlineRequest):
raise HTTPException(status_code=500, detail=f"生成失败: {str(e)}")
@router.post("/generate/world")
async def generate_world(request: GenerateWorldRequest):
"""
AI 辅助生成世界观设定
根据用户想法生成世界观设定
支持融入 Skills 的行为指导
支持自定义提示词
"""
try:
glm_client = get_glm_client()
skill_manager = get_skill_manager()
# 构建增强的 System Prompt融入 Skills
base_role = "你是专业的世界观设定专家,擅长构建架空世界的背景设定。"
system_prompt = await build_enhanced_system_prompt(
base_role=base_role,
skills=request.skills,
skill_manager=skill_manager
)
# 构建用户提示
# 自定义提示词
custom_requirements = ""
if request.customPrompt:
custom_requirements = f"\n【用户自定义要求】\n{request.customPrompt}\n"
prompt = f"""请根据以下想法生成世界观设定:
用户想法{request.idea}
类型{request.genre}
{f'项目名称:{request.projectName}' if request.projectName else ''}{custom_requirements}
要求
1. 描述时代背景朝代架空世界等
2. 描述地理环境和主要场景
3. 描述社会结构权力体系阶级关系
4. 描述文化特色习俗服饰语言等
5. 字数 200-500
6. 严格遵守上面应用技能指导中的要求
请输出详细的世界观设定
"""
logger.info(f"生成世界观设定,使用 {len(request.skills) if request.skills else 0} 个 Skills")
response = await glm_client.chat(
messages=[
{"role": "system", "content": system_prompt},
{"role": "user", "content": prompt}
],
temperature=0.7
)
content = response["choices"][0]["message"]["content"]
return {
"success": True,
"worldSetting": content,
"usage": response.get("usage")
}
except Exception as e:
logger.error(f"生成世界观设定失败: {str(e)}")
raise HTTPException(status_code=500, detail=f"生成失败: {str(e)}")
@router.post("/generate/world-from-script")
async def generate_world_from_script(request: GenerateWorldFromScriptRequest):
"""
AI 辅助从剧本分析世界观
从剧本中提取和分析世界观设定
支持融入 Skills 的行为指导
支持自定义提示词
"""
try:
glm_client = get_glm_client()
skill_manager = get_skill_manager()
# 构建增强的 System Prompt融入 Skills
base_role = """你是专业的世界观分析专家,擅长从剧本中提取和分析世界观设定。
你能识别人物关系时代背景社会结构地理环境等深层信息"""
system_prompt = await build_enhanced_system_prompt(
base_role=base_role,
skills=request.skills,
skill_manager=skill_manager
)
# 构建用户提示
# 自定义提示词
custom_requirements = ""
if request.customPrompt:
custom_requirements = f"\n【用户自定义要求】\n{request.customPrompt}\n"
prompt = f"""请分析以下剧本,提取世界观设定:
{f'项目名称:{request.projectName}' if request.projectName else ''}{custom_requirements}
剧本内容
{request.script[:5000]}
要求
1. 识别时代背景朝代架空世界等
2. 提取地理环境和主要场景信息
3. 分析社会结构权力体系阶级关系
4. 识别文化特色习俗服饰语言等
5. 输出结构化的世界观分析
请输出世界观分析
"""
logger.info(f"从剧本分析世界观,使用 {len(request.skills) if request.skills else 0} 个 Skills")
response = await glm_client.chat(
messages=[
{"role": "system", "content": system_prompt},
{"role": "user", "content": prompt}
],
temperature=0.3
)
content = response["choices"][0]["message"]["content"]
return {
"success": True,
"worldSetting": content,
"usage": response.get("usage")
}
except Exception as e:
logger.error(f"从剧本分析世界观失败: {str(e)}")
raise HTTPException(status_code=500, detail=f"分析失败: {str(e)}")
@router.post("/parse/script")
async def parse_script(request: ParseScriptRequest):
"""
@ -265,10 +433,44 @@ async def parse_script(request: ParseScriptRequest):
async def _parse_script_with_llm(request: ParseScriptRequest) -> Dict[str, Any]:
"""
使用 LLM 智能分析剧本支持 Skills 融入
支持长剧本分段分析和结果合并
"""
glm_client = get_glm_client()
skill_manager = get_skill_manager()
# 检查内容长度,决定是否需要分段
content = request.content
content_length = len(content)
# 阈值超过8000字符时使用分段分析
SPLIT_THRESHOLD = 8000
if content_length <= SPLIT_THRESHOLD:
# 内容较短,直接分析
return await _analyze_single_segment(
content=content,
request=request,
glm_client=glm_client,
skill_manager=skill_manager
)
else:
# 内容较长,使用分段分析
logger.info(f"剧本较长 ({content_length} 字符),启用分段分析模式")
return await _analyze_with_splitting(
content=content,
request=request,
glm_client=glm_client,
skill_manager=skill_manager
)
async def _analyze_single_segment(
content: str,
request: ParseScriptRequest,
glm_client,
skill_manager
) -> Dict[str, Any]:
"""分析单个片段(原有逻辑)"""
# 构建增强的 System Prompt融入 Skills
base_role = """你是专业的剧本分析专家,擅长从剧本中提取人物关系、剧情结构、对话风格等关键信息。
你能识别人物的出场频率人物关系情感走向对话特点等深层次信息"""
@ -278,12 +480,6 @@ async def _parse_script_with_llm(request: ParseScriptRequest) -> Dict[str, Any]:
skill_manager=skill_manager
)
# 截取剧本内容(避免过长)
content = request.content
max_length = 8000 # 约 3000-4000 字
if len(content) > max_length:
content = content[:max_length] + "\n\n...(剧本过长,已截断前部分进行分析)"
# 构建分析提示
analysis_requirements = []
if request.extractCharacters:
@ -303,18 +499,23 @@ async def _parse_script_with_llm(request: ParseScriptRequest) -> Dict[str, Any]:
- 分析故事结构起承转合
- 总结核心冲突""")
# 自定义提示词
custom_requirements = ""
if request.customPrompt:
custom_requirements = f"\n【用户自定义要求】\n{request.customPrompt}\n"
prompt = f"""请分析以下剧本内容,提取关键信息:
{content}
要求
{''.join(analysis_requirements)}
3. 严格遵守上面应用技能指导中的分析要求
3. 严格遵守上面应用技能指导中的分析要求{custom_requirements}
请以结构化的格式输出便于后续处理
"""
logger.info(f"使用 LLM 分析剧本,使用 {len(request.skills) if request.skills else 0} 个 Skills")
logger.info(f"使用 LLM 分析剧本 ({len(content)} 字符),使用 {len(request.skills) if request.skills else 0} 个 Skills")
response = await glm_client.chat(
messages=[
@ -329,14 +530,274 @@ async def _parse_script_with_llm(request: ParseScriptRequest) -> Dict[str, Any]:
return {
"success": True,
"method": "llm",
"analysis": analysis_result, # LLM 的完整分析结果
"characters": [], # 可选:从分析结果中解析
"outline": "", # 可选:从分析结果中解析
"summary": f"LLM 智能分析完成,使用 {len(request.skills) if request.skills else 0} 个 Skills",
"analysis": analysis_result,
"characters": [],
"outline": "",
"summary": f"LLM 智能分析完成 ({len(content)} 字符),使用 {len(request.skills) if request.skills else 0} 个 Skills",
"usage": response.get("usage")
}
async def _analyze_with_splitting(
content: str,
request: ParseScriptRequest,
glm_client,
skill_manager
) -> Dict[str, Any]:
"""分段分析长剧本并合并结果"""
# 1. 切分剧本
split_result = split_script(content)
segments = split_result["segments"]
summary = split_result["summary"]
logger.info(f"剧本已切分为 {len(segments)} 个片段: {summary}")
# 2. 并行分析各片段
segment_analyses = await _analyze_segments_parallel(
segments=segments,
request=request,
glm_client=glm_client,
skill_manager=skill_manager
)
# 3. 合并分析结果
merged_result = await _merge_analysis_results(
segment_analyses=segment_analyses,
request=request,
glm_client=glm_client,
skill_manager=skill_manager,
split_summary=summary
)
return merged_result
async def _analyze_segments_parallel(
segments: List[Dict[str, Any]],
request: ParseScriptRequest,
glm_client,
skill_manager
) -> List[Dict[str, Any]]:
"""并行分析多个片段"""
# 构建基础系统提示词
base_role = """你是专业的剧本分析专家,擅长从剧本中提取人物关系、剧情结构、对话风格等关键信息。
这是长剧本的分段分析请专注于当前片段的内容"""
system_prompt = await build_enhanced_system_prompt(
base_role=base_role,
skills=request.skills,
skill_manager=skill_manager
)
# 构建分析要求
analysis_requirements = []
if request.extractCharacters:
analysis_requirements.append("""
1. 人物分析
- 识别当前片段中的所有出场人物
- 统计每个人物的出场次数/对话次数
- 分析人物关系上下级敌对盟友等
- 提取人物性格特点和说话风格
- 按重要性排序输出""")
if request.extractOutline:
analysis_requirements.append("""
2. 剧情大纲
- 识别当前片段的剧情阶段
- 提取关键转折点
- 分析情节进展""")
# 为每个片段创建分析任务
# 自定义提示词
custom_requirements = ""
if request.customPrompt:
custom_requirements = f"\n【用户自定义要求】\n{request.customPrompt}\n"
async def analyze_segment(segment: Dict[str, Any]) -> Dict[str, Any]:
segment_index = segment["index"]
segment_content = segment["content"]
scene_marker = segment.get("scene_marker", "")
prompt = f"""请分析以下剧本片段(片段 {segment_index + 1}/{len(segments)}),提取关键信息:
{f"场景标记:{scene_marker}" if scene_marker else ""}
片段内容
{segment_content}
要求
{''.join(analysis_requirements)}
3. 严格遵守上面应用技能指导中的分析要求
4. 输出结构化的分析结果便于后续合并{custom_requirements}
请以结构化的格式输出
"""
try:
response = await glm_client.chat(
messages=[
{"role": "system", "content": system_prompt},
{"role": "user", "content": prompt}
],
temperature=0.3
)
return {
"segment_index": segment_index,
"scene_marker": scene_marker,
"analysis": response["choices"][0]["message"]["content"],
"usage": response.get("usage"),
"success": True
}
except Exception as e:
logger.error(f"分析片段 {segment_index} 失败: {str(e)}")
return {
"segment_index": segment_index,
"scene_marker": scene_marker,
"analysis": f"分析失败: {str(e)}",
"success": False
}
# 并行执行所有片段分析限制并发数为3
semaphore = asyncio.Semaphore(3)
async def analyze_with_semaphore(segment):
async with semaphore:
return await analyze_segment(segment)
results = await asyncio.gather(
*[analyze_with_semaphore(seg) for seg in segments],
return_exceptions=True
)
# 过滤异常结果
valid_results = [r for r in results if isinstance(r, dict)]
logger.info(f"并行分析完成:{len(valid_results)}/{len(segments)} 个片段成功")
return valid_results
async def _merge_analysis_results(
segment_analyses: List[Dict[str, Any]],
request: ParseScriptRequest,
glm_client,
skill_manager,
split_summary: Dict[str, Any]
) -> Dict[str, Any]:
"""合并多个片段的分析结果"""
# 收集所有片段的分析
all_analyses = []
total_usage = {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0}
for analysis in segment_analyses:
if analysis.get("success"):
all_analyses.append(f"""
---
片段 {analysis['segment_index'] + 1}{f"{analysis['scene_marker']}" if analysis['scene_marker'] else ""}
{analysis['analysis']}
---
""")
# 累计 token 使用量
if analysis.get("usage"):
for key in ["prompt_tokens", "completion_tokens", "total_tokens"]:
total_usage[key] += analysis["usage"].get(key, 0)
if not all_analyses:
return {
"success": False,
"method": "llm_batch",
"error": "所有片段分析均失败",
"summary": "分段分析失败"
}
# 使用 LLM 合并和去重
base_role = """你是专业的剧本分析专家,擅长整合和总结多源信息。
你的任务是将多个片段的分析结果合并成一个完整的去重的结构化的分析报告"""
system_prompt = await build_enhanced_system_prompt(
base_role=base_role,
skills=request.skills,
skill_manager=skill_manager
)
merge_requirements = []
if request.extractCharacters:
merge_requirements.append("""
1. 人物合并
- 将各片段中的人物列表合并去重
- 累加每个人物的出场次数
- 整合人物关系分析
- 输出完整的人物列表""")
if request.extractOutline:
merge_requirements.append("""
2. 大纲合并
- 将各片段的剧情大纲按时间/逻辑顺序整合
- 形成完整的剧情结构
- 标注关键转折点""")
# 自定义提示词
custom_requirements = ""
if request.customPrompt:
custom_requirements = f"\n【用户自定义要求】\n{request.customPrompt}\n"
merge_prompt = f"""以下是将长剧本切分后各片段的分析结果:
{''.join(all_analyses)}
请合并以上分析输出一个完整的去重的分析报告
要求
{''.join(merge_requirements)}
3. 保持结构化输出格式
4. 严格遵守上面应用技能指导中的分析要求{custom_requirements}
请输出合并后的完整分析报告
"""
logger.info("开始合并各片段分析结果...")
try:
response = await glm_client.chat(
messages=[
{"role": "system", "content": system_prompt},
{"role": "user", "content": merge_prompt}
],
temperature=0.3
)
merged_analysis = response["choices"][0]["message"]["content"]
# 加上合并步骤的 token 使用量
if response.get("usage"):
for key in ["prompt_tokens", "completion_tokens", "total_tokens"]:
total_usage[key] += response["usage"].get(key, 0)
return {
"success": True,
"method": "llm_batch",
"analysis": merged_analysis,
"characters": [],
"outline": "",
"summary": f"分段分析完成:共 {len(segment_analyses)} 个片段,{split_summary.get('split_methods', {})}",
"split_info": split_summary,
"segment_count": len(segment_analyses),
"usage": total_usage
}
except Exception as e:
logger.error(f"合并分析结果失败: {str(e)}")
# 如果合并失败,返回原始的片段分析
return {
"success": True,
"method": "llm_batch",
"analysis": "\n\n".join(all_analyses),
"characters": [],
"outline": "",
"summary": f"分段分析完成(合并失败,返回原始分析):共 {len(segment_analyses)} 个片段",
"split_info": split_summary,
"segment_count": len(segment_analyses),
"usage": total_usage,
"merge_error": str(e)
}
async def _parse_script_with_regex(request: ParseScriptRequest) -> Dict[str, Any]:
"""
使用正则表达式快速提取剧本信息不使用 LLM不消耗 token

View 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. 严格遵守上面应用技能指导中的要求
请按以下格式输出
阶段1EPxx-EPxx阶段名称
内容概要...
阶段2EPxx-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": "任务已创建,正在后台执行"
}

View 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
]

View File

@ -7,7 +7,9 @@ Skill 管理 API 路由
3. AI 辅助 Skill 创建完整流程
4. Skill 选择和路由LLM Skills 结合
5. Agent 工作流配置
6. 文档驱动的 Skill 生成新增
"""
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, status
from typing import List, Optional, Dict, Any
from pydantic import BaseModel
@ -21,6 +23,16 @@ from app.models.skill import (
SkillTestResponse,
SkillGenerateRequest,
SkillGenerateResponse,
# 新增模型
DocFetchRequest,
GitHubDocFetchRequest,
SkillGenerateFromDocsRequest,
SkillGenerateFromDocsResponse,
SkillPreviewRequest,
SkillPreviewResponse,
SkillSaveFromPreviewRequest,
SkillRefineRequest,
SkillRefineResponse,
)
from app.models.skill_integration import (
SkillMetadata,
@ -89,9 +101,40 @@ async def create_skill(
skill_data: SkillCreate,
skill_manager: SkillManager = Depends(get_skill_manager)
):
"""创建新的用户 Skill"""
"""
创建新的用户 Skill
如果不提供 ID系统将自动生成
- 使用 UUID 作为唯一标识
- 基于名称生成可读 IDkebab-case
"""
try:
skill = await skill_manager.create_user_skill(skill_data)
# 如果没有提供 ID自动生成
if not skill_data.id:
import uuid
import re
# 生成基于名称的 kebab-case ID
base_id = skill_data.name.lower()
base_id = re.sub(r'[^\w\s-]', '', base_id) # 移除特殊字符
base_id = re.sub(r'\s+', '-', base_id) # 空格替换为连字符
base_id = re.sub(r'-{2,}', '-', base_id) # 多个连字符合并为一个
# 如果生成结果为空,使用名称的拼音或默认前缀
if not base_id or len(base_id) < 3:
base_id = f"skill-{uuid.uuid4().hex[:8]}"
# 确保 ID 唯一(添加随机后缀)
unique_id = f"{base_id}-{uuid.uuid4().hex[:6]}"
# 创建新的 SkillCreate 对象,使用自动生成的 ID
skill_data_with_id = skill_data.copy()
skill_data_with_id.id = unique_id
skill = await skill_manager.create_user_skill(skill_data_with_id)
else:
skill = await skill_manager.create_user_skill(skill_data)
return skill
except Exception as e:
logger.error(f"创建 Skill 失败: {str(e)}")
@ -829,3 +872,614 @@ async def execute_tool(
"""
result = await skill_manager.route_and_execute_tool(tool_name, parameters)
return result
# ============================================================================
# 增强的 Skill 生成工作流(文档驱动)
# ============================================================================
import uuid
from collections import defaultdict
from app.core.documentation_fetcher import get_documentation_fetcher, DocumentationFetcher
# 预览存储(生产环境应使用 Redis 或数据库)
_preview_storage: Dict[str, Dict[str, Any]] = defaultdict(dict)
_preview_references: Dict[str, Dict[str, str]] = defaultdict(dict)
@router.post("/fetch-doc", response_model=dict)
async def fetch_documentation(
request: DocFetchRequest,
doc_fetcher: DocumentationFetcher = Depends(get_documentation_fetcher)
):
"""
获取文档内容用于预览或生成 Skill
Args:
request: 包含文档 URL 和可选的 CSS 选择器
Returns:
获取的文档内容
"""
try:
result = await doc_fetcher.fetch_from_url(request.url, request.selector)
if result.get("success"):
return {
"success": True,
"title": result.get("title"),
"content": result.get("content"),
"word_count": result.get("word_count"),
"char_count": result.get("char_count"),
"url": result.get("url")
}
else:
return {
"success": False,
"error": result.get("error")
}
except Exception as e:
logger.error(f"获取文档失败: {str(e)}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"获取文档失败: {str(e)}"
)
@router.post("/fetch-github-doc", response_model=dict)
async def fetch_github_documentation(
request: GitHubDocFetchRequest,
doc_fetcher: DocumentationFetcher = Depends(get_documentation_fetcher)
):
"""
获取 GitHub 文档内容
Args:
request: GitHub 仓库和文档路径
Returns:
获取的文档内容
"""
try:
result = await doc_fetcher.fetch_from_github(
repo_url=request.repo_url,
docs_path=request.docs_path
)
if result.get("success"):
return {
"success": True,
"repo_url": result.get("repo_url"),
"docs_path": result.get("docs_path"),
"content": result.get("content"),
"word_count": result.get("word_count"),
"char_count": result.get("char_count")
}
else:
return {
"success": False,
"error": result.get("error")
}
except Exception as e:
logger.error(f"获取 GitHub 文档失败: {str(e)}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"获取 GitHub 文档失败: {str(e)}"
)
@router.post("/generate-from-docs", response_model=SkillGenerateFromDocsResponse)
async def generate_skill_from_documentation(
request: SkillGenerateFromDocsRequest,
skill_manager: SkillManager = Depends(get_skill_manager),
glm_client: GLMClient = Depends(get_glm_client),
doc_fetcher: DocumentationFetcher = Depends(get_documentation_fetcher)
):
"""
基于文档生成 Skill新工作流
完整流程
1. 获取文档内容URL/GitHub/上传
2. 提取关键信息
3. 使用 LLM 生成 SKILL.md
4. 生成预览 ID
5. 返回可预览的内容
用户可以
- 预览生成的内容
- 手动编辑
- AI 调整
- 确认后保存到系统
"""
try:
logger.info(f"开始基于文档生成 Skill: {request.skill_name}")
# 1. 收集所有文档内容
all_docs = []
doc_summary_parts = []
# 从 URL 获取
if request.doc_urls:
for url in request.doc_urls:
result = await doc_fetcher.fetch_from_url(url)
if result.get("success"):
all_docs.append({
"source": f"URL: {url}",
"title": result.get("title"),
"content": result.get("content")
})
doc_summary_parts.append(f"- {result.get('title')} ({url})")
# 从 GitHub 获取
if request.github_repos:
for github_req in request.github_repos:
result = await doc_fetcher.fetch_from_github(
repo_url=github_req.repo_url,
docs_path=github_req.docs_path
)
if result.get("success"):
all_docs.append({
"source": f"GitHub: {github_req.repo_url}/{github_req.docs_path}",
"title": github_req.docs_path,
"content": result.get("content")
})
doc_summary_parts.append(f"- {github_req.repo_url}/{github_req.docs_path}")
# 用户上传的 references
if request.uploaded_references:
for filename, content in request.uploaded_references.items():
all_docs.append({
"source": f"上传: {filename}",
"title": filename,
"content": content
})
doc_summary_parts.append(f"- {filename} (用户上传)")
# 2. 构建 LLM 提示词
doc_context = ""
if all_docs:
# 取文档摘要(前 500 字)
doc_summary = "\n".join(doc_summary_parts)
# 构建文档上下文(避免超过上下文窗口)
doc_context_parts = []
for doc in all_docs[:3]: # 最多 3 个文档
content_preview = doc["content"][:500].replace("\n", " ")
doc_context_parts.append(f"#### {doc['title']}\n{content_preview}...")
doc_context = f"""
**文档来源**
{doc_summary}
**文档内容摘要**
{''.join(doc_context_parts)}
"""
# 3. 加载 skill-creator 标准
skill_creator = await skill_manager.load_skill("skill-creator")
creator_guide = skill_creator.behavior_guide if skill_creator else ""
# 4. 构建 LLM 提示词
system_prompt = f"""你是一个专业的 Skill 创建专家。
{creator_guide if creator_guide else ''}
你的任务是基于提供的文档内容创建一个高质量的 Skill
**重要要求**
1. SKILL.md 必须以 YAML frontmatter 开始
2. YAML 必须包含name, description, category, tags
3. description 应该清晰说明此 Skill 的用途和使用场景
4. 行为指导部分应该简洁具体从文档中提取关键信息
5. 如果文档包含代码示例API配置等应该在行为指导中体现
6. 使用 markdown 格式
请以 JSON 格式返回结果包含以下字段
- skill_content: 完整的 SKILL.md 内容 --- 开头的 YAML frontmatter + markdown 内容
- explanation: 对生成的 Skill 的简要说明中文
- suggested_category: 建议的分类
- suggested_tags: 建议的标签数组"""
user_prompt = f"""请基于以下文档创建一个 Skill
**Skill 名称**{request.skill_name}
**描述**{request.description or f'基于文档生成的 Skill{request.skill_name}'}
**指定分类**{request.category or '自动推断'}
**指定标签**{', '.join(request.tags) if request.tags else '自动推断'}
{doc_context if request.include_doc_summary else ''}
请生成完整的 SKILL.md"""
# 5. 调用 LLM 生成
response = await glm_client.chat(
messages=[
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_prompt}
],
temperature=request.temperature
)
response_text = response["choices"][0]["message"]["content"]
# 6. 解析 JSON 响应
import json
import re
json_match = re.search(r'\{[\s\S]*\}', response_text)
if json_match:
try:
result = json.loads(json_match.group())
skill_content = result.get("skill_content", response_text)
explanation = result.get("explanation", "AI 生成的 Skill")
suggested_category = result.get("suggested_category", request.category or "通用")
suggested_tags = result.get("suggested_tags", request.tags or ["自定义"])
except json.JSONDecodeError:
skill_content = response_text
explanation = "AI 生成的 Skill"
suggested_category = request.category or "通用"
suggested_tags = request.tags or ["自定义"]
else:
skill_content = response_text
explanation = "AI 生成的 Skill"
suggested_category = request.category or "通用"
suggested_tags = request.tags or ["自定义"]
# 7. 生成预览 ID
preview_id = str(uuid.uuid4())
# 8. 存储预览数据
_preview_storage[preview_id] = {
"skill_name": request.skill_name,
"skill_content": skill_content,
"category": suggested_category,
"tags": suggested_tags,
"doc_urls": request.doc_urls,
"github_repos": [r.model_dump() for r in request.github_repos],
"uploaded_references": request.uploaded_references,
"explanation": explanation,
"created_at": str(datetime.now())
}
# 9. 存储 references后续保存时使用
references = {}
for i, doc in enumerate(all_docs):
filename = f"{doc['title'].replace('/', '-').replace(' ', '_')}.md"
# 清理文件名
filename = ''.join(c for c in filename if c.isalnum() or c in '._-')
references[filename] = doc["content"]
_preview_references[preview_id] = references
# 10. 生成文档摘要
doc_summary = "\n".join(doc_summary_parts) if doc_summary_parts else "无文档来源"
return SkillGenerateFromDocsResponse(
success=True,
preview_id=preview_id,
skill_content=skill_content,
skill_name=request.skill_name,
suggested_id=request.skill_name.lower().replace(" ", "-").replace("_", "-"),
category=suggested_category,
tags=suggested_tags,
doc_summary=doc_summary,
references_count=len(references),
explanation=explanation
)
except HTTPException:
raise
except Exception as e:
logger.error(f"基于文档生成 Skill 失败: {str(e)}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"生成失败: {str(e)}"
)
@router.post("/preview", response_model=SkillPreviewResponse)
async def preview_skill(
request: SkillPreviewRequest
):
"""
预览 Skill验证和解析
返回
- 解析后的元数据
- 验证警告
- 字数统计
- 预估 token
"""
try:
# 解析 YAML frontmatter
metadata = {}
content = request.skill_content
import yaml
yaml_match = re.match(r'^---\n(.*?)\n---', content, re.DOTALL)
if yaml_match:
try:
metadata = yaml.safe_load(yaml_match.group(1)) or {}
except:
pass
# 验证警告
warnings = []
if not metadata.get("name"):
warnings.append("缺少 name 字段")
if not metadata.get("description"):
warnings.append("缺少 description 字段")
if len(content) < 100:
warnings.append("内容过短,可能不够详细")
if len(content) > 50000:
warnings.append("内容过长,可能占用过多上下文")
# 统计
word_count = len(content.split())
estimated_tokens = int(len(content) * 1.3) # 粗略估计
# 生成预览 ID
preview_id = str(uuid.uuid4())
_preview_storage[preview_id] = {
"skill_name": request.skill_name,
"skill_content": content,
"category": request.category,
"tags": request.tags,
"metadata": metadata,
"warnings": warnings,
"created_at": str(datetime.now())
}
return SkillPreviewResponse(
preview_id=preview_id,
skill_content=content,
parsed_metadata=metadata,
validation_warnings=warnings,
word_count=word_count,
estimated_tokens=estimated_tokens
)
except Exception as e:
logger.error(f"预览 Skill 失败: {str(e)}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"预览失败: {str(e)}"
)
@router.post("/refine", response_model=SkillRefineResponse)
async def refine_skill_with_ai(
request: SkillRefineRequest,
glm_client: GLMClient = Depends(get_glm_client)
):
"""
使用 AI 调整/优化 Skill
用户可以输入自然语言提示词 AI 修改 Skill 内容
示例提示词
- "把行为指导改得更简洁"
- "增加一个快速开始部分"
- "添加更多代码示例"
- "优化描述,让它更清晰"
"""
try:
logger.info(f"AI 调整 Skill提示词: {request.refinement_prompt[:50]}...")
prompt = f"""请根据以下要求调整 SKILL.md 内容:
**调整要求**
{request.refinement_prompt}
**原始 SKILL.md**
{request.skill_content}
请返回完整的调整后的 SKILL.md 内容不要包含其他解释"""
response = await glm_client.chat(
messages=[{"role": "user", "content": prompt}],
temperature=request.temperature
)
refined_content = response["choices"][0]["message"]["content"].strip()
# 清理可能的 markdown 代码块标记
if refined_content.startswith("```"):
lines = refined_content.split("\n")
refined_content = "\n".join(lines[1:])
if refined_content.endswith("```"):
refined_content = "\n".join(refined_content.split("\n")[:-1])
# 生成变更摘要
changes_summary = f"根据提示词「{request.refinement_prompt}」进行了调整"
return SkillRefineResponse(
success=True,
refined_content=refined_content.strip(),
changes_summary=changes_summary,
original_length=len(request.skill_content),
new_length=len(refined_content.strip())
)
except Exception as e:
logger.error(f"AI 调整失败: {str(e)}")
return SkillRefineResponse(
success=False,
refined_content=request.skill_content,
changes_summary=f"调整失败: {str(e)}",
original_length=len(request.skill_content),
new_length=len(request.skill_content)
)
@router.post("/save-from-preview", response_model=Skill)
async def save_skill_from_preview(
request: SkillSaveFromPreviewRequest,
skill_manager: SkillManager = Depends(get_skill_manager)
):
"""
从预览保存 Skill 到系统
保存流程
1. 创建 Skill 目录结构
2. 保存 SKILL.md
3. 保存 references如果有
4. 注册到 SkillManager
"""
try:
logger.info(f"从预览保存 Skill: {request.skill_id}")
# 1. 创建 Skill
skill_data = SkillCreate(
id=request.skill_id,
name=request.skill_id.replace("-", " ").title(),
content=request.skill_content,
category="通用", # 从 YAML frontmatter 解析
tags=[] # 从 YAML frontmatter 解析
)
skill = await skill_manager.create_user_skill(skill_data)
# 2. 保存 references如果有
if request.references:
skill_path = skill_manager._find_skill_path(request.skill_id)
if skill_path:
refs_dir = skill_path.parent / "references"
refs_dir.mkdir(exist_ok=True)
for filename, content in request.references.items():
# 确保文件名安全
safe_filename = filename.replace("..", "").replace("/", "").replace("\\", "")
if not safe_filename.endswith(".md"):
safe_filename += ".md"
ref_file = refs_dir / safe_filename
ref_file.write_text(content, encoding="utf-8")
logger.info(f"保存 reference: {request.skill_id}/references/{safe_filename}")
# 3. 清理预览数据
if request.preview_id in _preview_storage:
del _preview_storage[request.preview_id]
if request.preview_id in _preview_references:
del _preview_references[request.preview_id]
return skill
except Exception as e:
logger.error(f"保存 Skill 失败: {str(e)}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"保存失败: {str(e)}"
)
@router.get("/preview/{preview_id}", response_model=dict)
async def get_preview(preview_id: str):
"""
获取预览数据
"""
if preview_id not in _preview_storage:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"预览不存在: {preview_id}"
)
preview_data = _preview_storage[preview_id]
references = _preview_references.get(preview_id, {})
return {
"preview": preview_data,
"references": references,
"references_count": len(references)
}
@router.delete("/preview/{preview_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_preview(preview_id: str):
"""
删除预览数据
"""
if preview_id in _preview_storage:
del _preview_storage[preview_id]
if preview_id in _preview_references:
del _preview_references[preview_id]
return None
@router.put("/{skill_id}/with-references", response_model=Skill)
async def update_skill_with_references(
skill_id: str,
request: SkillSaveFromPreviewRequest,
skill_manager: SkillManager = Depends(get_skill_manager)
):
"""
更新 Skill 及其 References
用于编辑模式同时更新 SKILL.md references/ 目录中的文件
"""
try:
logger.info(f"更新 Skill 及 references: {skill_id}")
# 1. 更新 SKILL.md
skill_update = SkillUpdate(content=request.skill_content)
skill = await skill_manager.update_user_skill(skill_id, skill_update)
if not skill:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Skill 不存在: {skill_id}"
)
# 2. 更新 references如果有
if request.references is not None: # 允许传入空字典清空references
skill_path = skill_manager._find_skill_path(skill_id)
if skill_path:
refs_dir = skill_path.parent / "references"
# 如果传入空字典删除整个references目录
if len(request.references) == 0:
if refs_dir.exists():
import shutil
shutil.rmtree(refs_dir)
logger.info(f"清空 references: {skill_id}/references/")
else:
# 确保目录存在
refs_dir.mkdir(exist_ok=True)
# 清空现有文件
for existing_file in refs_dir.iterdir():
if existing_file.is_file():
existing_file.unlink()
# 写入新文件
for filename, content in request.references.items():
safe_filename = filename.replace("..", "").replace("/", "").replace("\\", "")
if not safe_filename.endswith(".md"):
safe_filename += ".md"
ref_file = refs_dir / safe_filename
ref_file.write_text(content, encoding="utf-8")
logger.info(f"更新 reference: {skill_id}/references/{safe_filename}")
# 清除缓存
if skill_id in skill_manager._cache:
del skill_manager._cache[skill_id]
# 重新加载并返回
return await skill_manager.load_skill(skill_id)
except HTTPException:
raise
except Exception as e:
logger.error(f"更新 Skill 失败: {str(e)}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"更新失败: {str(e)}"
)

View 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 IDkebab-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)
}

View 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

View File

@ -296,6 +296,9 @@ class SkillManager:
提取行为指导部分核心功能
查找 ## 行为指导 或 ## Behavior Guide 部分
如果找到专门的章节返回该章节内容
如果没有找到返回完整内容整个SKILL.md就是行为指导
"""
lines = content.split('\n')
start_idx = -1
@ -306,8 +309,9 @@ class SkillManager:
break
if start_idx == -1:
# 如果没有专门的章节,尝试从整体提取
return content[:500] # 返回前500字符作为默认
# 如果没有专门的章节,返回完整内容
# 整个SKILL.md就是行为指导不应该截断
return content
# 提取到下一个 ## 之前的内容
guide_lines = []

View 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

View File

@ -108,12 +108,15 @@ async def health_check():
# ============================================
# API 路由注册
# ============================================
from app.api.v1 import skills, projects, ai_assistant, memory, review, websocket, uploads, episodes
from app.api.v1 import skills, projects, ai_assistant, memory, review, websocket, uploads, episodes, async_tasks, ai_async, skills_async
app.include_router(skills.router, prefix="/api/v1")
app.include_router(skills_async.router, prefix="/api/v1")
app.include_router(projects.router, prefix="/api/v1")
app.include_router(episodes.router, prefix="/api/v1")
app.include_router(ai_assistant.router, prefix="/api/v1")
app.include_router(async_tasks.router, prefix="/api/v1")
app.include_router(ai_async.router, prefix="/api/v1")
app.include_router(memory.router, prefix="/api/v1")
app.include_router(review.router, prefix="/api/v1")
app.include_router(uploads.router, prefix="/api/v1")

View File

@ -65,7 +65,7 @@ class Skill(BaseModel):
class SkillCreate(BaseModel):
"""创建 Skill 请求"""
id: str
id: Optional[str] = None # ID 可选,不传则自动生成
name: str
content: str # Markdown 格式的完整 Skill 内容
category: str = "通用"
@ -118,3 +118,90 @@ class SkillGenerateResponse(BaseModel):
category: str
suggested_tags: List[str]
explanation: str # AI 对生成的 Skill 的说明
# ============================================================================
# 新增:文档驱动的 Skill 生成模型
# ============================================================================
class DocFetchRequest(BaseModel):
"""文档获取请求"""
url: str = Field(..., description="文档 URL")
selector: Optional[str] = Field(None, description="CSS 选择器(可选)")
class GitHubDocFetchRequest(BaseModel):
"""GitHub 文档获取请求"""
repo_url: str = Field(..., description="GitHub 仓库 URL")
docs_path: str = Field("README.md", description="文档路径")
class SkillGenerateFromDocsRequest(BaseModel):
"""基于文档生成 Skill 请求"""
skill_name: str = Field(..., description="Skill 名称")
description: Optional[str] = Field(None, description="Skill 描述(可选)")
category: Optional[str] = Field(None, description="分类")
tags: Optional[List[str]] = Field(None, description="标签")
# 文档来源
doc_urls: List[str] = Field(default_factory=list, description="文档 URL 列表")
github_repos: List[GitHubDocFetchRequest] = Field(default_factory=list, description="GitHub 仓库列表")
uploaded_references: Dict[str, str] = Field(default_factory=dict, description="用户上传的 references {文件名: 内容}")
# 生成选项
temperature: float = Field(0.7, ge=0, le=1, description="LLM 温度")
include_doc_summary: bool = Field(True, description="是否包含文档摘要")
class SkillGenerateFromDocsResponse(BaseModel):
"""基于文档生成 Skill 响应"""
success: bool
preview_id: str = Field(..., description="预览 ID用于后续保存")
skill_content: str = Field(..., description="生成的 SKILL.md 内容")
skill_name: str
suggested_id: str
category: str
tags: List[str]
doc_summary: Optional[str] = Field(None, description="文档摘要")
references_count: int = Field(0, description="生成的 reference 文件数")
explanation: str
class SkillPreviewRequest(BaseModel):
"""Skill 预览请求"""
skill_content: str
skill_name: str
category: Optional[str] = None
tags: Optional[List[str]] = None
class SkillPreviewResponse(BaseModel):
"""Skill 预览响应"""
preview_id: str
skill_content: str
parsed_metadata: Dict[str, Any]
validation_warnings: List[str] = Field(default_factory=list)
word_count: int
estimated_tokens: int
class SkillSaveFromPreviewRequest(BaseModel):
"""从预览保存 Skill 请求"""
preview_id: str
skill_id: str = Field(..., description="要保存的 Skill ID")
skill_content: str
references: Optional[Dict[str, str]] = Field(None, description="要保存的 references")
class SkillRefineRequest(BaseModel):
"""AI 调整 Skill 请求"""
skill_content: str
refinement_prompt: str = Field(..., description="调整提示词")
temperature: float = Field(0.7, ge=0, le=1)
class SkillRefineResponse(BaseModel):
"""AI 调整 Skill 响应"""
success: bool
refined_content: str
changes_summary: str
original_length: int
new_length: int

View 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

View 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
}

View 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

View File

@ -0,0 +1,31 @@
---
name: ancient-style-dialogue-writer
description: 专门用于创作符合中国古代背景、角色身份地位及性格特征的对话内容。
---
# 古风对话创作
此 Skill 用于指导生成符合中国古代语境的角色对话,确保语言风格贴合人物身份与时代背景。
## 角色分析
在创作对话前,需明确以下要素:
- **身份地位**:皇室、官宦、平民、江湖人士、僧道等。
- **性格特征**:傲慢、谦卑、儒雅、粗犷、狡诈等。
- **时代背景**:先秦、汉唐、宋明或架空朝代,不同时代用词习惯略有差异。
- **对话对象**:君臣、父子、挚友、仇敌等关系决定语气。
## 语言风格指南
### 1. 代词与称呼
- **自称**
- 皇室:朕、孤、本宫。
- 官员/文人:下官、学生、在下、愚兄。
- 普通百姓/江湖:老朽、洒家、奴家、妾身、某。
- 通称:吾、余、我。
- **对称**
- 尊称:陛下、殿下、大人、阁下、足下、先生、姑娘。
- 谦称/贬称:竖子、匹夫、老贼、尔、汝。
### 2. 语气与句式
- **文雅含

View File

@ -181,7 +181,6 @@
"integrity": "sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.28.6",
"@babel/generator": "^7.28.6",
@ -1818,7 +1817,6 @@
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.27.tgz",
"integrity": "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==",
"license": "MIT",
"peer": true,
"dependencies": {
"@types/prop-types": "*",
"csstype": "^3.2.2"
@ -1889,7 +1887,6 @@
"integrity": "sha512-tbsV1jPne5CkFQCgPBcDOt30ItF7aJoZL997JSF7MhGQqOeT3svWRYxiqlfA5RUdlHN6Fi+EI9bxqbdyAUZjYQ==",
"dev": true,
"license": "BSD-2-Clause",
"peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "6.21.0",
"@typescript-eslint/types": "6.21.0",
@ -2079,7 +2076,6 @@
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@ -2387,7 +2383,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.9.0",
"caniuse-lite": "^1.0.30001759",
@ -2816,8 +2811,7 @@
"version": "1.11.19",
"resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.19.tgz",
"integrity": "sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw==",
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/debug": {
"version": "4.4.3",
@ -3100,7 +3094,6 @@
"deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.2.0",
"@eslint-community/regexpp": "^4.6.1",
@ -4114,7 +4107,6 @@
"integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"jiti": "bin/jiti.js"
}
@ -5242,8 +5234,7 @@
"version": "0.45.0",
"resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.45.0.tgz",
"integrity": "sha512-mjv1G1ZzfEE3k9HZN0dQ2olMdwIfaeAAjFiwNprLfYNRSz7ctv9XuCT7gPtBGrMUeV1/iZzYKj17Khu1hxoHOA==",
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/ms": {
"version": "2.1.3",
@ -5530,7 +5521,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"nanoid": "^3.3.11",
"picocolors": "^1.1.1",
@ -6368,7 +6358,6 @@
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"loose-envify": "^1.1.0"
},
@ -6381,7 +6370,6 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
"license": "MIT",
"peer": true,
"dependencies": {
"loose-envify": "^1.1.0",
"scheduler": "^0.23.2"
@ -7245,7 +7233,6 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@ -7344,7 +7331,6 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@ -7553,7 +7539,6 @@
"integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.21.3",
"postcss": "^8.4.43",

View File

@ -1,10 +1,11 @@
import React from 'react'
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'
import { ConfigProvider, Layout, Menu, theme } from 'antd'
import { DashboardOutlined, PlusOutlined, BookOutlined, SettingOutlined } from '@ant-design/icons'
import { DashboardOutlined, BookOutlined, SettingOutlined } from '@ant-design/icons'
import { ProjectList } from './pages/ProjectList'
import { ProjectCreateEnhanced } from './pages/ProjectCreateEnhanced'
import { ProjectCreateProgressive } from './pages/ProjectCreateProgressive'
import { ProjectDetail } from './pages/ProjectDetail'
import { ProjectWorkspace } from './pages/ProjectWorkspace'
import { SkillManagement } from './pages/SkillManagement'
@ -21,7 +22,6 @@ function App() {
const menuItems = [
{ key: '/projects', icon: <DashboardOutlined />, label: '项目列表' },
{ key: '/projects/new', icon: <PlusOutlined />, label: '创建项目' },
{ key: '/skills', icon: <BookOutlined />, label: 'Skills 管理' },
{ key: '/agents', icon: <SettingOutlined />, label: 'Agents 管理' },
]
@ -69,6 +69,7 @@ function App() {
<Route path="/" element={<Navigate to="/projects" replace />} />
<Route path="/projects" element={<ProjectList />} />
<Route path="/projects/new" element={<ProjectCreateEnhanced />} />
<Route path="/projects/progressive" element={<ProjectCreateProgressive />} />
<Route path="/projects/:id" element={<ProjectDetail />} />
<Route path="/projects/:id/workspace" element={<ProjectWorkspace />} />
<Route path="/projects/:id/execute" element={<ExecutionMonitor />} />

View File

@ -386,7 +386,7 @@ export const ContentEditor: React.FC<ContentEditorProps> = ({
/>
) : (
<div style={{
maxHeight: isFullscreen ? 'calc(100vh - 300px)' : '500px',
maxHeight: isFullscreen ? 'calc(100vh - 200px)' : '65vh',
overflowY: 'auto',
borderRadius: '4px'
}}>

View 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

View 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

View File

@ -1156,9 +1156,11 @@ export const AgentManagement = () => {
background: '#f5f5f5',
padding: '16px',
borderRadius: '4px',
maxHeight: '400px',
overflow: 'auto',
fontSize: '12px'
maxHeight: '65vh',
overflowY: 'auto',
fontSize: '12px',
wordBreak: 'break-word',
lineHeight: '1.5'
}}>
{JSON.stringify(selectedAgent, null, 2)}
</pre>

View File

@ -270,9 +270,11 @@ export const ExecutionMonitor = () => {
<div>
<Title level={5}></Title>
<div style={{
maxHeight: '400px',
maxHeight: '65vh',
overflowY: 'auto',
borderRadius: '4px'
borderRadius: '4px',
background: '#fafafa',
padding: '12px'
}}>
<MarkdownRenderer content={selectedEpisode.content} />
</div>

File diff suppressed because it is too large Load Diff

View 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

View File

@ -1,18 +1,223 @@
/**
*
* -
*
*
* - 使
* -
* -
*/
import { useEffect } from 'react'
import { useEffect, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { Table, Button, Space, Tag, Card, message } from 'antd'
import { PlusOutlined } from '@ant-design/icons'
import { Button, Space, Tag, Card, message, Empty, Row, Col, Progress, Tooltip, Badge } from 'antd'
import {
PlusOutlined, EditOutlined, DeleteOutlined, EyeOutlined,
ClockCircleOutlined, CheckCircleOutlined, LoadingOutlined,
FileTextOutlined, SettingOutlined
} from '@ant-design/icons'
import { useProjectStore } from '@/stores/projectStore'
import { type ColumnsType } from 'antd/es/table'
import { SeriesProject } from '@/services/projectService'
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 = () => {
const navigate = useNavigate()
const { projects, loading, fetchProjects, deleteProject } = useProjectStore()
const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid')
useEffect(() => {
fetchProjects()
@ -27,79 +232,141 @@ export const ProjectList = () => {
}
}
const columns: ColumnsType<SeriesProject> = [
{
title: '项目名称',
dataIndex: 'name',
key: 'name',
},
{
title: '总集数',
dataIndex: 'totalEpisodes',
key: 'totalEpisodes',
width: 100,
},
{
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>
),
},
]
const handleContinueEdit = (id: string) => {
// 跳转到项目详情页继续编辑
navigate(`/projects/${id}`)
}
const handleView = (id: string) => {
navigate(`/projects/${id}`)
}
// 按状态分组项目
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')
return (
<div style={{ padding: '24px' }}>
<Card
title="我的项目"
extra={
{/* 头部 */}
<div style={{ marginBottom: '24px', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<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
type="primary"
size="large"
icon={<PlusOutlined />}
onClick={() => navigate('/projects/new')}
onClick={() => navigate('/projects/progressive')}
>
</Button>
}
>
<Table
columns={columns}
dataSource={projects}
rowKey="id"
loading={loading}
locale={{ emptyText: '暂无项目,点击上方按钮创建' }}
/>
</Card>
</Space>
</div>
{/* 项目列表 */}
{loading ? (
<div style={{ textAlign: 'center', padding: '100px 0' }}>
<LoadingOutlined style={{ fontSize: '48px', color: '#1677ff' }} />
<p style={{ marginTop: '16px', color: '#666' }}>...</p>
</div>
) : 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>
)
}

View File

@ -22,9 +22,12 @@ import {
Switch,
Empty,
Spin,
Alert
Alert,
Progress,
List,
Typography
} from 'antd'
import { SkillCreateWizard } from '@/components/SkillCreateWizard'
import { SkillCreate } from '@/components/SkillCreate'
import {
PlusOutlined,
EyeOutlined,
@ -39,11 +42,15 @@ import {
LockOutlined,
RobotOutlined,
EditFilled,
ReloadOutlined
ReloadOutlined,
ClockCircleOutlined,
CloseCircleOutlined,
CheckOutlined
} from '@ant-design/icons'
import { useNavigate } from 'react-router-dom'
import { useSkillStore, Skill } from '@/stores/skillStore'
import { skillService, SkillDraft } from '@/services/skillService'
import { taskService } from '@/services/taskService'
import { type ColumnsType, TablePaginationConfig } from 'antd/es/table'
import dayjs from 'dayjs'
@ -99,6 +106,10 @@ export const SkillManagement = () => {
const [aiCategory, setAiCategory] = useState<string | undefined>()
const [generating, setGenerating] = useState(false)
const [generatedDraft, setGeneratedDraft] = useState<SkillDraft | null>(null)
// 技能生成任务状态
const [skillGenerationTasks, setSkillGenerationTasks] = useState<any[]>([])
const [loadingTasks, setLoadingTasks] = useState(false)
const [optimizing, setOptimizing] = useState(false)
const [optimizeRequirements, setOptimizeRequirements] = useState('')
@ -113,8 +124,34 @@ export const SkillManagement = () => {
useEffect(() => {
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
const categories = Array.from(new Set(skills.map(s => s.category)))
@ -170,15 +207,9 @@ export const SkillManagement = () => {
message.warning('内置 Skill 不允许编辑')
return
}
// 使用新的 SkillCreate 组件进行编辑
setEditingSkill(skill)
form.setFieldsValue({
id: skill.id,
name: skill.name,
category: skill.category,
behavior_guide: skill.behavior_guide,
tags: skill.tags || []
})
setEditModalOpen(true)
setWizardVisible(true)
}
const handleCopy = (skill: Skill) => {
@ -553,6 +584,25 @@ export const SkillManagement = () => {
</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' }}>
<Space size="large" wrap>
@ -725,7 +775,7 @@ export const SkillManagement = () => {
background: '#f5f5f5',
padding: '16px',
borderRadius: '4px',
maxHeight: '500px',
maxHeight: '70vh',
overflowY: 'auto',
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
@ -744,7 +794,7 @@ export const SkillManagement = () => {
background: '#f5f5f5',
padding: '16px',
borderRadius: '4px',
maxHeight: '500px',
maxHeight: '70vh',
overflowY: 'auto'
}}>
{JSON.stringify(selectedSkill, null, 2)}
@ -829,7 +879,7 @@ export const SkillManagement = () => {
background: '#f5f5f5',
padding: '12px',
borderRadius: '4px',
maxHeight: '400px',
maxHeight: '60vh',
overflowY: 'auto',
fontSize: '13px',
lineHeight: '1.6'
@ -1415,14 +1465,19 @@ export const SkillManagement = () => {
</Form>
</Modal>
{/* Skill 创建向导 - 新的多步骤创建流程 */}
<SkillCreateWizard
{/* Skill 创建 - AI 自动生成 */}
<SkillCreate
visible={wizardVisible}
onClose={() => setWizardVisible(false)}
onClose={() => {
setWizardVisible(false)
setEditingSkill(null)
}}
onSuccess={() => {
fetchSkills()
setWizardVisible(false)
setEditingSkill(null)
}}
editingSkillId={editingSkill?.id}
/>
</div>
)

View File

@ -36,7 +36,9 @@ api.interceptors.response.use(
(response) => response.data,
(error) => {
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))
}
)

View File

@ -24,6 +24,8 @@ export interface GlobalContext {
sceneSettings?: Record<string, SceneSetting>
overallOutline?: string
styleGuide?: string
uploadedScript?: string
inspiration?: string
}
// Skills 配置相关类型

View File

@ -83,32 +83,209 @@ export const skillService = {
return await api.post<SkillGenerateResponse>('/skills/generate', request)
},
// 列出所有 Skills
// ========================================
// 基于文档生成 Skillskill-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 }) => {
return await api.get<Skill[]>('/skills', { params })
},
// 获取 Skill 详情
/**
* Skill
*/
getSkill: async (skillId: string) => {
return await api.get<Skill>(`/skills/${skillId}`)
},
// 测试 Skill
/**
* Skill
*/
testSkill: async (skillId: string, data: SkillTestRequest) => {
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)
},
// 更新 Skill
/**
* Skill
*/
updateSkill: async (skillId: string, data: { name?: string; content?: string }) => {
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) => {
return await api.delete(`/skills/${skillId}`)
},

View 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()
})
}
}