feat(web): add skills menu

This commit is contained in:
zhaoying
2026-02-05 10:53:16 +08:00
parent 161da723b9
commit 60231ec88d
26 changed files with 1722 additions and 47 deletions

View File

@@ -0,0 +1,192 @@
/*
* @Author: ZhaoYing
* @Date: 2026-02-03 16:26:03
* @Last Modified by: ZhaoYing
* @Last Modified time: 2026-02-05 10:51:22
*/
/**
* Tool List Component
* Manages tool configurations for the application
* Allows adding, removing, and enabling/disabling tools
*/
import { type FC, useRef, useState, useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import { Space, Button, List } from 'antd'
import Card from '@/views/ApplicationConfig/components/Card'
import type {
ToolModalRef,
ToolOption
} from './types'
import Empty from '@/components/Empty'
import ToolModal from './ToolModal'
import { getToolMethods, getToolDetail } from '@/api/tools'
/**
* Tool List Component Props
*/
interface ToolListProps {
/** Current tool configurations */
value?: ToolOption[];
/** Callback when tools change */
onChange?: (config: ToolOption[]) => void;
}
/**
* Tool list management component
* @param value - Current tool configurations
* @param onChange - Callback when tools change
*/
const ToolList: FC<ToolListProps> = ({value, onChange}) => {
const { t } = useTranslation()
const toolModalRef = useRef<ToolModalRef>(null)
const [toolList, setToolList] = useState<ToolOption[]>([])
useEffect(() => {
if (value) {
const processedData = value.map(async (item) => {
// Skip if tool already has label (already processed)
if (!item.label && item.tool_id) {
try {
// Fetch tool details and methods in parallel
const [toolDetail, methods] = await Promise.all([
getToolDetail(item.tool_id),
getToolMethods(item.tool_id)
])
// Process based on tool type
switch ((toolDetail as any).tool_type) {
case 'mcp':
// MCP tools: Find method by operation name
const mcpFilterItem = (methods as any[]).find(vo => vo.name === item.operation)
return {
...item,
label: mcpFilterItem?.description,
method_id: mcpFilterItem?.method_id,
value: mcpFilterItem?.name,
description: mcpFilterItem?.description,
parameters: mcpFilterItem?.parameters
}
break
case 'builtin':
// Builtin tools: Handle single or multiple methods
if ((methods as any[]).length > 1) {
const builtinFilterItem = (methods as any[]).find(vo => vo.name === item.operation)
return {
...item,
label: builtinFilterItem?.description,
method_id: builtinFilterItem?.method_id,
value: builtinFilterItem?.name,
description: builtinFilterItem?.description,
parameters: builtinFilterItem?.parameters
}
}
// Single method: Use first method
return {
...item,
label: (methods as any[])[0]?.description,
method_id: (methods as any[])[0]?.method_id,
value: (methods as any[])[0]?.name,
description: (methods as any[])[0]?.description,
parameters: (methods as any[])[0]?.parameters
}
break
default:
// Custom tools: Find method by method_id
const customFilterItem = (methods as any[]).find(vo => vo.method_id === item.operation)
return {
...item,
label: customFilterItem?.name,
method_id: customFilterItem?.method_id,
value: customFilterItem?.name,
description: customFilterItem?.description,
parameters: customFilterItem?.parameters
}
}
} catch (error) {
// Return original item if fetch fails
return item
}
}
return item
})
// Wait for all tools to be processed
Promise.all(processedData).then(setToolList)
}
}, [value])
/**
* Opens the tool selection modal
*/
const handleAddTool = () => {
toolModalRef.current?.handleOpen()
}
/**
* Adds a new tool to the list
* Updates both local state and parent component
* @param tool - Tool to add
*/
const updateTools = (tool: ToolOption) => {
const list = [...toolList, tool]
setToolList(list)
onChange && onChange(list)
}
/**
* Removes a tool from the list by index
* Updates both local state and parent component
* @param index - Index of tool to remove
*/
const handleDeleteTool = (index: number) => {
const list = toolList.filter((_item, idx) => idx !== index)
setToolList([...list])
onChange && onChange(list)
}
return (
<Card
title={t('application.toolConfiguration')}
extra={
<Button style={{ padding: '0 8px', height: '24px' }} onClick={handleAddTool}>
+ {t('application.addTool')}
</Button>
}
>
{/* Show empty state or tool list */}
{toolList.length === 0
? <Empty size={88} />
:
<List
grid={{ gutter: 12, column: 1 }}
dataSource={toolList}
renderItem={(item, index) => (
<List.Item>
{/* Tool card with delete button */}
<div key={index} className="rb:flex rb:items-center rb:justify-between rb:p-[12px_16px] rb:bg-[#FBFDFF] rb:border rb:border-[#DFE4ED] rb:rounded-lg">
{/* Tool label/description */}
<div className="rb:font-medium rb:leading-4">
{item.label}
</div>
<Space size={12}>
{/* Delete button with hover effect */}
<div
className="rb:w-6 rb:h-6 rb:cursor-pointer rb:bg-[url('@/assets/images/deleteBorder.svg')] rb:hover:bg-[url('@/assets/images/deleteBg.svg')]"
onClick={() => handleDeleteTool(index)}
></div>
</Space>
</div>
</List.Item>
)}
/>
}
{/* Tool selection modal */}
<ToolModal
ref={toolModalRef}
refresh={updateTools}
/>
</Card>
)
}
export default ToolList

View File

@@ -0,0 +1,209 @@
/*
* @Author: ZhaoYing
* @Date: 2026-02-03 16:26:06
* @Last Modified by: ZhaoYing
* @Last Modified time: 2026-02-05 10:52:13
*/
/**
* Tool Selection Modal
* Provides cascading selection of tools by type, tool, and method
* Supports MCP, builtin, and custom tool types
*/
import { forwardRef, useImperativeHandle, useState } from 'react';
import { Form, Cascader, type CascaderProps } from 'antd';
import { useTranslation } from 'react-i18next';
import type { ToolModalRef, ToolOption } from './types'
import RbModal from '@/components/RbModal'
import { getToolMethods, getTools } from '@/api/tools'
import type { ToolType, ToolItem } from '@/views/ToolManagement/types'
const FormItem = Form.Item;
/**
* Component Props
*/
interface ToolModalProps {
/** Callback to add selected tool to parent component */
refresh: (tool: ToolOption) => void;
}
/**
* Modal for selecting tools
*/
const ToolModal = forwardRef<ToolModalRef, ToolModalProps>(({
refresh,
}, ref) => {
const { t } = useTranslation();
const [visible, setVisible] = useState(false);
const [form] = Form.useForm();
const [loading, setLoading] = useState(false)
/**
* Initial cascader options for tool types
* Level 1: Tool type selection (MCP, Builtin, Custom)
*/
const [optionList, setOptionList] = useState<ToolOption[]>([
{ value: 'mcp', label: t('tool.mcp'), isLeaf: false },
{ value: 'builtin', label: t('tool.inner'), isLeaf: false },
{ value: 'custom', label: t('tool.custom'), isLeaf: false },
])
/**
* Stores the complete selection path
* [0] = Tool type, [1] = Specific tool, [2] = Tool method
*/
const [selectdTools, setSelectedTools] = useState<ToolOption[]>([])
/**
* Closes the modal and resets all state
* Clears form, loading state, and selections
*/
const handleClose = () => {
setVisible(false);
form.resetFields();
setLoading(false)
setSelectedTools([])
};
/**
* Opens the modal and resets state
* Clears any previous selections
*/
const handleOpen = () => {
setVisible(true);
form.resetFields();
setSelectedTools([])
};
/**
* Saves the selected tool and closes modal
*/
const handleSave = () => {
form.validateFields().then(() => {
setLoading(false)
let operation: any = undefined
// Determine operation based on tool type
if (selectdTools[0].value === 'mcp' ||
(selectdTools[0].value === 'builtin' &&
selectdTools[1]?.children &&
selectdTools[1].children.length > 1)) {
// MCP or builtin with multiple methods: use method name
operation = selectdTools[2].value
} else if (selectdTools[0].value === 'custom') {
// Custom tools: use method_id
operation = selectdTools[2].method_id
}
// Construct tool object
const tool = {
...selectdTools[2],
// Custom tools use label, others use description
label: selectdTools[0].value === 'custom' ? selectdTools[2].label : selectdTools[2].description,
tool_id: selectdTools[1].value as string,
enabled: true
}
// Add operation if determined
if (operation) {
tool.operation = operation
}
refresh(tool)
handleClose()
})
}
/**
* Dynamically loads cascader options based on selection
*/
const loadData = (selectedOptions: ToolOption[]) => {
const targetOption = selectedOptions[selectedOptions.length - 1];
if (selectedOptions.length === 1) {
// Level 1 selected: Load tools of this type
getTools({ tool_type: targetOption.value as ToolType })
.then(res => {
const response = res as ToolItem[]
targetOption.children = response.map((vo: any) => {
return {
value: vo.id,
label: vo.name,
isLeaf: response.length === 0,
}
})
setOptionList([...optionList])
})
} else {
// Level 2 selected: Load methods for this tool
getToolMethods(targetOption.value as string)
.then(res => {
const response = res as Array<{ method_id: string; name: string }>
targetOption.children = response.map((vo: any) => {
return {
value: vo.name,
label: vo.name,
description: vo.description,
isLeaf: true,
method_id: vo.method_id,
parameters: vo.parameters
}
})
setOptionList([...optionList])
})
}
};
/**
* Handles cascader selection change
*/
const handleChange: CascaderProps<ToolOption>['onChange'] = (_value, selectedOptions) => {
console.log('selectedOptions', selectedOptions)
setSelectedTools(selectedOptions)
}
/**
* Exposes methods to parent component via ref
*/
useImperativeHandle(ref, () => ({
handleOpen,
handleClose
}));
return (
<RbModal
title={t(`application.addTool`)}
open={visible}
onCancel={handleClose}
okText={t('common.save')}
onOk={handleSave}
confirmLoading={loading}
>
<Form
form={form}
layout="vertical"
>
<FormItem
name="agent_id"
label={t('application.tool')}
rules={[
{ required: true, message: t('common.pleaseSelect') },
]}
>
{/* Three-level cascading selector */}
<Cascader
placeholder={t('common.pleaseSelect')}
options={optionList}
loadData={loadData}
onChange={handleChange}
changeOnSelect={false}
/>
</FormItem>
</Form>
</RbModal>
);
});
export default ToolModal;

View File

@@ -0,0 +1,67 @@
/*
* @Author: ZhaoYing
* @Date: 2026-02-03 16:26:10
* @Last Modified by: ZhaoYing
* @Last Modified time: 2026-02-03 16:26:10
*/
/**
* Type definitions for tool configuration in application settings
*/
/**
* Tool option for cascader selection
*/
export interface ToolOption {
/** Option value */
value?: string | number | null;
/** Display label */
label?: React.ReactNode;
/** Tool description */
description?: string;
/** Child options for nested selection */
children?: ToolOption[];
/** Whether this is a leaf node */
isLeaf?: boolean;
/** Method ID for API operations */
method_id?: string;
/** Operation name */
operation?: string;
/** Method parameters */
parameters?: Parameter[];
/** Tool ID */
tool_id?: string;
/** Whether tool is enabled */
enabled?: boolean;
}
/**
* Parameter definition for tool methods
*/
export interface Parameter {
/** Parameter name */
name: string;
/** Parameter data type */
type: string;
/** Parameter description */
description: string;
/** Whether parameter is required */
required: boolean;
/** Default value */
default: any;
/** Enum values if applicable */
enum: null | string[];
/** Minimum value for numeric types */
minimum: number;
/** Maximum value for numeric types */
maximum: number;
/** Regex pattern for validation */
pattern: null | string;
}
/**
* Modal ref for tool selection
*/
export interface ToolModalRef {
/** Open tool selection modal */
handleOpen: () => void;
}

View File

@@ -0,0 +1,85 @@
/*
* @Author: ZhaoYing
* @Date: 2026-02-05 10:43:49
* @Last Modified by: ZhaoYing
* @Last Modified time: 2026-02-05 10:43:49
*/
import React, { useRef } from 'react';
import { Button, Tooltip } from 'antd';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import type { Skill } from './types'
import RbCard from '@/components/RbCard/Card'
import { getSkillListUrl } from '@/api/skill'
import PageScrollList, { type PageScrollListRef } from '@/components/PageScrollList'
/**
* Skills List Page Component
*
* Main page for displaying and managing skills.
* Features:
* - Grid layout of skill cards
* - Infinite scroll pagination
* - Create new skills
* - Navigate to skill configuration
* - Display skill name and description
*
* @returns Skills list page with grid of skill cards
*/
const Skills: React.FC = () => {
const { t } = useTranslation();
const navigate = useNavigate();
const scrollListRef = useRef<PageScrollListRef>(null)
/**
* Navigate to create new skill page
*/
const handleAdd = () => {
navigate('/skills/add')
}
/**
* Navigate to skill configuration page
* @param item - Skill to view/edit
*/
const handleView = (item: Skill) => {
navigate(`/skills/config/${item.id}`)
}
return (
<>
{/* Create skill button */}
<div className="rb:text-right rb:mb-4">
<Button type="primary" onClick={handleAdd}>
+ {t('skills.create')}
</Button>
</div>
{/* Infinite scroll skill list */}
<PageScrollList<Skill>
ref={scrollListRef}
url={getSkillListUrl}
query={{ is_active: true, type: 'service' }}
column={3}
renderItem={(item) => {
return (
<RbCard
title={item.name}
avatar={<div className="rb:w-12 rb:h-12 rb:text-center rb:font-semibold rb:text-[28px] rb:leading-12 rb:rounded-lg rb:text-[#FBFDFF] rb:bg-[#155EEF] rb:mr-2">{item.name[0]}</div>}
className="rb:cursor-pointer"
onClick={() => handleView(item)}
>
{/* Skill description with tooltip */}
<Tooltip title={item.description}>
<div className="rb:text-[#5B6167] rb:text-[12px] rb:leading-4.25 rb:font-regular rb:-mt-1 rb:wrap-break-word rb:line-clamp-1">{item.description}</div>
</Tooltip>
</RbCard>
);
}}
/>
</>
);
};
export default Skills;

View File

@@ -0,0 +1,220 @@
/*
* @Author: ZhaoYing
* @Date: 2026-02-05 10:44:08
* @Last Modified by: ZhaoYing
* @Last Modified time: 2026-02-05 10:52:29
*/
import { type FC, useEffect, useRef, useState } from "react";
import { useTranslation } from 'react-i18next';
import { useNavigate, useParams } from 'react-router-dom';
import { Form, Input, Button, Space, Select, App } from 'antd'
import Card from '@/views/ApplicationConfig/components/Card'
import aiPrompt from '@/assets/images/application/aiPrompt.png'
import AiPromptModal from '@/views/ApplicationConfig/components/AiPromptModal'
import ToolList from '../components/ToolList/ToolList'
import type { AiPromptModalRef } from '@/views/ApplicationConfig/types'
import exitIcon from '@/assets/images/knowledgeBase/exit.png';
import type { SkillFormData, Skill } from '../types'
import { getSkillDetail, createSkill, updateSkill } from '@/api/skill'
/**
* Skill Configuration Page Component
*
* Page for creating and editing skills with the following sections:
* - Manifest: Basic skill information (name, description, keywords)
* - Prompt Configuration: AI instructions with AI assistant
* - Tool Configuration: Associated tools for the skill
*
* Features:
* - Create new skills or edit existing ones
* - AI-powered prompt generation
* - Tool selection and management
* - Form validation
* - Auto-save functionality
*
* @returns Skill configuration form page
*/
const SkillConfig: FC = () => {
const { t } = useTranslation();
const navigate = useNavigate()
const { id } = useParams()
const { message } = App.useApp()
const [loading, setLoading] = useState(false)
const [form] = Form.useForm<SkillFormData>();
const [data, setData] = useState<Skill>({} as Skill)
/**
* Effect: Load skill data if editing existing skill
*/
useEffect(() => {
if (id) {
getConfig()
} else {
// Initialize default config for new skill
form.setFieldsValue({
config: {
enabled: false,
keywords: []
}
})
}
}, [id])
/**
* Fetch skill configuration from API
*/
const getConfig = () => {
if (!id) return
setLoading(true)
getSkillDetail(id)
.then(res => {
setData(res as Skill)
form.setFieldsValue(res as SkillFormData)
})
.finally(() => {
setLoading(false)
})
}
const aiPromptModalRef = useRef<AiPromptModalRef>(null)
/**
* Open AI prompt generation modal
*/
const handlePrompt = () => {
aiPromptModalRef.current?.handleOpen()
}
/**
* Update prompt field with AI-generated content
* @param value - Generated prompt text
*/
const updatePrompt = (value: string) => {
form.setFieldValue('prompt', value)
}
/**
* Navigate back to skills list
*/
const handleBack = () => {
navigate('/skills')
};
/**
* Save skill configuration
* Validates form and calls create or update API
*/
const handleSave = () => {
form.validateFields()
.then((values) => {
console.log(values, data)
const { tools, ...rest } = values;
// Format tools data for API
const formData = {
...rest,
tools: tools?.map((item: any) => ({
tool_id: item.tool_id,
operation: item.operation
}))
}
// Choose create or update based on whether id exists
const request = id ? updateSkill(id, formData) : createSkill(formData)
request
.then(() => {
message.success(id ? t('common.saveSuccess') : t('common.createSuccess'))
handleBack()
})
.finally(() => {
setLoading(false)
})
})
}
return (
<div className="rb:w-250 rb:mt-5 rb:pb-5 rb:mx-auto">
{/* Back button */}
<div className='rb:flex rb:items-center rb:gap-2 rb:mb-4 rb:cursor-pointer' onClick={handleBack}>
<img src={exitIcon} alt='exit' className='rb:w-4 rb:h-4' />
<span className='rb:text-gray-500 rb:text-sm'>{t('common.exit')}</span>
</div>
<Form form={form} layout="vertical">
<Space size={16} direction="vertical" className="rb:w-full">
{/* Manifest Section: Basic skill information */}
<Card title={t('skills.mainfest')}>
<Form.Item
name="name"
label={t('skills.name')}
rules={[{ required: true, message: t('common.inputPlaceholder', { title: t('skills.name') }) }]}
>
<Input placeholder={t('common.pleaseEnter')} />
</Form.Item>
<Form.Item
name="description"
label={t('skills.description')}
>
<Input.TextArea placeholder={t('skills.descriptionPlaceholder')} />
</Form.Item>
<Form.Item
name={['config', 'keywords']}
label={t('skills.keywords')}
rules={[{ required: true, message: t('common.inputPlaceholder', { title: t('skills.keywords') }) }]}
>
<Select
mode="tags"
placeholder={t('common.pleaseEnter')}
/>
</Form.Item>
</Card>
{/* Prompt Configuration Section: AI instructions */}
<Card title={t('skills.promptConfiguration')}
extra={
<Button style={{ padding: '0 8px', height: '24px' }} onClick={handlePrompt}>
<img src={aiPrompt} className="rb:size-5" />
{t('skills.aiPrompt')}
</Button>
}
>
<Form.Item
name="prompt"
className="rb:mb-0!"
>
<Input.TextArea
placeholder={t('skills.promptPlaceholder')}
styles={{
textarea: {
minHeight: '200px',
borderRadius: '8px'
},
}}
/>
</Form.Item>
</Card>
{/* Tool Configuration Section */}
<Form.Item
name="tools"
rules={[{ required: true, message: t('common.selectPlaceholder', { title: t('skills.tools') }) }]}
className="rb:mb-0!"
>
<ToolList />
</Form.Item>
{/* Save button */}
<Button type="primary" block disabled={loading} onClick={handleSave}>{t('skills.save')}</Button>
</Space>
</Form>
{/* AI Prompt Generation Modal */}
<AiPromptModal
ref={aiPromptModalRef}
refresh={updatePrompt}
source="skills"
/>
</div>
)
}
export default SkillConfig;

View File

@@ -0,0 +1,49 @@
/*
* @Author: ZhaoYing
* @Date: 2026-02-05 10:49:35
* @Last Modified by: ZhaoYing
* @Last Modified time: 2026-02-05 10:49:35
*/
/**
* Skill Form Data Structure
* Defines the data structure for creating and updating skills
*/
export interface SkillFormData {
/** Skill name */
name: string;
/** Skill description */
description: string;
/** Array of tools associated with this skill */
tools: Array<{
/** Tool identifier */
tool_id: string;
}>;
/** Skill configuration settings */
config: {
/** Keywords for skill matching and discovery */
keywords: string[];
/** Whether the skill is enabled */
enabled: boolean;
};
/** AI prompt/instructions for the skill */
prompt: string;
/** Whether the skill is active */
is_active: boolean;
/** Whether the skill is publicly accessible */
is_public: boolean;
}
/**
* Complete Skill Data Structure
* Extends SkillFormData with system-generated fields
*/
export interface Skill extends SkillFormData {
/** Unique skill identifier */
id: string;
/** Tenant/organization identifier */
tenant_id: string;
/** Creation timestamp */
created_at: number;
/** Last update timestamp */
updated_at: number;
}