1 line
147 KiB
JSON
1 line
147 KiB
JSON
{"repo": "alibaba/nacos", "files": {"console-ui/src/pages/AI/NewSkill/NewSkill.js": {"315faf6c5a4d198c5eabeafd8a115914a98e73bb": {"t": 1785768937.6538086, "n": 142086, "h": "27ade4c34b59d01f", "c": "/*\n * Copyright 1999-2025 Alibaba Group Holding Ltd.\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport React from 'react';\nimport PropTypes from 'prop-types';\nimport {\n Button,\n Card,\n Collapse,\n ConfigProvider,\n Field,\n Form,\n Input,\n Message,\n Grid,\n Icon,\n Dialog,\n Select,\n Checkbox,\n Loading,\n Balloon,\n Tag,\n} from '@alifd/next';\nimport PageTitle from 'components/PageTitle';\nimport SkillOptimizeDialog from '../SkillManagement/SkillOptimizeDialog';\nimport MonacoEditor from '../../../components/MonacoEditor/MonacoEditor';\nimport MarkdownRenderer from '../../../components/MarkdownRenderer/MarkdownRenderer';\nimport MagicWandIcon from '../../../components/MagicWandIcon/MagicWandIcon';\nimport { getLanguageFromFileName } from '../../../utils/languageDetector';\nimport { getParams, request } from '@/globalLib';\nimport { COPILOT_ENABLED } from '@/constants';\nimport './NewSkill.scss';\n\nconst { Row, Col } = Grid;\nconst { Panel } = Collapse;\n\n@ConfigProvider.config\nclass NewSkill extends React.Component {\n static displayName = 'SkillManagement';\n\n static propTypes = {\n locale: PropTypes.object,\n history: PropTypes.object,\n };\n\n constructor(props) {\n super(props);\n this.field = new Field(this);\n\n const skillName = getParams('name');\n const mode = getParams('mode');\n const isEdit = mode === 'edit' && !!skillName;\n const isDraft = getParams('isDraft') === 'true';\n\n this.state = {\n loading: false,\n generating: false,\n streaming: false,\n streamContent: '',\n thinkingContent: '',\n thinkingCollapsed: false,\n showInputCollapsed: false,\n inputSectionCollapsed: false,\n resultContentCollapsed: false,\n inputTooltipVisible: false,\n inputTooltipPosition: { x: 0, y: 0 },\n thinkingTooltipVisible: false,\n thinkingTooltipPosition: { x: 0, y: 0 },\n parsingResult: false,\n isEdit: isEdit,\n isDraft: isDraft,\n skillName,\n backgroundInfo: '',\n conversationHistory: null, // 对话历史\n conversationHistoryJson: '', // 对话历史 JSON 字符串(用于编辑)\n showConversationHistory: false, // 是否显示对话历史输入\n showAiGenerateDialog: false, // 控制是否显示AI生成弹窗\n generatedSkill: null, // 存储生成的Skill数据\n mcpServers: [], // MCP服务器列表\n selectedMcpServer: null, // 选中的MCP服务器\n mcpTools: [], // MCP工具列表\n selectedMcpTools: [], // 选中的MCP工具\n loadingMcpServers: false, // 加载MCP服务器列表\n loadingMcpTools: false, // 加载MCP工具列表\n mcpToolSearchKeyword: '', // MCP工具搜索关键词\n optimizeDialogVisible: false, // 控制是否显示AI优化弹窗\n currentSkillData: null, // 存储当前Skill数据用于优化\n editModeFileTree: null, // 编辑模式下的文件树\n editModeSelectedFile: null, // 编辑模式下选中的文件\n editingFileName: null, // 正在编辑的文件名(格式:{nodeKey, oldName, type})\n editingFileNameValue: '', // 正在编辑的文件名的临时值\n draggingFile: null, // 正在拖拽的文件(格式:{resourceKey, name, type})\n dragOverFolder: null, // 当前拖拽悬停的文件夹名称\n prevFormValues: {}, // 上一次的表单值,用于检测变化\n originalFormData: null, // 原始表单数据(用于比较是否有修改)\n originalResources: [], // 原始资源数据(用于比较是否有修改)\n hasChanges: false, // 是否有数据变化\n // 创建模式下默认添加一个空的资源项\n resources: isEdit\n ? []\n : [\n {\n name: '',\n type: '',\n content: '',\n metadata: null,\n },\n ],\n // 资源面板展开状态,创建模式下默认展开第一个\n expandedKeys: isEdit ? [] : ['0'],\n // 正在编辑的资源索引(用于标题编辑)\n editingResourceIndex: null,\n // 鼠标位置和tooltip显示状态\n tooltipVisible: false,\n tooltipPosition: { x: 0, y: 0 },\n tooltipResourceIndex: null,\n };\n this.streamReader = null;\n }\n\n componentDidMount() {\n if (this.state.isEdit) {\n // Check if there's optimized skill data from optimization dialog\n const optimizedParam = getParams('optimized');\n if (optimizedParam === 'true') {\n this.loadOptimizedSkillData();\n } else {\n this.loadSkillData();\n }\n }\n // Add click listener to handle clicking outside edit area\n // Use capture phase to handle before other click handlers\n document.addEventListener('click', this.handleDocumentClick, true);\n }\n\n componentWillUnmount() {\n // Remove click listener\n document.removeEventListener('click', this.handleDocumentClick, true);\n }\n\n componentDidUpdate(prevProps, prevState) {\n // 在编辑模式下,当表单值或资源变化时更新文件树和变化状态\n if (this.state.isEdit) {\n const values = this.field.getValues();\n const prevValues = prevState.prevFormValues || {};\n\n // 检查表单值是否变化\n const formChanged =\n values.name !== prevValues.name ||\n values.description !== prevValues.description ||\n values.instruction !== prevValues.instruction;\n\n // 检查资源是否变化\n const resourcesChanged =\n JSON.stringify(this.state.resources) !== JSON.stringify(prevState.resources);\n\n if (formChanged || resourcesChanged) {\n this.updateEditModeFileTree();\n // 保存当前表单值用于下次比较\n this.setState({ prevFormValues: values });\n }\n\n // 检查是否有数据变化并更新状态\n const hasChanged = this.hasDataChanged();\n if (hasChanged !== this.state.hasChanges) {\n this.setState({ hasChanges: hasChanged });\n }\n }\n }\n\n handleDocumentClick = e => {\n // Skip if clicking on resource title text (which should enter edit mode)\n if (e.target.closest('.resource-title-text')) {\n return;\n }\n\n // If editing resource title, check if click is outside the editor\n if (this.state.editingResourceIndex !== null) {\n // Find the editor element for the current editing resource\n const editorElements = document.querySelectorAll('.resource-title-editor');\n const currentEditor = editorElements[this.state.editingResourceIndex];\n\n if (currentEditor && !currentEditor.contains(e.target)) {\n // Click is outside the editor, exit edit mode\n this.setState({ editingResourceIndex: null });\n }\n }\n };\n\n loadOptimizedSkillData = () => {\n try {\n const optimizedSkillStr = localStorage.getItem('nacos_optimized_skill');\n if (optimizedSkillStr) {\n const optimizedSkill = JSON.parse(optimizedSkillStr);\n\n // 先加载原始数据作为基准,用于比较变化\n const { skillName } = this.state;\n const namespaceId = optimizedSkill.namespaceId || getParams('namespace') || '';\n\n this.setState({ loading: true });\n\n const params = new URLSearchParams();\n params.append('skillName', skillName);\n params.append('namespaceId', namespaceId);\n\n request({\n url: `v3/console/ai/skills?${params.toString()}`,\n success: data => {\n this.setState({ loading: false });\n if (data && (data.code === 0 || data.code === 200) && data.data) {\n const originalSkillData = data.data;\n const originalResources = originalSkillData.resource\n ? Object.values(originalSkillData.resource)\n : [];\n\n // 保存原始数据用于比较\n const originalFormData = {\n name: originalSkillData.name,\n description: originalSkillData.description || '',\n instruction: originalSkillData.skillMd || '',\n };\n\n // Fill form with optimized skill data\n // Important: Keep original skill name, don't use optimized name\n const originalName = this.state.skillName || '';\n this.field.setValues({\n name: originalName, // Always use original skill name\n description: optimizedSkill.description || '',\n instruction: optimizedSkill.instruction || '',\n });\n\n // Fill resources if any\n let resources = [];\n let resourceMap = {};\n if (optimizedSkill.resource && Object.keys(optimizedSkill.resource).length > 0) {\n resources = Object.values(optimizedSkill.resource).map(resource => ({\n name: resource.name || '',\n type: resource.type || '',\n content: resource.content || '',\n metadata: resource.metadata || null,\n }));\n // Build resource map for currentSkillData\n Object.entries(optimizedSkill.resource).forEach(([key, resource]) => {\n resourceMap[key] = resource;\n });\n }\n\n // Build currentSkillData for AI optimization feature\n const currentSkillData = {\n name: originalName,\n namespaceId: namespaceId,\n description: optimizedSkill.description || '',\n instruction: optimizedSkill.instruction || '',\n resource: resourceMap,\n };\n\n // 获取被优化的文件名\n const targetFileName = optimizedSkill.targetFileName;\n\n // 比较优化后的数据与原始数据,判断是否有变化\n const formChanged =\n (optimizedSkill.description || '') !== (originalSkillData.description || '') ||\n (optimizedSkill.instruction || '') !== (originalSkillData.skillMd || '');\n const resourcesChanged =\n JSON.stringify(resources) !== JSON.stringify(originalResources);\n const hasChanges = formChanged || resourcesChanged;\n\n this.setState(\n {\n resources,\n expandedKeys: resources.map((_, index) => String(index)),\n currentSkillData, // Set currentSkillData so AI optimization can work\n originalFormData, // 保存原始表单数据用于比较\n originalResources: JSON.parse(JSON.stringify(originalResources)), // 深拷贝原始资源\n hasChanges, // 设置变化状态\n },\n () => {\n // 在 resources 更新后构建文件树\n const previewData = this.buildPreviewData();\n const fileTree = this.buildFileTree(previewData);\n\n // 根据 targetFileName 查找并选中对应的文件\n let selectedFile = null;\n if (targetFileName && fileTree && Array.isArray(fileTree)) {\n // 递归查找文件\n const findFileInTree = tree => {\n if (Array.isArray(tree)) {\n for (const node of tree) {\n if (node.type === 'file') {\n // 检查是否匹配\n let fileIdentifier = node.name;\n if (node.fileType === 'resource' && node.resourceKey) {\n fileIdentifier = node.resourceKey;\n }\n if (fileIdentifier === targetFileName) {\n return node;\n }\n }\n if (node.type === 'folder' && node.children) {\n const found = findFileInTree(node.children);\n if (found) {\n return found;\n }\n }\n }\n } else if (tree.type === 'file') {\n let fileIdentifier = tree.name;\n if (tree.fileType === 'resource' && tree.resourceKey) {\n fileIdentifier = tree.resourceKey;\n }\n if (fileIdentifier === targetFileName) {\n return tree;\n }\n } else if (tree.type === 'folder' && tree.children) {\n return findFileInTree(tree.children);\n }\n return null;\n };\n\n selectedFile = findFileInTree(fileTree);\n }\n\n // 如果没有找到目标文件,默认选中 SKILL.md 或第一个文件\n if (!selectedFile && fileTree && Array.isArray(fileTree)) {\n selectedFile =\n fileTree.find(\n file => file.name === 'SKILL.md' && file.fileType === 'skill-md'\n ) || (fileTree.length > 0 ? fileTree[0] : null);\n }\n\n this.setState({\n editModeFileTree: fileTree,\n editModeSelectedFile: selectedFile,\n });\n\n // Clear the stored data\n localStorage.removeItem('nacos_optimized_skill');\n\n const { locale = {} } = this.props;\n Message.success(\n this.getLocaleValue(\n 'optimizedSkillLoaded',\n 'Optimized skill data loaded successfully'\n )\n );\n }\n );\n } else {\n // 如果加载原始数据失败,fallback 到正常加载\n this.loadSkillData();\n }\n },\n error: () => {\n this.setState({ loading: false });\n // 如果加载原始数据失败,fallback 到正常加载\n this.loadSkillData();\n },\n });\n } else {\n // Fallback to normal load\n this.loadSkillData();\n }\n } catch (e) {\n // eslint-disable-next-line no-console\n console.error('Failed to load optimized skill data', e);\n // Fallback to normal load\n this.loadSkillData();\n }\n };\n\n loadSkillData = callback => {\n const { skillName, isDraft } = this.state;\n const namespaceId = getParams('namespace') || '';\n\n this.setState({ loading: true });\n\n const params = new URLSearchParams();\n params.append('skillName', skillName);\n params.append('namespaceId', namespaceId);\n\n request({\n url: `v3/console/ai/skills?${params.toString()}`,\n success: data => {\n if (data && (data.code === 0 || data.code === 200) && data.data) {\n const skillData = data.data;\n // SkillAdminDetail may not include name; fill from URL params\n if (!skillData.name) {\n skillData.name = skillName;\n }\n const editingVersion = skillData.editingVersion || skillData.editingVersionStr;\n\n // If isDraft and there is an editingVersion, load draft version content\n if (isDraft && editingVersion) {\n const versionParams = new URLSearchParams();\n versionParams.append('skillName', skillName);\n versionParams.append('version', editingVersion);\n if (namespaceId) {\n versionParams.append('namespaceId', namespaceId);\n }\n\n request({\n url: `v3/console/ai/skills/version?${versionParams.toString()}`,\n success: versionData => {\n this.setState({ loading: false });\n if (\n versionData &&\n (versionData.code === 0 || versionData.code === 200) &&\n versionData.data\n ) {\n // Use draft version content instead of default content\n const draftContent = versionData.data;\n const mergedSkillData = {\n ...skillData,\n name: draftContent.name || skillData.name || skillName,\n instruction: draftContent.skillMd || '',\n description: draftContent.description || '',\n resource: draftContent.resource || skillData.resource || {},\n };\n this._applySkillData(mergedSkillData, callback);\n } else {\n // Fallback: use base skillData if version load fails\n this._applySkillData(skillData, callback);\n }\n },\n error: () => {\n this.setState({ loading: false });\n // Fallback: use base skillData if version load fails\n this._applySkillData(skillData, callback);\n },\n });\n } else {\n this.setState({ loading: false });\n this._applySkillData(skillData, callback);\n }\n } else {\n this.setState({ loading: false });\n Message.error(\n data?.message ||\n this.getLocaleValue('getSkillInfoFailed', 'Failed to get Skill information')\n );\n }\n },\n error: () => {\n this.setState({ loading: false });\n Message.error(this.getLocaleValue('getSkillInfoFailed', 'Failed to get Skill information'));\n },\n });\n };\n\n _applySkillData = (skillData, callback) => {\n const resources = skillData.resource ? Object.values(skillData.resource) : [];\n\n this.field.setValues({\n name: skillData.name,\n description: skillData.description || '',\n instruction: skillData.skillMd || skillData.instruction || '',\n });\n\n // 保存原始数据用于比较\n const originalFormData = {\n name: skillData.name,\n description: skillData.description || '',\n instruction: skillData.skillMd || skillData.instruction || '',\n };\n const originalResources = JSON.parse(JSON.stringify(resources)); // 深拷贝\n\n // 先更新 resources,然后再构建文件树,确保使用最新的数据\n this.setState(\n {\n resources,\n expandedKeys: resources.map((_, index) => String(index)),\n currentSkillData: skillData,\n prevFormValues: originalFormData,\n originalFormData,\n originalResources,\n hasChanges: false, // 初始状态没有变化\n },\n () => {\n // 在 resources 更新后构建文件树\n const previewData = this.buildPreviewData();\n const fileTree = this.buildFileTree(previewData);\n // Find SKILL.md file in the file list\n const skillMdFile =\n fileTree && Array.isArray(fileTree)\n ? fileTree.find(file => file.name === 'SKILL.md' && file.fileType === 'skill-md')\n : null;\n\n // 更新文件树和选中的文件\n this.setState(\n {\n editModeFileTree: fileTree,\n editModeSelectedFile:\n skillMdFile || (fileTree && fileTree.length > 0 ? fileTree[0] : null),\n },\n () => {\n // 执行回调,允许调用方在数据加载完成后执行额外操作\n if (callback && typeof callback === 'function') {\n callback();\n }\n }\n );\n }\n );\n };\n\n handleSubmit = () => {\n const { locale = {} } = this.props;\n\n // 如果有正在编辑的文件名,先保存它\n const { editingFileName, editingFileNameValue, resources } = this.state;\n if (editingFileName && editingFileNameValue) {\n const nameToSave = editingFileNameValue.trim();\n if (nameToSave && nameToSave !== editingFileName.oldName) {\n // 同步保存文件名\n const filteredName = nameToSave.replace(/[^a-zA-Z0-9._-]/g, '');\n\n if (filteredName && filteredName !== editingFileName.oldName) {\n // 检查是否重名\n const isDuplicate = resources.some(\n r =>\n r.name === filteredName &&\n r.type === editingFileName.type &&\n r.name !== editingFileName.oldName\n );\n\n if (isDuplicate) {\n Message.warning(locale.fileNameDuplicate || 'File name already exists');\n return;\n }\n\n const resourceIndex = resources.findIndex(\n r => r.name === editingFileName.oldName && r.type === editingFileName.type\n );\n\n if (resourceIndex !== -1) {\n const newResources = [...resources];\n newResources[resourceIndex] = {\n ...newResources[resourceIndex],\n name: filteredName,\n };\n // 更新 resources,然后继续提交\n this.setState(\n {\n resources: newResources,\n editingFileName: null,\n editingFileNameValue: '',\n },\n () => {\n // 继续提交\n this.doSubmit(newResources);\n }\n );\n return;\n }\n }\n }\n // 如果文件名没有变化或无效,取消编辑状态后继续提交\n this.setState({ editingFileName: null, editingFileNameValue: '' }, () => {\n this.doSubmit();\n });\n return;\n }\n\n this.doSubmit();\n };\n\n doSubmit = (resourcesToUse = null) => {\n const { locale = {} } = this.props;\n\n // 在 validate 之前获取 resources,避免闭包问题\n const resourcesSnapshot = resourcesToUse || [...this.state.resources];\n console.log('[doSubmit] resources snapshot:', JSON.stringify(resourcesSnapshot));\n\n this.field.validate((errors, values) => {\n if (errors) {\n return;\n }\n\n this.setState({ loading: true });\n\n // 使用快照的 resources\n const resources = resourcesSnapshot;\n\n const namespaceId = getParams('namespace') || '';\n const { isEdit } = this.state;\n\n // 构建 skillCard 对象\n const skillCard = {\n name: values.name,\n description: values.description || '',\n skillMd: values.instruction || '',\n };\n\n // 构建 resource Map,过滤掉无效的资源(没有 name 或 name 为空的资源)\n if (resources && resources.length > 0) {\n const resourceMap = {};\n const duplicateNames = new Set();\n resources.forEach((resource, index) => {\n // 只包含有效的资源(有 name 且 name 不为空)\n if (resource.name && resource.name.trim() !== '') {\n const key = resource.name.trim();\n // 检查是否有重复的文件名\n if (resourceMap[key]) {\n duplicateNames.add(key);\n console.warn(\n `Duplicate resource name detected: ${key}. Previous content will be overwritten.`\n );\n }\n resourceMap[key] = {\n name: resource.name.trim(),\n type: resource.type || '',\n content: resource.content || '',\n metadata: resource.metadata || null,\n };\n } else {\n console.warn(\n `Skipping resource at index ${index} because name is empty or invalid:`,\n resource\n );\n }\n });\n if (duplicateNames.size > 0) {\n console.warn(\n `Found ${duplicateNames.size} duplicate resource name(s):`,\n Array.from(duplicateNames)\n );\n }\n skillCard.resource = resourceMap;\n console.log('[doSubmit] Built resourceMap:', JSON.stringify(resourceMap));\n } else {\n skillCard.resource = {};\n }\n\n console.log('[doSubmit] Final skillCard:', JSON.stringify(skillCard));\n\n // 准备请求数据\n const requestData = {\n namespaceId: namespaceId,\n skillName: values.name,\n skillCard: JSON.stringify(skillCard),\n };\n\n const { isDraft } = this.state;\n\n if (isEdit && isDraft) {\n // Edit draft mode: PUT /draft\n request({\n url: 'v3/console/ai/skills/draft',\n method: 'PUT',\n data: requestData,\n contentType: 'application/x-www-form-urlencoded',\n success: data => {\n this.setState({ loading: false });\n if (\n data &&\n (data.code === 0 ||\n data.code === 200 ||\n data.data === 'ok' ||\n data.message === 'success')\n ) {\n Message.success(\n this.getLocaleValue('updateDraftSuccess', 'Draft updated successfully')\n );\n // Reload data and restore selected file\n const { editModeSelectedFile } = this.state;\n const selectedFileName = editModeSelectedFile?.name;\n const selectedFileType = editModeSelectedFile?.fileType;\n const selectedResourceKey = editModeSelectedFile?.resourceKey;\n\n this.loadSkillData(() => {\n if (selectedFileName && selectedFileType === 'resource') {\n const { editModeFileTree } = this.state;\n if (editModeFileTree && Array.isArray(editModeFileTree)) {\n const findFileInTree = tree => {\n if (Array.isArray(tree)) {\n for (const node of tree) {\n if (\n node.fileType === 'resource' &&\n (node.resourceKey === selectedResourceKey ||\n node.name === selectedFileName)\n ) {\n return node;\n }\n if (node.children) {\n const found = findFileInTree(node.children);\n if (found) return found;\n }\n }\n }\n return null;\n };\n const restoredFile = findFileInTree(editModeFileTree);\n if (restoredFile) {\n this.setState({ editModeSelectedFile: restoredFile });\n }\n }\n }\n });\n } else {\n Message.error(\n data?.message || this.getLocaleValue('updateDraftFailed', 'Failed to update draft')\n );\n }\n },\n error: () => {\n this.setState({ loading: false });\n Message.error(this.getLocaleValue('updateDraftFailed', 'Failed to update draft'));\n },\n });\n } else if (!isEdit) {\n // Create mode: POST /draft to create, then PUT /draft to write content\n request({\n url: 'v3/console/ai/skills/draft',\n method: 'POST',\n data: {\n skillName: values.name,\n namespaceId: namespaceId,\n basedOnVersion: '',\n },\n contentType: 'application/x-www-form-urlencoded',\n success: createData => {\n if (\n createData &&\n (createData.code === 0 ||\n createData.code === 200 ||\n createData.data === 'ok' ||\n createData.message === 'success')\n ) {\n // Draft created, now update with content\n request({\n url: 'v3/console/ai/skills/draft',\n method: 'PUT',\n data: requestData,\n contentType: 'application/x-www-form-urlencoded',\n success: updateData => {\n this.setState({ loading: false });\n if (\n updateData &&\n (updateData.code === 0 ||\n updateData.code === 200 ||\n updateData.data === 'ok' ||\n updateData.message === 'success')\n ) {\n Message.success(this.getLocaleValue('createSuccess', 'Create successful'));\n setTimeout(() => {\n // Navigate to detail page\n this.props.history.push(\n `/skillDetail?namespace=${namespaceId}&name=${values.name}`\n );\n }, 1000);\n } else {\n Message.error(\n updateData?.message || this.getLocaleValue('createFailed', 'Create failed')\n );\n }\n },\n error: () => {\n this.setState({ loading: false });\n Message.error(this.getLocaleValue('createFailed', 'Create failed'));\n },\n });\n } else {\n this.setState({ loading: false });\n Message.error(\n createData?.message ||\n this.getLocaleValue('createDraftFailed', 'Failed to create draft')\n );\n }\n },\n error: () => {\n this.setState({ loading: false });\n Message.error(this.getLocaleValue('createDraftFailed', 'Failed to create draft'));\n },\n });\n } else {\n // Legacy edit mode (e.g., after ZIP upload): PUT /skills (backward compatible)\n const url = 'v3/console/ai/skills';\n\n request({\n url: url,\n method: 'PUT',\n data: requestData,\n contentType: 'application/x-www-form-urlencoded',\n success: data => {\n this.setState({ loading: false });\n if (\n data &&\n (data.code === 0 ||\n data.code === 200 ||\n data.data === 'ok' ||\n data.message === 'success')\n ) {\n Message.success(this.getLocaleValue('updateSuccess', 'Update successful'));\n\n const { editModeSelectedFile } = this.state;\n const selectedFileName = editModeSelectedFile?.name;\n const selectedFileType = editModeSelectedFile?.fileType;\n const selectedResourceKey = editModeSelectedFile?.resourceKey;\n\n this.loadSkillData(() => {\n if (selectedFileName && selectedFileType === 'resource') {\n const { editModeFileTree } = this.state;\n if (editModeFileTree && Array.isArray(editModeFileTree)) {\n const findFileInTree = tree => {\n if (Array.isArray(tree)) {\n for (const node of tree) {\n if (\n node.fileType === 'resource' &&\n (node.resourceKey === selectedResourceKey ||\n node.name === selectedFileName)\n ) {\n return node;\n }\n if (node.children) {\n const found = findFileInTree(node.children);\n if (found) return found;\n }\n }\n }\n return null;\n };\n const restoredFile = findFileInTree(editModeFileTree);\n if (restoredFile) {\n this.setState({ editModeSelectedFile: restoredFile });\n }\n }\n }\n });\n } else {\n Message.error(data?.message || this.getLocaleValue('updateFailed', 'Update failed'));\n }\n },\n error: () => {\n this.setState({ loading: false });\n Message.error(this.getLocaleValue('updateFailed', 'Update failed'));\n },\n });\n }\n });\n };\n\n handleGoBack = () => {\n const namespaceId = getParams('namespace') || '';\n this.props.history.push(`/skillManagement?namespace=${namespaceId}`);\n };\n\n handleAddResource = () => {\n const { resources } = this.state;\n const newResource = {\n name: '',\n type: '',\n content: '',\n metadata: null,\n };\n const newIndex = resources.length;\n this.setState(\n {\n resources: [...resources, newResource],\n expandedKeys: [String(newIndex)], // 只展开新添加的资源\n },\n () => {\n // 更新文件树和变化状态\n if (this.state.isEdit) {\n this.updateEditModeFileTree();\n // 检查是否有变化\n const hasChanged = this.hasDataChanged();\n if (hasChanged !== this.state.hasChanges) {\n this.setState({ hasChanges: hasChanged });\n }\n }\n }\n );\n };\n\n handleExpandChange = expandedKeys => {\n this.setState({ expandedKeys });\n };\n\n handleRemoveResource = index => {\n Dialog.confirm({\n title: this.getLocaleValue('deleteConfirm', 'Delete Confirmation'),\n content: this.getLocaleValue(\n 'deleteResourceConfirm',\n 'Are you sure you want to delete this resource?'\n ),\n onOk: () => {\n const { resources, expandedKeys } = this.state;\n const newResources = resources.filter((_, i) => i !== index);\n // 更新 expandedKeys,移除被删除的索引并重新计算后续索引\n const newExpandedKeys = expandedKeys\n .filter(key => key !== String(index))\n .map(key => {\n const keyNum = parseInt(key, 10);\n return keyNum > index ? String(keyNum - 1) : key;\n });\n this.setState({ resources: newResources, expandedKeys: newExpandedKeys }, () => {\n // 更新文件树和变化状态\n if (this.state.isEdit) {\n this.updateEditModeFileTree();\n // 检查是否有变化\n const hasChanged = this.hasDataChanged();\n if (hasChanged !== this.state.hasChanges) {\n this.setState({ hasChanges: hasChanged });\n }\n }\n });\n },\n });\n };\n\n handleResourceChange = (index, field, value) => {\n let filteredValue = value;\n\n // 资源名称:支持英文大小写、数字、点号、下划线、横杠,不能有空格\n if (field === 'name') {\n filteredValue = value.replace(/[^a-zA-Z0-9._-]/g, '');\n }\n // 资源类型:只支持英文大小写、横杠、点号\n if (field === 'type') {\n filteredValue = value.replace(/[^a-zA-Z.-]/g, '');\n }\n\n const { resources } = this.state;\n const newResources = [...resources];\n newResources[index] = {\n ...newResources[index],\n [field]: filteredValue,\n };\n\n this.setState({ resources: newResources }, () => {\n // 更新文件树和变化状态\n if (this.state.isEdit) {\n this.updateEditModeFileTree();\n // 检查是否有变化\n const hasChanged = this.hasDataChanged();\n if (hasChanged !== this.state.hasChanges) {\n this.setState({ hasChanges: hasChanged });\n }\n }\n });\n };\n\n handleResourceTitleClick = (index, e) => {\n e.stopPropagation();\n this.setState({ editingResourceIndex: index });\n };\n\n handleResourceTitleMouseEnter = (index, e) => {\n this.setState({\n tooltipVisible: true,\n tooltipPosition: { x: e.clientX, y: e.clientY },\n tooltipResourceIndex: index,\n });\n };\n\n handleResourceTitleMouseMove = e => {\n if (this.state.tooltipVisible) {\n this.setState({\n tooltipPosition: { x: e.clientX, y: e.clientY },\n });\n }\n };\n\n handleResourceTitleMouseLeave = () => {\n this.setState({\n tooltipVisible: false,\n tooltipResourceIndex: null,\n });\n };\n\n handleResourceTitleCancel = () => {\n this.setState({ editingResourceIndex: null });\n };\n\n handleSkillNameChange = value => {\n // Skill名称:只允许英文、下划线、横杠,输入非法字符时实时提醒\n const filteredValue = value.replace(/[^a-zA-Z_-]/g, '');\n if (filteredValue.length !== value.length) {\n Message.warning(\n this.getLocaleValue(\n 'skillNameInvalidChars',\n 'Name can only contain English letters, underscore and hyphen'\n )\n );\n }\n this.field.setValue('name', filteredValue);\n // 实时校验(双下划线、必填等),在表单项下展示错误\n this.field.validate('name');\n // 检查是否有变化\n if (this.state.isEdit) {\n setTimeout(() => {\n const hasChanged = this.hasDataChanged();\n if (hasChanged !== this.state.hasChanges) {\n this.setState({ hasChanges: hasChanged });\n }\n }, 0);\n }\n };\n\n validateSkillName = (rule, value, callback) => {\n if (!value || value.trim() === '') {\n callback(this.getLocaleValue('requiredField', 'This field is required'));\n return;\n }\n if (!/^[a-zA-Z_-]+$/.test(value)) {\n callback(\n this.getLocaleValue(\n 'skillNameInvalidChars',\n 'Name can only contain English letters, underscore and hyphen'\n )\n );\n return;\n }\n if (value.includes('__')) {\n callback(\n this.getLocaleValue(\n 'skillNameNoDoubleUnderscore',\n 'Name cannot contain double underscores (__)'\n )\n );\n return;\n }\n callback();\n };\n\n validateRequired = (rule, value, callback) => {\n if (!value || value.trim() === '') {\n callback(this.getLocaleValue('requiredField', 'This field is required'));\n } else {\n callback();\n }\n };\n\n handleGenerateSkill = () => {\n const { backgroundInfo } = this.state;\n\n if (!backgroundInfo || backgroundInfo.trim() === '') {\n Message.warning(\n this.getLocaleValue('backgroundInfoRequired', 'Please enter background information')\n );\n return;\n }\n\n this.setState({\n generating: true,\n streaming: true,\n streamContent: '',\n thinkingContent: '',\n generatedSkill: null,\n showInputCollapsed: true, // 折叠用户输入\n inputSectionCollapsed: true, // 折叠输入区域\n thinkingCollapsed: false, // 展开思考内容\n resultContentCollapsed: false, // 展开结果内容\n parsingResult: false,\n });\n\n // Parse conversation history if provided\n let conversationHistory = null;\n if (this.state.conversationHistoryJson && this.state.conversationHistoryJson.trim()) {\n try {\n conversationHistory = JSON.parse(this.state.conversationHistoryJson);\n } catch (e) {\n Message.error(\n this.getLocaleValue(\n 'conversationHistoryInvalid',\n 'Invalid conversation history JSON format'\n )\n );\n this.setState({ generating: false, streaming: false });\n return;\n }\n } else if (this.state.conversationHistory) {\n conversationHistory = this.state.conversationHistory;\n }\n\n // Build request payload\n const payload = {\n backgroundInfo: backgroundInfo.trim(),\n selectedMcpTools: this.state.selectedMcpTools.map(tool => ({\n name: tool.name,\n description: tool.description,\n inputSchema: tool.inputSchema,\n })),\n };\n\n if (conversationHistory) {\n payload.conversationHistory = conversationHistory;\n }\n\n // Use SSE stream\n const ctxPath = window.location.pathname.replace(/\\/(next|legacy)(\\/.*)?$/, '/') || '/';\n const url = `${window.location.origin}${ctxPath}v3/console/copilot/skill/generate`;\n const token = localStorage.getItem('token');\n\n this.startSSEStream(url, payload, token);\n };\n\n startSSEStream = (url, payload, token) => {\n let accessToken = '';\n try {\n const tokenObj = JSON.parse(token);\n accessToken = tokenObj.accessToken || '';\n } catch (e) {\n // eslint-disable-next-line no-console\n console.error('Failed to parse token:', e);\n }\n\n // Use fetch API for POST request with SSE\n fetch(url, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n Accept: 'text/event-stream',\n ...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}),\n ...(accessToken ? { AccessToken: accessToken } : {}),\n },\n body: JSON.stringify(payload),\n })\n .then(response => {\n if (!response.ok) {\n throw new Error(`HTTP error! status: ${response.status}`);\n }\n\n this.streamReader = response.body.getReader();\n const decoder = new TextDecoder();\n let buffer = '';\n let currentEventType = 'message'; // Default event type\n let pendingData = null; // Store data when event type comes after data\n\n const readStream = () => {\n this.streamReader\n .read()\n .then(({ done, value }) => {\n if (done) {\n this.setState({ streaming: false, generating: false });\n return;\n }\n\n buffer += decoder.decode(value, { stream: true });\n const lines = buffer.split('\\n');\n buffer = lines.pop() || ''; // Keep incomplete line in buffer\n\n lines.forEach(line => {\n if (line.startsWith('event:')) {\n const eventType = line.substring(6).trim();\n currentEventType = eventType;\n // If we have pending data and this is an error event, handle it now\n if (eventType === 'error' && pendingData) {\n const errorMessage =\n pendingData.explanation ||\n pendingData.message ||\n this.getLocaleValue('generateFailed', '生成失败');\n Message.error(errorMessage);\n this.setState({\n streaming: false,\n generating: false,\n error: errorMessage,\n });\n pendingData = null;\n }\n } else if (line.startsWith('data:')) {\n const dataStr = line.substring(5).trim();\n if (dataStr) {\n try {\n const data = JSON.parse(dataStr);\n // Handle error event - check if current event type is error\n if (currentEventType === 'error') {\n const errorMessage =\n data.explanation ||\n data.message ||\n this.getLocaleValue('generateFailed', '生成失败');\n Message.error(errorMessage);\n this.setState({\n streaming: false,\n generating: false,\n error: errorMessage,\n });\n } else if (data.done && data.explanation && !data.skill) {\n // Handle case where error comes in data with done=true but no skill\n // This might be an error response\n const errorMessage =\n data.explanation ||\n data.message ||\n this.getLocaleValue('generateFailed', '生成失败');\n Message.error(errorMessage);\n this.setState({\n streaming: false,\n generating: false,\n error: errorMessage,\n });\n pendingData = null; // Clear pending data since we've handled the error\n } else {\n // Store data temporarily in case event:error comes after\n pendingData = data;\n this.handleSSEMessage(data);\n }\n } catch (e) {\n // Failed to parse SSE data\n console.error('Failed to parse SSE data:', e, dataStr);\n }\n }\n }\n });\n\n readStream();\n })\n .catch(error => {\n this.setState({\n streaming: false,\n generating: false,\n error: error.message || 'Stream read failed',\n });\n Message.error(this.getLocaleValue('generateFailed', 'Failed to generate skill'));\n });\n };\n\n readStream();\n })\n .catch(error => {\n this.setState({\n streaming: false,\n generating: false,\n });\n Message.error(this.getLocaleValue('generateFailed', 'Failed to generate skill'));\n });\n };\n\n handleSSEMessage = data => {\n if (!data) {\n return;\n }\n\n const typeStr = data.type?.code || data.type || 'CONTENT';\n\n // Handle different response types\n if (typeStr === 'THINKING' || data.type === 'THINKING') {\n // Accumulate thinking content separately\n this.setState(prevState => ({\n thinkingContent: (prevState.thinkingContent || '') + (data.chunk || ''),\n streamType: 'THINKING',\n }));\n } else if (\n typeStr === 'TOOL_CALL' ||\n data.type === 'TOOL_CALL' ||\n typeStr === 'CONTENT' ||\n data.type === 'CONTENT'\n ) {\n // Accumulate stream content\n const currentContent = this.state.streamContent || '';\n this.setState({\n streamContent: currentContent + (data.chunk || ''),\n streamType: typeStr === 'TOOL_CALL' ? 'TOOL_CALL' : 'CONTENT',\n thinkingCollapsed: true, // 折叠思考内容,开始显示结果\n resultContentCollapsed: false, // 展开结果内容\n });\n } else if (typeStr === 'DONE' || data.done) {\n // Final response received - start parsing\n this.setState({\n streaming: false,\n thinkingCollapsed: true, // 折叠思考内容\n resultContentCollapsed: true, // 折叠结果内容,显示生成的Skill\n parsingResult: true, // 开始解析结果\n });\n\n // Parse result after a short delay\n setTimeout(() => {\n // DONE event doesn't contain full content, must parse from accumulated streamContent\n let skillData = null;\n\n // Always try to parse from accumulated streamContent first\n if (this.state.streamContent) {\n try {\n // Try to extract JSON from streamContent\n const content = this.state.streamContent;\n let jsonContent = content;\n\n // Try to extract JSON from markdown code blocks\n if (content.includes('```json')) {\n const start = content.indexOf('```json') + 7;\n const end = content.indexOf('```', start);\n if (end > start) {\n jsonContent = content.substring(start, end).trim();\n }\n } else if (content.includes('```')) {\n const start = content.indexOf('```') + 3;\n const end = content.indexOf('```', start);\n if (end > start) {\n jsonContent = content.substring(start, end).trim();\n }\n }\n\n // Try to find JSON object by matching braces\n if (!jsonContent.startsWith('{') && !jsonContent.startsWith('[')) {\n const jsonMatch = jsonContent.match(/\\{[\\s\\S]*\\}/);\n if (jsonMatch) {\n jsonContent = jsonMatch[0];\n }\n }\n\n // Parse JSON\n const parsed = JSON.parse(jsonContent);\n // Support multiple formats: {skill: {...}} or direct skill object\n skillData = parsed.skill || parsed;\n\n // eslint-disable-next-line no-console\n console.log('Parsed skill from streamContent:', skillData);\n } catch (e) {\n // eslint-disable-next-line no-console\n console.error(\n 'Failed to parse skill from streamContent:',\n e,\n 'Content:',\n this.state.streamContent\n );\n }\n }\n\n // Fallback: check if skill is directly in DONE event (unlikely but possible)\n if (!skillData && data.skill) {\n skillData = data.skill;\n // eslint-disable-next-line no-console\n console.log('Using skill from DONE event:', skillData);\n }\n\n if (skillData) {\n this.setState({\n generating: false,\n parsingResult: false,\n generatedSkill: {\n skill: skillData,\n explanation: this.getLocaleValue('generateSuccess', 'Skill generated successfully'),\n },\n });\n } else {\n // eslint-disable-next-line no-console\n console.error('No skill found in DONE event or streamContent:', {\n data,\n streamContent: this.state.streamContent,\n });\n Message.error(\n this.getLocaleValue(\n 'generateFailed',\n 'Failed to generate skill: no skill data returned'\n )\n );\n this.setState({ generating: false, parsingResult: false });\n }\n }, 500);\n } else if (data.explanation && data.explanation.includes('失败')) {\n // Error response\n Message.error(data.explanation);\n this.setState({ generating: false, streaming: false, parsingResult: false });\n }\n };\n\n handleApplyGeneratedSkill = () => {\n const { generatedSkill } = this.state;\n\n if (!generatedSkill || !generatedSkill.skill) {\n return;\n }\n\n const skill = generatedSkill.skill;\n\n // Fill form with generated skill\n this.field.setValues({\n name: skill.name || '',\n description: skill.description || '',\n instruction: skill.instruction || skill.skillMd || '',\n });\n\n // Fill resources if any\n if (skill.resource && Object.keys(skill.resource).length > 0) {\n const resources = Object.values(skill.resource).map(resource => ({\n name: resource.name || '',\n type: resource.type || '',\n content: resource.content || '',\n metadata: resource.metadata || null,\n }));\n this.setState({\n resources,\n expandedKeys: resources.map((_, index) => String(index)),\n });\n }\n\n Message.success(\n generatedSkill.explanation ||\n this.getLocaleValue('generateSuccess', 'Skill generated successfully')\n );\n\n // Close dialog and reset\n this.setState({\n showAiGenerateDialog: false,\n backgroundInfo: '',\n generatedSkill: null,\n });\n };\n\n handleBackgroundInfoChange = value => {\n this.setState({ backgroundInfo: value });\n };\n\n handleShowAiGenerate = () => {\n this.setState({\n showAiGenerateDialog: true,\n backgroundInfo: '',\n generatedSkill: null,\n selectedMcpServer: null,\n mcpTools: [],\n selectedMcpTools: [],\n mcpToolSearchKeyword: '',\n thinkingContent: '',\n thinkingCollapsed: false,\n showInputCollapsed: false,\n inputTooltipVisible: false,\n thinkingTooltipVisible: false,\n parsingResult: false,\n streaming: false,\n streamContent: '',\n });\n this.loadMcpServers();\n };\n\n handleCloseAiGenerateDialog = () => {\n this.setState({\n showAiGenerateDialog: false,\n backgroundInfo: '',\n generatedSkill: null,\n selectedMcpServer: null,\n mcpTools: [],\n selectedMcpTools: [],\n mcpToolSearchKeyword: '',\n thinkingContent: '',\n thinkingCollapsed: false,\n showInputCollapsed: false,\n inputTooltipVisible: false,\n thinkingTooltipVisible: false,\n parsingResult: false,\n });\n };\n\n handleInputMouseEnter = e => {\n this.setState({\n inputTooltipVisible: true,\n inputTooltipPosition: { x: e.clientX, y: e.clientY },\n });\n };\n\n handleInputMouseMove = e => {\n if (this.state.inputTooltipVisible) {\n this.setState({\n inputTooltipPosition: { x: e.clientX, y: e.clientY },\n });\n }\n };\n\n handleInputMouseLeave = () => {\n this.setState({\n inputTooltipVisible: false,\n });\n };\n\n handleThinkingMouseEnter = e => {\n this.setState({\n thinkingTooltipVisible: true,\n thinkingTooltipPosition: { x: e.clientX, y: e.clientY },\n });\n };\n\n handleThinkingMouseMove = e => {\n if (this.state.thinkingTooltipVisible) {\n this.setState({\n thinkingTooltipPosition: { x: e.clientX, y: e.clientY },\n });\n }\n };\n\n handleThinkingMouseLeave = () => {\n this.setState({\n thinkingTooltipVisible: false,\n });\n };\n\n renderUserInput = () => {\n const { backgroundInfo, selectedMcpTools, showInputCollapsed } = this.state;\n\n if (!showInputCollapsed) {\n return null;\n }\n\n const selectedToolsText =\n selectedMcpTools.length > 0\n ? selectedMcpTools.map(t => t.name).join(', ')\n : this.getLocaleValue('noToolsSelected', 'No tools selected');\n\n const fullContent = `${this.getLocaleValue(\n 'backgroundInfo',\n 'Background Information'\n )}: ${backgroundInfo}\\n\\n${this.getLocaleValue(\n 'selectedTools',\n 'Selected Tools'\n )}: ${selectedToolsText}`;\n\n return (\n <div\n style={{\n marginBottom: 16,\n padding: '12px 16px',\n background: '#fafafa',\n borderRadius: 4,\n border: '1px solid #e6e6e6',\n cursor: 'pointer',\n }}\n onMouseEnter={this.handleInputMouseEnter}\n onMouseMove={this.handleInputMouseMove}\n onMouseLeave={this.handleInputMouseLeave}\n >\n <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>\n <div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>\n <Icon type=\"info\" size=\"small\" style={{ color: '#1890ff' }} />\n <span style={{ fontSize: '13px', color: '#666' }}>\n {this.getLocaleValue('userInput', 'User Input')}\n </span>\n </div>\n <Icon type=\"arrow-up\" size=\"small\" style={{ color: '#999' }} />\n </div>\n <div\n style={{\n marginTop: 8,\n fontSize: '12px',\n color: '#999',\n maxHeight: 40,\n overflow: 'hidden',\n }}\n >\n {backgroundInfo.substring(0, 100)}\n {backgroundInfo.length > 100 ? '...' : ''}\n </div>\n </div>\n );\n };\n\n renderThinkingContent = () => {\n const { thinkingContent, thinkingCollapsed, streaming } = this.state;\n\n if (!streaming && !thinkingContent) {\n return null;\n }\n\n if (thinkingCollapsed && thinkingContent) {\n return (\n <div\n style={{\n marginBottom: 16,\n padding: '12px 16px',\n background: '#fafafa',\n borderRadius: 4,\n border: '1px solid #e6e6e6',\n cursor: 'pointer',\n }}\n onMouseEnter={this.handleThinkingMouseEnter}\n onMouseMove={this.handleThinkingMouseMove}\n onMouseLeave={this.handleThinkingMouseLeave}\n >\n <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>\n <div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>\n <Tag type=\"normal\" size=\"small\">\n {this.getLocaleValue('thinking', 'Thinking')}\n </Tag>\n <span style={{ fontSize: '13px', color: '#666' }}>\n {this.getLocaleValue('thinkingProcess', 'Thinking Process')}\n </span>\n </div>\n <Icon type=\"arrow-up\" size=\"small\" style={{ color: '#999' }} />\n </div>\n <div\n style={{\n marginTop: 8,\n fontSize: '12px',\n color: '#999',\n maxHeight: 40,\n overflow: 'hidden',\n }}\n >\n {thinkingContent.substring(0, 100)}\n {thinkingContent.length > 100 ? '...' : ''}\n </div>\n </div>\n );\n }\n\n if (!thinkingCollapsed && thinkingContent) {\n return (\n <div style={{ marginBottom: 16 }}>\n <Collapse\n defaultExpandedKeys={['0']}\n style={{ border: '1px solid #e6e6e6', borderRadius: '4px' }}\n >\n <Panel\n key=\"0\"\n title={\n <div style={{ display: 'flex', alignItems: 'center' }}>\n <Tag type=\"normal\" size=\"small\" style={{ marginRight: 8 }}>\n {this.getLocaleValue('thinking', 'Thinking')}\n </Tag>\n <span>{this.getLocaleValue('thinkingProcess', 'Thinking Process')}</span>\n </div>\n }\n >\n <div\n style={{\n whiteSpace: 'pre-wrap',\n wordBreak: 'break-word',\n margin: 0,\n fontSize: '13px',\n }}\n >\n {thinkingContent}\n </div>\n </Panel>\n </Collapse>\n </div>\n );\n }\n\n return null;\n };\n\n // 获取文本的前N行预览\n getPreviewLines = (text, lines = 2) => {\n if (!text) return '';\n const textLines = text.split('\\n');\n if (textLines.length <= lines) return text;\n return `${textLines.slice(0, lines).join('\\n')}\\n...`;\n };\n\n // 渲染可折叠内容区域(参考 SkillOptimizeDialog)\n renderCollapsibleSection = (\n title,\n content,\n isCollapsed,\n onToggle,\n icon = null,\n loading = false,\n thinkingContentForIcon = null\n ) => {\n if (!content && !loading) {\n return null;\n }\n\n const contentLines = content ? content.split('\\n') : [];\n const previewContent = isCollapsed && content ? this.getPreviewLines(content, 2) : content;\n const hasMore = isCollapsed && contentLines.length > 2;\n\n return (\n <div\n style={{\n border: '1px solid #e6e6e6',\n borderRadius: '4px',\n marginBottom: 16,\n overflow: 'hidden',\n }}\n >\n <div\n style={{\n display: 'flex',\n alignItems: 'center',\n justifyContent: 'space-between',\n padding: '12px 16px',\n background: '#fafafa',\n cursor: 'pointer',\n borderBottom: content || loading ? '1px solid #e6e6e6' : 'none',\n }}\n onClick={onToggle}\n >\n <div style={{ display: 'flex', alignItems: 'center', flex: 1 }}>\n {icon && (\n <div style={{ marginRight: 8, display: 'flex', alignItems: 'center' }}>{icon}</div>\n )}\n <span style={{ fontWeight: 500 }}>{title}</span>\n {thinkingContentForIcon && thinkingContentForIcon.trim() && (\n <span\n style={{ marginLeft: 8, display: 'inline-flex', alignItems: 'center' }}\n onClick={e => {\n e.stopPropagation();\n }}\n >\n <Balloon\n trigger={\n <Icon\n type=\"info\"\n size=\"small\"\n style={{\n color: '#1890ff',\n cursor: 'pointer',\n fontSize: '14px',\n }}\n />\n }\n triggerType=\"hover\"\n align=\"t\"\n style={{ maxWidth: '500px' }}\n >\n <div style={{ maxHeight: '300px', overflowY: 'auto' }}>\n <div style={{ fontWeight: 500, marginBottom: 8 }}>\n {this.getLocaleValue('thinking', 'Thinking')}\n </div>\n <pre\n style={{\n whiteSpace: 'pre-wrap',\n wordBreak: 'break-word',\n margin: 0,\n fontSize: '12px',\n lineHeight: '1.6',\n }}\n >\n {thinkingContentForIcon}\n </pre>\n </div>\n </Balloon>\n </span>\n )}\n {loading && <Loading size=\"medium\" style={{ marginLeft: 8 }} />}\n </div>\n <Icon\n type={isCollapsed ? 'arrow-down' : 'arrow-up'}\n style={{ fontSize: 12, color: '#666' }}\n />\n </div>\n {(content || loading) && (\n <div\n style={{\n padding: '12px 16px',\n maxHeight: isCollapsed ? '60px' : 'none',\n overflow: isCollapsed ? 'hidden' : 'auto',\n background: '#fff',\n position: 'relative',\n }}\n >\n {loading && !content ? (\n <div style={{ display: 'flex', alignItems: 'center', color: '#999' }}>\n <Loading size=\"medium\" style={{ marginRight: 8 }} />\n <span>{title}</span>\n </div>\n ) : (\n <pre\n style={{\n whiteSpace: 'pre-wrap',\n wordBreak: 'break-word',\n margin: 0,\n fontSize: '13px',\n lineHeight: '1.6',\n color: '#333',\n }}\n >\n {previewContent}\n </pre>\n )}\n {isCollapsed && hasMore && (\n <div\n style={{\n position: 'absolute',\n bottom: 0,\n left: 0,\n right: 0,\n height: '20px',\n background:\n 'linear-gradient(to bottom, rgba(255,255,255,0) 0%, rgba(255,255,255,1) 100%)',\n pointerEvents: 'none',\n }}\n />\n )}\n </div>\n )}\n </div>\n );\n };\n\n // 渲染流式内容(参考 SkillOptimizeDialog)\n renderStreamContent = () => {\n const {\n streamContent,\n thinkingContent,\n streamType,\n thinkingCollapsed,\n resultContentCollapsed,\n streaming,\n } = this.state;\n\n return (\n <div>\n {/* 思考内容区域 - 仅在 THINKING 阶段显示 */}\n {thinkingContent &&\n streamType === 'THINKING' &&\n this.renderCollapsibleSection(\n this.getLocaleValue('thinking', '思考') || '思考',\n thinkingContent,\n thinkingCollapsed,\n () => this.setState({ thinkingCollapsed: !thinkingCollapsed }),\n <Tag type=\"normal\" size=\"small\">\n {this.getLocaleValue('thinking', '思考') || '思考'}\n </Tag>,\n streaming && streamType === 'THINKING' && !thinkingContent\n )}\n\n {/* 结果内容区域 - 当开始接收模型回复时显示,包含思考内容图标 */}\n {streamContent &&\n streamType !== 'THINKING' &&\n this.renderCollapsibleSection(\n this.getLocaleValue('generatingContent', '生成内容') || '生成内容',\n streamContent,\n resultContentCollapsed,\n () => this.setState({ resultContentCollapsed: !resultContentCollapsed }),\n <Tag type=\"primary\" size=\"small\">\n {this.getLocaleValue('generatingContent', '生成内容') || '生成内容'}\n </Tag>,\n streaming && !streamContent,\n this.state.thinkingContent\n )}\n </div>\n );\n };\n\n renderGeneratedSkill = () => {\n const {\n generatedSkill,\n backgroundInfo,\n selectedMcpTools,\n thinkingContent,\n streamContent,\n inputSectionCollapsed,\n thinkingCollapsed,\n resultContentCollapsed,\n } = this.state;\n if (!generatedSkill || !generatedSkill.skill) {\n return null;\n }\n\n try {\n const skill = generatedSkill.skill;\n const resources = skill.resource ? Object.values(skill.resource) : [];\n\n // 构建用户输入内容\n const selectedToolsText =\n selectedMcpTools.length > 0\n ? selectedMcpTools.map(t => t.name).join(', ')\n : this.getLocaleValue('noToolsSelected', 'No tools selected');\n const userInputContent = `${this.getLocaleValue(\n 'backgroundInfo',\n 'Background Information'\n )}: ${backgroundInfo}\\n\\n${this.getLocaleValue(\n 'selectedTools',\n 'Selected Tools'\n )}: ${selectedToolsText}`;\n const userInputPreview = backgroundInfo\n ? backgroundInfo.substring(0, 100) + (backgroundInfo.length > 100 ? '...' : '')\n : '';\n\n return (\n <div>\n {/* 显示用户输入 - 折叠状态,可展开查看 */}\n {this.renderCollapsibleSection(\n this.getLocaleValue('userInput', 'User Input') || '用户输入',\n inputSectionCollapsed ? userInputPreview : userInputContent,\n inputSectionCollapsed,\n () => this.setState({ inputSectionCollapsed: !this.state.inputSectionCollapsed }),\n null,\n false\n )}\n\n {/* 思考内容 - 始终显示(如果存在) */}\n {thinkingContent &&\n this.renderCollapsibleSection(\n this.getLocaleValue('thinking', '思考') || '思考',\n thinkingContent,\n thinkingCollapsed,\n () => this.setState({ thinkingCollapsed: !thinkingCollapsed }),\n <Tag type=\"normal\" size=\"small\">\n {this.getLocaleValue('thinking', '思考') || '思考'}\n </Tag>,\n false\n )}\n\n {/* 结果内容 - 始终显示(如果存在) */}\n {streamContent &&\n this.renderCollapsibleSection(\n this.getLocaleValue('generatingContent', '生成内容') || '生成内容',\n streamContent,\n resultContentCollapsed,\n () => this.setState({ resultContentCollapsed: !resultContentCollapsed }),\n <Tag type=\"primary\" size=\"small\">\n {this.getLocaleValue('generatingContent', '生成内容') || '生成内容'}\n </Tag>,\n false,\n thinkingContent\n )}\n\n {/* 生成的Skill预览 */}\n <div style={{ marginTop: 24, paddingTop: 16, borderTop: '2px solid #e6e6e6' }}>\n <div\n style={{\n background: '#fff',\n padding: '20px',\n borderRadius: '4px',\n border: '1px solid #e6e6e6',\n }}\n >\n <Row gutter={16} style={{ marginBottom: 16 }}>\n <Col span={12}>\n <div style={{ marginBottom: 16 }}>\n <label\n style={{\n display: 'block',\n marginBottom: 8,\n fontSize: '14px',\n fontWeight: 500,\n color: '#333',\n }}\n >\n {this.getLocaleValue('skillName', 'Skill Name')}:\n </label>\n <span style={{ fontSize: '14px', color: '#666' }}>{skill.name || '--'}</span>\n </div>\n </Col>\n </Row>\n <Row gutter={16} style={{ marginBottom: 24 }}>\n <Col span={24}>\n <div style={{ marginBottom: 16 }}>\n <label\n style={{\n display: 'block',\n marginBottom: 8,\n fontSize: '14px',\n fontWeight: 500,\n color: '#333',\n }}\n >\n {this.getLocaleValue('description', 'Description')}:\n </label>\n <span style={{ fontSize: '14px', color: '#666' }}>\n {skill.description || '--'}\n </span>\n </div>\n </Col>\n </Row>\n\n <div style={{ marginBottom: 24 }}>\n <div\n style={{ fontSize: '14px', fontWeight: 500, marginBottom: '12px', color: '#333' }}\n >\n {this.getLocaleValue('instruction', 'Instruction')}\n </div>\n <div\n style={{\n minHeight: 200,\n padding: '12px',\n background: '#fafafa',\n borderRadius: 4,\n border: '1px solid #e6e6e6',\n }}\n >\n <MarkdownRenderer content={skill.instruction || ''} />\n </div>\n </div>\n\n <div>\n <div\n style={{ fontSize: '14px', fontWeight: 500, marginBottom: '12px', color: '#333' }}\n >\n {this.getLocaleValue('resources', 'Resources')}\n </div>\n {resources.length > 0 ? (\n <div>\n <Collapse>\n {resources.map((resource, index) => (\n <Panel\n key={String(index)}\n title={\n <div>\n <span>\n {resource.type && resource.name\n ? `${resource.type}/${resource.name}`\n : `${this.getLocaleValue('resource', 'Resource')} ${index + 1}`}\n </span>\n </div>\n }\n >\n <Row gutter={16}>\n <Col span={12}>\n <div style={{ marginBottom: 16 }}>\n <label\n style={{\n display: 'block',\n marginBottom: 8,\n fontSize: '13px',\n color: '#666',\n }}\n >\n {this.getLocaleValue('resourceName', 'Resource Name')}:\n </label>\n <span style={{ fontSize: '13px', color: '#333' }}>\n {resource.name || '--'}\n </span>\n </div>\n </Col>\n <Col span={12}>\n <div style={{ marginBottom: 16 }}>\n <label\n style={{\n display: 'block',\n marginBottom: 8,\n fontSize: '13px',\n color: '#666',\n }}\n >\n {this.getLocaleValue('resourceType', 'Resource Type')}:\n </label>\n <span>\n {resource.type ? (\n <Tag type=\"primary\" size=\"small\">\n {resource.type}\n </Tag>\n ) : (\n '--'\n )}\n </span>\n </div>\n </Col>\n </Row>\n <Row gutter={16}>\n <Col span={24}>\n <div>\n <label\n style={{\n display: 'block',\n marginBottom: 8,\n fontSize: '13px',\n color: '#666',\n }}\n >\n {this.getLocaleValue('resourceContent', 'Resource Content')}:\n </label>\n <div\n style={{\n border: '1px solid #e6e6e6',\n borderRadius: '4px',\n marginTop: '8px',\n }}\n >\n {resource.content ? (\n <MonacoEditor\n language={getLanguageFromFileName(resource.name || '')}\n width=\"100%\"\n height={300}\n value={resource.content}\n options={{\n readOnly: true,\n wordWrap: 'on',\n minimap: { enabled: false },\n lineNumbers: 'on',\n scrollBeyondLastLine: false,\n }}\n />\n ) : (\n <div\n style={{ padding: '12px', color: '#999', marginTop: '8px' }}\n >\n {this.getLocaleValue('noContent', 'No content')}\n </div>\n )}\n </div>\n </div>\n </Col>\n </Row>\n </Panel>\n ))}\n </Collapse>\n </div>\n ) : (\n <div\n style={{\n padding: '20px',\n textAlign: 'center',\n color: '#999',\n fontSize: '13px',\n }}\n >\n {this.getLocaleValue('noResources', 'No resources')}\n </div>\n )}\n </div>\n </div>\n </div>\n\n <div style={{ color: '#999', fontSize: '12px', marginTop: 16 }}>\n {this.getLocaleValue(\n 'applyGeneratedSkillHint',\n 'Click \"Apply\" to fill the form with the generated Skill'\n )}\n </div>\n </div>\n );\n } catch (error) {\n // eslint-disable-next-line no-console\n console.error('Error rendering generated skill:', error, generatedSkill);\n return (\n <div\n style={{\n padding: '20px',\n color: '#f5222d',\n background: '#fff2f0',\n border: '1px solid #ffccc7',\n borderRadius: '4px',\n }}\n >\n <div style={{ marginBottom: 8, fontWeight: 500 }}>渲染生成的 Skill 时出错</div>\n <div style={{ fontSize: '12px' }}>{error.message || 'Unknown error'}</div>\n </div>\n );\n }\n };\n\n loadMcpServers = async () => {\n this.setState({ loadingMcpServers: true });\n try {\n const namespaceId = getParams('namespace') || 'public';\n const result = await request({\n url: 'v3/console/ai/mcp/list',\n method: 'get',\n data: {\n namespaceId,\n pageNo: 1,\n pageSize: 100,\n },\n });\n if (result.code === 0 && result.data) {\n // Map items to ensure id field exists (use name as id if id is not available)\n const servers = (result.data.pageItems || []).map(item => ({\n ...item,\n id: item.id || item.name,\n }));\n this.setState({\n mcpServers: servers,\n loadingMcpServers: false,\n });\n } else {\n this.setState({ loadingMcpServers: false });\n }\n } catch (error) {\n console.error('Failed to load MCP servers:', error);\n this.setState({ loadingMcpServers: false });\n }\n };\n\n handleMcpServerChange = async value => {\n this.setState({\n selectedMcpServer: value,\n mcpTools: [],\n selectedMcpTools: [],\n mcpToolSearchKeyword: '', // 重置搜索关键词\n });\n\n if (value) {\n await this.loadMcpTools(value);\n }\n };\n\n handleMcpToolSearchChange = value => {\n this.setState({\n mcpToolSearchKeyword: value,\n });\n };\n\n getFilteredMcpTools = () => {\n const { mcpTools, mcpToolSearchKeyword } = this.state;\n if (!mcpToolSearchKeyword.trim()) {\n return mcpTools;\n }\n const keyword = mcpToolSearchKeyword.toLowerCase();\n return mcpTools.filter(tool => tool.name && tool.name.toLowerCase().includes(keyword));\n };\n\n loadMcpTools = async mcpServerId => {\n this.setState({ loadingMcpTools: true });\n try {\n const namespaceId = getParams('namespace') || 'public';\n const result = await request({\n url: 'v3/console/ai/mcp',\n method: 'get',\n data: {\n namespaceId,\n mcpId: mcpServerId,\n },\n });\n if (result.code === 0 && result.data && result.data.toolSpec) {\n const tools = result.data.toolSpec.tools || [];\n this.setState({\n mcpTools: tools,\n loadingMcpTools: false,\n });\n } else {\n this.setState({ loadingMcpTools: false });\n }\n } catch (error) {\n console.error('Failed to load MCP tools:', error);\n this.setState({ loadingMcpTools: false });\n }\n };\n\n handleMcpToolChange = (checked, tool) => {\n const { selectedMcpTools } = this.state;\n if (checked) {\n this.setState({\n selectedMcpTools: [...selectedMcpTools, tool],\n });\n } else {\n this.setState({\n selectedMcpTools: selectedMcpTools.filter(t => t.name !== tool.name),\n });\n }\n };\n\n handleShowOptimizeDialog = () => {\n this.setState({ optimizeDialogVisible: true });\n };\n\n handleOptimizeDialogClose = () => {\n this.setState({ optimizeDialogVisible: false });\n };\n\n handleOptimizeSuccess = optimizedSkill => {\n const { locale = {} } = this.props;\n console.log('handleOptimizeSuccess called with:', optimizedSkill);\n\n // 优化成功后,将优化后的skill填充到表单中\n if (optimizedSkill) {\n // 处理资源数据,确保格式正确\n let resources = [];\n const resourceData = optimizedSkill.resource || optimizedSkill.resources || {};\n console.log('Resource data:', resourceData);\n\n if (resourceData && Object.keys(resourceData).length > 0) {\n // 使用Object.entries来同时获取key和value,因为Map的key可能是资源名称\n resources = Object.entries(resourceData).map(([resourceKey, resource]) => {\n // 确保资源对象包含所有必要字段\n // 如果resource对象没有name,使用resourceKey作为name\n const resourceObj = {\n name: resource.name || resourceKey || '',\n type: resource.type || '',\n content: resource.content || '',\n metadata: resource.metadata || null,\n };\n return resourceObj;\n });\n }\n\n console.log('Processed resources:', resources);\n\n // Important: Keep original skill name, don't use optimized name\n const originalName = this.field.getValue('name') || this.state.skillName;\n\n // 获取当前的原始数据用于比较(不要更新原始数据)\n const { originalFormData, originalResources } = this.state;\n\n // Update form fields with optimized values\n // Use optimized values directly, even if they're empty (to allow clearing fields)\n const optimizedDescription =\n optimizedSkill.description !== undefined\n ? optimizedSkill.description\n : this.field.getValue('description');\n const optimizedInstruction =\n optimizedSkill.instruction !== undefined\n ? optimizedSkill.instruction\n : this.field.getValue('instruction');\n\n this.field.setValues({\n name: originalName, // Always use original skill name\n description: optimizedDescription,\n instruction: optimizedInstruction,\n });\n\n // 比较优化后的数据与原始数据,判断是否有变化\n // 如果 originalFormData 或 originalResources 不存在,说明是新建模式,不需要比较\n let hasChanges = false;\n if (originalFormData && originalResources) {\n const formChanged =\n optimizedDescription !== (originalFormData.description || '') ||\n optimizedInstruction !== (originalFormData.instruction || '');\n const resourcesChanged = JSON.stringify(resources) !== JSON.stringify(originalResources);\n hasChanges = formChanged || resourcesChanged;\n } else {\n // 新建模式:如果有任何内容,就认为有变化\n hasChanges =\n optimizedDescription !== '' || optimizedInstruction !== '' || resources.length > 0;\n }\n\n // Force form to re-render by updating state\n // 重要:不要更新 originalFormData 和 originalResources,保持它们作为比较基准\n this.setState(\n {\n resources,\n expandedKeys: resources.map((_, index) => String(index)),\n currentSkillData: optimizedSkill,\n // 保持 originalFormData 和 originalResources 不变,用于后续比较\n hasChanges, // 根据比较结果设置变化状态\n },\n () => {\n // After state update, ensure form fields are refreshed\n // Force a re-render by calling setValue again if needed\n if (optimizedSkill.description !== undefined) {\n this.field.setValue('description', optimizedSkill.description);\n }\n if (optimizedSkill.instruction !== undefined) {\n this.field.setValue('instruction', optimizedSkill.instruction);\n }\n }\n );\n\n // 如果添加了新资源,显示提示信息\n if (resources.length > 0) {\n // All resources are considered new when applied from optimization\n const newResourcesCount = resources.length;\n if (newResourcesCount > 0) {\n Message.success(\n (locale.optimizeSuccess || 'Optimization applied successfully') +\n ` (${newResourcesCount} new resource(s) added)`\n );\n } else {\n Message.success(locale.optimizeSuccess || 'Optimization applied successfully');\n }\n } else {\n Message.success(locale.optimizeSuccess || 'Optimization applied successfully');\n }\n } else {\n Message.success(locale.optimizeSuccess || 'Optimization applied successfully');\n }\n\n this.handleOptimizeDialogClose();\n };\n\n // Helper function to get locale value with fallback\n getLocaleValue = (key, fallback) => {\n const { locale = {} } = this.props;\n return locale[key] || fallback;\n };\n\n // Generate resource unique identifier\n // Format: \"type::name\" if type is not blank, otherwise \"name\"\n // The separator \"::\" is used because it's not in the allowed character set for type and name\n getResourceIdentifier = resource => {\n if (resource.type && resource.type.trim() !== '') {\n return `${resource.type}::${resource.name || ''}`;\n }\n return resource.name || '';\n };\n\n // Build preview data from current form values\n buildPreviewData = () => {\n const values = this.field.getValues();\n const { resources } = this.state;\n\n // 构建 skillCard 对象(与 handleSubmit 中的逻辑一致)\n const skillCard = {\n name: values.name || '',\n description: values.description || '',\n instruction: values.instruction || '',\n };\n\n // 构建 resource Map,过滤掉无效的资源(没有 name 或 name 为空的资源)\n if (resources && resources.length > 0) {\n const resourceMap = {};\n resources.forEach((resource, index) => {\n // 只包含有效的资源(有 name 且 name 不为空)\n if (resource.name && resource.name.trim() !== '') {\n const key = resource.name.trim();\n resourceMap[key] = {\n name: resource.name.trim(),\n type: resource.type || '',\n content: resource.content || '',\n metadata: resource.metadata || null,\n };\n }\n });\n skillCard.resource = resourceMap;\n } else {\n skillCard.resource = {};\n }\n\n return skillCard;\n };\n\n // Update file tree when resources change\n updateEditModeFileTree = callback => {\n const previewData = this.buildPreviewData();\n const fileTree = this.buildFileTree(previewData);\n this.setState({ editModeFileTree: fileTree }, () => {\n if (callback && typeof callback === 'function') {\n callback(fileTree);\n }\n });\n };\n\n // Check if data has been modified\n hasDataChanged = () => {\n const { originalFormData, originalResources } = this.state;\n if (!originalFormData || !originalResources) {\n return false;\n }\n\n const currentValues = this.field.getValues();\n\n // Check form data changes\n const formChanged =\n currentValues.name !== originalFormData.name ||\n currentValues.description !== originalFormData.description ||\n currentValues.instruction !== originalFormData.instruction;\n\n // Check resources changes\n const currentResources = this.state.resources || [];\n const resourcesChanged = JSON.stringify(currentResources) !== JSON.stringify(originalResources);\n\n return formChanged || resourcesChanged;\n };\n\n // Build file tree structure; type may contain \"/\" for multi-level folders (e.g. folder1/folder2)\n buildFileTree = previewData => {\n if (!previewData || !previewData.name) {\n return null;\n }\n\n const fileList = [\n {\n name: 'SKILL.md',\n type: 'file',\n fileType: 'skill-md',\n },\n ];\n\n const rootChildren = [];\n const resourcesWithoutType = [];\n\n const getOrCreateFolder = (children, folderName, level = 0) => {\n let folder = children.find(n => n.type === 'folder' && n.name === folderName);\n if (!folder) {\n // Default expand folders up to level 2 (0-indexed: 0, 1, 2 = 3 levels)\n folder = { name: folderName, type: 'folder', children: [], expanded: level < 3 };\n children.push(folder);\n }\n return { children: folder.children, level: level + 1 };\n };\n\n const sortNodeChildren = nodes => {\n nodes.sort((a, b) =>\n (a.name || '').localeCompare(b.name || '', undefined, { sensitivity: 'base' })\n );\n nodes.forEach(n => {\n if (n.type === 'folder' && n.children && n.children.length) {\n sortNodeChildren(n.children);\n }\n });\n };\n\n if (previewData.resource && Object.keys(previewData.resource).length > 0) {\n Object.entries(previewData.resource).forEach(([key, resource]) => {\n const fileNode = {\n name: resource.name || key,\n type: 'file',\n fileType: 'resource',\n resourceKey: key,\n resource: resource,\n };\n if (!resource.type || resource.type.trim() === '') {\n resourcesWithoutType.push(fileNode);\n } else {\n const pathSegments = resource.type\n .trim()\n .split('/')\n .filter(Boolean);\n let target = { children: rootChildren, level: 0 };\n for (const seg of pathSegments) {\n target = getOrCreateFolder(target.children, seg, target.level);\n }\n target.children.push(fileNode);\n }\n });\n }\n\n sortNodeChildren(rootChildren);\n const sortedRootFiles = resourcesWithoutType.sort((a, b) =>\n (a.name || '').localeCompare(b.name || '', undefined, { sensitivity: 'base' })\n );\n const afterSkill = [...rootChildren, ...sortedRootFiles].sort((a, b) =>\n (a.name || '').localeCompare(b.name || '', undefined, { sensitivity: 'base' })\n );\n fileList.push(...afterSkill);\n\n return fileList;\n };\n\n // Escape YAML value (handle special characters)\n escapeYamlValue = value => {\n if (!value) {\n return '';\n }\n // If value contains special characters, wrap in quotes\n if (value.includes(':') || value.includes('\"') || value.includes(\"'\") || value.includes('\\n')) {\n return `\"${value.replace(/\\\\/g, '\\\\\\\\').replace(/\"/g, '\\\\\"')}\"`;\n }\n return value;\n };\n\n // Build SKILL.md content\n buildSkillMarkdown = previewData => {\n if (!previewData) {\n return '';\n }\n\n let markdown = '---\\n';\n markdown += `name: ${this.escapeYamlValue(previewData.name || '')}\\n`;\n markdown += `description: ${this.escapeYamlValue(previewData.description || '')}\\n`;\n markdown += '---\\n\\n';\n\n // Instructions section - directly show instruction content without \"## Instructions\" header\n if (previewData.instruction && previewData.instruction.trim() !== '') {\n markdown += `${previewData.instruction}\\n`;\n }\n\n return markdown;\n };\n\n handleEditModeFileClick = (file, e) => {\n if (e) {\n e.preventDefault();\n e.stopPropagation();\n }\n // Clear editModeSelectedFile first to force MonacoEditor to unmount, then set new file\n this.setState({ editModeSelectedFile: null }, () => {\n setTimeout(() => {\n this.setState({ editModeSelectedFile: file });\n }, 0);\n });\n };\n\n // 开始编辑文件名\n handleStartEditFileName = (node, e) => {\n if (e) {\n e.preventDefault();\n e.stopPropagation();\n }\n // SKILL.md 不能被编辑\n if (node.fileType === 'skill-md' || node.name === 'SKILL.md') {\n return;\n }\n // 只有资源文件可以编辑\n if (node.fileType === 'resource') {\n this.setState({\n editingFileName: {\n nodeKey: node.resourceKey || node.name,\n oldName: node.name,\n type: node.resource?.type || '',\n },\n editingFileNameValue: node.name, // 初始化编辑值\n });\n }\n };\n\n // 更新正在编辑的文件名临时值\n handleEditingFileNameChange = value => {\n // 过滤文件名:只允许英文大小写、数字、点号、下划线、横杠\n const filteredValue = value.replace(/[^a-zA-Z0-9._-]/g, '');\n this.setState({ editingFileNameValue: filteredValue });\n };\n\n // 保存文件名修改\n handleSaveFileName = newName => {\n const { editingFileName, resources } = this.state;\n if (!editingFileName) {\n this.setState({ editingFileName: null, editingFileNameValue: '' });\n return;\n }\n\n // 使用传入的 newName 或当前编辑值\n const nameToSave = newName || this.state.editingFileNameValue || editingFileName.oldName;\n if (!nameToSave || nameToSave.trim() === '') {\n this.setState({ editingFileName: null, editingFileNameValue: '' });\n return;\n }\n\n // 过滤文件名:只允许英文大小写、数字、点号、下划线、横杠\n const filteredName = nameToSave.replace(/[^a-zA-Z0-9._-]/g, '');\n\n if (filteredName === editingFileName.oldName) {\n // 没有变化,取消编辑\n this.setState({ editingFileName: null, editingFileNameValue: '' });\n return;\n }\n\n // 检查是否重名\n const isDuplicate = resources.some(\n r =>\n r.name === filteredName &&\n r.type === editingFileName.type &&\n r.name !== editingFileName.oldName\n );\n\n if (isDuplicate) {\n const { locale = {} } = this.props;\n Message.warning(locale.fileNameDuplicate || 'File name already exists');\n return;\n }\n\n // 更新资源名称\n const resourceIndex = resources.findIndex(\n r => r.name === editingFileName.oldName && r.type === editingFileName.type\n );\n\n if (resourceIndex !== -1) {\n const newResources = [...resources];\n newResources[resourceIndex] = {\n ...newResources[resourceIndex],\n name: filteredName,\n };\n this.setState(\n {\n resources: newResources,\n editingFileName: null,\n editingFileNameValue: '',\n },\n () => {\n // 更新文件树和变化状态\n if (this.state.isEdit) {\n // 使用 callback 确保文件树更新完成后再查找新文件\n this.updateEditModeFileTree(fileTree => {\n // 更新选中的文件,使其指向新的文件名\n const { editModeSelectedFile } = this.state;\n if (\n editModeSelectedFile &&\n editModeSelectedFile.fileType === 'resource' &&\n editModeSelectedFile.resourceKey === editingFileName.oldName\n ) {\n if (fileTree && Array.isArray(fileTree)) {\n // 递归查找文件\n const findFileInTree = tree => {\n if (Array.isArray(tree)) {\n for (const node of tree) {\n if (\n node.fileType === 'resource' &&\n node.resourceKey === filteredName &&\n node.resource?.type === editingFileName.type\n ) {\n return node;\n }\n if (node.children) {\n const found = findFileInTree(node.children);\n if (found) return found;\n }\n }\n }\n return null;\n };\n\n const newFile = findFileInTree(fileTree);\n if (newFile) {\n this.setState({ editModeSelectedFile: newFile });\n } else {\n // 如果找不到,清空选中状态\n this.setState({ editModeSelectedFile: null });\n }\n }\n }\n\n const hasChanged = this.hasDataChanged();\n if (hasChanged !== this.state.hasChanges) {\n this.setState({ hasChanges: hasChanged });\n }\n });\n }\n }\n );\n } else {\n this.setState({ editingFileName: null, editingFileNameValue: '' });\n }\n };\n\n // 取消编辑文件名\n handleCancelEditFileName = () => {\n this.setState({ editingFileName: null, editingFileNameValue: '' });\n };\n\n // 在文件树中新增文件(支持指定目标文件夹)\n handleAddFileInTree = folderType => {\n // 确定目标文件夹路径\n let targetFolder = '';\n if (typeof folderType === 'string') {\n // 从文件夹按钮直接传入的路径\n targetFolder = folderType;\n } else if (\n this.state.editModeSelectedFile &&\n this.state.editModeSelectedFile.fileType === 'resource'\n ) {\n // 从当前选中文件推断文件夹\n targetFolder = this.state.editModeSelectedFile.resource?.type || '';\n }\n\n const newResource = {\n name: `new_file_${Date.now()}`,\n type: targetFolder,\n content: '',\n metadata: null,\n };\n\n const newResources = [...this.state.resources, newResource];\n\n // 递归查找文件树中的文件\n const findFileInTree = nodes => {\n if (!nodes || !Array.isArray(nodes)) return null;\n for (const node of nodes) {\n if (node.fileType === 'resource' && node.resourceKey === newResource.name) {\n return node;\n }\n if (node.type === 'folder' && node.children) {\n const found = findFileInTree(node.children);\n if (found) return found;\n }\n }\n return null;\n };\n\n this.setState(\n {\n resources: newResources,\n },\n () => {\n // 更新文件树\n if (this.state.isEdit) {\n this.updateEditModeFileTree(fileTree => {\n // 选中新文件并进入编辑模式(递归查找)\n if (fileTree && Array.isArray(fileTree)) {\n const newFile = findFileInTree(fileTree);\n if (newFile) {\n this.setState({ editModeSelectedFile: newFile });\n // 自动开始编辑文件名\n setTimeout(() => {\n this.setState({\n editingFileName: {\n nodeKey: newResource.name,\n oldName: newResource.name,\n type: targetFolder,\n },\n editingFileNameValue: newResource.name,\n });\n }, 100);\n }\n }\n const hasChanged = this.hasDataChanged();\n if (hasChanged !== this.state.hasChanges) {\n this.setState({ hasChanges: hasChanged });\n }\n });\n }\n }\n );\n };\n\n // 拖拽开始\n handleDragStart = (node, e) => {\n if (node.fileType === 'resource' && node.name !== 'SKILL.md') {\n e.dataTransfer.effectAllowed = 'move';\n e.dataTransfer.setData(\n 'text/plain',\n JSON.stringify({\n resourceKey: node.resourceKey || node.name,\n name: node.name,\n type: node.resource?.type || '',\n })\n );\n this.setState({\n draggingFile: {\n resourceKey: node.resourceKey || node.name,\n name: node.name,\n type: node.resource?.type || '',\n },\n });\n } else {\n e.preventDefault();\n }\n };\n\n // 拖拽结束\n handleDragEnd = () => {\n this.setState({\n draggingFile: null,\n dragOverFolder: null,\n });\n };\n\n // 拖拽悬停在文件夹上\n handleDragOver = (folderName, e) => {\n e.preventDefault();\n e.stopPropagation();\n e.dataTransfer.dropEffect = 'move';\n if (this.state.dragOverFolder !== folderName) {\n this.setState({ dragOverFolder: folderName });\n }\n };\n\n // 拖拽离开文件夹\n handleDragLeave = e => {\n e.preventDefault();\n e.stopPropagation();\n // 只有当真正离开文件夹区域时才清除状态\n const relatedTarget = e.relatedTarget;\n if (!relatedTarget || !e.currentTarget.contains(relatedTarget)) {\n this.setState({ dragOverFolder: null });\n }\n };\n\n // 文件拖放到文件夹\n handleDrop = (folderName, e) => {\n e.preventDefault();\n e.stopPropagation();\n\n const { draggingFile, resources } = this.state;\n if (!draggingFile) {\n this.setState({ dragOverFolder: null });\n return;\n }\n\n // 找到要移动的资源\n const resourceIndex = resources.findIndex(\n r => r.name === draggingFile.name && r.type === draggingFile.type\n );\n\n if (resourceIndex !== -1) {\n const newResources = [...resources];\n // 更新资源的 type 为文件夹名称\n newResources[resourceIndex] = {\n ...newResources[resourceIndex],\n type: folderName,\n };\n\n this.setState(\n {\n resources: newResources,\n draggingFile: null,\n dragOverFolder: null,\n },\n () => {\n // 更新文件树和变化状态\n if (this.state.isEdit) {\n this.updateEditModeFileTree();\n const hasChanged = this.hasDataChanged();\n if (hasChanged !== this.state.hasChanges) {\n this.setState({ hasChanges: hasChanged });\n }\n }\n }\n );\n } else {\n this.setState({ draggingFile: null, dragOverFolder: null });\n }\n };\n\n toggleFolderExpanded = (fileTree, targetKey) => {\n if (!fileTree || !Array.isArray(fileTree)) {\n return fileTree;\n }\n\n const toggleNode = (nodes, currentKey = '') => {\n return nodes.map(node => {\n const nodeKey = node.resourceKey\n ? `${currentKey}/${node.resourceKey}`\n : currentKey\n ? `${currentKey}/${node.name}`\n : node.name;\n\n if (nodeKey === targetKey && node.type === 'folder') {\n return { ...node, expanded: !node.expanded };\n }\n\n if (node.type === 'folder' && node.children) {\n return {\n ...node,\n children: toggleNode(node.children, nodeKey),\n };\n }\n\n return node;\n });\n };\n\n return toggleNode(fileTree);\n };\n\n renderFileTree = (fileList, level = 0, parentKey = '', isEditMode = false) => {\n if (!fileList) {\n return null;\n }\n\n // If fileList is an array, render each item\n if (Array.isArray(fileList)) {\n return fileList.map(node => this.renderFileTree(node, level, parentKey, isEditMode));\n }\n\n // If it's a single node\n const node = fileList;\n const nodeKey = node.resourceKey\n ? `${parentKey}/${node.resourceKey}`\n : parentKey\n ? `${parentKey}/${node.name}`\n : node.name;\n\n const selectedFile = this.state.editModeSelectedFile;\n const isSelected =\n selectedFile &&\n selectedFile.name === node.name &&\n selectedFile.fileType === node.fileType &&\n selectedFile.resourceKey === node.resourceKey;\n\n if (node.type === 'folder') {\n const { dragOverFolder, draggingFile } = this.state;\n const isDragOver = dragOverFolder === node.name && draggingFile;\n const isExpanded = node.expanded !== false; // Default to true if not set\n const folderStyle = {\n paddingLeft: level === 0 ? '8px' : `${level * 20 + 8}px`,\n paddingTop: '8px',\n paddingBottom: '6px',\n paddingRight: '12px',\n marginTop: level === 0 ? '8px' : '12px',\n marginBottom: '4px',\n display: 'flex',\n alignItems: 'center',\n fontWeight: 600,\n color: '#666',\n fontSize: '13px',\n backgroundColor: isDragOver ? '#e6f7ff' : 'transparent',\n borderRadius: 4,\n transition: 'background-color 0.2s',\n cursor: 'pointer',\n };\n\n const toggleExpand = e => {\n e.stopPropagation();\n const updatedTree = this.toggleFolderExpanded(\n isEditMode ? this.state.editModeFileTree : this.state.fileTree,\n nodeKey\n );\n if (isEditMode) {\n this.setState({ editModeFileTree: updatedTree });\n } else {\n this.setState({ fileTree: updatedTree });\n }\n };\n\n return (\n <div key={nodeKey} className=\"file-tree-folder\">\n <div\n className=\"file-tree-item file-tree-folder-item\"\n style={folderStyle}\n onClick={toggleExpand}\n onDragOver={isEditMode ? e => this.handleDragOver(node.name, e) : undefined}\n onDragLeave={isEditMode ? this.handleDragLeave : undefined}\n onDrop={isEditMode ? e => this.handleDrop(node.name, e) : undefined}\n >\n <Icon\n type={isExpanded ? 'arrow-down' : 'arrow-right'}\n style={{ marginRight: 4, fontSize: 12, color: '#999' }}\n />\n <Icon type=\"folder\" style={{ marginRight: 8, color: '#666' }} />\n <span style={{ fontWeight: 600, flex: 1 }}>{node.name}</span>\n {isEditMode && (\n <Icon\n type=\"add\"\n size=\"xs\"\n style={{ color: '#999', cursor: 'pointer', marginLeft: 4, flexShrink: 0 }}\n onClick={e => {\n e.stopPropagation();\n this.handleAddFileInTree(nodeKey);\n }}\n />\n )}\n </div>\n {isExpanded &&\n node.children &&\n node.children.map(child => this.renderFileTree(child, level + 1, nodeKey, isEditMode))}\n </div>\n );\n } else {\n const itemStyle = {\n paddingLeft: level === 0 ? '8px' : `${level * 20 + 8}px`,\n paddingTop: '10px',\n paddingBottom: '10px',\n paddingRight: '12px',\n cursor: 'pointer',\n backgroundColor: isSelected ? '#e6f7ff' : 'transparent',\n color: isSelected ? '#1890ff' : '#333',\n fontWeight: isSelected ? 500 : 'normal',\n borderRadius: 4,\n margin: '2px 4px',\n display: 'flex',\n alignItems: 'center',\n };\n\n const { editingFileName, draggingFile } = this.state;\n const isEditing =\n editingFileName &&\n editingFileName.nodeKey === (node.resourceKey || node.name) &&\n editingFileName.oldName === node.name;\n const canEdit = isEditMode && node.fileType === 'resource' && node.name !== 'SKILL.md';\n const isDragging =\n draggingFile &&\n draggingFile.resourceKey === (node.resourceKey || node.name) &&\n draggingFile.name === node.name;\n\n const row = (\n <div\n key={nodeKey}\n className={`file-tree-item file-tree-file-item ${isSelected ? 'selected' : ''}`}\n style={{\n ...itemStyle,\n opacity: isDragging ? 0.5 : 1,\n cursor: canEdit ? 'move' : 'pointer',\n }}\n draggable={canEdit && !isEditing}\n onDragStart={canEdit && !isEditing ? e => this.handleDragStart(node, e) : undefined}\n onDragEnd={canEdit ? this.handleDragEnd : undefined}\n onClick={e => {\n e.preventDefault();\n e.stopPropagation();\n if (isEditMode && !isEditing) {\n this.handleEditModeFileClick(node, e);\n }\n }}\n onDoubleClick={e => {\n e.preventDefault();\n e.stopPropagation();\n if (canEdit) {\n this.handleStartEditFileName(node, e);\n }\n }}\n >\n <Icon type=\"file\" style={{ marginRight: 8 }} />\n {isEditing ? (\n <Input\n size=\"small\"\n value={this.state.editingFileNameValue}\n autoFocus\n style={{ flex: 1, marginRight: 4 }}\n onChange={value => {\n this.handleEditingFileNameChange(value);\n }}\n onBlur={e => {\n this.handleSaveFileName(e.target.value);\n }}\n onPressEnter={e => {\n this.handleSaveFileName(e.target.value);\n }}\n onKeyDown={e => {\n if (e.key === 'Escape') {\n this.handleCancelEditFileName();\n }\n }}\n onClick={e => {\n e.stopPropagation();\n }}\n />\n ) : (\n <span\n style={{\n pointerEvents: 'none',\n flex: 1,\n minWidth: 0,\n overflow: 'hidden',\n textOverflow: 'ellipsis',\n whiteSpace: 'nowrap',\n }}\n >\n {node.name}\n </span>\n )}\n </div>\n );\n if (!isEditing) {\n return (\n <Balloon\n trigger={row}\n triggerType=\"hover\"\n delay={100}\n align=\"tl\"\n closable={false}\n popupStyle={{\n padding: '4px 8px',\n fontSize: 12,\n }}\n >\n <span style={{ whiteSpace: 'nowrap' }}>{node.name}</span>\n </Balloon>\n );\n }\n return row;\n }\n };\n\n renderEditModeFileContent = () => {\n const { editModeSelectedFile } = this.state;\n const values = this.field.getValues();\n const { resources } = this.state;\n\n if (!editModeSelectedFile) {\n return (\n <div\n className=\"file-content-empty\"\n style={{\n display: 'flex',\n alignItems: 'center',\n justifyContent: 'center',\n height: '100%',\n color: '#999',\n fontSize: '14px',\n }}\n >\n {this.getLocaleValue('selectFileToEdit', 'Select a file to edit')}\n </div>\n );\n }\n\n if (editModeSelectedFile.fileType === 'skill-md') {\n const markdown = this.buildSkillMarkdown(this.buildPreviewData());\n return (\n <div\n className=\"file-content\"\n style={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}\n >\n <div\n className=\"file-content-header\"\n style={{\n padding: '12px 16px',\n borderBottom: '1px solid #e6e6e6',\n display: 'flex',\n alignItems: 'center',\n fontWeight: 500,\n background: '#fafafa',\n }}\n >\n <Icon type=\"file\" style={{ marginRight: 8 }} />\n <span>{editModeSelectedFile.name}</span>\n </div>\n <div style={{ flex: 1, overflow: 'hidden', padding: '16px' }}>\n <div style={{ border: '1px solid #d9d9d9', borderRadius: '4px', height: '100%' }}>\n <MonacoEditor\n language=\"markdown\"\n width=\"100%\"\n height=\"100%\"\n value={values.instruction || ''}\n onChange={value => {\n this.field.setValue('instruction', value);\n // 更新文件树和变化状态\n setTimeout(() => {\n if (this.state.isEdit) {\n this.updateEditModeFileTree();\n // 检查是否有变化\n const hasChanged = this.hasDataChanged();\n if (hasChanged !== this.state.hasChanges) {\n this.setState({ hasChanges: hasChanged });\n }\n }\n }, 0);\n }}\n options={{\n wordWrap: 'on',\n minimap: { enabled: false },\n lineNumbers: 'on',\n scrollBeyondLastLine: false,\n }}\n />\n </div>\n </div>\n </div>\n );\n } else if (editModeSelectedFile.fileType === 'resource') {\n const resource = editModeSelectedFile.resource;\n // 找到对应的资源索引\n const resourceIndex = resources.findIndex(r => {\n if (editModeSelectedFile.resourceKey) {\n return r.name === editModeSelectedFile.resourceKey;\n }\n return r.name === resource.name && r.type === resource.type;\n });\n\n if (resourceIndex === -1) {\n return (\n <div\n className=\"file-content-empty\"\n style={{\n display: 'flex',\n alignItems: 'center',\n justifyContent: 'center',\n height: '100%',\n color: '#999',\n fontSize: '14px',\n }}\n >\n {this.getLocaleValue('resourceNotFound', 'Resource not found')}\n </div>\n );\n }\n\n const currentResource = resources[resourceIndex];\n return (\n <div\n className=\"file-content\"\n style={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}\n >\n <div\n className=\"file-content-header\"\n style={{\n padding: '12px 16px',\n borderBottom: '1px solid #e6e6e6',\n display: 'flex',\n alignItems: 'center',\n fontWeight: 500,\n background: '#fafafa',\n }}\n >\n <Icon type=\"file\" style={{ marginRight: 8 }} />\n <span>{editModeSelectedFile.name}</span>\n </div>\n <div style={{ flex: 1, overflow: 'hidden', padding: '16px' }}>\n <div style={{ border: '1px solid #d9d9d9', borderRadius: '4px', height: '100%' }}>\n <MonacoEditor\n key={`${editModeSelectedFile.resourceKey ||\n editModeSelectedFile.name}-${getLanguageFromFileName(\n currentResource.name || ''\n )}`}\n language={getLanguageFromFileName(currentResource.name || '')}\n width=\"100%\"\n height=\"100%\"\n value={currentResource.content || ''}\n onChange={value => {\n this.handleResourceChange(resourceIndex, 'content', value);\n }}\n options={{\n wordWrap: 'on',\n minimap: { enabled: false },\n lineNumbers: 'on',\n scrollBeyondLastLine: false,\n }}\n />\n </div>\n </div>\n </div>\n );\n }\n\n return null;\n };\n\n render() {\n const { locale = {} } = this.props;\n const {\n loading,\n generating,\n isEdit,\n isDraft,\n resources,\n expandedKeys,\n showAiGenerateInput,\n } = this.state;\n\n const formItemLayout = {\n labelCol: { span: 3 },\n wrapperCol: { span: 20 },\n };\n\n // 检查是否有数据变化(使用state中的值,避免每次render都计算)\n const hasChanged = isEdit ? this.state.hasChanges : false;\n\n return (\n <div className=\"new-skill-container\">\n <div\n className=\"page-title\"\n style={{\n display: 'flex',\n alignItems: 'center',\n justifyContent: 'space-between',\n marginBottom: 8,\n marginTop: 8,\n }}\n >\n <div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>\n <span style={{ fontSize: 28, height: 40, fontWeight: 500 }}>\n {isEdit\n ? isDraft\n ? this.getLocaleValue('editDraft', 'Edit Draft')\n : this.getLocaleValue('editSkill', 'Edit Skill')\n : this.getLocaleValue('createSkill', 'Create Skill')}\n </span>\n <div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>\n {!isEdit && localStorage.getItem(COPILOT_ENABLED) === 'true' && (\n <Button type=\"primary\" onClick={this.handleShowAiGenerate}>\n <MagicWandIcon size={16} style={{ marginRight: 4, verticalAlign: 'middle' }} />{' '}\n {this.getLocaleValue('aiGenerate', 'AI生成')}\n </Button>\n )}\n {isEdit && localStorage.getItem(COPILOT_ENABLED) === 'true' && (\n <Button onClick={this.handleShowOptimizeDialog}>\n <MagicWandIcon size={16} style={{ marginRight: 4, verticalAlign: 'middle' }} />{' '}\n {this.getLocaleValue('aiOptimize', 'AI 优化')}\n </Button>\n )}\n {isEdit && (\n <>\n <Button\n type=\"primary\"\n onClick={this.handleSubmit}\n loading={loading}\n disabled={!hasChanged}\n >\n {this.getLocaleValue('update', 'Update')}\n </Button>\n <Button warning onClick={this.handleGoBack} disabled={!hasChanged}>\n {this.getLocaleValue('cancel', 'Cancel')}\n </Button>\n </>\n )}\n </div>\n </div>\n {!isEdit && (\n <div>\n <Button onClick={this.handleGoBack}>{this.getLocaleValue('cancel', 'Cancel')}</Button>\n </div>\n )}\n </div>\n\n {isEdit ? (\n // 编辑模式:使用文件目录形式展示(与查看详情页面一致)\n <div\n className=\"skill-detail-container\"\n style={{\n background: '#fff',\n borderRadius: '4px',\n border: '1px solid #e6e6e6',\n height: 'calc(100vh - 200px)',\n display: 'flex',\n overflow: 'hidden',\n }}\n >\n <div\n className=\"skill-detail-sidebar\"\n style={{\n width: '300px',\n borderRight: '1px solid #e6e6e6',\n display: 'flex',\n flexDirection: 'column',\n background: '#fafafa',\n }}\n >\n <div\n className=\"skill-detail-sidebar-header\"\n style={{\n padding: '12px 16px',\n borderBottom: '1px solid #e6e6e6',\n fontWeight: 500,\n background: '#fff',\n display: 'flex',\n alignItems: 'center',\n justifyContent: 'space-between',\n }}\n >\n <span>\n {this.field.getValue('name') || this.getLocaleValue('projectFiles', '项目文件')}\n </span>\n <Button\n size=\"small\"\n type=\"primary\"\n text\n onClick={this.handleAddFileInTree}\n style={{ padding: '0 4px' }}\n title={this.getLocaleValue('addFile', 'Add File')}\n >\n <Icon type=\"add\" size=\"small\" />\n </Button>\n </div>\n <div\n className=\"skill-detail-file-tree\"\n style={{ flex: 1, overflowY: 'auto', padding: '8px 0' }}\n onDragOver={e => {\n e.preventDefault();\n e.stopPropagation();\n if (this.state.draggingFile) {\n e.dataTransfer.dropEffect = 'move';\n if (this.state.dragOverFolder !== '') {\n this.setState({ dragOverFolder: '' });\n }\n }\n }}\n onDrop={e => {\n e.preventDefault();\n e.stopPropagation();\n const { draggingFile, resources } = this.state;\n if (!draggingFile) return;\n\n const resourceIndex = resources.findIndex(\n r => r.name === draggingFile.name && r.type === draggingFile.type\n );\n\n if (resourceIndex !== -1) {\n const newResources = [...resources];\n newResources[resourceIndex] = {\n ...newResources[resourceIndex],\n type: '', // 拖到根目录,清空 type\n };\n this.setState(\n {\n resources: newResources,\n draggingFile: null,\n dragOverFolder: null,\n },\n () => {\n if (this.state.isEdit) {\n this.updateEditModeFileTree();\n const hasChanged = this.hasDataChanged();\n if (hasChanged !== this.state.hasChanges) {\n this.setState({ hasChanges: hasChanged });\n }\n }\n }\n );\n }\n }}\n >\n {this.state.editModeFileTree ? (\n this.renderFileTree(this.state.editModeFileTree, 0, '', true)\n ) : (\n <div\n className=\"file-tree-empty\"\n style={{\n padding: '40px 20px',\n textAlign: 'center',\n color: '#999',\n fontSize: '13px',\n }}\n >\n {this.getLocaleValue('noPreviewData', 'No preview data available')}\n </div>\n )}\n </div>\n </div>\n <div\n className=\"skill-detail-content-area\"\n style={{\n flex: 1,\n display: 'flex',\n flexDirection: 'column',\n overflow: 'hidden',\n background: '#fff',\n }}\n >\n {this.renderEditModeFileContent()}\n </div>\n </div>\n ) : (\n // 创建模式:使用传统表单形式\n <div\n style={{\n background: '#fff',\n padding: '20px',\n borderRadius: '4px',\n border: '1px solid #e6e6e6',\n }}\n >\n <Form field={this.field} {...formItemLayout} className=\"new-skill-form\">\n <Form.Item\n label={this.getLocaleValue('skillName', 'Skill Name')}\n required\n validator={this.validateSkillName}\n >\n <Input\n name=\"name\"\n placeholder={this.getLocaleValue(\n 'skillNamePlaceholder',\n 'Please enter Skill name (letters, underscore, hyphen only)'\n )}\n disabled={isEdit}\n maxLength={255}\n onChange={this.handleSkillNameChange}\n />\n </Form.Item>\n\n <Form.Item label={this.getLocaleValue('description', 'Description')}>\n <Input.TextArea\n name=\"description\"\n placeholder={this.getLocaleValue(\n 'descriptionPlaceholder',\n 'Please enter Skill description'\n )}\n rows={3}\n maxLength={1000}\n />\n </Form.Item>\n\n <Form.Item\n label={this.getLocaleValue('instruction', 'Instruction')}\n required\n validator={this.validateRequired}\n >\n <div\n style={{ border: '1px solid #d9d9d9', borderRadius: '4px', minHeight: '400px' }}\n >\n <MonacoEditor\n language=\"markdown\"\n width=\"100%\"\n height={400}\n value={this.field.getValue('instruction') || ''}\n onChange={value => {\n this.field.setValue('instruction', value);\n }}\n options={{\n wordWrap: 'on',\n minimap: { enabled: false },\n lineNumbers: 'on',\n }}\n />\n </div>\n </Form.Item>\n\n <Form.Item label={this.getLocaleValue('resources', 'Resources')}>\n <div className=\"resources-section\">\n <Button\n type=\"primary\"\n onClick={this.handleAddResource}\n style={{ marginBottom: 16 }}\n >\n <Icon type=\"add\" /> {this.getLocaleValue('addResource', 'Add Resource')}\n </Button>\n\n {resources.length > 0 && (\n <Collapse expandedKeys={expandedKeys} onExpand={this.handleExpandChange}>\n {resources.map((resource, index) => {\n const isEditing = this.state.editingResourceIndex === index;\n const displayText =\n resource.type && resource.name\n ? `${resource.type}/${resource.name}`\n : `${this.getLocaleValue('resource', 'Resource')} ${index + 1}`;\n\n return (\n <Collapse.Panel\n key={String(index)}\n title={\n <div className=\"resource-panel-header\">\n {isEditing ? (\n <div\n className=\"resource-title-editor\"\n onClick={e => e.stopPropagation()}\n style={{\n display: 'flex',\n alignItems: 'center',\n gap: '8px',\n flex: 1,\n }}\n >\n <Input\n size=\"small\"\n value={resource.type || ''}\n onChange={value => {\n const filteredValue = value.replace(/[^a-zA-Z.-]/g, '');\n const newResources = [...this.state.resources];\n newResources[index] = {\n ...newResources[index],\n type: filteredValue,\n };\n this.setState({ resources: newResources }, () => {\n if (this.state.isEdit) {\n this.updateEditModeFileTree();\n }\n });\n }}\n placeholder={this.getLocaleValue(\n 'resourceTypePlaceholder',\n 'Type'\n )}\n style={{ width: '120px' }}\n onPressEnter={() => {\n this.setState({ editingResourceIndex: null });\n }}\n />\n <span>/</span>\n <Input\n size=\"small\"\n value={resource.name || ''}\n onChange={value => {\n const filteredValue = value.replace(/[^a-zA-Z0-9._-]/g, '');\n const newResources = [...this.state.resources];\n newResources[index] = {\n ...newResources[index],\n name: filteredValue,\n };\n this.setState({ resources: newResources }, () => {\n if (this.state.isEdit) {\n this.updateEditModeFileTree();\n }\n });\n }}\n placeholder={this.getLocaleValue(\n 'resourceNamePlaceholder',\n 'Name'\n )}\n style={{ flex: 1 }}\n onPressEnter={() => {\n this.setState({ editingResourceIndex: null });\n }}\n />\n <Button\n text\n size=\"small\"\n onClick={e => {\n e.stopPropagation();\n this.handleResourceTitleCancel();\n }}\n >\n <Icon type=\"close\" />\n </Button>\n </div>\n ) : (\n <>\n <span\n className=\"resource-title-text\"\n onClick={e => this.handleResourceTitleClick(index, e)}\n onMouseEnter={e =>\n this.handleResourceTitleMouseEnter(index, e)\n }\n onMouseMove={this.handleResourceTitleMouseMove}\n onMouseLeave={this.handleResourceTitleMouseLeave}\n style={{ cursor: 'pointer', flex: 1 }}\n >\n {displayText}\n </span>\n <Button\n text\n warning\n onClick={e => {\n e.stopPropagation();\n this.handleRemoveResource(index);\n }}\n >\n <Icon type=\"delete\" />{' '}\n {this.getLocaleValue('delete', 'Delete')}\n </Button>\n </>\n )}\n </div>\n }\n >\n <Form.Item>\n <div style={{ border: '1px solid #e6e6e6', borderRadius: '4px' }}>\n <MonacoEditor\n language={getLanguageFromFileName(resource.name || '')}\n width=\"100%\"\n height={300}\n value={resource.content || ''}\n onChange={value =>\n this.handleResourceChange(index, 'content', value)\n }\n options={{\n readOnly: false,\n wordWrap: 'on',\n minimap: { enabled: false },\n lineNumbers: 'on',\n scrollBeyondLastLine: false,\n theme: 'vs-dark-enhanced',\n }}\n />\n </div>\n </Form.Item>\n </Collapse.Panel>\n );\n })}\n </Collapse>\n )}\n\n {resources.length === 0 && (\n <div className=\"empty-resources\">\n {this.getLocaleValue(\n 'noResources',\n 'No resources added yet. Click \"Add Resource\" to add one.'\n )}\n </div>\n )}\n </div>\n </Form.Item>\n\n <Form.Item wrapperCol={{ offset: 3, span: 20 }}>\n <Button\n type=\"primary\"\n onClick={this.handleSubmit}\n loading={loading}\n style={{ marginRight: 10 }}\n >\n {this.getLocaleValue('create', 'Create')}\n </Button>\n </Form.Item>\n </Form>\n </div>\n )}\n\n {!isEdit && (\n <Dialog\n visible={this.state.showAiGenerateDialog}\n title={this.getLocaleValue('aiGenerate', 'AI生成')}\n onClose={this.handleCloseAiGenerateDialog}\n onCancel={this.handleCloseAiGenerateDialog}\n onOk={\n this.state.generatedSkill ? this.handleApplyGeneratedSkill : this.handleGenerateSkill\n }\n okProps={{\n loading: this.state.generating,\n children: this.state.generatedSkill\n ? this.getLocaleValue('apply', 'Apply')\n : this.getLocaleValue('generateSkill', 'Generate Skill'),\n }}\n cancelProps={{\n children: this.getLocaleValue('cancel', 'Cancel'),\n }}\n style={{ width: 1000 }}\n >\n {!this.state.generatedSkill ? (\n <div>\n {/* 用户输入区域 - 生成前显示完整,生成后折叠 */}\n {!this.state.showInputCollapsed ? (\n <div>\n <Form.Item\n label={this.getLocaleValue('backgroundInfo', 'Background Information')}\n >\n <Input.TextArea\n value={this.state.backgroundInfo}\n onChange={this.handleBackgroundInfoChange}\n placeholder={this.getLocaleValue(\n 'backgroundInfoPlaceholder',\n 'Please describe what you want the Skill to do, e.g., \"I need a Skill to check Nacos configuration status and provide solutions when issues are found\"'\n )}\n rows={6}\n maxLength={2000}\n />\n </Form.Item>\n\n <Form.Item\n label={this.getLocaleValue('selectMcpTools', 'Select MCP Tools (Optional)')}\n >\n <Loading visible={this.state.loadingMcpServers} style={{ width: '100%' }}>\n <Select\n placeholder={this.getLocaleValue('selectMcpServer', 'Select MCP Server')}\n value={this.state.selectedMcpServer}\n onChange={this.handleMcpServerChange}\n style={{ width: '100%', marginBottom: 12 }}\n dataSource={this.state.mcpServers.map(server => ({\n label: server.name,\n value: server.id || server.name,\n }))}\n />\n </Loading>\n\n {this.state.selectedMcpServer && (\n <Loading visible={this.state.loadingMcpTools} style={{ width: '100%' }}>\n <Input\n placeholder={this.getLocaleValue(\n 'searchTools',\n 'Search tools by name...'\n )}\n value={this.state.mcpToolSearchKeyword}\n onChange={this.handleMcpToolSearchChange}\n style={{ width: '100%', marginBottom: 12 }}\n hasClear\n />\n <div\n style={{\n border: '1px solid #e6e6e6',\n borderRadius: 4,\n padding: 12,\n maxHeight: 200,\n overflowY: 'auto',\n background: '#fafafa',\n }}\n >\n {this.getFilteredMcpTools().length > 0 ? (\n this.getFilteredMcpTools().map((tool, index) => (\n <Checkbox\n key={index}\n checked={this.state.selectedMcpTools.some(\n t => t.name === tool.name\n )}\n onChange={checked => this.handleMcpToolChange(checked, tool)}\n style={{ display: 'block', marginBottom: 8 }}\n >\n <div>\n <strong>{tool.name}</strong>\n {tool.description && (\n <div\n style={{ fontSize: '12px', color: '#666', marginTop: 4 }}\n >\n {tool.description}\n </div>\n )}\n </div>\n </Checkbox>\n ))\n ) : (\n <div style={{ color: '#999', fontSize: '12px' }}>\n {this.state.mcpToolSearchKeyword.trim()\n ? this.getLocaleValue(\n 'noToolsFound',\n 'No tools found matching your search'\n )\n : this.getLocaleValue(\n 'noToolsAvailable',\n 'No tools available in this MCP server'\n )}\n </div>\n )}\n </div>\n </Loading>\n )}\n </Form.Item>\n\n <Form.Item\n label={this.getLocaleValue(\n 'conversationHistory',\n 'Conversation History (Optional)'\n )}\n >\n <div style={{ marginBottom: 8 }}>\n <Button\n text\n size=\"small\"\n onClick={() =>\n this.setState({\n showConversationHistory: !this.state.showConversationHistory,\n })\n }\n >\n {this.state.showConversationHistory\n ? this.getLocaleValue(\n 'hideConversationHistory',\n 'Hide Conversation History'\n )\n : this.getLocaleValue(\n 'showConversationHistory',\n 'Show Conversation History'\n )}\n <Icon\n type={this.state.showConversationHistory ? 'arrow-up' : 'arrow-down'}\n style={{ marginLeft: 4 }}\n />\n </Button>\n </div>\n {this.state.showConversationHistory && (\n <div>\n <Input.TextArea\n value={this.state.conversationHistoryJson}\n onChange={value => this.setState({ conversationHistoryJson: value })}\n placeholder={this.getLocaleValue(\n 'conversationHistoryPlaceholder',\n 'Enter conversation history in JSON format:\\n{\\n \"title\": \"Conversation Title\",\\n \"context\": \"Context description\",\\n \"messages\": [\\n {\\n \"type\": \"user\",\\n \"content\": \"User input\"\\n },\\n {\\n \"type\": \"tool_call\",\\n \"toolName\": \"tool_name\",\\n \"toolInput\": {\"param\": \"value\"},\\n \"toolOutput\": \"result\"\\n },\\n {\\n \"type\": \"model\",\\n \"content\": \"Model response\"\\n }\\n ]\\n}'\n )}\n rows={12}\n style={{ fontFamily: 'monospace', fontSize: '12px' }}\n />\n <div style={{ marginTop: 8, color: '#999', fontSize: '12px' }}>\n {this.getLocaleValue(\n 'conversationHistoryHint',\n 'The system will analyze the conversation history to determine if it is suitable for skill generation. Include user inputs, tool calls, and model responses.'\n )}\n </div>\n </div>\n )}\n </Form.Item>\n\n <div style={{ marginTop: 8, color: '#999', fontSize: '12px' }}>\n {this.getLocaleValue(\n 'generateHint',\n 'Enter background information and click to generate a Skill based on best practices'\n )}\n </div>\n </div>\n ) : (\n this.renderUserInput()\n )}\n\n {/* 结果解析中提示 */}\n {this.state.parsingResult && (\n <div style={{ marginTop: 16, textAlign: 'center', padding: '20px' }}>\n <Loading\n visible={true}\n tip={this.getLocaleValue('parsingResult', 'Parsing result...')}\n />\n </div>\n )}\n\n {/* 流式内容 - 思考内容和结果内容 */}\n {(this.state.streaming || this.state.thinkingContent || this.state.streamContent) &&\n this.renderStreamContent()}\n </div>\n ) : (\n this.renderGeneratedSkill()\n )}\n\n {/* Tooltip for user input */}\n {this.state.inputTooltipVisible && (\n <div\n style={{\n position: 'fixed',\n left: `${this.state.inputTooltipPosition.x}px`,\n top: `${this.state.inputTooltipPosition.y - 10}px`,\n transform: 'translate(-50%, -100%)',\n background: '#333',\n color: '#fff',\n padding: '12px 16px',\n borderRadius: '4px',\n fontSize: '12px',\n maxWidth: '500px',\n maxHeight: '400px',\n overflowY: 'auto',\n boxShadow: '0 2px 8px rgba(0, 0, 0, 0.15)',\n zIndex: 9999,\n pointerEvents: 'none',\n whiteSpace: 'pre-wrap',\n wordBreak: 'break-word',\n }}\n >\n {(() => {\n const { backgroundInfo, selectedMcpTools } = this.state;\n const selectedToolsText =\n selectedMcpTools.length > 0\n ? selectedMcpTools.map(t => t.name).join(', ')\n : this.getLocaleValue('noToolsSelected', 'No tools selected');\n return `${this.getLocaleValue(\n 'backgroundInfo',\n 'Background Information'\n )}: ${backgroundInfo}\\n\\n${this.getLocaleValue(\n 'selectedTools',\n 'Selected Tools'\n )}: ${selectedToolsText}`;\n })()}\n </div>\n )}\n\n {/* Tooltip for thinking content */}\n {this.state.thinkingTooltipVisible && this.state.thinkingContent && (\n <div\n style={{\n position: 'fixed',\n left: `${this.state.thinkingTooltipPosition.x}px`,\n top: `${this.state.thinkingTooltipPosition.y - 10}px`,\n transform: 'translate(-50%, -100%)',\n background: '#333',\n color: '#fff',\n padding: '12px 16px',\n borderRadius: '4px',\n fontSize: '12px',\n maxWidth: '500px',\n maxHeight: '400px',\n overflowY: 'auto',\n boxShadow: '0 2px 8px rgba(0, 0, 0, 0.15)',\n zIndex: 9999,\n pointerEvents: 'none',\n whiteSpace: 'pre-wrap',\n wordBreak: 'break-word',\n }}\n >\n {this.state.thinkingContent}\n </div>\n )}\n </Dialog>\n )}\n\n {isEdit && (\n <SkillOptimizeDialog\n visible={this.state.optimizeDialogVisible}\n skill={this.state.currentSkillData}\n fileTree={this.state.editModeFileTree}\n selectedFile={this.state.editModeSelectedFile}\n onClose={this.handleOptimizeDialogClose}\n onSuccess={this.handleOptimizeSuccess}\n locale={this.props.locale}\n history={this.props.history}\n />\n )}\n\n {/* Custom Tooltip for resource title - follows mouse position */}\n {this.state.tooltipVisible && this.state.tooltipResourceIndex !== null && (\n <div\n style={{\n position: 'fixed',\n left: `${this.state.tooltipPosition.x}px`,\n top: `${this.state.tooltipPosition.y - 10}px`,\n transform: 'translate(-50%, -100%)',\n background: '#333',\n color: '#fff',\n padding: '8px 12px',\n borderRadius: '4px',\n fontSize: '12px',\n boxShadow: '0 2px 8px rgba(0, 0, 0, 0.15)',\n zIndex: 9999,\n pointerEvents: 'none',\n whiteSpace: 'nowrap',\n }}\n >\n {this.getLocaleValue('clickToEdit', '点击进行编辑')}\n </div>\n )}\n </div>\n );\n }\n}\n\nexport default NewSkill;\n"}}}} |