{"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
\n {thinkingContentForIcon}\n \n \n {previewContent}\n \n )}\n {isCollapsed && hasMore && (\n \n )}\n