mirror of
https://github.com/csunny/DB-GPT.git
synced 2026-07-16 17:15:22 +00:00
feat:dbgpt skill
This commit is contained in:
504
web/pages/construct/skills/index.tsx
Normal file
504
web/pages/construct/skills/index.tsx
Normal 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;
|
||||
Reference in New Issue
Block a user