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

30
web/src/api/skill.ts Normal file
View File

@@ -0,0 +1,30 @@
/*
* @Author: ZhaoYing
* @Date: 2026-02-05 10:28:44
* @Last Modified by: ZhaoYing
* @Last Modified time: 2026-02-05 10:28:44
*/
import { request } from '@/utils/request'
import type { SkillFormData } from '@/views/Skills/types'
// Get skill list
export const getSkillListUrl = '/skills'
export const getSkillList = (data?: any) => {
return request.get(getSkillListUrl, data)
}
// Get skill details
export const getSkillDetail = (skill_id: string, data?: any) => {
return request.get(`/skills/${skill_id}`, data)
}
// Create skill
export const createSkill = (data: SkillFormData) => {
return request.post('/skills', data)
}
// Update skill
export const updateSkill = (skill_id: string, data: SkillFormData) => {
return request.put(`/skills/${skill_id}`, data)
}
// Delete skill
export const deleteSkill = (skill_id: string) => {
return request.delete(`/skills/${skill_id}`)
}

View File

@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="16px" height="16px" viewBox="0 0 16 16" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<title>技能点</title>
<g id="页面-1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<g id="首页-超级管理员" transform="translate(-46, -456)" fill-rule="nonzero" stroke="#5B6167" stroke-width="1.1">
<g id="技能点" transform="translate(46, 456)">
<g id="编组-21" transform="translate(1, 1)">
<path d="M7,0.55 C8.78106,0.55 10.3935572,1.27197468 11.5607913,2.43920873 C12.7280253,3.60644278 13.45,5.21894 13.45,7 C13.45,8.78106 12.7280253,10.3935572 11.5607913,11.5607913 C10.3935572,12.7280253 8.78106,13.45 7,13.45 C5.21894,13.45 3.60644278,12.7280253 2.43920873,11.5607913 C1.27197468,10.3935572 0.55,8.78106 0.55,7 C0.55,5.21894 1.27197468,3.60644278 2.43920873,2.43920873 C3.60644278,1.27197468 5.21894,0.55 7,0.55 Z" id="路径"></path>
<polygon id="路径" stroke-linejoin="round" points="6.66706155 11 6.44510258 7.80088942 4 7.80088942 7.55505896 3 7.55505896 6.20059295 10 6.4285479"></polygon>
</g>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="16px" height="16px" viewBox="0 0 16 16" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<title>技能点备份</title>
<g id="页面-1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<g id="首页-超级管理员" transform="translate(-22, -456)" fill-rule="nonzero" stroke="#212332" stroke-width="1.1">
<g id="技能点备份" transform="translate(22, 456)">
<g id="编组-21" transform="translate(1, 1)">
<path d="M7,0.55 C8.78106,0.55 10.3935572,1.27197468 11.5607913,2.43920873 C12.7280253,3.60644278 13.45,5.21894 13.45,7 C13.45,8.78106 12.7280253,10.3935572 11.5607913,11.5607913 C10.3935572,12.7280253 8.78106,13.45 7,13.45 C5.21894,13.45 3.60644278,12.7280253 2.43920873,11.5607913 C1.27197468,10.3935572 0.55,8.78106 0.55,7 C0.55,5.21894 1.27197468,3.60644278 2.43920873,2.43920873 C3.60644278,1.27197468 5.21894,0.55 7,0.55 Z" id="路径"></path>
<polygon id="路径" stroke-linejoin="round" points="6.66706155 11 6.44510258 7.80088942 4 7.80088942 7.55505896 3 7.55505896 6.20059295 10 6.4285479"></polygon>
</g>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@@ -0,0 +1,45 @@
/*
* @Author: ZhaoYing
* @Date: 2026-02-02 15:12:42
* @Last Modified by: ZhaoYing
* @Last Modified time: 2026-02-04 14:06:28
*/
/**
* BasicLayout Component
*
* A minimal layout wrapper that provides:
* - User information initialization
* - Storage type initialization
* - Simple container for child routes without navigation UI
*
* Used for pages that don't require sidebar/header (e.g., login, public pages).
*
* @component
*/
import { Outlet } from 'react-router-dom';
import { useEffect, type FC } from 'react';
import { useUser } from '@/store/user';
/**
* Basic layout component for pages without navigation UI.
* Fetches user info and storage type on mount, then renders child routes.
*/
const BasicLayout: FC = () => {
const { getUserInfo } = useUser();
// Fetch user information and storage type on component mount
useEffect(() => {
getUserInfo();
}, [getUserInfo]);
return (
<div className="rb:relative rb:h-full rb:w-full">
{/* Render child routes without additional UI */}
<Outlet />
</div>
)
};
export default BasicLayout;

View File

