1104 lines
37 KiB
TypeScript
1104 lines
37 KiB
TypeScript
/**
|
||
* 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<SkillCreateProps> = ({
|
||
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<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('')
|
||
|
||
// 预览ID(用于保存)
|
||
const [previewId, setPreviewId] = useState<string | null>(null)
|
||
|
||
const contentPreviewRef = useRef<HTMLDivElement>(null)
|
||
|
||
// LocalStorage key for saving task state
|
||
const SKILL_TASK_STORAGE_KEY = 'skill_generation_task'
|
||
|
||
// 重置表单
|
||
const resetForm = () => {
|
||
setStep('input')
|
||
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: (
|
||
<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}
|
||
destroyOnHidden
|
||
>
|
||
<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 }}>
|
||
<Alert
|
||
message="任务已在后台创建"
|
||
description="您可以关闭此弹窗,任务将在后台继续运行。稍后重新打开创建弹窗即可查看生成结果。"
|
||
type="info"
|
||
showIcon
|
||
closable
|
||
style={{ marginBottom: 16 }}
|
||
/>
|
||
<TaskProgressTracker
|
||
taskId={currentTaskId}
|
||
onComplete={() => {
|
||
// 任务完成后的处理逻辑已经在 handleGenerate 中了
|
||
}}
|
||
onError={(error) => {
|
||
message.error(`生成失败: ${error}`)
|
||
}}
|
||
/>
|
||
<div style={{ marginTop: 16, textAlign: 'center' }}>
|
||
<Button onClick={handleClose}>
|
||
后台运行并关闭
|
||
</Button>
|
||
</div>
|
||
</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>
|
||
|
||
{/* AI 调整弹窗 */}
|
||
<Modal
|
||
title="输入调整需求"
|
||
open={refineModalVisible}
|
||
onOk={confirmRefineWithAI}
|
||
onCancel={() => setRefineModalVisible(false)}
|
||
okText="开始调整"
|
||
cancelText="取消"
|
||
>
|
||
<Space direction="vertical" style={{ width: '100%' }}>
|
||
<TextArea
|
||
rows={4}
|
||
placeholder="例如:把内容改得更简洁、增加代码示例、优化描述..."
|
||
value={refinePrompt}
|
||
onChange={(e) => setRefinePrompt(e.target.value)}
|
||
/>
|
||
</Space>
|
||
</Modal>
|
||
</>
|
||
)
|
||
}
|
||
|
||
// 导出组件
|
||
const SkillCreate = SkillCreateComponent
|
||
export default SkillCreate
|