diff --git a/web/pages/construct/skills/index.tsx b/web/pages/construct/skills/index.tsx new file mode 100644 index 000000000..241e9bf16 --- /dev/null +++ b/web/pages/construct/skills/index.tsx @@ -0,0 +1,504 @@ +import MarkDownContext from '@/new-components/common/MarkdownContext'; +import ConstructLayout from '@/new-components/layout/Construct'; +import axios from '@/utils/ctx-axios'; +import { + CloseOutlined, + CloudUploadOutlined, + DownOutlined, + EllipsisOutlined, + InboxOutlined, + PlusOutlined, + SearchOutlined, +} from '@ant-design/icons'; +import { useRequest } from 'ahooks'; +import { + Button, + Dropdown, + Input, + MenuProps, + Modal, + Spin, + Switch, + Tag, + Tooltip, + Tree, + Upload, + UploadFile, + UploadProps, + message, +} from 'antd'; +import type { DataNode } from 'antd/es/tree'; +import { useCallback, useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; + +interface SkillItem { + id: string; + name: string; + description: string; + version: string; + author: string; + skill_type: string; + tags: string[]; + type: string; + file_path: string; +} + +interface TreeNode { + title: string; + key: string; + children?: TreeNode[]; +} + +interface SkillDetail { + skill_name: string; + file_path: string; + root_dir: string; + tree: TreeNode; + frontmatter: string; + instructions: string; + raw_content: string; + content_type: string; + metadata: Record; +} + +function getSkillEmoji(skillType: string): string { + switch (skillType) { + case 'data_analysis': + return '\u{1F4CA}'; + case 'coding': + return '\u{1F4BB}'; + case 'web_search': + return '\u{1F50D}'; + case 'knowledge_qa': + return '\u{1F4DA}'; + case 'chat': + return '\u{1F4AC}'; + default: + return '\u26A1'; + } +} + +function toAntTreeData(node: TreeNode): DataNode { + const result: DataNode = { + title: node.title, + key: node.key, + }; + if (node.children && node.children.length > 0) { + result.children = node.children.map(toAntTreeData); + } + return result; +} + +function Skills() { + const { t } = useTranslation(); + const [searchValue, setSearchValue] = useState(''); + const [officialOnly, setOfficialOnly] = useState(false); + const [enabledMap, setEnabledMap] = useState>({}); + const [detailOpen, setDetailOpen] = useState(false); + const [selectedSkill, setSelectedSkill] = useState(null); + const [uploadOpen, setUploadOpen] = useState(false); + const [uploadFileList, setUploadFileList] = useState([]); + const [uploading, setUploading] = useState(false); + + const { + data: skillsList = [], + loading: listLoading, + refresh: refreshList, + } = useRequest(async () => { + try { + const response = await axios.get(`${process.env.API_BASE_URL ?? ''}/api/v1/skills/list`); + if (response?.success && Array.isArray(response.data)) { + return response.data as SkillItem[]; + } + return []; + } catch (err) { + console.error('[Skills] Failed to fetch list:', err); + return []; + } + }); + + const { + data: skillDetail, + loading: detailLoading, + run: fetchDetail, + mutate: setSkillDetail, + } = useRequest( + async (skillName: string, filePath: string) => { + try { + const response = await axios.get(`${process.env.API_BASE_URL ?? ''}/api/v1/skills/detail`, { + params: { skill_name: skillName, file_path: filePath }, + }); + if (response?.success && response.data) { + return response.data as SkillDetail; + } + return null; + } catch (err) { + console.error('[Skills] Failed to fetch detail:', err); + return null; + } + }, + { manual: true }, + ); + + const filteredSkills = useMemo(() => { + let list = skillsList; + if (officialOnly) { + list = list.filter(s => s.type === 'official'); + } + if (searchValue.trim()) { + const q = searchValue.trim().toLowerCase(); + list = list.filter(s => s.name.toLowerCase().includes(q) || s.description.toLowerCase().includes(q)); + } + return list; + }, [skillsList, officialOnly, searchValue]); + + const handleCardClick = useCallback( + (skill: SkillItem) => { + setSelectedSkill(skill); + setDetailOpen(true); + fetchDetail(skill.name, skill.file_path); + }, + [fetchDetail], + ); + + const handleCloseDetail = useCallback(() => { + setDetailOpen(false); + setSelectedSkill(null); + setSkillDetail(null); + }, [setSkillDetail]); + + const handleToggle = useCallback((skillId: string, checked: boolean) => { + setEnabledMap(prev => ({ ...prev, [skillId]: checked })); + }, []); + + const handleTreeSelect = useCallback( + (selectedKeys: React.Key[]) => { + if (!selectedSkill || selectedKeys.length === 0) return; + const key = selectedKeys[0] as string; + // Only fetch if it looks like a file (no children in the tree) + if (skillDetail?.tree) { + const findNode = (node: TreeNode, target: string): TreeNode | null => { + if (node.key === target) return node; + if (node.children) { + for (const child of node.children) { + const found = findNode(child, target); + if (found) return found; + } + } + return null; + }; + const targetNode = findNode(skillDetail.tree, key); + if (targetNode && (!targetNode.children || targetNode.children.length === 0)) { + const rootDir = skillDetail.root_dir || ''; + const filePath = rootDir ? `${rootDir}/${key}` : key; + fetchDetail(selectedSkill.name, filePath); + } + } + }, + [selectedSkill, skillDetail, fetchDetail], + ); + + const treeData = useMemo(() => { + if (!skillDetail?.tree) return []; + return [toAntTreeData(skillDetail.tree)]; + }, [skillDetail]); + + const handleUpload = useCallback(async () => { + if (uploadFileList.length === 0) return; + setUploading(true); + let successCount = 0; + for (const f of uploadFileList) { + const rawFile = f.originFileObj; + if (!rawFile) { + message.error(`${f.name}: \u6587\u4EF6\u65E0\u6548`); + continue; + } + const formData = new FormData(); + formData.append('file', rawFile, rawFile.name); + try { + const res = await fetch(`${process.env.API_BASE_URL ?? ''}/api/v1/skills/upload`, { + method: 'POST', + body: formData, + }); + const json = await res.json(); + if (json?.success) { + successCount++; + } else { + message.error(`${f.name}: ${json?.err_msg || '\u4E0A\u4F20\u5931\u8D25'}`); + } + } catch (err) { + console.error('[Skills] Upload error:', err); + message.error(`${f.name}: \u4E0A\u4F20\u5931\u8D25`); + } + } + setUploading(false); + if (successCount > 0) { + message.success(`\u6210\u529F\u4E0A\u4F20 ${successCount} \u4E2A\u6280\u80FD`); + setUploadOpen(false); + setUploadFileList([]); + refreshList(); + } + }, [uploadFileList, refreshList]); + + const uploadProps: UploadProps = { + multiple: true, + accept: '.zip,.skill,.md,.yaml,.yml,.json', + fileList: uploadFileList, + beforeUpload: file => { + const entry: UploadFile = { + uid: file.uid || `${Date.now()}-${file.name}`, + name: file.name, + size: file.size, + type: file.type, + originFileObj: file as any, + }; + setUploadFileList(prev => [...prev, entry]); + return false; // prevent auto upload + }, + onRemove: file => { + setUploadFileList(prev => prev.filter(f => f.uid !== file.uid)); + }, + }; + + const addMenuItems: MenuProps['items'] = [ + { + key: 'upload', + icon: , + label: ( +
+
{'\u4E0A\u4F20\u6280\u80FD'}
+
{'\u4E0A\u4F20 .zip\u3001.skill \u6216\u6587\u4EF6\u5939'}
+
+ ), + onClick: () => setUploadOpen(true), + }, + ]; + + return ( + + +
+ {/* Header */} +
+

{t('skills') || '\u6280\u80FD'}

+

+ { + '\u4E3A\u60A8\u7684\u667A\u80FD\u4F53\u63D0\u4F9B\u9884\u5C01\u88C5\u4E14\u53EF\u91CD\u590D\u7684\u6700\u4F73\u5B9E\u8DF5\u4E0E\u5DE5\u5177' + } +

+
+ + {/* Controls bar */} +
+ } + placeholder={'\u641C\u7D22\u6280\u80FD'} + value={searchValue} + onChange={e => setSearchValue(e.target.value)} + allowClear + className='w-[240px] h-[36px] backdrop-filter backdrop-blur-lg bg-white bg-opacity-30 border border-gray-200 rounded-lg dark:border-[#6f7f95] dark:bg-[#6f7f95] dark:bg-opacity-60' + /> + setOfficialOnly(!officialOnly)} + > + {officialOnly ? '\u2713 ' : ''} + {'\u5B98\u65B9'} + +
+ + + +
+ + {/* Skill cards grid */} + {filteredSkills.length === 0 && !listLoading ? ( +
+ {'\u6682\u65E0\u6280\u80FD'} +
+ ) : ( +
+ {filteredSkills.map(skill => ( +
handleCardClick(skill)} + > + {/* Toggle switch */} +
e.stopPropagation()}> + handleToggle(skill.id || skill.name, checked)} + /> +
+ + {/* Name + emoji */} +
+ {getSkillEmoji(skill.skill_type)} + + + {skill.name} + + +
+ + {/* Description */} +

+ {skill.description || '\u6682\u65E0\u63CF\u8FF0'} +

+ + {/* Footer */} +
+
+ {skill.type === 'official' ? ( + + {'\u5B98\u65B9'} + + ) : ( + @{skill.author || 'unknown'} + )} + {'\u00B7'} + {'\u66F4\u65B0\u4E8E 2026\u5E742\u67086\u65E5'} +
+
e.stopPropagation()} + > + +
+
+
+ ))} +
+ )} +
+ + + {/* Detail Modal */} + + {/* Modal Header */} +
+
+ + {selectedSkill?.name || ''}.skill + + + {'\u6280\u80FD'} + +
+
+ + + +
+
+ + {/* Modal Body */} + +
+ {/* Left sidebar — file tree */} +
+ {treeData.length > 0 ? ( + + ) : ( +
{'\u52A0\u8F7D\u4E2D...'}
+ )} +
+ + {/* Right content area */} +
+ {skillDetail ? ( + <> + {/* YAML frontmatter block */} + {skillDetail.frontmatter && ( +
+
+ YAML +
+
+                        {skillDetail.frontmatter}
+                      
+
+ )} + + {/* Markdown content */} + {skillDetail.instructions && ( +
+ {skillDetail.instructions} +
+ )} + + {/* Fallback: raw content if no parsed sections */} + {!skillDetail.frontmatter && !skillDetail.instructions && skillDetail.raw_content && ( +
+ {skillDetail.raw_content} +
+ )} + + ) : ( + !detailLoading && ( +
+ {'\u9009\u62E9\u6587\u4EF6\u67E5\u770B\u5185\u5BB9'} +
+ ) + )} +
+
+
+
+ {/* Upload Modal */} + { + setUploadOpen(false); + setUploadFileList([]); + }} + title={'\u4E0A\u4F20\u6280\u80FD'} + okText={'\u4E0A\u4F20'} + cancelText={'\u53D6\u6D88'} + onOk={handleUpload} + confirmLoading={uploading} + okButtonProps={{ disabled: uploadFileList.length === 0 }} + destroyOnClose + > +
+ +

+ +

+

{'\u70B9\u51FB\u6216\u62D6\u62FD\u6587\u4EF6\u5230\u6B64\u5904'}

+

+ {'\u652F\u6301 .zip\u3001.skill\u3001.md\u3001.yaml\u3001.json \u683C\u5F0F'} +

+
+
+
+ + ); +} + +export default Skills;