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