@@ -1,8 +1,8 @@
/*
* @Author: ZhaoYing
* @Date: 2026-02-02 15:21:14
* @Last Modified by: ZhaoYing
* @Last Modified time: 2026-02-02 15:21:14
* @Last Modified by: ZhaoYing
* @Last Modified time: 2026-02-04 13:49:05
*/
/**
* RbCard Component
@@ -18,7 +18,7 @@
*/
import { type FC, type ReactNode } from 'react'
import { Card, Tooltip } from 'antd';
import { Card, Tooltip, Flex } from 'antd';
import clsx from 'clsx';
/** Props interface for RbCard component */
@@ -51,6 +51,7 @@ interface RbCardProps {
className?: string;
/** Click handler */
onClick?: () => void;
variant?: 'borderL';
}
/** Custom card component with flexible styling and header options */
@@ -68,6 +69,7 @@ const RbCard: FC<RbCardProps> = ({
bgColor = '#FBFDFF',
height = 'auto',
className,
variant,
...props
}) => {
/** Calculate body padding based on header type and avatar presence */
@@ -82,7 +84,45 @@ const RbCard: FC<RbCardProps> = ({
: (headerType === 'border' && !avatarUrl && !avatar) || headerType === 'borderBL'
? 'rb:p-[16px_16px_20px_16px]!'
: ''
if (variant === 'borderL') {
return (
<div
className="rb:p-[12px_16px] rb:rounded-lg rb:shadow-[inset_4px_0px_0px_0px_#155EEF] rb:border rb:border-[#DFE4ED]"
>
<Flex justify="space-between" className={`rb:mb-3! ${headerClassName || ''}`}>
<Flex vertical gap={4}>
<div className="rb:font-medium rb:leading-5.5">
{typeof title === 'function' ? title() : title ?
<div className="rb:flex rb:items-center">
{avatarUrl
? <img src={avatarUrl} className="rb:mr-3.25 rb:w-12 rb:h-12 rb:rounded-lg" />
: avatar ? avatar : null
}
<div className={
clsx(
{
'rb:max-w-full': !avatarUrl && !avatar,
'rb:max-w-[calc(100%-60px)]': avatarUrl || avatar,
}
)
}>
<div className="rb:w-full rb:text-ellipsis rb:overflow-hidden rb:whitespace-nowrap">{title}</div>
{subTitle && <div className="rb:text-[#5B6167] rb:text-[12px]">{subTitle}</div>}
</div>
</div> : null
}
</div>
{subTitle && <div className="rb:text-[12px] rb:text-[#5B6167] rb:font-regular rb:leading-4">{subTitle}</div>}
</Flex>
{extra}
</Flex>
<div className={bodyClassNames ? bodyClassNames : children ? bodyClassName : 'rb:p-0!'}>
{children}
</div>
</div>
)
}
return (
<Card
{...props}
@@ -126,7 +166,7 @@ const RbCard: FC<RbCardProps> = ({
},
headerClassName,
),
body: bodyClassNames ? bodyClassNames : children ? bodyClassName : 'rb:p-[0]!',
body: bodyClassNames ? bodyClassNames : children ? bodyClassName : 'rb:p-0!',
}}
style={{
background: bgColor,

View File

@@ -1,8 +1,8 @@
/*
* @Author: ZhaoYing
* @Date: 2026-02-02 15:25:31
* @Last Modified by: ZhaoYing
* @Last Modified time: 2026-02-02 15:25:31
* @Last Modified by: ZhaoYing
* @Last Modified time: 2026-02-04 13:49:16
*/
/**
* SiderMenu Component
@@ -67,6 +67,8 @@ import ontologyIcon from '@/assets/images/menu/ontology.svg'
import ontologyActiveIcon from '@/assets/images/menu/ontology_active.svg'
import promptIcon from '@/assets/images/menu/prompt.svg'
import promptActiveIcon from '@/assets/images/menu/prompt_active.svg'
import skillsIcon from '@/assets/images/menu/skills.svg'
import skillsActiveIcon from '@/assets/images/menu/skills_active.svg'
/** Icon path mapping table for menu items (normal and active states) */
const iconPathMap: Record<string, string> = {
@@ -102,6 +104,8 @@ const iconPathMap: Record<string, string> = {
'ontologyActive': ontologyActiveIcon,
'prompt': promptIcon,
'promptActive': promptActiveIcon,
'skills': skillsIcon,
'skillsActive': skillsActiveIcon,
};
const { Sider } = Layout;

View File

@@ -115,6 +115,7 @@ export const en = {
spaceConfig: 'Space Configuration',
ontology: 'Ontology Engineering',
prompt: 'Prompt Engineering',
skills: 'Skill Library',
},
dashboard: {
total_models: 'Available Models',
@@ -1248,6 +1249,22 @@ export const en = {
daily_new_users: 'Daily New Users',
daily_api_calls: 'Daily API Calls',
daily_tokens: 'Token Consumption',
skill: 'Skill Configuration',
skillTitle: 'Configure Agent skills and matching modes',
skillHelp: 'Help Center',
addSkill: 'Add Skill',
dynamicBindingSkill: 'Dynamic Optional Skills',
dynamicBindingSkill_subTitle: 'Skill pool that Agent can automatically match based on tasks',
dynamicBindingSkill_empty: 'No dynamic skills configured yet, click the button above to add or enable "Allow access to all skills"',
chooseSkill: 'Choose Skill',
allSkill: 'Allow access to all skills',
allSkillIntro: 'Access to all skills enabled, Agent will automatically match optimal skills based on tasks',
executeProcessPreview: 'Execution Process Preview',
receiveTask: 'Receive Task',
analyTask: 'Analyze Task Intent',
dynamicMatchSkill: 'Dynamic Match Skill',
executeTask: 'Execute Task',
},
userMemory: {
userMemory: 'User Memory',
@@ -2494,5 +2511,28 @@ Memory Bear: After the rebellion, regional warlordism intensified for several re
initialInput: 'Original Input',
saveTitle: 'Title',
},
skills: {
searchPlaceholder: 'Search skills',
create: 'Add Skill',
mainfest: 'Define Encapsulation Container',
name: 'Skill Name',
description: 'Brief Description',
descriptionPlaceholder: 'Describe the intent and purpose of the skill...',
keywords: 'Keywords',
promptConfiguration: 'Inject Experience Logic',
aiPrompt: 'AI Experience Refinement',
prompt_type: 'System Instructions / Expert Knowledge',
promptPlaceholder: 'Enter system instructions or expert knowledge...',
save: 'Save',
AIPromptAssistant: 'AI Experience Refinement',
model: 'Model',
promptChatEmpty: 'No conversation content available',
you: 'You',
ai: 'AI Assistant',
promptChatPlaceholder: 'Describe your requirements...',
conversationOptimizationPrompt: 'Refined Content',
apply: 'Apply',
tools: 'Tools',
},
},
};

View File

@@ -114,6 +114,7 @@ export const zh = {
spaceConfig: '空间配置',
ontology: '本体工程',
prompt: '提示词工程',
skills: '技能库',
},
knowledgeBase: {
home: '首页',
@@ -667,6 +668,22 @@ export const zh = {
daily_new_users: '新增用户数',
daily_api_calls: '调用次数',
daily_tokens: 'Token消耗',
skill: '技能配置',
skillTitle: '配置 Agent 可使用的技能及匹配模式',
skillHelp: '帮助中心',
addSkill: '添加技能',
dynamicBindingSkill: '动态可选技能',
dynamicBindingSkill_subTitle: 'Agent 可根据任务自动匹配的技能池',
dynamicBindingSkill_empty: '暂未配置动态技能,点击上方按钮添加或开启"允许访问所有技能"',
chooseSkill: '选择技能',
allSkill: '允许访问所有技能',
allSkillIntro: '已开启访问所有技能Agent 将根据任务自动匹配最优技能',
executeProcessPreview: '执行流程预览',
receiveTask: '收到任务',
analyTask: '分析任务意图',
dynamicMatchSkill: '动态匹配技能',
executeTask: '执行任务',
},
role: {
roleManagement: '角色管理',
@@ -2583,5 +2600,28 @@ export const zh = {
initialInput: '原始输入',
saveTitle: '标题',
},
skills: {
searchPlaceholder: '搜索技能',
create: '添加技能',
mainfest: '定义封装容器',
name: '技能名称',
description: '简要描述',
descriptionPlaceholder: '描述技能的意图和用途...',
keywords: '关键词',
promptConfiguration: '注入经验逻辑',
aiPrompt: 'AI 经验提炼',
prompt_type: '系统指令 / 专家知识',
promptPlaceholder: '输入系统指令或专家知识...',
save: '保存',
AIPromptAssistant: 'AI 经验提炼',
model: '模型',
promptChatEmpty: '目前没有对话内容',
you: '你',
ai: 'AI 助手',
promptChatPlaceholder: '描述你的需求...',
conversationOptimizationPrompt: '提炼内容',
apply: '应用',
tools: '工具',
},
},
}

View File

@@ -1,8 +1,8 @@
/*
* @Author: ZhaoYing
* @Date: 2026-02-02 16:33:11
* @Last Modified by: ZhaoYing
* @Last Modified time: 2026-02-02 16:33:11
* @Last Modified by: ZhaoYing
* @Last Modified time: 2026-02-04 14:06:45
*/
/**
* Route Configuration
@@ -52,6 +52,7 @@ const componentMap: Record<string, LazyExoticComponent<ComponentType<object>>> =
BasicLayout: lazy(() => import('@/components/Layout/BasicLayout')),
LoginLayout: lazy(() => import('@/components/Layout/LoginLayout')),
NoAuthLayout: lazy(() => import('@/components/Layout/NoAuthLayout')),
BasicAuthLayout: lazy(() => import('@/components/Layout/BasicAuthLayout')),
/** View components */
Index: lazy(() => import('@/views/Index')),
Home: lazy(() => import('@/views/Home')),
@@ -88,6 +89,8 @@ const componentMap: Record<string, LazyExoticComponent<ComponentType<object>>> =
Ontology: lazy(() => import('@/views/Ontology')),
OntologyDetail: lazy(() => import('@/views/Ontology/pages/Detail')),
Prompt: lazy(() => import('@/views/Prompt')),
Skills: lazy(() => import('@/views/Skills')),
SkillConfig: lazy(() => import('@/views/Skills/pages/SkillConfig')),
Login: lazy(() => import('@/views/Login')),
InviteRegister: lazy(() => import('@/views/InviteRegister')),
NoPermission: lazy(() => import('@/views/NoPermission')),

View File

@@ -10,6 +10,7 @@
{ "path": "/pricing", "element": "Pricing" },
{ "path": "/order-pay", "element": "OrderPayment" },
{ "path": "/orders", "element": "OrderHistory" },
{ "path": "/skills", "element": "Skills" },
{ "path": "/no-permission", "element": "NoPermission" }
]
},
@@ -50,6 +51,13 @@
{ "path": "/ontology/:id", "element": "OntologyDetail" }
]
},
{
"element": "BasicAuthLayout",
"children": [
{ "path": "/skills/add", "element": "SkillConfig" },
{ "path": "/skills/config/:id", "element": "SkillConfig" }
]
},
{
"element": "NoAuthLayout",
"children": [

View File

@@ -52,6 +52,21 @@
"sort": 0,
"subs": []
},
{
"id": 8,
"parent": 0,
"code": "skills",
"label": "技能库",
"i18nKey": "menu.skills",
"path": "/skills",
"enable": true,
"display": true,
"level": 1,
"sort": 0,
"icon": null,
"iconActive": null,
"subs": null
},
{
"id": 6,
"parent": 0,

View File

@@ -1,8 +1,8 @@
/*
* @Author: ZhaoYing
* @Date: 2026-02-03 16:29:21
* @Last Modified by: ZhaoYing
* @Last Modified time: 2026-02-03 16:29:21
* @Last Modified by: ZhaoYing
* @Last Modified time: 2026-02-04 20:16:45
*/
import { type FC, type ReactNode, useEffect, useRef, useState, forwardRef, useImperativeHandle } from 'react';
import clsx from 'clsx'
@@ -39,6 +39,8 @@ import aiPrompt from '@/assets/images/application/aiPrompt.png'
import AiPromptModal from './components/AiPromptModal'
import ToolList from './components/ToolList/ToolList'
import ChatVariableConfigModal from './components/ChatVariableConfigModal';
import SkillList from './components/Skill'
import type { Skill } from '@/views/Skills/types'
/**
* Description wrapper component
@@ -164,6 +166,8 @@ const Agent = forwardRef<AgentRef>((_props, ref) => {
setLoading(true)
getApplicationConfig(id as string).then(res => {
const response = res as Config
const { skills } = response
let allSkills = Array.isArray(skills?.skill_ids) ? skills?.skill_ids.map(vo => ({ id: vo })) : []
let allTools = Array.isArray(response.tools) ? response.tools : []
const memoryContent = response.memory?.memory_content
const parsedMemoryContent = memoryContent === null || memoryContent === ''
@@ -175,6 +179,10 @@ const Agent = forwardRef<AgentRef>((_props, ref) => {
memory: {
...response.memory,
memory_content: parsedMemoryContent
},
skills: {
...skills,
skill_ids: allSkills
}
})
setData({
@@ -252,7 +260,7 @@ const Agent = forwardRef<AgentRef>((_props, ref) => {
*/
const handleSave = (flag = true) => {
if (!isSave || !data) return Promise.resolve()
const { memory, knowledge_retrieval, tools, ...rest } = values
const { memory, knowledge_retrieval, tools, skills, ...rest } = values
const { knowledge_bases = [], ...knowledgeRest } = knowledge_retrieval || {}
const { memory_content } = memory || {}
// Get other necessary properties of memory from original data
@@ -278,7 +286,11 @@ const Agent = forwardRef<AgentRef>((_props, ref) => {
tool_id: vo.tool_id,
operation: vo.operation,
enabled: vo.enabled
}))
})),
skills: {
...skills,
skill_ids: (skills?.skill_ids as Skill[])?.map(vo => vo.id)
}
}
return new Promise((resolve, reject) => {
@@ -438,12 +450,16 @@ const Agent = forwardRef<AgentRef>((_props, ref) => {
</Space>
</Card>
<Form.Item name="variables">
<Form.Item name="variables" noStyle>
<VariableList />
</Form.Item>
<Form.Item name="skills" noStyle>
<SkillList />
</Form.Item>
{/* Tool Configuration */}
<Form.Item name="tools">
<Form.Item name="tools" noStyle>
<ToolList />
</Form.Item>
</Space>

View File

@@ -1,8 +1,8 @@
/*
* @Author: ZhaoYing
* @Date: 2026-02-03 16:26:44
* @Last Modified by: ZhaoYing
* @Last Modified time: 2026-02-03 16:26:44
* @Last Modified by: ZhaoYing
* @Last Modified time: 2026-02-05 10:31:12
*/
/**
* AI Prompt Assistant Modal
@@ -38,7 +38,8 @@ interface AiPromptModalProps {
/** Callback to refresh prompt with optimized value */
refresh: (value: string) => void;
/** Default model to pre-select */
defaultModel: ModelListItem | null;
defaultModel?: ModelListItem | null;
source?: 'app' | 'skills'
}
/**
@@ -48,6 +49,7 @@ interface AiPromptModalProps {
const AiPromptModal = forwardRef<AiPromptModalRef, AiPromptModalProps>(({
refresh,
defaultModel,
source = 'application'
}, ref) => {
const { t } = useTranslation();
const { message } = App.useApp()
@@ -92,11 +94,11 @@ const AiPromptModal = forwardRef<AiPromptModalRef, AiPromptModalProps>(({
const handleSend = () => {
if (!promptSession) return
if (!values.model_id) {
message.warning(t('common.selectPlaceholder', { title: t('application.model') }))
message.warning(t('common.selectPlaceholder', { title: t(`${source}.model`) }))
return
}
if (!values.message) {
message.warning(t('application.promptChatPlaceholder'))
message.warning(t(`${source}.promptChatPlaceholder`))
return
}
const messageContent = values.message
@@ -144,15 +146,10 @@ const AiPromptModal = forwardRef<AiPromptModalRef, AiPromptModalProps>(({
}
})
};
updatePromptMessages(promptSession, values, handleStreamMessage)
// .then(res => {
// const response = res as { prompt: string; desc: string; variables: string[] }
// form.setFieldsValue({ current_prompt: response.prompt })
// setChatList(prev => {
// return [...prev, { role: 'assistant', content: response.desc }]
// })
// setVariables(response.variables)
// })
updatePromptMessages(promptSession, {
...values,
skill: source === 'skills'
}, handleStreamMessage)
.finally(() => {
setLoading(false)
})
@@ -192,7 +189,7 @@ const AiPromptModal = forwardRef<AiPromptModalRef, AiPromptModalProps>(({
console.log(values)
return (
<RbModal
title={t('application.AIPromptAssistant')}
title={t(`${source}.AIPromptAssistant`)}
open={visible}
onCancel={handleClose}
footer={null}
@@ -202,7 +199,7 @@ const AiPromptModal = forwardRef<AiPromptModalRef, AiPromptModalProps>(({
<div className="rb:grid rb:grid-cols-2 rb:border-t rb:border-t-[#EBEBEB]">
<div className="rb:border-r rb:border-r-[#EBEBEB] rb:pr-6 rb:pt-3">
<Form.Item
label={t('application.model')}
label={t(`${source}.model`)}
name="model_id"
rules={[{ required: true, message: t('common.pleaseSelect') }]}
>
@@ -219,18 +216,18 @@ const AiPromptModal = forwardRef<AiPromptModalRef, AiPromptModalProps>(({
<ChatContent
classNames="rb:h-100.5 rb:px-[16px] rb:py-[20px] rb:bg-[#FBFDFF] rb:border rb:border-[#DFE4ED] rb:rounded-[8px]"
contentClassNames="rb:max-w-[260px]!"
empty={<Empty url={ConversationEmptyIcon} title={t('application.promptChatEmpty')} isNeedSubTitle={false} size={[240, 200]} className="rb:h-full" />}
empty={<Empty url={ConversationEmptyIcon} title={t(`${source}.promptChatEmpty`)} isNeedSubTitle={false} size={[240, 200]} className="rb:h-full" />}
data={chatList || []}
streamLoading={false}
labelPosition="top"
labelFormat={(item) => item.role === 'user' ? t('application.you') : t('application.ai')}
labelFormat={(item) => item.role === 'user' ? t(`${source}.you`) : t(`${source}.ai`)}
/>
<div className="rb:flex rb:items-center rb:gap-2.5 rb:py-4">
<Form.Item name="message" className="rb:mb-0!" style={{ width: 'calc(100% - 54px)' }}>
<Input
className="rb:h-11 rb:shadow-[0px_2px_8px_0px_rgba(33,35,50,0.1)]"
placeholder={t('application.promptChatPlaceholder')}
placeholder={t(`${source}.promptChatPlaceholder`)}
onPressEnter={handleSend}
/>
</Form.Item>
@@ -242,12 +239,12 @@ const AiPromptModal = forwardRef<AiPromptModalRef, AiPromptModalProps>(({
<div className="rb:pl-6 rb:pt-3">
<Row>
<Col span={12}>
<Form.Item label={t('application.conversationOptimizationPrompt')}></Form.Item>
</Col>
<Col span={12} className="rb:text-right">
<Button onClick={handleAdd}>+ {t('application.addVariable')}</Button>
<Col span={source === 'application' ? 12 : 24}>
<Form.Item label={t(`${source}.conversationOptimizationPrompt`)}></Form.Item>
</Col>
{source === 'application' && <Col span={12} className="rb:text-right">
<Button onClick={handleAdd}>+ {t(`${source}.addVariable`)}</Button>
</Col>}
</Row>
<Form.Item name="current_prompt">
<Editor
@@ -258,7 +255,7 @@ const AiPromptModal = forwardRef<AiPromptModalRef, AiPromptModalProps>(({
</Form.Item>
<div className="rb:grid rb:grid-cols-2 rb:gap-4 rb:mt-6">
<Button block disabled={!values?.current_prompt} onClick={handleCopy}>{t('common.copy')}</Button>
<Button type="primary" block disabled={!values?.current_prompt} onClick={handleApply}>{t('application.apply')}</Button>
<Button type="primary" block disabled={!values?.current_prompt} onClick={handleApply}>{t(`${source}.apply`)}</Button>
</div>
</div>
</div>

View File

@@ -1,8 +1,8 @@
/*
* @Author: ZhaoYing
* @Date: 2026-02-03 16:27:31
* @Last Modified by: ZhaoYing
* @Last Modified time: 2026-02-03 16:27:31
* @Last Modified by: ZhaoYing
* @Last Modified time: 2026-02-04 13:50:47
*/
import { type FC, type ReactNode } from 'react'
@@ -20,6 +20,7 @@ interface CardProps {
children: ReactNode;
/** Extra content in header */
extra?: ReactNode;
variant?: 'borderL';
}
/**
@@ -31,14 +32,16 @@ const Card: FC<CardProps> = ({
subTitle,
children,
extra,
variant
}) => {
return (
<RbCard
title={title}
subTitle={subTitle}
extra={extra}
variant={variant}
headerType="borderL"
headerClassName="rb:before:bg-[#155EEF]! rb:before:h-[19px]"
headerClassName={variant ? '' : "rb:before:bg-[#155EEF]! rb:before:h-[19px]"}
>
{children}
</RbCard>

View File

@@ -0,0 +1,216 @@
/*
* @Author: ZhaoYing
* @Date: 2026-02-05 10:45:08
* @Last Modified by: ZhaoYing
* @Last Modified time: 2026-02-05 10:45:08
*/
import { forwardRef, useEffect, useImperativeHandle, useState } from 'react';
import { Space, List, Flex, Tooltip } from 'antd';
import { useTranslation } from 'react-i18next';
import clsx from 'clsx'
import type { SkillModalRef } from './types'
import type { Skill } from '@/views/Skills/types'
import RbModal from '@/components/RbModal'
import { getSkillList } from '@/api/skill'
import SearchInput from '@/components/SearchInput'
import Empty from '@/components/Empty'
/**
* Props for SkillListModal Component
*/
interface SkillModalProps {
/** Callback function to refresh parent component with selected skills */
refresh: (rows: Skill[], type: 'skill') => void;
/** Array of already selected skills to exclude from selection */
selectedList: Skill[];
}
/**
* Skill List Modal Component
*
* A modal dialog for selecting skills from a searchable list.
* Features:
* - Search functionality to filter skills by keywords
* - Grid layout displaying skill cards with icons and descriptions
* - Multi-select capability with visual feedback
* - Excludes already selected skills from the list
* - Displays skill name initial as avatar when no icon available
*
* @param refresh - Callback to update parent with selected skills
* @param selectedList - Currently selected skills to filter out
* @param ref - Forwarded ref exposing handleOpen and handleClose methods
*/
const SkillListModal = forwardRef<SkillModalRef, SkillModalProps>(({
refresh,
selectedList
}, ref) => {
const { t } = useTranslation();
const [visible, setVisible] = useState(false);
const [list, setList] = useState<Skill[]>([])
const [filterList, setFilterList] = useState<Skill[]>([])
const [query, setQuery] = useState<{keywords?: string}>({})
const [selectedIds, setSelectedIds] = useState<string[]>([])
const [selectedRows, setSelectedRows] = useState<Skill[]>([])
/**
* Closes the modal and resets all state
* Clears search query, selected IDs, and selected rows
*/
const handleClose = () => {
setVisible(false);
setQuery({})
setSelectedIds([])
setSelectedRows([])
};
/**
* Opens the modal and resets selection state
* Clears any previous selections when reopening
*/
const handleOpen = () => {
setVisible(true);
setQuery({})
setSelectedIds([])
setSelectedRows([])
};
/**
* Effect: Fetch skill list when modal is visible or search query changes
*/
useEffect(() => {
if (visible) {
getList()
}
}, [query.keywords, visible])
/**
* Fetches the skill list from API with current search parameters
* Sorts by creation date in descending order
*/
const getList = () => {
getSkillList({
...query,
pagesize: 100,
})
.then(res => {
const response = res as { items: Skill[] }
setList(response.items || [])
})
}
/**
* Saves selected skills and closes modal
* Passes selected skills to parent component via refresh callback
*/
const handleSave = () => {
refresh(selectedRows, 'skill')
setVisible(false);
}
/**
* Exposes methods to parent component via ref
* Allows parent to programmatically open/close the modal
*/
useImperativeHandle(ref, () => ({
handleOpen,
handleClose
}));
/**
* Handles search input changes and resets selection
* Clears current selections when search query changes
* @param value - Search keyword
*/
const handleSearch = (value?: string) => {
setQuery({keywords: value})
setSelectedIds([])
setSelectedRows([])
}
/**
* Toggles skill selection state
* Adds skill to selection if not selected, removes if already selected
* @param item - Skill to select/deselect
*/
const handleSelect = (item: Skill) => {
const index = selectedIds.indexOf(item.id)
if (index === -1) {
// Add to selection
setSelectedIds([...selectedIds, item.id])
setSelectedRows([...selectedRows, item])
} else {
// Remove from selection
setSelectedIds(selectedIds.filter(id => id !== item.id))
setSelectedRows(selectedRows.filter(row => row.id !== item.id))
}
}
/**
* Effect: Filter out already selected skills from the display list
* Updates filterList whenever list or selectedList changes
*/
useEffect(() => {
if (list.length && selectedList.length) {
const unSelectedList = list.filter(item => selectedList.findIndex(vo => vo.id === item.id) < 0)
setFilterList([...unSelectedList])
} else if (list.length) {
setFilterList([...list])
}
}, [list, selectedList])
return (
<>
<RbModal
title={t('application.chooseSkill')}
open={visible}
onCancel={handleClose}
okText={t('common.save')}
onOk={handleSave}
width={1000}
>
<Space size={24} direction="vertical" className="rb:w-full">
{/* Search input for filtering skills */}
<SearchInput
placeholder={t('skills.searchPlaceholder')}
onSearch={handleSearch}
style={{ width: '100%' }}
/>
{/* Display empty state or skill grid */}
{filterList.length === 0
? <Empty />
: <List
grid={{ gutter: 16, column: 2 }}
dataSource={filterList}
renderItem={(item: Skill) => (
<List.Item>
{/* Skill card with selection state styling */}
<div key={item.id} className={clsx("rb:border rb:rounded-lg rb:p-[17px_16px] rb:cursor-pointer rb:hover:bg-[#F0F3F8]", {
"rb:bg-[rgba(21,94,239,0.06)] rb:border-[#155EEF] rb:text-[#155EEF]": selectedIds.includes(item.id),
"rb:border-[#DFE4ED] rb:text-[#212332]": !selectedIds.includes(item.id),
})} onClick={() => handleSelect(item)}>
<Flex>
{/* Skill avatar showing first letter of name */}
<div className="rb:w-12 rb:h-12 rb:rounded-lg rb:mr-3.25 rb:bg-[#155eef] rb:flex rb:items-center rb:justify-center rb:text-[28px] rb:text-[#ffffff]">
{item.name[0]}
</div>
{/* Skill name and description */}
<div className="rb:flex-1 rb:max-w-[calc(100%-60px)]">
<div className="rb:font-medium rb:wrap-break-word rb:line-clamp-1">{item.name}</div>
<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>
</div>
</Flex>
</div>
</List.Item>
)}
/>
}
</Space>
</RbModal>
</>
);
});
export default SkillListModal;

View File

@@ -0,0 +1,161 @@
/*
* @Author: ZhaoYing
* @Date: 2026-02-05 10:43:03
* @Last Modified by: ZhaoYing
* @Last Modified time: 2026-02-05 10:43:03
*/
import { type FC, useEffect, useRef } from 'react'
import { useTranslation } from 'react-i18next'
import { Space, Button, Form, Flex, Tooltip, Checkbox } from 'antd'
import { CloseOutlined, CheckCircleFilled } from '@ant-design/icons'
import type {
SkillConfigForm,
SkillModalRef,
} from './types'
import Empty from '@/components/Empty'
import SkillListModal from './SkillListModal'
import Card from '../Card'
import RbAlert from '@/components/RbAlert'
import type { Skill } from '@/views/Skills/types'
/**
* Props for SkillsItem Component
*/
interface SkillsItemProps {
/** Title displayed in the card header */
title: string;
/** Form field path for nested form structure */
parentName: string[];
/** Whether to show "Allow all skills" checkbox option */
supportAll?: boolean;
/** Message displayed when no skills are configured */
emptyTitle: string;
}
/**
* Skills Item Component
*
* Displays and manages a list of configured skills with the following features:
* - Add new skills via modal selection
* - Optional "Allow all skills" toggle
* - Display skill cards with icons and descriptions
* - Remove individual skills
* - Empty state when no skills configured
* - Alert message when all skills are enabled
*
* @param title - Card header title
* @param parentName - Form field path array for nested structure
* @param supportAll - Enable "Allow all skills" checkbox
* @param emptyTitle - Empty state message
*/
const SkillsItem: FC<SkillsItemProps> = ({
title,
parentName,
supportAll = false,
emptyTitle
}) => {
const { t } = useTranslation()
const skillModalRef = useRef<SkillModalRef>(null)
const form = Form.useFormInstance()
const allSkills = Form.useWatch([...parentName, 'all_skills'], form)
/**
* Opens the skill selection modal
*/
const handleAddSkill = () => {
skillModalRef.current?.handleOpen()
}
/**
* Updates form with newly selected skills
* Merges new selections with existing skills, avoiding duplicates
* @param values - Array of newly selected skills
*/
const refresh = (values: SkillConfigForm['skill_ids']) => {
const currentSkills = form.getFieldValue([...parentName, 'skill_ids']) || []
const newSkills = values?.filter(v => !currentSkills?.find((s: Skill) => s.id === (v as Skill).id)) || []
form.setFieldValue([...parentName, 'skill_ids'], [...currentSkills, ...newSkills])
}
/**
* Effect: Clear skill list when "all skills" is enabled
*/
useEffect(() => {
form.setFieldValue([...parentName, 'skill_ids'], [])
}, [allSkills])
return (
<Card
title={title}
extra={
<Space>
{/* "Allow all skills" checkbox - only shown if supportAll is true */}
{supportAll && <Form.Item name={[...parentName, 'all_skills']} valuePropName="checked" noStyle>
<Checkbox>{t('application.allSkill')}</Checkbox>
</Form.Item>}
{/* Add skill button - disabled when all skills are enabled */}
<Button disabled={allSkills} style={{ padding: '0 8px', height: '24px' }} onClick={handleAddSkill}>+ {t('application.addSkill')}</Button>
</Space>
}
variant="borderL"
>
{/* Show alert when all skills enabled, otherwise show skill list */}
{allSkills
? <RbAlert color="green" icon={<CheckCircleFilled />}>{t('application.allSkillIntro')}</RbAlert>
: <>
<Form.List name={[...parentName, 'skill_ids']}>
{(fields, { remove }) => (
fields.length === 0 ? (
/* Empty state when no skills configured */
<Empty size={88} subTitle={emptyTitle} />
) : (
/* Render list of configured skills */
<Space direction="vertical" size={12} className="rb:w-full">
{fields.map((field) => {
const skill = form.getFieldValue([...parentName, 'skill_ids', field.name])
return (
/* Individual skill card */
<div key={field.key} className="rb:flex rb:items-center rb:justify-between rb:p-[12px_16px] rb:bg-[#FBFDFF] rb:border rb:border-[#DFE4ED] rb:rounded-lg">
<Flex className="rb:flex-1 rb:max-w-[calc(100%-186px)]!">
{/* Skill icon or fallback initial */}
{skill.icon
? <img src={skill.icon} className="rb:mr-3.25 rb:size-12 rb:rounded-lg" />
: <div className="rb:w-12 rb:h-12 rb:rounded-lg rb:mr-3.25 rb:bg-[#155eef] rb:flex rb:items-center rb:justify-center rb:text-[28px] rb:text-[#ffffff]">
{skill.name?.[0]}
</div>
}
{/* Skill name and description */}
<div className="rb:flex-1 rb:max-w-[calc(100%-60px)]">
<div className="rb:font-medium rb:wrap-break-word rb:line-clamp-1">{skill.name}</div>
<Tooltip title={skill.desciption}>
<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">{skill.desciption}</div>
</Tooltip>
</div>
</Flex>
<Space size={16} align="center">
{/* Remove skill button */}
<CloseOutlined
className="rb:cursor-pointer rb:text-[#5B6167] hover:rb:text-[#155EEF]"
onClick={() => remove(field.name)}
/>
</Space>
</div>
)
})}
</Space>
)
)}
</Form.List>
</>
}
{/* Skill selection modal */}
<SkillListModal
ref={skillModalRef}
selectedList={form.getFieldValue([...parentName, 'skill_ids']) || []}
refresh={refresh}
/>
</Card>
)
}
export default SkillsItem

View File

@@ -0,0 +1,151 @@
/*
* @Author: ZhaoYing
* @Date: 2026-02-05 10:42:56
* @Last Modified by: ZhaoYing
* @Last Modified time: 2026-02-05 10:42:56
*/
import { useEffect, type FC } from 'react'
import { useTranslation } from 'react-i18next'
import { Space, Button, Switch, Form, Flex } from 'antd'
import type {
SkillConfigForm,
} from './types'
import Card from '../Card'
import SkillsItem from './SkillsItem'
import { getSkillList } from '@/api/skill'
import type { Skill } from '@/views/Skills/types'
/**
* Process flow steps for skill execution
* Defines the sequential steps in the skill execution workflow
*/
const processObj = [
'receiveTask', // Step 1: Receive task
'analyTask', // Step 2: Analyze task intent
'dynamicMatchSkill', // Step 3: Dynamically match appropriate skill
'executeTask' // Step 4: Execute the task
]
/**
* Skill Configuration Component
*
* Main component for managing agent skill configuration including:
* - Enabling/disabling skill functionality
* - Configuring dynamic skill binding
* - Displaying skill execution process flow
* - Managing skill selection and assignment
*
* @param value - Current skill configuration values
* @param onChange - Callback function when configuration changes
*/
const Skill: FC<{value?: SkillConfigForm; onChange?: (config: SkillConfigForm) => void}> = () => {
const { t } = useTranslation()
const form = Form.useFormInstance()
const skillConfig = Form.useWatch(['skills'], form)
/**
* Effect: Fetch and populate skill details for skills without names
* Ensures all selected skills have complete information by fetching from API
*/
useEffect(() => {
const { skill_ids = [] } = skillConfig || {}
// Filter skills that don't have name property
const skillsWithoutName = skill_ids.filter((vo: Skill) => !vo.name)
if (skillsWithoutName.length > 0) {
getSkillList({ page: 1, pagesize: 100 })
.then(res => {
const response = res as { items: Skill[] }
// Create a map of skill ID to skill object for quick lookup
const map = response.items.reduce((prev: any, curr: any) => {
prev[curr.id] = curr
return prev
}, {})
// Merge fetched skill details with existing skill IDs
const newSkillIds = skill_ids.map((vo: any) => {
return {
...vo,
...map[vo.id]
}
})
form.setFieldValue(['skills', 'skill_ids'], newSkillIds)
})
}
}, [skillConfig?.skill_ids])
/**
* Effect: Reset skill configuration when skill functionality is disabled
* Clears all_skills flag and skill_ids array when enabled is set to false
*/
useEffect(() => {
if (!skillConfig?.enabled) {
form.setFieldValue('skills', {
...skillConfig,
all_skills: false,
skill_ids: []
})
}
}, [skillConfig?.enabled])
return (
<Card
title={<>
{t('application.skill')}
<span className="rb:font-regular rb:text-[12px] rb:text-[#5B6167]"> ({t('application.skillTitle')})</span>
</>}
extra={
<Space>
{/* Help button for skill configuration guidance */}
<Button style={{ padding: '0 8px', height: '24px' }}>{t('application.skillHelp')}</Button>
{/* Toggle switch to enable/disable skill functionality */}
<Form.Item
valuePropName="checked"
name={['skills', 'enabled']}
noStyle
>
<Switch />
</Form.Item>
</Space>
}
>
{/* Render skill configuration UI only when enabled */}
{skillConfig?.enabled && <Flex vertical gap={12}>
{/* Dynamic skill binding configuration section */}
<Form.Item noStyle>
<SkillsItem
title={t('application.dynamicBindingSkill')}
parentName={['skills']}
supportAll={true}
emptyTitle={t('application.dynamicBindingSkill_empty')}
/>
</Form.Item>
{/* Execution process preview card showing workflow steps */}
<Card
title={t('application.executeProcessPreview')}
variant="borderL"
>
<Flex align="center" gap={8} className="rb:text-[12px]">
{/* Render each step in the process flow with numbered badges */}
{processObj.map((key, index) => (<>
<Flex align="center" gap={8} className="rb:border rb:border-[#DFE4ED] rb:rounded-lg rb:bg-white rb:p-2!">
{/* Step number badge */}
<div className="rb:size-4 rb:rounded-full rb:bg-[#155EEF] rb:text-white rb:flex rb:items-center rb:justify-center rb:font-medium">{index + 1}</div>
{/* Step label */}
<span>{t(`application.${key}`)}</span>
</Flex>
{/* Arrow separator between steps (except after last step) */}
{index !== processObj.length - 1 && <div className="rb:text-[#8C9196]"></div>}
</>))}
</Flex>
</Card>
</Flex>}
</Card>
)
}
export default Skill

View File

@@ -0,0 +1,53 @@
/*
* @Author: ZhaoYing
* @Date: 2026-02-05 10:43:09
* @Last Modified by: ZhaoYing
* @Last Modified time: 2026-02-05 10:43:09
*/
import type { Skill } from '@/views/Skills/types'
/**
* Skill Configuration Form Data Structure
* Used to manage skill configuration settings in the application
*/
export interface SkillConfigForm {
/** Whether skill configuration is enabled */
enabled?: boolean;
/** Whether all skills are accessible */
all_skills?: boolean;
/** Array of selected skill IDs or full skill objects */
skill_ids?: Skill[] | string[];
}
/**
* Skill Binding Mode Types
* Defines different strategies for skill assignment
*/
export type SkillMode = 'staticBinding' | 'dynamicBinding' | 'mixedMode'
/**
* Skill Configuration Modal Reference Interface
* Provides methods to control the skill configuration modal
*/
export interface SkillConfigModalRef {
/** Opens the modal with optional initial data */
handleOpen: (data: SkillConfigForm) => void;
}
/**
* Skill Global Configuration Modal Reference Interface
* Provides methods to control the global skill configuration modal
*/
export interface SkillGlobalConfigModalRef {
/** Opens the global configuration modal */
handleOpen: () => void;
}
/**
* Skill Selection Modal Reference Interface
* Provides methods to control the skill selection modal
*/
export interface SkillModalRef {
/** Opens the modal with optional skill configuration array */
handleOpen: (config?: SkillConfigForm[]) => void;
}

View File

@@ -131,7 +131,7 @@ const ToolList: FC<{ value?: ToolOption[]; onChange?: (config: ToolOption[]) =>
<Card
title={t('application.toolConfiguration')}
extra={
<Button style={{ padding: '0 8px', height: '24px' }} onClick={handleAddTool}>+{t('application.addTool')}</Button>
<Button style={{ padding: '0 8px', height: '24px' }} onClick={handleAddTool}>+ {t('application.addTool')}</Button>
}
>
{toolList.length === 0

View File

@@ -1,8 +1,8 @@
/*
* @Author: ZhaoYing
* @Date: 2026-02-03 16:29:49
* @Last Modified by: ZhaoYing
* @Last Modified time: 2026-02-03 16:29:49
* @Last Modified by: ZhaoYing
* @Last Modified time: 2026-02-05 10:31:10
*/
import type { KnowledgeConfig } from './components/Knowledge/types'
import type { Variable } from './components/VariableList/types'
@@ -10,6 +10,7 @@ import type { ToolOption } from './components/ToolList/types'
import type { ChatItem } from '@/components/Chat/types'
import type { GraphRef } from '@/views/Workflow/types';
import type { ApiKey } from '@/views/ApiKeyManagement/types'
import type { SkillConfigForm } from './components/Skill/types'
/**
* Model configuration parameters
@@ -75,6 +76,7 @@ export interface Config extends MultiAgentConfig {
created_at: number;
/** Last update timestamp */
updated_at: number;
skills?: SkillConfigForm | null;
}
/**
@@ -353,6 +355,7 @@ export interface AiPromptForm {
message?: string;
/** Current prompt */
current_prompt?: string;
skill?: boolean;
}
/**

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;
}