feat:dbgpt skill

This commit is contained in:
aries_ckt
2026-02-13 16:06:34 +08:00
parent 9842dcb06b
commit 688c604c71

View File

@@ -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<string, string>;
}
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<Record<string, boolean>>({});
const [detailOpen, setDetailOpen] = useState(false);
const [selectedSkill, setSelectedSkill] = useState<SkillItem | null>(null);
const [uploadOpen, setUploadOpen] = useState(false);
const [uploadFileList, setUploadFileList] = useState<UploadFile[]>([]);
const [uploading, setUploading] = useState(false);
const {
data: skillsList = [],
loading: listLoading,
refresh: refreshList,
} = useRequest<SkillItem[], []>(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<SkillDetail | null, [string, string]>(
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: <CloudUploadOutlined />,
label: (
<div>
<div className='font-medium'>{'\u4E0A\u4F20\u6280\u80FD'}</div>
<div className='text-xs text-gray-400'>{'\u4E0A\u4F20 .zip\u3001.skill \u6216\u6587\u4EF6\u5939'}</div>
</div>
),
onClick: () => setUploadOpen(true),
},
];
return (
<ConstructLayout>
<Spin spinning={listLoading}>
<div className='h-screen w-full p-4 md:p-6 overflow-y-auto'>
{/* Header */}
<div className='mb-6'>
<h1 className='text-2xl font-bold text-gray-900 dark:text-white mb-1'>{t('skills') || '\u6280\u80FD'}</h1>
<p className='text-sm text-gray-500 dark:text-gray-400'>
{
'\u4E3A\u60A8\u7684\u667A\u80FD\u4F53\u63D0\u4F9B\u9884\u5C01\u88C5\u4E14\u53EF\u91CD\u590D\u7684\u6700\u4F73\u5B9E\u8DF5\u4E0E\u5DE5\u5177'
}
</p>
</div>
{/* Controls bar */}
<div className='flex items-center gap-3 mb-6'>
<Input
prefix={<SearchOutlined className='text-gray-400' />}
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'
/>
<Tag
className='cursor-pointer select-none px-3 py-1 rounded-lg text-sm'
color={officialOnly ? 'blue' : undefined}
onClick={() => setOfficialOnly(!officialOnly)}
>
{officialOnly ? '\u2713 ' : ''}
{'\u5B98\u65B9'}
</Tag>
<div className='flex-1' />
<Dropdown menu={{ items: addMenuItems }} trigger={['click']}>
<Button className='border-none text-white bg-button-gradient flex items-center' icon={<PlusOutlined />}>
{'\u6DFB\u52A0'} <DownOutlined className='ml-1 text-[10px]' />
</Button>
</Dropdown>
</div>
{/* Skill cards grid */}
{filteredSkills.length === 0 && !listLoading ? (
<div className='flex items-center justify-center h-60 text-gray-400 dark:text-gray-500'>
{'\u6682\u65E0\u6280\u80FD'}
</div>
) : (
<div className='grid grid-cols-1 md:grid-cols-2 gap-4 pb-12'>
{filteredSkills.map(skill => (
<div
key={skill.id || skill.name}
className='backdrop-filter backdrop-blur-lg bg-white bg-opacity-70 border-2 border-white rounded-lg shadow p-5 cursor-pointer transition-all duration-200 hover:shadow-lg hover:border-blue-200 relative group dark:border-[#6f7f95] dark:bg-[#6f7f95] dark:bg-opacity-60'
onClick={() => handleCardClick(skill)}
>
{/* Toggle switch */}
<div className='absolute top-4 right-4 z-10' onClick={e => e.stopPropagation()}>
<Switch
size='small'
checked={enabledMap[skill.id || skill.name] ?? true}
onChange={checked => handleToggle(skill.id || skill.name, checked)}
/>
</div>
{/* Name + emoji */}
<div className='flex items-center gap-2 mb-2 pr-12'>
<span className='text-lg'>{getSkillEmoji(skill.skill_type)}</span>
<Tooltip title={skill.name}>
<span className='font-semibold text-base text-gray-900 dark:text-white line-clamp-1'>
{skill.name}
</span>
</Tooltip>
</div>
{/* Description */}
<p className='text-sm text-gray-500 dark:text-gray-400 line-clamp-2 min-h-[40px] mb-3'>
{skill.description || '\u6682\u65E0\u63CF\u8FF0'}
</p>
{/* Footer */}
<div className='flex items-center justify-between text-xs text-gray-400 dark:text-gray-500'>
<div className='flex items-center gap-2'>
{skill.type === 'official' ? (
<Tag color='blue' className='text-xs m-0'>
{'\u5B98\u65B9'}
</Tag>
) : (
<span>@{skill.author || 'unknown'}</span>
)}
<span>{'\u00B7'}</span>
<span>{'\u66F4\u65B0\u4E8E 2026\u5E742\u67086\u65E5'}</span>
</div>
<div
className='opacity-0 group-hover:opacity-100 transition-opacity'
onClick={e => e.stopPropagation()}
>
<EllipsisOutlined className='p-1 hover:bg-gray-100 dark:hover:bg-gray-700 rounded cursor-pointer' />
</div>
</div>
</div>
))}
</div>
)}
</div>
</Spin>
{/* Detail Modal */}
<Modal
open={detailOpen}
onCancel={handleCloseDetail}
footer={null}
width='80vw'
style={{ maxWidth: 1000, top: 40 }}
closable={false}
bodyStyle={{ padding: 0 }}
destroyOnClose
>
{/* Modal Header */}
<div className='flex items-center justify-between px-5 py-3 border-b border-gray-100 dark:border-gray-700'>
<div className='flex items-center gap-2'>
<span className='font-semibold text-base text-gray-900 dark:text-white'>
{selectedSkill?.name || ''}.skill
</span>
<Tag color='blue' className='text-xs'>
{'\u6280\u80FD'}
</Tag>
</div>
<div className='flex items-center gap-2'>
<Button type='default' size='small'>
{'\u8BD5\u7528\u4E00\u4E0B'}
</Button>
<EllipsisOutlined className='p-1 hover:bg-gray-100 dark:hover:bg-gray-700 rounded cursor-pointer' />
<CloseOutlined
className='p-1 hover:bg-gray-100 dark:hover:bg-gray-700 rounded cursor-pointer'
onClick={handleCloseDetail}
/>
</div>
</div>
{/* Modal Body */}
<Spin spinning={detailLoading}>
<div className='flex' style={{ minHeight: 480 }}>
{/* Left sidebar — file tree */}
<div className='w-[220px] border-r border-gray-100 dark:border-gray-700 p-3 overflow-y-auto bg-gray-50 dark:bg-[#2a2f38]'>
{treeData.length > 0 ? (
<Tree
showLine
defaultExpandAll
treeData={treeData}
onSelect={handleTreeSelect}
className='bg-transparent'
/>
) : (
<div className='text-gray-400 text-sm text-center mt-8'>{'\u52A0\u8F7D\u4E2D...'}</div>
)}
</div>
{/* Right content area */}
<div className='flex-1 overflow-y-auto p-6' style={{ maxHeight: 'calc(80vh - 100px)' }}>
{skillDetail ? (
<>
{/* YAML frontmatter block */}
{skillDetail.frontmatter && (
<div className='mb-6 rounded-lg bg-gray-50 dark:bg-[#2a2f38] border border-gray-200 dark:border-gray-700 overflow-hidden'>
<div className='flex items-center justify-between px-4 py-2 border-b border-gray-200 dark:border-gray-700'>
<Tag className='text-xs m-0'>YAML</Tag>
</div>
<pre className='p-4 m-0 text-sm font-mono text-gray-800 dark:text-gray-200 overflow-x-auto whitespace-pre-wrap'>
<code>{skillDetail.frontmatter}</code>
</pre>
</div>
)}
{/* Markdown content */}
{skillDetail.instructions && (
<div className='prose dark:prose-invert max-w-none'>
<MarkDownContext>{skillDetail.instructions}</MarkDownContext>
</div>
)}
{/* Fallback: raw content if no parsed sections */}
{!skillDetail.frontmatter && !skillDetail.instructions && skillDetail.raw_content && (
<div className='prose dark:prose-invert max-w-none'>
<MarkDownContext>{skillDetail.raw_content}</MarkDownContext>
</div>
)}
</>
) : (
!detailLoading && (
<div className='flex items-center justify-center h-full text-gray-400'>
{'\u9009\u62E9\u6587\u4EF6\u67E5\u770B\u5185\u5BB9'}
</div>
)
)}
</div>
</div>
</Spin>
</Modal>
{/* Upload Modal */}
<Modal
open={uploadOpen}
onCancel={() => {
setUploadOpen(false);
setUploadFileList([]);
}}
title={'\u4E0A\u4F20\u6280\u80FD'}
okText={'\u4E0A\u4F20'}
cancelText={'\u53D6\u6D88'}
onOk={handleUpload}
confirmLoading={uploading}
okButtonProps={{ disabled: uploadFileList.length === 0 }}
destroyOnClose
>
<div className='py-4'>
<Upload.Dragger {...uploadProps}>
<p className='ant-upload-drag-icon'>
<InboxOutlined />
</p>
<p className='text-base font-medium'>{'\u70B9\u51FB\u6216\u62D6\u62FD\u6587\u4EF6\u5230\u6B64\u5904'}</p>
<p className='text-sm text-gray-400 mt-1'>
{'\u652F\u6301 .zip\u3001.skill\u3001.md\u3001.yaml\u3001.json \u683C\u5F0F'}
</p>
</Upload.Dragger>
</div>
</Modal>
</ConstructLayout>
);
}
export default Skills;