/** * 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, Button, Space, Card, Alert, message, Typography, Tag, Upload, Divider, Steps, Tabs, Row, Col, List, Popconfirm } from 'antd' import { RobotOutlined, FileTextOutlined, UploadOutlined, LinkOutlined, GithubOutlined, 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 } = Typography const { Step } = Steps const { TabPane } = Tabs 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' const SkillCreateComponent: React.FC = ({ visible, onClose, onSuccess, editingSkillId }) => { const [form] = Form.useForm() // 使用 ref 跟踪弹窗实际可见性(避免闭包问题) const dialogVisibleRef = useRef(visible) // 当 visible prop 变化时更新 ref useEffect(() => { dialogVisibleRef.current = visible }, [visible]) // 步骤状态 const [step, setStep] = useState('input') const [aiDescription, setAiDescription] = useState('') const [generating, setGenerating] = useState(false) const [currentTaskId, setCurrentTaskId] = useState(null) // 生成的Skill内容 const [generatedSkill, setGeneratedSkill] = useState(null) const [editingContent, setEditingContent] = useState(false) const [saving, setSaving] = useState(false) // References管理 const [references, setReferences] = useState([]) const [activeReferenceTab, setActiveReferenceTab] = useState<'url' | 'github' | 'upload' | 'manual'>('url') const [urlInput, setUrlInput] = useState('') const [githubInput, setGithubInput] = useState('') const [manualFileName, setManualFileName] = useState('') const [manualContent, setManualContent] = useState('') // 预览ID(用于保存) const [previewId, setPreviewId] = useState(null) const contentPreviewRef = useRef(null) // LocalStorage key for saving task state const SKILL_TASK_STORAGE_KEY = 'skill_generation_task' // 重置表单 const resetForm = () => { setStep('input') setAiDescription('') setGeneratedSkill(null) setEditingContent(false) setReferences([]) setUrlInput('') setGithubInput('') setManualFileName('') setManualContent('') setPreviewId(null) // 不清除 currentTaskId,让用户可以继续跟踪任务 form.resetFields() } // 加载编辑中的Skill useEffect(() => { if (editingSkillId && visible) { loadSkillForEdit(editingSkillId) } else if (visible) { // 检查是否有正在进行的任务 const savedTask = localStorage.getItem(SKILL_TASK_STORAGE_KEY) if (savedTask) { try { const taskData = JSON.parse(savedTask) // 检查任务是否是最近的(10分钟内) const taskAge = Date.now() - taskData.timestamp if (taskAge < 10 * 60 * 1000 && taskData.taskId && taskData.description) { setCurrentTaskId(taskData.taskId) setAiDescription(taskData.description) setStep('generating') setGenerating(true) // 继续轮询任务 const pollSavedTask = async () => { try { const result = await taskService.pollTask(taskData.taskId, (progress) => { console.log('Task progress:', progress) }) if (result.success && result.result) { const skillData = result.result setGeneratedSkill({ suggested_id: skillData.suggested_id, suggested_name: skillData.suggested_name, skill_content: skillData.skill_content, category: skillData.category, suggested_tags: skillData.suggested_tags, explanation: skillData.explanation }) form.setFieldsValue({ content: skillData.skill_content }) setStep('preview') setGenerating(false) setCurrentTaskId(null) localStorage.removeItem(SKILL_TASK_STORAGE_KEY) message.success('AI 生成成功!您可以预览和编辑内容') } } catch (error) { message.error(`AI 生成失败: ${(error as Error).message}`) setStep('input') setGenerating(false) setCurrentTaskId(null) localStorage.removeItem(SKILL_TASK_STORAGE_KEY) } } pollSavedTask() message.info('检测到正在进行的生成任务,正在继续...') return } } catch (error) { console.error('Failed to parse saved task:', error) localStorage.removeItem(SKILL_TASK_STORAGE_KEY) } } resetForm() setStep('input') } }, [visible, editingSkillId]) const loadSkillForEdit = async (skillId: string) => { try { // 检查是否是虚拟的 task ID(正在生成中的 skill) // 这种 ID 以 "task-" 开头,不是真实的 skill,不应该加载 if (skillId.startsWith('task-')) { console.log('[SkillCreate] Skipping load for virtual task ID:', skillId) message.info('该 Skill 正在生成中,请等待生成完成后再编辑') handleClose() return } const result = await skillService.getSkillWithReferences(skillId, true) if (result) { const { skill, references: refs } = result // 设置生成的内容 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}`) // 关闭弹窗,让父组件处理重定向 handleClose() } } const handleClose = () => { // 如果有后台任务正在运行,可以关闭弹窗让任务继续在后台运行 if (currentTaskId && generating) { message.info('任务将在后台继续运行,您可以稍后再来查看结果') } resetForm() onClose() } // ========== 步骤1: 输入需求 ========== const handleGenerate = async () => { if (!aiDescription.trim()) { message.warning('请描述你想要创建的 Skill') return } setGenerating(true) setStep('generating') try { // 创建异步任务 const response = await taskService.generateSkill({ description: aiDescription, temperature: 0.7 }) const taskId = response.taskId setCurrentTaskId(taskId) // 保存任务信息到 localStorage,以便关闭弹窗后恢复 localStorage.setItem(SKILL_TASK_STORAGE_KEY, JSON.stringify({ taskId, description: aiDescription, timestamp: Date.now() })) message.success('任务已创建,正在后台生成中...您可以关闭此弹窗,生成完成后会自动保存') // 启动后台轮询,但不阻塞 UI const pollTaskInBackground = async () => { try { const result = await taskService.pollTask(taskId, (progress) => { console.log('Task progress:', progress) }) // 任务完成后,如果弹窗还开着,显示结果 if (result.success && result.result) { const skillData = result.result console.log('Task completed, skill data:', skillData) // 清除 localStorage 中的任务信息 localStorage.removeItem(SKILL_TASK_STORAGE_KEY) // 检查弹窗是否还打开(使用 ref 避免闭包问题) if (dialogVisibleRef.current) { // 弹窗打开:显示结果供用户查看 // 注意:后端已经自动保存了,这里只是显示结果 setGeneratedSkill({ suggested_id: skillData.suggested_id, suggested_name: skillData.suggested_name, skill_content: skillData.skill_content, category: skillData.category, suggested_tags: skillData.suggested_tags, explanation: skillData.explanation }) form.setFieldsValue({ content: skillData.skill_content }) setStep('preview') const savedInfo = skillData.auto_saved ? `${skillData.suggested_name} 已自动保存!(ID: ${skillData.saved_skill_id})` : `${skillData.suggested_name} 生成完成!` message.success(savedInfo) // 通知父组件刷新列表 onSuccess() } else { // 弹窗已关闭:后端已经自动保存,只需通知用户 console.log('Dialog closed, skill was auto-saved by backend') if (skillData.auto_saved) { message.success(`${skillData.suggested_name} 已自动保存到 skills 列表!`) // 通知父组件刷新列表 onSuccess() } else if (skillData.save_error) { message.warning(`生成完成但自动保存失败: ${skillData.save_error}`) } } } else { throw new Error(result.error || '生成失败') } } catch (error) { message.error(`AI 生成失败: ${(error as Error).message}`) setStep('input') // 清除 localStorage 中的任务信息 localStorage.removeItem(SKILL_TASK_STORAGE_KEY) } finally { setGenerating(false) setCurrentTaskId(null) } } // 启动后台轮询 pollTaskInBackground() } catch (error) { message.error(`任务创建失败: ${(error as Error).message}`) setGenerating(false) setStep('input') } } // ========== 步骤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 [refineModalVisible, setRefineModalVisible] = useState(false) const [refinePrompt, setRefinePrompt] = useState('') const handleRefineWithAI = async () => { const currentContent = form.getFieldValue('content') if (!currentContent) { message.warning('没有可调整的内容') return } setRefinePrompt('') setRefineModalVisible(true) } const confirmRefineWithAI = async () => { if (!refinePrompt.trim()) { message.warning('请输入调整需求') return } const currentContent = form.getFieldValue('content') setRefineModalVisible(false) setGenerating(true) try { const result = await skillService.refineSkill(currentContent, refinePrompt, 0.7) if (result.success) { form.setFieldsValue({ content: result.refined_content }) setGeneratedSkill(prev => prev ? { ...prev, skill_content: result.refined_content } : null) Modal.info({ title: '调整完成', content: (

变更说明:{result.changes_summary}

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

新长度: {result.new_length} 字符

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