Merge #68 into develop_web from feature/20251219_zy
feat(web): workflow’s Editor Variable support Tag * feature/20251219_zy: (4 commits) feat(web): add tool management fix(web): get the parent domain name adaptation IP fix(web): Conversation add initialValue feat(web): workflow’s Editor Variable support Tag Signed-off-by: zhaoying <zhaoying@redbearai.com> Merged-by: zhaoying <zhaoying@redbearai.com> CR-link: https://codeup.aliyun.com/redbearai/python/redbear-mem-open/change/68
This commit is contained in:
@@ -17,7 +17,11 @@
|
|||||||
"@dnd-kit/modifiers": "^9.0.0",
|
"@dnd-kit/modifiers": "^9.0.0",
|
||||||
"@dnd-kit/sortable": "^10.0.0",
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
"@dnd-kit/utilities": "^3.2.2",
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
|
"@lexical/code": "^0.39.0",
|
||||||
|
"@lexical/link": "^0.39.0",
|
||||||
|
"@lexical/list": "^0.39.0",
|
||||||
"@lexical/react": "^0.39.0",
|
"@lexical/react": "^0.39.0",
|
||||||
|
"@lexical/rich-text": "^0.39.0",
|
||||||
"antd": "^5.27.4",
|
"antd": "^5.27.4",
|
||||||
"axios": "^1.12.2",
|
"axios": "^1.12.2",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
|||||||
31
web/src/api/tools.ts
Normal file
31
web/src/api/tools.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import { request } from '@/utils/request'
|
||||||
|
import type { Query, CustomToolItem, ExecuteData, MCPToolItem, InnerToolItem } from '@/views/ToolManagement/types'
|
||||||
|
|
||||||
|
// 工具列表
|
||||||
|
export const getTools = (data: Query) => {
|
||||||
|
return request.get('/tools', data)
|
||||||
|
}
|
||||||
|
// 创建MCP工具
|
||||||
|
export const addTool = (values: MCPToolItem | CustomToolItem) => {
|
||||||
|
return request.post('/tools', values)
|
||||||
|
}
|
||||||
|
// 更新工具
|
||||||
|
export const updateTool = (tool_id: string, data: MCPToolItem | InnerToolItem | CustomToolItem) => {
|
||||||
|
return request.put(`/tools/${tool_id}`, data)
|
||||||
|
}
|
||||||
|
// 删除工具
|
||||||
|
export const deleteTool = (tool_id: string) => {
|
||||||
|
return request.delete(`/tools/${tool_id}`)
|
||||||
|
}
|
||||||
|
// MCP 测试连接
|
||||||
|
export const testConnection = (tool_id: string) => {
|
||||||
|
return request.post(`/tools/${tool_id}/test`)
|
||||||
|
}
|
||||||
|
// 工具测试
|
||||||
|
export const execute = (data: ExecuteData) => {
|
||||||
|
return request.post(`/tools/execution/execute`, data)
|
||||||
|
}
|
||||||
|
export const parseSchema = (data: Record<string, any>) => {
|
||||||
|
return request.post(`/tools/parse_schema`, data)
|
||||||
|
|
||||||
|
}
|
||||||
@@ -1372,13 +1372,19 @@ Memory Bear: After the rebellion, regional warlordism intensified for several re
|
|||||||
addService: 'Add MCP Service',
|
addService: 'Add MCP Service',
|
||||||
addServiceSuccess: 'Service added successfully',
|
addServiceSuccess: 'Service added successfully',
|
||||||
server_url: 'Service URL',
|
server_url: 'Service URL',
|
||||||
lastConnection: 'Last Connection',
|
last_health_check: 'Last Connection',
|
||||||
responseTime: 'Response Time',
|
responseTime: 'Response Time',
|
||||||
status: {
|
status: {
|
||||||
active: 'Active',
|
available: '可用',
|
||||||
inactive: 'Inactive',
|
unconfigured: '未配置',
|
||||||
|
configured_disabled: '已配置未启用',
|
||||||
|
error: '链接异常'
|
||||||
},
|
},
|
||||||
testConnectionSuccess: 'Connection test successful',
|
available_desc: 'API 已配置并启用',
|
||||||
|
unconfigured_desc: '需要配置 API Key',
|
||||||
|
configured_disabled_desc: 'API 已配置但未启用',
|
||||||
|
error_desc: 'API 已配置但链接异常',
|
||||||
|
|
||||||
serviceEndpoint: 'Service Endpoint URL',
|
serviceEndpoint: 'Service Endpoint URL',
|
||||||
serviceEndpointPlaceholder: 'URL of the service endpoint',
|
serviceEndpointPlaceholder: 'URL of the service endpoint',
|
||||||
serviceEndpointExtra: 'Complete access address of the MCP service',
|
serviceEndpointExtra: 'Complete access address of the MCP service',
|
||||||
@@ -1392,13 +1398,14 @@ Memory Bear: After the rebellion, regional warlordism intensified for several re
|
|||||||
auth: 'Authentication',
|
auth: 'Authentication',
|
||||||
requestHeader: 'Request Headers',
|
requestHeader: 'Request Headers',
|
||||||
config: 'Configuration',
|
config: 'Configuration',
|
||||||
authType: 'Authentication Type',
|
auth_type: 'Authentication Type',
|
||||||
noAuth: 'No Authentication',
|
none: 'No Authentication',
|
||||||
apiKey: 'API Key',
|
api_key: 'API Key',
|
||||||
basicAuth: 'Basic Auth',
|
basic_auth: 'Basic Auth',
|
||||||
bearerToken: 'Bearer Token',
|
bearer_token: 'Bearer Token',
|
||||||
username: 'Username',
|
username: 'Username',
|
||||||
password: 'Password',
|
password: 'Password',
|
||||||
|
key_name: 'Key Name',
|
||||||
requestHeaderDesc: 'Additional HTTP request headers sent to MCP server',
|
requestHeaderDesc: 'Additional HTTP request headers sent to MCP server',
|
||||||
addRequestHeader: 'Add Request Header',
|
addRequestHeader: 'Add Request Header',
|
||||||
editRequestHeader: 'Edit Request Header',
|
editRequestHeader: 'Edit Request Header',
|
||||||
@@ -1472,7 +1479,6 @@ Memory Bear: After the rebellion, regional warlordism intensified for several re
|
|||||||
MinerUTool_config_desc: 'MinerU is a high-precision PDF document parsing tool that requires an API Key to use.',
|
MinerUTool_config_desc: 'MinerU is a high-precision PDF document parsing tool that requires an API Key to use.',
|
||||||
TextInTool_config_desc: 'TextIn provides intelligent OCR text recognition service with multi-language support.',
|
TextInTool_config_desc: 'TextIn provides intelligent OCR text recognition service with multi-language support.',
|
||||||
link: 'Application URL',
|
link: 'Application URL',
|
||||||
api_key: 'API Key',
|
|
||||||
BaiduSearchTool_api_key_desc: 'API Key obtained from Baidu Open Platform',
|
BaiduSearchTool_api_key_desc: 'API Key obtained from Baidu Open Platform',
|
||||||
MinerUTool_api_key_desc: 'API Key obtained from MinerU platform',
|
MinerUTool_api_key_desc: 'API Key obtained from MinerU platform',
|
||||||
secret_key: 'Secret Key',
|
secret_key: 'Secret Key',
|
||||||
@@ -1529,7 +1535,8 @@ Memory Bear: After the rebellion, regional warlordism intensified for several re
|
|||||||
desc: 'Description',
|
desc: 'Description',
|
||||||
method: 'Method',
|
method: 'Method',
|
||||||
path: 'Path',
|
path: 'Path',
|
||||||
viewDetail: 'View Details'
|
viewDetail: 'View Details',
|
||||||
|
noResult: 'Processing results will be displayed here'
|
||||||
},
|
},
|
||||||
workflow: {
|
workflow: {
|
||||||
coreNode: 'Core Nodes',
|
coreNode: 'Core Nodes',
|
||||||
|
|||||||
@@ -1360,6 +1360,8 @@ export const zh = {
|
|||||||
startANewConversation: '开始新对话',
|
startANewConversation: '开始新对话',
|
||||||
normalReply: '正常回复',
|
normalReply: '正常回复',
|
||||||
quickReply: '快速回复',
|
quickReply: '快速回复',
|
||||||
|
web_search: '联网搜索',
|
||||||
|
memory: '记忆',
|
||||||
},
|
},
|
||||||
login: {
|
login: {
|
||||||
title: '红熊记忆科学',
|
title: '红熊记忆科学',
|
||||||
@@ -1470,12 +1472,19 @@ export const zh = {
|
|||||||
addService: '添加MCP服务',
|
addService: '添加MCP服务',
|
||||||
addServiceSuccess: '服务添加成功',
|
addServiceSuccess: '服务添加成功',
|
||||||
server_url: '服务地址',
|
server_url: '服务地址',
|
||||||
lastConnection: '最后连接',
|
last_health_check: '最后连接',
|
||||||
responseTime: '响应时间',
|
responseTime: '响应时间',
|
||||||
status: {
|
status: {
|
||||||
active: '活跃',
|
available: '可用',
|
||||||
inactive: '不活跃',
|
unconfigured: '未配置',
|
||||||
|
configured_disabled: '已配置未启用',
|
||||||
|
error: '链接异常'
|
||||||
},
|
},
|
||||||
|
available_desc: 'API 已配置并启用',
|
||||||
|
unconfigured_desc: '需要配置 API Key',
|
||||||
|
configured_disabled_desc: 'API 已配置但未启用',
|
||||||
|
error_desc: 'API 已配置但链接异常',
|
||||||
|
|
||||||
testConnectionSuccess: '测试连接成功',
|
testConnectionSuccess: '测试连接成功',
|
||||||
serviceEndpoint: '服务端点 URL',
|
serviceEndpoint: '服务端点 URL',
|
||||||
serviceEndpointPlaceholder: '服务端点的 URL',
|
serviceEndpointPlaceholder: '服务端点的 URL',
|
||||||
@@ -1490,13 +1499,14 @@ export const zh = {
|
|||||||
auth: '认证',
|
auth: '认证',
|
||||||
requestHeader: '请求头',
|
requestHeader: '请求头',
|
||||||
config: '配置',
|
config: '配置',
|
||||||
authType: '认证方式',
|
auth_Type: '认证方式',
|
||||||
noAuth: '无需认证',
|
none: '无需认证',
|
||||||
apiKey: 'API Key',
|
api_key: 'API Key',
|
||||||
basicAuth: 'Basic Auth',
|
basic_auth: 'Basic Auth',
|
||||||
bearerToken: 'Bearer Token',
|
bearer_token: 'Bearer Token',
|
||||||
username: '用户名',
|
username: '用户名',
|
||||||
password: '密码',
|
password: '密码',
|
||||||
|
key_name: 'Key Name',
|
||||||
requestHeaderDesc: '发送到 MCP 服务器的额外 HTTP 请求头',
|
requestHeaderDesc: '发送到 MCP 服务器的额外 HTTP 请求头',
|
||||||
addRequestHeader: '添加请求头',
|
addRequestHeader: '添加请求头',
|
||||||
editRequestHeader: '编辑请求头',
|
editRequestHeader: '编辑请求头',
|
||||||
@@ -1570,7 +1580,6 @@ export const zh = {
|
|||||||
MinerUTool_config_desc: 'MinerU是高精度PDF文档解析工具,需要API Key才能使用。',
|
MinerUTool_config_desc: 'MinerU是高精度PDF文档解析工具,需要API Key才能使用。',
|
||||||
TextInTool_config_desc: 'TextIn提供智能OCR文字识别服务,支持多语言识别。',
|
TextInTool_config_desc: 'TextIn提供智能OCR文字识别服务,支持多语言识别。',
|
||||||
link: '申请地址',
|
link: '申请地址',
|
||||||
api_key: 'API Key',
|
|
||||||
BaiduSearchTool_api_key_desc: '从百度开放平台获取的API Key',
|
BaiduSearchTool_api_key_desc: '从百度开放平台获取的API Key',
|
||||||
MinerUTool_api_key_desc: '从MinerU平台获取的API Key',
|
MinerUTool_api_key_desc: '从MinerU平台获取的API Key',
|
||||||
secret_key: 'Secret Key',
|
secret_key: 'Secret Key',
|
||||||
@@ -1627,7 +1636,9 @@ export const zh = {
|
|||||||
desc: '描述',
|
desc: '描述',
|
||||||
method: '方法',
|
method: '方法',
|
||||||
path: '路径',
|
path: '路径',
|
||||||
viewDetail: '查看详情'
|
viewDetail: '查看详情',
|
||||||
|
textLink: '测试连接',
|
||||||
|
noResult: '处理结果将显示在这里'
|
||||||
},
|
},
|
||||||
workflow: {
|
workflow: {
|
||||||
coreNode: '核心节点',
|
coreNode: '核心节点',
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ const componentMap: Record<string, LazyExoticComponent<ComponentType<object>>> =
|
|||||||
OrderPayment: lazy(() => import('@/views/OrderPayment')),
|
OrderPayment: lazy(() => import('@/views/OrderPayment')),
|
||||||
OrderHistory: lazy(() => import('@/views/OrderHistory')),
|
OrderHistory: lazy(() => import('@/views/OrderHistory')),
|
||||||
Pricing: lazy(() => import('@/views/Pricing')),
|
Pricing: lazy(() => import('@/views/Pricing')),
|
||||||
|
ToolManagement: lazy(() => import('@/views/ToolManagement')),
|
||||||
Login: lazy(() => import('@/views/Login')),
|
Login: lazy(() => import('@/views/Login')),
|
||||||
InviteRegister: lazy(() => import('@/views/InviteRegister')),
|
InviteRegister: lazy(() => import('@/views/InviteRegister')),
|
||||||
NoPermission: lazy(() => import('@/views/NoPermission')),
|
NoPermission: lazy(() => import('@/views/NoPermission')),
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
{ "path": "/user-management", "element": "UserManagement" },
|
{ "path": "/user-management", "element": "UserManagement" },
|
||||||
{ "path": "/model", "element": "ModelManagement" },
|
{ "path": "/model", "element": "ModelManagement" },
|
||||||
{ "path": "/space", "element": "SpaceManagement" },
|
{ "path": "/space", "element": "SpaceManagement" },
|
||||||
|
{ "path": "/tool", "element": "ToolManagement" },
|
||||||
{ "path": "/pricing", "element": "Pricing" },
|
{ "path": "/pricing", "element": "Pricing" },
|
||||||
{ "path": "/order-pay", "element": "OrderPayment" },
|
{ "path": "/order-pay", "element": "OrderPayment" },
|
||||||
{ "path": "/orders", "element": "OrderHistory" },
|
{ "path": "/orders", "element": "OrderHistory" },
|
||||||
|
|||||||
@@ -26,6 +26,19 @@
|
|||||||
"sort": 0,
|
"sort": 0,
|
||||||
"subs": []
|
"subs": []
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"id": 7,
|
||||||
|
"parent": 0,
|
||||||
|
"code": "tool",
|
||||||
|
"label": "工具管理",
|
||||||
|
"i18nKey": "menu.toolManagement",
|
||||||
|
"path": "/tool",
|
||||||
|
"enable": true,
|
||||||
|
"display": true,
|
||||||
|
"level": 1,
|
||||||
|
"sort": 0,
|
||||||
|
"subs": []
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"id": 6,
|
"id": 6,
|
||||||
"parent": 0,
|
"parent": 0,
|
||||||
|
|||||||
@@ -302,6 +302,10 @@ export const request = {
|
|||||||
// 获取父级域名
|
// 获取父级域名
|
||||||
const getParentDomain = () => {
|
const getParentDomain = () => {
|
||||||
const hostname = window.location.hostname
|
const hostname = window.location.hostname
|
||||||
|
// 检查是否为IP地址
|
||||||
|
if (/^\d+\.\d+\.\d+\.\d+$/.test(hostname)) {
|
||||||
|
return hostname
|
||||||
|
}
|
||||||
const parts = hostname.split('.')
|
const parts = hostname.split('.')
|
||||||
return parts.length > 2 ? `.${parts.slice(-2).join('.')}` : hostname
|
return parts.length > 2 ? `.${parts.slice(-2).join('.')}` : hostname
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -208,7 +208,7 @@ const Conversation: FC = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Flex className="rb:w-full rb:p-[-16px]!">
|
<Flex className="rb:w-full rb:p-[-16px]!">
|
||||||
<div className="rb:w-[345px] rb:h-screen rb:overflow-hidden rb:border-r rb:border-[#EAECEE] rb:p-3">
|
<div className="rb:w-86.25 rb:h-screen rb:overflow-hidden rb:border-r rb:border-[#EAECEE] rb:p-3">
|
||||||
<div className="rb:group rb:flex rb:items-center rb:justify-center rb:font-regular rb:cursor-pointer rb:mb-5 rb:border rb:border-[#DFE4ED] rb:hover:border-[#155EEF] rb:hover:text-[#155EEF] rb:rounded-lg rb:py-2.5"
|
<div className="rb:group rb:flex rb:items-center rb:justify-center rb:font-regular rb:cursor-pointer rb:mb-5 rb:border rb:border-[#DFE4ED] rb:hover:border-[#155EEF] rb:hover:text-[#155EEF] rb:rounded-lg rb:py-2.5"
|
||||||
onClick={() => handleChangeHistory(null)}
|
onClick={() => handleChangeHistory(null)}
|
||||||
>
|
>
|
||||||
@@ -250,7 +250,7 @@ const Conversation: FC = () => {
|
|||||||
</InfiniteScroll>
|
</InfiniteScroll>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
<img src={BgImg} className="rb:absolute rb:bottom-0 rb:left-0 rb:w-[345px]" />
|
<img src={BgImg} className="rb:absolute rb:bottom-0 rb:left-0 rb:w-86.25" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="rb:relative rb:h-screen rb:px-4 rb:flex-[1_1_auto]">
|
<div className="rb:relative rb:h-screen rb:px-4 rb:flex-[1_1_auto]">
|
||||||
@@ -264,7 +264,7 @@ const Conversation: FC = () => {
|
|||||||
onSend={handleSend}
|
onSend={handleSend}
|
||||||
labelFormat={(item) => dayjs(item.created_at).locale('en').format('MMMM D, YYYY [at] h:mm A')}
|
labelFormat={(item) => dayjs(item.created_at).locale('en').format('MMMM D, YYYY [at] h:mm A')}
|
||||||
>
|
>
|
||||||
<Form form={form}>
|
<Form form={form} initialValues={{ memory: false, web_search: false}}>
|
||||||
<Flex gap={8}>
|
<Flex gap={8}>
|
||||||
<Form.Item name="web_search" valuePropName="checked" className="rb:mb-0!">
|
<Form.Item name="web_search" valuePropName="checked" className="rb:mb-0!">
|
||||||
<ButtonCheckbox
|
<ButtonCheckbox
|
||||||
|
|||||||
146
web/src/views/ToolManagement/Custom.tsx
Normal file
146
web/src/views/ToolManagement/Custom.tsx
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
import React, { useState, useRef, useEffect, type ReactNode } from 'react';
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Row,
|
||||||
|
Col,
|
||||||
|
App,
|
||||||
|
List,
|
||||||
|
Space
|
||||||
|
} from 'antd';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
|
|
||||||
|
import type { ToolItem, Query, CustomToolModalRef } from './types';
|
||||||
|
import CustomToolModal from './components/CustomToolModal';
|
||||||
|
import SearchInput from '@/components/SearchInput'
|
||||||
|
import BodyWrapper from '@/components/Empty/BodyWrapper'
|
||||||
|
import RbCard from '@/components/RbCard'
|
||||||
|
import { getTools, deleteTool } from '@/api/tools'
|
||||||
|
|
||||||
|
const Custom: React.FC<{ getStatusTag: (status: string) => ReactNode }> = ({ getStatusTag }) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { message, modal } = App.useApp()
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [data, setData] = useState<ToolItem[]>([]);
|
||||||
|
const [query, setQuery] = useState<Query>({ name: undefined, tool_type: 'custom' });
|
||||||
|
const customToolModalRef = useRef<CustomToolModalRef>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
getData()
|
||||||
|
}, [query.name])
|
||||||
|
|
||||||
|
const getData = () => {
|
||||||
|
setLoading(true)
|
||||||
|
getTools(query)
|
||||||
|
.then((res) => {
|
||||||
|
setData(res as ToolItem[])
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
setLoading(false)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
const handleSearch = (value?: string) => {
|
||||||
|
setQuery(prev => ({ ...prev, name: value }))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 打开添加服务弹窗
|
||||||
|
const handleEdit = (data?: ToolItem) => {
|
||||||
|
customToolModalRef.current?.handleOpen(data);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 删除服务
|
||||||
|
const handleDeleteService = (item: ToolItem) => {
|
||||||
|
modal.confirm({
|
||||||
|
title: t('common.confirmDeleteDesc', { name: item.name }),
|
||||||
|
okText: t('common.delete'),
|
||||||
|
okType: 'danger',
|
||||||
|
onOk: () => {
|
||||||
|
deleteTool(item.id).then(() => {
|
||||||
|
message.success(t('common.deleteSuccess'));
|
||||||
|
getData()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Row gutter={16} className='rb:mb-4 rb:w-full'>
|
||||||
|
<Col span={8}>
|
||||||
|
<SearchInput
|
||||||
|
placeholder={t('tool.customSearchPlaceholder')}
|
||||||
|
onSearch={handleSearch}
|
||||||
|
style={{width: '100%'}}
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
<Col span={16} className="rb:text-right">
|
||||||
|
<Button type="primary" onClick={() => {handleEdit()}}>{t('tool.addCustom')}</Button>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
<BodyWrapper loading={loading} empty={data.length === 0}>
|
||||||
|
<List
|
||||||
|
grid={{ gutter: 16, column: 2 }}
|
||||||
|
dataSource={data}
|
||||||
|
renderItem={(item) => (
|
||||||
|
<List.Item key={item.id}>
|
||||||
|
<RbCard
|
||||||
|
// avatar={
|
||||||
|
// <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>
|
||||||
|
// }
|
||||||
|
title={
|
||||||
|
<div>
|
||||||
|
{item.name}<br/>
|
||||||
|
{/* <div className="rb:mt-1 rb:text-[12px] rb:leading-4 rb:font-regular rb:text-[#5B6167]">xx个工具</div> */}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
extra={getStatusTag(item.status)}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
{['auth_type', 'tag', 'created_at'].map(key => (
|
||||||
|
<div
|
||||||
|
key={key}
|
||||||
|
className="rb:flex rb:gap-4 rb:justify-start rb:text-[#5B6167] rb:text-[14px] rb:leading-5 rb:mb-3"
|
||||||
|
>
|
||||||
|
<div className="rb:whitespace-nowrap rb:w-32">{t(`tool.${key}`)}</div>
|
||||||
|
<div className='rb:flex-1 rb:text-ellipsis rb:overflow-hidden rb:whitespace-nowrap rb:flex-inline rb:text-left rb:py-px rb:rounded rb:font-medium'>
|
||||||
|
{key === 'created_at' && item[key]
|
||||||
|
? dayjs(item[key]).format('YYYY-MM-DD HH:mm:ss')
|
||||||
|
: key === 'auth_type'
|
||||||
|
? t(`tool.${(item.config_data as any)?.[key]}`)
|
||||||
|
: (item.config_data as any)?.[key] || '-'
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<div className="rb:mt-4 rb:text-[12px] rb:leading-4 rb:font-regular rb:text-[#5B6167] rb:flex rb:items-center rb:justify-end">
|
||||||
|
<Space size={16}>
|
||||||
|
<div
|
||||||
|
className="rb:w-5 rb:h-5 rb:cursor-pointer rb:bg-cover rb:bg-[url('@/assets/images/edit.svg')] rb:hover:bg-[url('@/assets/images/edit_hover.svg')]"
|
||||||
|
onClick={() => handleEdit(item)}
|
||||||
|
></div>
|
||||||
|
<div
|
||||||
|
className="rb:w-5 rb:h-5 rb:cursor-pointer rb:bg-cover rb:bg-[url('@/assets/images/delete.svg')] rb:hover:bg-[url('@/assets/images/delete_hover.svg')]"
|
||||||
|
onClick={() => handleDeleteService(item)}
|
||||||
|
></div>
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</RbCard>
|
||||||
|
</List.Item>
|
||||||
|
)}
|
||||||
|
className="rb:h-[calc(100vh-178px)] rb:overflow-y-auto rb:overflow-x-hidden"
|
||||||
|
/>
|
||||||
|
</BodyWrapper>
|
||||||
|
|
||||||
|
{/* 添加服务弹窗组件 */}
|
||||||
|
<CustomToolModal
|
||||||
|
ref={customToolModalRef}
|
||||||
|
refresh={getData}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Custom;
|
||||||
168
web/src/views/ToolManagement/Inner.tsx
Normal file
168
web/src/views/ToolManagement/Inner.tsx
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
import React, { useState, useRef, useEffect, type ReactNode } from 'react';
|
||||||
|
import {
|
||||||
|
Row,
|
||||||
|
Col,
|
||||||
|
Tag,
|
||||||
|
List,
|
||||||
|
Space
|
||||||
|
} from 'antd';
|
||||||
|
import { EyeOutlined } from '@ant-design/icons';
|
||||||
|
import clsx from 'clsx'
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import dayjs, { type Dayjs } from 'dayjs'
|
||||||
|
|
||||||
|
import type { Query, ToolItem, TimeToolModalRef, JsonToolModalRef, InnerToolModalRef } from './types';
|
||||||
|
import SearchInput from '@/components/SearchInput'
|
||||||
|
import BodyWrapper from '@/components/Empty/BodyWrapper'
|
||||||
|
import RbCard from '@/components/RbCard/Card'
|
||||||
|
import TimeToolModal from './components/TimeToolModal'
|
||||||
|
import JsonToolModal from './components/JsonToolModal'
|
||||||
|
import InnerToolModal from './components/InnerToolModal'
|
||||||
|
import { getTools } from '@/api/tools'
|
||||||
|
import { InnerConfigData } from './constant'
|
||||||
|
|
||||||
|
const Inner: React.FC<{ getStatusTag: (status: string) => ReactNode }> = ({ getStatusTag }) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [data, setData] = useState<ToolItem[]>([]);
|
||||||
|
const [query, setQuery] = useState<Query>({ name: undefined, tool_type: 'builtin' });
|
||||||
|
const [curTime, setCurTime] = useState<Dayjs>(dayjs())
|
||||||
|
const timeToolModalRef = useRef<TimeToolModalRef>(null)
|
||||||
|
const jsonToolModalRef = useRef<JsonToolModalRef>(null)
|
||||||
|
const innerToolModalRef = useRef<InnerToolModalRef>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
getData()
|
||||||
|
const timer = setInterval(() => {
|
||||||
|
setCurTime(dayjs())
|
||||||
|
}, 1000)
|
||||||
|
return () => {
|
||||||
|
clearInterval(timer)
|
||||||
|
}
|
||||||
|
}, [query.name])
|
||||||
|
|
||||||
|
const getData = () => {
|
||||||
|
setLoading(true)
|
||||||
|
getTools(query)
|
||||||
|
.then((res) => {
|
||||||
|
setData(res as ToolItem[])
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
setLoading(false)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
const handleSearch = (value?: string) => {
|
||||||
|
setQuery(prev => ({ ...prev, name: value }))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 打开添加服务弹窗
|
||||||
|
const handleEdit = (data: ToolItem) => {
|
||||||
|
switch (data.config_data.tool_class) {
|
||||||
|
case 'DateTimeTool':
|
||||||
|
timeToolModalRef.current?.handleOpen(data);
|
||||||
|
break
|
||||||
|
case 'JsonTool':
|
||||||
|
jsonToolModalRef.current?.handleOpen(data);
|
||||||
|
break
|
||||||
|
default:
|
||||||
|
innerToolModalRef.current?.handleOpen(data);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Row gutter={16} className='rb:mb-4 rb:w-full'>
|
||||||
|
<Col span={8}>
|
||||||
|
<SearchInput
|
||||||
|
placeholder={t('tool.innerSearchPlaceholder')}
|
||||||
|
onSearch={handleSearch}
|
||||||
|
style={{width: '100%'}}
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
<BodyWrapper loading={loading} empty={data.length === 0}>
|
||||||
|
<List
|
||||||
|
grid={{ gutter: 16, column: 2 }}
|
||||||
|
dataSource={data}
|
||||||
|
renderItem={(item) => (
|
||||||
|
<List.Item key={item.id}>
|
||||||
|
<RbCard
|
||||||
|
className={clsx({
|
||||||
|
'rb:h-82.5!': item.config_data.tool_class === 'DateTimeTool' || item.config_data.tool_class === 'JsonTool'
|
||||||
|
})}
|
||||||
|
// avatar={
|
||||||
|
// <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>
|
||||||
|
// }
|
||||||
|
title={item.name}
|
||||||
|
extra={getStatusTag(item.status)}
|
||||||
|
bodyClassName='rb:h-[calc(100%-40px)]'
|
||||||
|
>
|
||||||
|
<div className="rb:h-full rb:flex rb:flex-col rb:justify-between">
|
||||||
|
<div className="rb:text-[12px] rb:leading-4 rb:font-regular rb:text-[#5B6167]">
|
||||||
|
{t(`tool.${item.config_data.tool_class}_features`)} <br />
|
||||||
|
<Space size={4} className="rb:mt-2">
|
||||||
|
{InnerConfigData[item.config_data.tool_class].features.map(vo => <Tag key={vo} color="default">{ t(`tool.${vo}`) }</Tag>) }
|
||||||
|
</Space>
|
||||||
|
|
||||||
|
{item.config_data.tool_class === 'DateTimeTool'
|
||||||
|
? <div className="rb:mt-3 rb:bg-[#F0F3F8] rb:px-3 rb:py-2.5 rb:rounded-md">
|
||||||
|
{t('tool.currentTime')}
|
||||||
|
<div className="rb:font-medium rb:bg-white rb:px-3 rb:py-2.5 rb:rounded-md rb:my-2">
|
||||||
|
{curTime.format('YYYY-MM-DD HH:mm:ss')}
|
||||||
|
</div>
|
||||||
|
{t('tool.timestamp')}
|
||||||
|
<div className="rb:font-medium rb:bg-white rb:px-3 rb:py-2.5 rb:rounded-md rb:mt-2">
|
||||||
|
{curTime.unix()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
:item.config_data.tool_class === 'JsonTool'
|
||||||
|
? <div className="rb:mt-3 rb:bg-[#F0F3F8] rb:px-3 rb:py-2.5 rb:rounded-md">
|
||||||
|
{t('tool.jsonEg')}
|
||||||
|
<div className="rb:font-medium rb:bg-white rb:px-3 rb:py-2.5 rb:rounded-md rb:my-2">
|
||||||
|
{InnerConfigData[item.config_data.tool_class].eg}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
: <div className="rb:mt-3 rb:bg-[#F0F3F8] rb:px-3 rb:py-2.5 rb:rounded-md">
|
||||||
|
{t('tool.configStatus')}
|
||||||
|
<div className="rb:font-medium rb:bg-white rb:px-3 rb:py-2.5 rb:rounded-md rb:my-2">
|
||||||
|
{t(`tool.${item.status}_desc`)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rb:mt-4 rb:flex rb:items-center rb:justify-end">
|
||||||
|
{item.config_data.tool_class === 'DateTimeTool' || item.config_data.tool_class === 'JsonTool' ?
|
||||||
|
<EyeOutlined className="rb:text-5 rb:text-[#5B6167]! rb:hover:text-[#212332]!" onClick={() => handleEdit(item)} />
|
||||||
|
: <div
|
||||||
|
className="rb:w-5 rb:h-5 rb:cursor-pointer rb:bg-cover rb:bg-[url('@/assets/images/edit.svg')] rb:hover:bg-[url('@/assets/images/edit_hover.svg')]"
|
||||||
|
onClick={() => handleEdit(item)}
|
||||||
|
></div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</RbCard>
|
||||||
|
</List.Item>
|
||||||
|
)}
|
||||||
|
className="rb:h-[calc(100vh-178px)] rb:overflow-y-auto rb:overflow-x-hidden"
|
||||||
|
/>
|
||||||
|
</BodyWrapper>
|
||||||
|
|
||||||
|
<TimeToolModal
|
||||||
|
ref={timeToolModalRef}
|
||||||
|
/>
|
||||||
|
<JsonToolModal
|
||||||
|
ref={jsonToolModalRef}
|
||||||
|
/>
|
||||||
|
<InnerToolModal
|
||||||
|
ref={innerToolModalRef}
|
||||||
|
refreshTable={getData}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Inner;
|
||||||
167
web/src/views/ToolManagement/Mcp.tsx
Normal file
167
web/src/views/ToolManagement/Mcp.tsx
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
import React, { useState, useRef, useEffect, type ReactNode } from 'react';
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Row,
|
||||||
|
Col,
|
||||||
|
App,
|
||||||
|
List,
|
||||||
|
Space,
|
||||||
|
} from 'antd';
|
||||||
|
import { LinkOutlined } from '@ant-design/icons';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import type { ToolItem, Query, McpServiceModalRef } from './types';
|
||||||
|
import McpServiceModal from './components/McpServiceModal';
|
||||||
|
import SearchInput from '@/components/SearchInput'
|
||||||
|
import BodyWrapper from '@/components/Empty/BodyWrapper'
|
||||||
|
import RbCard from '@/components/RbCard/Card'
|
||||||
|
import { getTools, deleteTool, testConnection } from '@/api/tools'
|
||||||
|
|
||||||
|
const Mcp: React.FC<{ getStatusTag: (status: string) => ReactNode }> = ({ getStatusTag }) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { message, modal } = App.useApp()
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [data, setData] = useState<ToolItem[]>([]);
|
||||||
|
const [query, setQuery] = useState<Query>({ name: undefined, tool_type: 'mcp' });
|
||||||
|
const addServiceModalRef = useRef<McpServiceModalRef>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
getData()
|
||||||
|
}, [query.name])
|
||||||
|
|
||||||
|
const getData = () => {
|
||||||
|
setLoading(true)
|
||||||
|
getTools(query)
|
||||||
|
.then((res) => {
|
||||||
|
setData(res as ToolItem[])
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
setLoading(false)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
const handleSearch = (value?: string) => {
|
||||||
|
setQuery(prev => ({ ...prev, name: value }))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 打开添加服务弹窗
|
||||||
|
const handleEdit = (data?: ToolItem) => {
|
||||||
|
addServiceModalRef.current?.handleOpen(data);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 测试连接
|
||||||
|
const handleTestConnection = (item: ToolItem) => {
|
||||||
|
if (!item.id) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
testConnection(item.id)
|
||||||
|
.then(() => {
|
||||||
|
message.success(t('tool.testConnectionSuccess'));
|
||||||
|
getData()
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
// 删除服务
|
||||||
|
const handleDeleteService = (item: ToolItem) => {
|
||||||
|
if (!item.id) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
modal.confirm({
|
||||||
|
title: t('common.confirmDeleteDesc', { name: item.name }),
|
||||||
|
okText: t('common.delete'),
|
||||||
|
okType: 'danger',
|
||||||
|
onOk: () => {
|
||||||
|
deleteTool(item.id as string)
|
||||||
|
.then(() => {
|
||||||
|
message.success(t('common.deleteSuccess'));
|
||||||
|
getData()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Row gutter={16} className='rb:mb-4 rb:w-full'>
|
||||||
|
<Col span={8}>
|
||||||
|
<SearchInput
|
||||||
|
placeholder={t('tool.mcpSearchPlaceholder')}
|
||||||
|
onSearch={handleSearch}
|
||||||
|
style={{width: '100%'}}
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
<Col span={16} className="rb:text-right">
|
||||||
|
<Button type="primary" onClick={() => {handleEdit()}}>{t('tool.addService')}</Button>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
<BodyWrapper loading={loading} empty={data?.length === 0}>
|
||||||
|
<List
|
||||||
|
grid={{ gutter: 16, column: 3 }}
|
||||||
|
dataSource={data}
|
||||||
|
renderItem={(item) => (
|
||||||
|
<List.Item key={item.id}>
|
||||||
|
<RbCard
|
||||||
|
// avatar={
|
||||||
|
// <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>
|
||||||
|
// }
|
||||||
|
title={item.name}
|
||||||
|
extra={getStatusTag(item.status)}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
{[
|
||||||
|
'server_url',
|
||||||
|
'last_health_check',
|
||||||
|
].map(key => {
|
||||||
|
const value = item.config_data?.[key as keyof typeof item.config_data];
|
||||||
|
let displayValue: React.ReactNode;
|
||||||
|
|
||||||
|
if (key === 'last_health_check') {
|
||||||
|
displayValue = value ? new Date(value as number).toLocaleString() : '-';
|
||||||
|
} else if (typeof value === 'string' || typeof value === 'number') {
|
||||||
|
displayValue = value;
|
||||||
|
} else {
|
||||||
|
displayValue = '-';
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={key}
|
||||||
|
className="rb:flex rb:gap-4 rb:justify-start rb:text-[#5B6167] rb:text-[14px] rb:leading-5 rb:mb-3"
|
||||||
|
>
|
||||||
|
<div className="rb:whitespace-nowrap rb:w-27.5">{t(`tool.${key}`)}</div>
|
||||||
|
{displayValue}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<div className="rb:mt-4 rb:text-[12px] rb:leading-4 rb:font-regular rb:text-[#5B6167] rb:flex rb:items-center rb:justify-end">
|
||||||
|
<Space size={16}>
|
||||||
|
<div
|
||||||
|
className="rb:w-5 rb:h-5 rb:cursor-pointer rb:bg-cover rb:bg-[url('@/assets/images/edit.svg')] rb:hover:bg-[url('@/assets/images/edit_hover.svg')]"
|
||||||
|
onClick={() => handleEdit(item)}
|
||||||
|
></div>
|
||||||
|
<Button type="text" icon={<LinkOutlined />} onClick={() => handleTestConnection(item)}></Button>
|
||||||
|
<div
|
||||||
|
className="rb:w-5 rb:h-5 rb:cursor-pointer rb:bg-cover rb:bg-[url('@/assets/images/delete.svg')] rb:hover:bg-[url('@/assets/images/delete_hover.svg')]"
|
||||||
|
onClick={() => handleDeleteService(item)}
|
||||||
|
></div>
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</RbCard>
|
||||||
|
</List.Item>
|
||||||
|
)}
|
||||||
|
className="rb:h-[calc(100vh-178px)] rb:overflow-y-auto rb:overflow-x-hidden"
|
||||||
|
/>
|
||||||
|
</BodyWrapper>
|
||||||
|
|
||||||
|
{/* 添加服务弹窗组件 */}
|
||||||
|
<McpServiceModal
|
||||||
|
ref={addServiceModalRef}
|
||||||
|
refresh={getData}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Mcp;
|
||||||
285
web/src/views/ToolManagement/components/CustomToolModal.tsx
Normal file
285
web/src/views/ToolManagement/components/CustomToolModal.tsx
Normal file
@@ -0,0 +1,285 @@
|
|||||||
|
import { forwardRef, useImperativeHandle, useState } from 'react';
|
||||||
|
import { Form, Input, Select, Row, Col, App, Button } from 'antd';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import type { CustomToolItem, CustomToolModalRef, ToolItem } from '../types'
|
||||||
|
import RbModal from '@/components/RbModal';
|
||||||
|
import { parseSchema, addTool, updateTool } from '@/api/tools';
|
||||||
|
import Table from '@/components/Table';
|
||||||
|
const FormItem = Form.Item;
|
||||||
|
|
||||||
|
interface CustomToolModalProps {
|
||||||
|
refresh: () => void;
|
||||||
|
}
|
||||||
|
interface ParseSchemaData {
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
version: string;
|
||||||
|
base_url: string;
|
||||||
|
operations: Array<{
|
||||||
|
method: string;
|
||||||
|
path: string;
|
||||||
|
summary: string;
|
||||||
|
description: string;
|
||||||
|
parameters: Record<string, Record<string, string | null>>
|
||||||
|
request_body: null | string;
|
||||||
|
responses: Record<string, Record<string, string | null>>
|
||||||
|
tags: string[]
|
||||||
|
}>
|
||||||
|
}
|
||||||
|
const authTypeList = ['none', 'api_key', 'basic_auth']
|
||||||
|
const CustomToolModal = forwardRef<CustomToolModalRef, CustomToolModalProps>(({
|
||||||
|
refresh
|
||||||
|
}, ref) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { message } = App.useApp();
|
||||||
|
const [visible, setVisible] = useState(false);
|
||||||
|
const [form] = Form.useForm<CustomToolItem>();
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [editVo, setEditVo] = useState<ToolItem | null>(null)
|
||||||
|
const values = Form.useWatch<CustomToolItem>([], form)
|
||||||
|
const [parseSchemaData, setParseSchemaData] = useState<ParseSchemaData>({} as ParseSchemaData)
|
||||||
|
|
||||||
|
// 封装取消方法,添加关闭弹窗逻辑
|
||||||
|
const handleClose = () => {
|
||||||
|
setVisible(false);
|
||||||
|
form.resetFields();
|
||||||
|
setLoading(false);
|
||||||
|
setEditVo(null)
|
||||||
|
setParseSchemaData({} as ParseSchemaData)
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOpen = (data?: ToolItem) => {
|
||||||
|
if (data?.id) {
|
||||||
|
const { config_data, ...rest } = data
|
||||||
|
form.setFieldsValue({
|
||||||
|
...rest,
|
||||||
|
config: {...config_data}
|
||||||
|
})
|
||||||
|
setEditVo(data)
|
||||||
|
formatSchema(config_data.schema_content)
|
||||||
|
} else {
|
||||||
|
form.resetFields();
|
||||||
|
}
|
||||||
|
setVisible(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 封装保存方法,添加提交逻辑
|
||||||
|
const handleSave = () => {
|
||||||
|
form
|
||||||
|
.validateFields()
|
||||||
|
.then(() => {
|
||||||
|
setLoading(true);
|
||||||
|
// 创建新服务对象
|
||||||
|
const { config, ...reset } = values
|
||||||
|
const request = editVo?.id ? updateTool(editVo?.id, {
|
||||||
|
...editVo,
|
||||||
|
...reset,
|
||||||
|
config: {
|
||||||
|
...editVo.config_data,
|
||||||
|
...config
|
||||||
|
}
|
||||||
|
}) : addTool({
|
||||||
|
...values,
|
||||||
|
tool_type: 'custom'
|
||||||
|
})
|
||||||
|
request.then(() => {
|
||||||
|
message.success(t('tool.addServiceSuccess'));
|
||||||
|
handleClose();
|
||||||
|
refresh()
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
setLoading(false);
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
console.log('表单验证失败:', err);
|
||||||
|
setLoading(false);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
const formatSchema = (value: string) => {
|
||||||
|
setParseSchemaData({} as ParseSchemaData)
|
||||||
|
try {
|
||||||
|
const json = JSON.parse(value)
|
||||||
|
parseSchema({ schema_content: json })
|
||||||
|
.then(res => {
|
||||||
|
const response = res as { data: ParseSchemaData }
|
||||||
|
setParseSchemaData(response.data)
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.log('error', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 暴露给父组件的方法
|
||||||
|
useImperativeHandle(ref, () => ({
|
||||||
|
handleOpen,
|
||||||
|
handleClose
|
||||||
|
}));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<RbModal
|
||||||
|
title={editVo?.id ? t('tool.editCustom') : t('tool.addCustom')}
|
||||||
|
open={visible}
|
||||||
|
onCancel={handleClose}
|
||||||
|
okText={t('common.save')}
|
||||||
|
onOk={handleSave}
|
||||||
|
confirmLoading={loading}
|
||||||
|
width={1000}
|
||||||
|
>
|
||||||
|
<Form
|
||||||
|
form={form}
|
||||||
|
layout="vertical"
|
||||||
|
initialValues={{
|
||||||
|
config: {
|
||||||
|
auth_type: 'none'
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Form.Item
|
||||||
|
name="name"
|
||||||
|
label={t('tool.name')}
|
||||||
|
rules={[{ required: true, message: t('common.pleaseEnter') }]}
|
||||||
|
>
|
||||||
|
<Input placeholder={t('common.pleaseEnter')} />
|
||||||
|
</Form.Item>
|
||||||
|
{/* 名称和图标 */}
|
||||||
|
{/* <Form.Item label={t('tool.nameAndIcon')} required>
|
||||||
|
<Row gutter={8}>
|
||||||
|
<Col span={16}>
|
||||||
|
<Form.Item
|
||||||
|
name="name"
|
||||||
|
noStyle
|
||||||
|
rules={[{ required: true, message: t('common.pleaseEnter') }]}
|
||||||
|
>
|
||||||
|
<Input placeholder={t('common.pleaseEnter')} />
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
<Col span={8}>
|
||||||
|
<Button>icon</Button>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
</Form.Item> */}
|
||||||
|
<Form.Item
|
||||||
|
name={['config', 'schema_content']}
|
||||||
|
label={t('tool.schema')}
|
||||||
|
rules={[{ required: true, message: t('common.pleaseEnter') }]}
|
||||||
|
>
|
||||||
|
<Input.TextArea rows={10} placeholder={t('tool.schemaPlaceholder')} onBlur={(e) => formatSchema(e.target.value)} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
label={t('tool.availableTools')}
|
||||||
|
>
|
||||||
|
<Table
|
||||||
|
rowKey="summary"
|
||||||
|
pagination={false}
|
||||||
|
columns={[
|
||||||
|
{
|
||||||
|
title: t('tool.name'),
|
||||||
|
dataIndex: 'summary',
|
||||||
|
key: 'summary',
|
||||||
|
render: (summary) => (
|
||||||
|
<span>{summary ?? parseSchemaData.title}</span>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t('tool.desc'),
|
||||||
|
dataIndex: 'description',
|
||||||
|
key: 'description',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t('tool.method'),
|
||||||
|
dataIndex: 'method',
|
||||||
|
key: 'method',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t('tool.path'),
|
||||||
|
dataIndex: 'path',
|
||||||
|
key: 'path',
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
initialData={parseSchemaData.operations || []}
|
||||||
|
emptySize={88}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<>
|
||||||
|
{/* 认证方式 */}
|
||||||
|
<FormItem
|
||||||
|
name={['config', 'auth_type']}
|
||||||
|
label={t('tool.auth_type')}
|
||||||
|
>
|
||||||
|
<Select
|
||||||
|
placeholder={t('common.pleaseSelect')}
|
||||||
|
options={authTypeList.map(value => ({
|
||||||
|
label: t(`tool.${value}`),
|
||||||
|
value
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
</FormItem>
|
||||||
|
|
||||||
|
{/* API Key: 认证方式 = api_key 展示 */}
|
||||||
|
{values?.config?.auth_type === 'api_key' && <>
|
||||||
|
<FormItem
|
||||||
|
name={['config', 'auth_config', "key_name"]}
|
||||||
|
label={t('tool.key_name')}
|
||||||
|
>
|
||||||
|
<Input placeholder={t('common.inputPlaceholder', { title: t('tool.key_name') })} />
|
||||||
|
</FormItem>
|
||||||
|
<FormItem
|
||||||
|
name={['config', 'auth_config', "api_key"]}
|
||||||
|
label={t('tool.api_key')}
|
||||||
|
rules={[{ required: true, message: t('common.pleaseEnter') }]}
|
||||||
|
>
|
||||||
|
<Input.Password placeholder={t('common.inputPlaceholder', { title: t('tool.api_key') })} />
|
||||||
|
</FormItem>
|
||||||
|
</>}
|
||||||
|
|
||||||
|
{/* API Key: 认证方式 = bearer_token 展示 */}
|
||||||
|
{values?.config?.auth_type === 'bearer_token' &&
|
||||||
|
<FormItem
|
||||||
|
name={['config', 'auth_config', "token"]}
|
||||||
|
label={t('tool.bearer_token')}
|
||||||
|
rules={[{ required: true, message: t('common.pleaseEnter') }]}
|
||||||
|
>
|
||||||
|
<Input.Password placeholder={t('common.inputPlaceholder', { title: t('tool.bearer_token') })} />
|
||||||
|
</FormItem>
|
||||||
|
}
|
||||||
|
|
||||||
|
{/* API Key: 认证方式 = basic_auth 展示 */}
|
||||||
|
{values?.config?.auth_type === 'basic_auth' &&
|
||||||
|
<>
|
||||||
|
<FormItem
|
||||||
|
name={['config', 'auth_config', "username"]}
|
||||||
|
label={t('tool.username')}
|
||||||
|
rules={[{ required: true, message: t('common.pleaseEnter') }]}
|
||||||
|
>
|
||||||
|
<Input placeholder={t('common.inputPlaceholder', { title: t('tool.username') })} />
|
||||||
|
</FormItem>
|
||||||
|
<FormItem
|
||||||
|
name={['config', 'auth_config', "password"]}
|
||||||
|
label={t('tool.password')}
|
||||||
|
rules={[{ required: true, message: t('common.pleaseEnter') }]}
|
||||||
|
>
|
||||||
|
<Input.Password placeholder={t('common.inputPlaceholder', { title: t('tool.password') })} />
|
||||||
|
</FormItem>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
</>
|
||||||
|
<FormItem
|
||||||
|
name="tags"
|
||||||
|
label={t('tool.tag')}
|
||||||
|
extra={t('tool.tagDesc')}
|
||||||
|
>
|
||||||
|
<Select
|
||||||
|
mode="tags"
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
placeholder={t('common.pleaseEnter')}
|
||||||
|
/>
|
||||||
|
</FormItem>
|
||||||
|
</Form>
|
||||||
|
</RbModal>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
export default CustomToolModal;
|
||||||
155
web/src/views/ToolManagement/components/InnerToolModal.tsx
Normal file
155
web/src/views/ToolManagement/components/InnerToolModal.tsx
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
import { forwardRef, useImperativeHandle, useState } from 'react';
|
||||||
|
import { Form, Input, Select, Checkbox, InputNumber, Button, App } from 'antd';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import type { InnerToolModalRef, ToolItem, InnerConfigItem, InnerToolItem } from '../types'
|
||||||
|
import RbModal from '@/components/RbModal'
|
||||||
|
import { InnerConfigData } from '../constant'
|
||||||
|
import RbAlert from '@/components/RbAlert';
|
||||||
|
import { updateTool, testConnection } from '@/api/tools'
|
||||||
|
|
||||||
|
const FormItem = Form.Item;
|
||||||
|
|
||||||
|
interface InnerToolModalProps {
|
||||||
|
refreshTable: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const InnerToolModal = forwardRef<InnerToolModalRef, InnerToolModalProps>(({
|
||||||
|
refreshTable
|
||||||
|
}, ref) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { message } = App.useApp();
|
||||||
|
const [visible, setVisible] = useState(false);
|
||||||
|
const [form] = Form.useForm<InnerToolItem>();
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [editVo, setEditVo] = useState<ToolItem>({} as ToolItem)
|
||||||
|
const [config, setConfig] = useState<InnerConfigItem['config']>({});
|
||||||
|
const search_type = Form.useWatch(['config', 'parameters', 'search_type'], form)
|
||||||
|
|
||||||
|
// 封装取消方法,添加关闭弹窗逻辑
|
||||||
|
const handleClose = () => {
|
||||||
|
setVisible(false);
|
||||||
|
form.resetFields();
|
||||||
|
setLoading(false)
|
||||||
|
setConfig({})
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOpen = (data: ToolItem) => {
|
||||||
|
setEditVo(data)
|
||||||
|
const { config_data } = data
|
||||||
|
form.setFieldsValue({
|
||||||
|
config: {
|
||||||
|
...config_data,
|
||||||
|
parameters: {
|
||||||
|
search_type: 'web',
|
||||||
|
...(config_data as any).parameters
|
||||||
|
},
|
||||||
|
}
|
||||||
|
})
|
||||||
|
setConfig(InnerConfigData[config_data.tool_class].config)
|
||||||
|
setVisible(true)
|
||||||
|
};
|
||||||
|
// 封装保存方法,添加提交逻辑
|
||||||
|
const handleSave = () => {
|
||||||
|
form
|
||||||
|
.validateFields()
|
||||||
|
.then((values) => {
|
||||||
|
updateTool(editVo.id, {
|
||||||
|
config: {
|
||||||
|
...editVo.config_data,
|
||||||
|
...values.config,
|
||||||
|
}
|
||||||
|
} as any)
|
||||||
|
.then(() => {
|
||||||
|
handleClose()
|
||||||
|
message.success(t('common.saveSuccess'))
|
||||||
|
refreshTable()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
console.log('err', err)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const handleTestConnection = () => {
|
||||||
|
testConnection(editVo.id)
|
||||||
|
.then(() => {
|
||||||
|
message.success(t('tool.testConnectionSuccess'));
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
// 暴露给父组件的方法
|
||||||
|
useImperativeHandle(ref, () => ({
|
||||||
|
handleOpen,
|
||||||
|
handleClose
|
||||||
|
}));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<RbModal
|
||||||
|
title={`${editVo.name} ${t('tool.config')}`}
|
||||||
|
open={visible}
|
||||||
|
onCancel={handleClose}
|
||||||
|
confirmLoading={loading}
|
||||||
|
footer={[
|
||||||
|
<Button onClick={handleClose}>{t('common.cancel')}</Button>,
|
||||||
|
<Button onClick={handleTestConnection}>{t('tool.textLink')}</Button>,
|
||||||
|
<Button type="primary" loading={loading} onClick={handleSave}>{t('common.save')}</Button>,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{editVo?.config_data?.tool_class && config && <>
|
||||||
|
<RbAlert className="rb:mb-3">
|
||||||
|
<div>
|
||||||
|
<div className="rb:text-[14px] rb:font-medium">{t('tool.configDesc')}</div>
|
||||||
|
<div className="rb:mt-2">{t(`tool.${editVo?.config_data?.tool_class}_config_desc`)}</div>
|
||||||
|
<div className="rb:font-medium">{t('tool.link')}: <Button size="small" type="link">{InnerConfigData[editVo?.config_data?.tool_class].link}</Button></div>
|
||||||
|
</div>
|
||||||
|
</RbAlert>
|
||||||
|
<Form
|
||||||
|
form={form}
|
||||||
|
layout="vertical"
|
||||||
|
>
|
||||||
|
{Object.keys(config).map((key) => {
|
||||||
|
const range = key === 'pagesize' && search_type ? config[key].range?.[search_type] ?? [] : [ config[key].min, config[key].max ]
|
||||||
|
return (
|
||||||
|
<FormItem
|
||||||
|
key={key}
|
||||||
|
label={config[key].type === 'checkbox' ? null : t(`tool.${key}`)}
|
||||||
|
name={config[key].name}
|
||||||
|
extra={config[key].desc ? t(`tool.${config[key].desc}`, { count1: range[0], count2: range[1] }) : null}
|
||||||
|
valuePropName={config[key].type === 'checkbox' ? 'checked' : 'value'}
|
||||||
|
rules={config[key].rules ? config[key].rules.map(vo => ({
|
||||||
|
...vo,
|
||||||
|
message: t(vo.message)
|
||||||
|
})) : []}
|
||||||
|
>
|
||||||
|
{config[key].type === 'input'
|
||||||
|
? <Input placeholder={t('common.inputPlaceholder', { title: t(`tool.${key}`) })} />
|
||||||
|
: config[key].type === 'number'
|
||||||
|
? <InputNumber
|
||||||
|
placeholder={t('common.pleaseEnter')}
|
||||||
|
min={range[0]}
|
||||||
|
max={range[1]}
|
||||||
|
step={config[key].step}
|
||||||
|
className="rb:w-full!"
|
||||||
|
/>
|
||||||
|
: config[key].type === 'checkbox'
|
||||||
|
? <Checkbox>{t(`tool.${key}`)}</Checkbox>
|
||||||
|
: config[key].type === 'select' && config[key].options
|
||||||
|
? <Select
|
||||||
|
placeholder={t('common.pleaseSelect')}
|
||||||
|
options={config[key].options.map(vo => ({
|
||||||
|
...vo,
|
||||||
|
label: t(`tool.${vo.label}`)
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
: null
|
||||||
|
}
|
||||||
|
</FormItem>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</Form>
|
||||||
|
</>}
|
||||||
|
</RbModal>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
export default InnerToolModal;
|
||||||
161
web/src/views/ToolManagement/components/JsonToolModal.tsx
Normal file
161
web/src/views/ToolManagement/components/JsonToolModal.tsx
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
import { forwardRef, useImperativeHandle, useState } from 'react';
|
||||||
|
import { Form, Input, Button, Space, Tree } from 'antd';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import type { TreeDataNode } from 'antd';
|
||||||
|
|
||||||
|
import type { ToolItem, JsonToolModalRef, ExecuteData } from '../types'
|
||||||
|
import RbModal from '@/components/RbModal';
|
||||||
|
import FormItem from 'antd/es/form/FormItem';
|
||||||
|
import CodeBlock from '@/components/Markdown/CodeBlock';
|
||||||
|
import { execute } from '@/api/tools';
|
||||||
|
|
||||||
|
const JsonToolModal = forwardRef<JsonToolModalRef>((_props, ref) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [visible, setVisible] = useState(false);
|
||||||
|
const [form] = Form.useForm<{ json: string; }>();
|
||||||
|
const [data, setData] = useState<ToolItem>({} as ToolItem)
|
||||||
|
const [formatValue, setFormatValue] = useState<string | Record<string, any> | null>(null)
|
||||||
|
|
||||||
|
// 转换数据结构为Tree组件需要的格式
|
||||||
|
const convertToTreeData = (data: Record<string, any>, parentKey = ''): TreeDataNode[] => {
|
||||||
|
if (data.children) {
|
||||||
|
return convertToTreeData(data.children, parentKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Object.entries(data).map(([key, item]) => {
|
||||||
|
const nodeKey = parentKey ? `${parentKey}-${key}` : key;
|
||||||
|
const title = `${key}: ${item.value || ''}`;
|
||||||
|
|
||||||
|
const node: TreeDataNode = {
|
||||||
|
key: nodeKey,
|
||||||
|
title,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (item.children) {
|
||||||
|
node.children = convertToTreeData(item.children, nodeKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
return node;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// 封装取消方法,添加关闭弹窗逻辑
|
||||||
|
const handleClose = () => {
|
||||||
|
setVisible(false);
|
||||||
|
form.resetFields();
|
||||||
|
setData({} as ToolItem)
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOpen = (data: ToolItem) => {
|
||||||
|
setData(data)
|
||||||
|
setVisible(true);
|
||||||
|
};
|
||||||
|
const handleParse = async () => {
|
||||||
|
try {
|
||||||
|
const text = await navigator.clipboard.readText();
|
||||||
|
form.setFieldValue('json', text);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to read clipboard:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const handleOperate = (type: string) => {
|
||||||
|
const json = form.getFieldValue('json')
|
||||||
|
if (!json || !data.id) return
|
||||||
|
let params: ExecuteData = {
|
||||||
|
tool_id: data.id,
|
||||||
|
parameters: {
|
||||||
|
operation: type,
|
||||||
|
input_data: json
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (type === 'format') {
|
||||||
|
params = {
|
||||||
|
...params,
|
||||||
|
parameters: {
|
||||||
|
...params.parameters,
|
||||||
|
indent: 2,
|
||||||
|
ensure_ascii: false,
|
||||||
|
sort_keys: false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
execute(params)
|
||||||
|
.then(res => {
|
||||||
|
const { data } = res as {data: {
|
||||||
|
formatted_json: string;
|
||||||
|
minified_json: string;
|
||||||
|
is_valid: boolean;
|
||||||
|
converted_json: string;
|
||||||
|
error: string;
|
||||||
|
structure: Record<string, string | number>
|
||||||
|
}}
|
||||||
|
switch (type) {
|
||||||
|
case 'format':
|
||||||
|
setFormatValue(data.formatted_json);
|
||||||
|
break
|
||||||
|
case 'minify':
|
||||||
|
setFormatValue(data.minified_json)
|
||||||
|
break
|
||||||
|
case 'validate':
|
||||||
|
setFormatValue(data.structure)
|
||||||
|
break
|
||||||
|
case 'convert':
|
||||||
|
setFormatValue(data.converted_json)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
const clear = () => {
|
||||||
|
form.setFieldValue('json', undefined)
|
||||||
|
setFormatValue(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 暴露给父组件的方法
|
||||||
|
useImperativeHandle(ref, () => ({
|
||||||
|
handleOpen,
|
||||||
|
handleClose
|
||||||
|
}));
|
||||||
|
return (
|
||||||
|
<RbModal
|
||||||
|
title={data.name}
|
||||||
|
open={visible}
|
||||||
|
onCancel={handleClose}
|
||||||
|
footer={null}
|
||||||
|
>
|
||||||
|
<Form
|
||||||
|
form={form}
|
||||||
|
layout="vertical"
|
||||||
|
>
|
||||||
|
<FormItem
|
||||||
|
name="json"
|
||||||
|
label={<Space size={8}>
|
||||||
|
{t('tool.enterJson')}
|
||||||
|
<Button onClick={clear}>{t('tool.clear')}</Button>
|
||||||
|
<Button onClick={handleParse}>{t('tool.parse')}</Button>
|
||||||
|
</Space>}
|
||||||
|
>
|
||||||
|
<Input.TextArea rows={10} placeholder={t('tool.jsonPlaceholder')} />
|
||||||
|
</FormItem>
|
||||||
|
|
||||||
|
<Space size={8} className="rb:mb-3">
|
||||||
|
<Button onClick={() => handleOperate('format')}>{t('tool.format')}</Button>
|
||||||
|
<Button onClick={() => handleOperate('minify')}>{t('tool.minify')}</Button>
|
||||||
|
<Button onClick={() => handleOperate('validate')}>{t('tool.validate')}</Button>
|
||||||
|
</Space>
|
||||||
|
<FormItem
|
||||||
|
label={t('tool.outputResult')}
|
||||||
|
>
|
||||||
|
{typeof formatValue === "string" && formatValue
|
||||||
|
? <CodeBlock value={formatValue} />
|
||||||
|
: formatValue && typeof formatValue === "object"
|
||||||
|
? <div className="rb:bg-[#F0F3F8] rb:p-[16px_20px_16px_24px] rb:rounded-lg"><Tree showLine treeData={convertToTreeData(formatValue)} /></div>
|
||||||
|
: <div className="rb:bg-[#F0F3F8] rb:p-[16px_20px_16px_24px] rb:rounded-lg rb:text-[#A8A9AA]">{t('tool.noResult')}</div>
|
||||||
|
}
|
||||||
|
</FormItem>
|
||||||
|
</Form>
|
||||||
|
</RbModal>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
export default JsonToolModal;
|
||||||
355
web/src/views/ToolManagement/components/McpServiceModal.tsx
Normal file
355
web/src/views/ToolManagement/components/McpServiceModal.tsx
Normal file
@@ -0,0 +1,355 @@
|
|||||||
|
import { forwardRef, useImperativeHandle, useState, useRef } from 'react';
|
||||||
|
import { Form, Input, Select, App, Button, Tabs, Space } from 'antd';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import type { MCPToolItem, ToolItem } from '../types'
|
||||||
|
import RbModal from '@/components/RbModal';
|
||||||
|
import Empty from '@/components/Empty';
|
||||||
|
import RequestHeaderModal from './RequestHeaderModal';
|
||||||
|
import Table from '@/components/Table';
|
||||||
|
import { addTool, updateTool, testConnection } from '@/api/tools'
|
||||||
|
import type { McpServiceModalRef } from '../types'
|
||||||
|
|
||||||
|
const FormItem = Form.Item;
|
||||||
|
|
||||||
|
interface McpServiceModalProps {
|
||||||
|
refresh: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RequestHeader {
|
||||||
|
key: string;
|
||||||
|
value: string;
|
||||||
|
[key: string]: string | undefined;
|
||||||
|
}
|
||||||
|
export interface RequestHeaderModalRef {
|
||||||
|
handleOpen: (index?: number, data?: RequestHeader) => void;
|
||||||
|
handleClose: () => void;
|
||||||
|
}
|
||||||
|
const authTypeList = ['none', 'api_key', 'basic_auth']
|
||||||
|
const tabKeys = ['auth', 'requestHeader', 'config']
|
||||||
|
const McpServiceModal = forwardRef<McpServiceModalRef, McpServiceModalProps>(({
|
||||||
|
refresh
|
||||||
|
}, ref) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { message } = App.useApp();
|
||||||
|
const [visible, setVisible] = useState(false);
|
||||||
|
const [form] = Form.useForm<MCPToolItem>();
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [editVo, setEditVo] = useState<ToolItem | null>(null)
|
||||||
|
const [activeTab, setActiveTab] = useState('auth');
|
||||||
|
const values = Form.useWatch<MCPToolItem>([], form)
|
||||||
|
const requestHeaderModalRef = useRef<RequestHeaderModalRef>(null)
|
||||||
|
const [requestHeaderList, setRequestHeaderList] = useState<RequestHeader[]>([])
|
||||||
|
|
||||||
|
const formatTabItems = () => {
|
||||||
|
return tabKeys.map(key => ({
|
||||||
|
key,
|
||||||
|
label: t(`tool.${key}`),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
const handleChangeTab = (key: string) => {
|
||||||
|
setActiveTab(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 封装取消方法,添加关闭弹窗逻辑
|
||||||
|
const handleClose = () => {
|
||||||
|
setVisible(false);
|
||||||
|
form.resetFields();
|
||||||
|
setLoading(false);
|
||||||
|
setEditVo(null)
|
||||||
|
setActiveTab('auth')
|
||||||
|
setRequestHeaderList([])
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOpen = (data?: ToolItem) => {
|
||||||
|
if (data?.id) {
|
||||||
|
const { config_data, name, description, icon } = data
|
||||||
|
form.setFieldsValue({
|
||||||
|
name, description, icon,
|
||||||
|
config: { ...config_data }
|
||||||
|
})
|
||||||
|
|
||||||
|
if (config_data.connection_config.headers) {
|
||||||
|
console.log(Object.keys(config_data.connection_config.headers).map(key => ({
|
||||||
|
key,
|
||||||
|
value: config_data.connection_config.headers[key]
|
||||||
|
})))
|
||||||
|
setRequestHeaderList(Object.keys(config_data.connection_config.headers).map(key => ({
|
||||||
|
key,
|
||||||
|
value: config_data.connection_config.headers[key]
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
setEditVo(data)
|
||||||
|
} else {
|
||||||
|
form.resetFields();
|
||||||
|
}
|
||||||
|
setVisible(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 封装保存方法,添加提交逻辑
|
||||||
|
const handleSave = () => {
|
||||||
|
form
|
||||||
|
.validateFields()
|
||||||
|
.then(() => {
|
||||||
|
setLoading(true);
|
||||||
|
// 创建新服务对象
|
||||||
|
const { config, ...rest } = values
|
||||||
|
|
||||||
|
const newService: MCPToolItem = {
|
||||||
|
...rest,
|
||||||
|
tool_type: 'mcp',
|
||||||
|
config: {
|
||||||
|
...config,
|
||||||
|
connection_config: {
|
||||||
|
...config.connection_config,
|
||||||
|
headers: requestHeaderList.reduce((acc: Record<string, string>, cur) => {
|
||||||
|
acc[cur.key] = cur.value
|
||||||
|
return acc
|
||||||
|
}, {})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const request = editVo?.id ? updateTool(editVo.id, newService) : addTool(newService)
|
||||||
|
request.then((res: any) => {
|
||||||
|
message.success(t('common.saveSuccess'));
|
||||||
|
testConnection(res.tool_id || editVo?.id)
|
||||||
|
.then(() => {
|
||||||
|
handleClose();
|
||||||
|
refresh()
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
setLoading(false);
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
setLoading(false);
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
console.log('表单验证失败:', err);
|
||||||
|
setLoading(false);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
const handleEditRequestHeader = (index?: number, data?: RequestHeader) => {
|
||||||
|
requestHeaderModalRef.current?.handleOpen(index, data)
|
||||||
|
}
|
||||||
|
const handleDeleteRequestHeader = (index: number) => {
|
||||||
|
const list = requestHeaderList.filter((_item, idx) => idx !== index)
|
||||||
|
setRequestHeaderList([...list])
|
||||||
|
}
|
||||||
|
|
||||||
|
// 暴露给父组件的方法
|
||||||
|
useImperativeHandle(ref, () => ({
|
||||||
|
handleOpen,
|
||||||
|
handleClose
|
||||||
|
}));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<RbModal
|
||||||
|
title={editVo?.id ? t('tool.editService') : `${t('tool.addService')} (HTTP)`}
|
||||||
|
open={visible}
|
||||||
|
onCancel={handleClose}
|
||||||
|
okText={t('tool.saveAndTest')}
|
||||||
|
onOk={handleSave}
|
||||||
|
confirmLoading={loading}
|
||||||
|
>
|
||||||
|
<Form
|
||||||
|
form={form}
|
||||||
|
layout="vertical"
|
||||||
|
initialValues={{
|
||||||
|
config: {
|
||||||
|
connection_config: {
|
||||||
|
auth_type: 'none',
|
||||||
|
timeout: 30,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* 服务端点 URL */}
|
||||||
|
<FormItem
|
||||||
|
name={['config', "server_url"]}
|
||||||
|
label={t('tool.serviceEndpoint')}
|
||||||
|
extra={t('tool.serviceEndpointExtra')}
|
||||||
|
rules={[{ required: true, message: t('common.pleaseEnter') }]}
|
||||||
|
>
|
||||||
|
<Input placeholder={t('tool.serviceEndpointPlaceholder')} />
|
||||||
|
</FormItem>
|
||||||
|
<Form.Item
|
||||||
|
name="name"
|
||||||
|
label={t('tool.name')}
|
||||||
|
rules={[{ required: true, message: t('common.pleaseEnter') }]}
|
||||||
|
>
|
||||||
|
<Input placeholder={t('tool.namePlaceholder')} />
|
||||||
|
</Form.Item>
|
||||||
|
{/* 名称和图标 */}
|
||||||
|
{/* <Form.Item label={t('tool.nameAndIcon')} required>
|
||||||
|
<Row gutter={8}>
|
||||||
|
<Col span={16}>
|
||||||
|
<Form.Item
|
||||||
|
name="name"
|
||||||
|
noStyle
|
||||||
|
rules={[{ required: true, message: t('common.pleaseEnter') }]}
|
||||||
|
>
|
||||||
|
<Input placeholder={t('tool.namePlaceholder')} />
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
<Col span={8}>
|
||||||
|
<Button>icon</Button>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
</Form.Item> */}
|
||||||
|
|
||||||
|
{/* 描述 */}
|
||||||
|
<FormItem
|
||||||
|
name="description"
|
||||||
|
label={t('tool.description')}
|
||||||
|
>
|
||||||
|
<Input.TextArea rows={3} placeholder={t('common.inputPlaceholder', { title: t('tool.description') })}/>
|
||||||
|
</FormItem>
|
||||||
|
|
||||||
|
|
||||||
|
{/* 认证、请求头、配置 */}
|
||||||
|
<Tabs
|
||||||
|
activeKey={activeTab}
|
||||||
|
items={formatTabItems()}
|
||||||
|
onChange={handleChangeTab}
|
||||||
|
/>
|
||||||
|
{/* 认证模块 */}
|
||||||
|
<>
|
||||||
|
{/* 认证方式 */}
|
||||||
|
<FormItem
|
||||||
|
name={['config', 'connection_config', 'auth_type']}
|
||||||
|
label={t('tool.auth_type')}
|
||||||
|
hidden={activeTab !== 'auth'}
|
||||||
|
>
|
||||||
|
<Select
|
||||||
|
placeholder={t('common.pleaseSelect')}
|
||||||
|
options={authTypeList.map(value => ({
|
||||||
|
label: t(`tool.${value}`),
|
||||||
|
value
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
</FormItem>
|
||||||
|
|
||||||
|
{/* API Key: 认证方式 = api_key 展示 */}
|
||||||
|
{values?.config?.connection_config?.auth_type === 'api_key' && <>
|
||||||
|
<FormItem
|
||||||
|
name={['config', 'connection_config', 'auth_config', "key_name"]}
|
||||||
|
label={t('tool.key_name')}
|
||||||
|
hidden={activeTab !== 'auth'}
|
||||||
|
>
|
||||||
|
<Input placeholder={t('common.inputPlaceholder', { title: t('tool.key_name') })} />
|
||||||
|
</FormItem>
|
||||||
|
<FormItem
|
||||||
|
name={['config', 'connection_config', 'auth_config', "api_key"]}
|
||||||
|
label={t('tool.api_key')}
|
||||||
|
hidden={activeTab !== 'auth'}
|
||||||
|
rules={[{ required: true, message: t('common.pleaseEnter') }]}
|
||||||
|
>
|
||||||
|
<Input.Password placeholder={t('common.inputPlaceholder', { title: t('tool.api_key') })} />
|
||||||
|
</FormItem>
|
||||||
|
</>}
|
||||||
|
|
||||||
|
{/* API Key: 认证方式 = bearer_token 展示 */}
|
||||||
|
{values?.config?.connection_config?.auth_type === 'bearer_token' &&
|
||||||
|
<FormItem
|
||||||
|
name={['config', 'connection_config', 'auth_config', "token"]}
|
||||||
|
label={t('tool.bearer_token')}
|
||||||
|
hidden={activeTab !== 'auth'}
|
||||||
|
rules={[{ required: true, message: t('common.pleaseEnter') }]}
|
||||||
|
>
|
||||||
|
<Input.Password placeholder={t('common.inputPlaceholder', { title: t('tool.bearer_token') })} />
|
||||||
|
</FormItem>
|
||||||
|
}
|
||||||
|
|
||||||
|
{/* API Key: 认证方式 = basic_auth 展示 */}
|
||||||
|
{values?.config?.connection_config?.auth_type === 'basic_auth' &&
|
||||||
|
<>
|
||||||
|
<FormItem
|
||||||
|
name={['config', 'connection_config', 'auth_config', "username"]}
|
||||||
|
label={t('tool.username')}
|
||||||
|
hidden={activeTab !== 'auth'}
|
||||||
|
rules={[{ required: true, message: t('common.pleaseEnter') }]}
|
||||||
|
>
|
||||||
|
<Input placeholder={t('common.inputPlaceholder', { title: t('tool.username') })} />
|
||||||
|
</FormItem>
|
||||||
|
<FormItem
|
||||||
|
name={['config', 'connection_config', 'auth_config', "password"]}
|
||||||
|
label={t('tool.password')}
|
||||||
|
hidden={activeTab !== 'auth'}
|
||||||
|
rules={[{ required: true, message: t('common.pleaseEnter') }]}
|
||||||
|
>
|
||||||
|
<Input.Password placeholder={t('common.inputPlaceholder', { title: t('tool.password') })} />
|
||||||
|
</FormItem>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
</>
|
||||||
|
{/* 请求头模块 */}
|
||||||
|
<div className={activeTab !== 'requestHeader' ? 'rb:hidden' : ''}>
|
||||||
|
<div className="rb:flex rb:items-center rb:justify-between rb:mb-1 rb:w-full">
|
||||||
|
<div className="rb:font-medium rb:leading-5">{t('tool.requestHeader')}</div>
|
||||||
|
<Button style={{padding: '0 8px', height: '24px'}} onClick={() => handleEditRequestHeader()}>+{t('tool.addRequestHeader')}</Button>
|
||||||
|
</div>
|
||||||
|
<div className="rb:text-[12px] rb:text-[#5B6167] rb:leading-4 rb:mb-3">{t('tool.requestHeaderDesc')}</div>
|
||||||
|
|
||||||
|
{requestHeaderList.length === 0
|
||||||
|
? <Empty size={88} />
|
||||||
|
:
|
||||||
|
<Table
|
||||||
|
rowKey="key"
|
||||||
|
pagination={false}
|
||||||
|
columns={[
|
||||||
|
{
|
||||||
|
title: t('tool.requestHeaderName'),
|
||||||
|
dataIndex: 'key',
|
||||||
|
key: 'key',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t('tool.requestHeaderValue'),
|
||||||
|
dataIndex: 'value',
|
||||||
|
key: 'value',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t('common.operation'),
|
||||||
|
key: 'action',
|
||||||
|
render: (_, record, index: number) => (
|
||||||
|
<Space size="middle">
|
||||||
|
<Button
|
||||||
|
type="link"
|
||||||
|
onClick={() => handleEditRequestHeader(index, record as RequestHeader)}
|
||||||
|
>
|
||||||
|
{t('common.edit')}
|
||||||
|
</Button>
|
||||||
|
<Button type="link" danger onClick={() => handleDeleteRequestHeader(index)}>
|
||||||
|
{t('common.delete')}
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
initialData={requestHeaderList}
|
||||||
|
emptySize={88}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
{/* 配置模块 */}
|
||||||
|
<>
|
||||||
|
<FormItem
|
||||||
|
name={['config', 'connection_config', "timeout"]}
|
||||||
|
label={t('tool.timeout')}
|
||||||
|
hidden={activeTab !== 'config'}
|
||||||
|
>
|
||||||
|
<Input type="number" min={5} max={300} placeholder={t('common.pleaseEnter')} />
|
||||||
|
</FormItem>
|
||||||
|
</>
|
||||||
|
</Form>
|
||||||
|
|
||||||
|
<RequestHeaderModal
|
||||||
|
ref={requestHeaderModalRef}
|
||||||
|
refreshTable={setRequestHeaderList}
|
||||||
|
/>
|
||||||
|
</RbModal>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
export default McpServiceModal;
|
||||||
102
web/src/views/ToolManagement/components/RequestHeaderModal.tsx
Normal file
102
web/src/views/ToolManagement/components/RequestHeaderModal.tsx
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
import { forwardRef, useImperativeHandle, useState, type SetStateAction, type Dispatch } from 'react';
|
||||||
|
import { Form, Input } from 'antd';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import type { RequestHeader, RequestHeaderModalRef } from './McpServiceModal'
|
||||||
|
import RbModal from '@/components/RbModal'
|
||||||
|
|
||||||
|
const FormItem = Form.Item;
|
||||||
|
|
||||||
|
interface RequestHeaderModalProps {
|
||||||
|
refreshTable: Dispatch<SetStateAction<RequestHeader[]>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const RequestHeaderModal = forwardRef<RequestHeaderModalRef, RequestHeaderModalProps>(({
|
||||||
|
refreshTable
|
||||||
|
}, ref) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [visible, setVisible] = useState(false);
|
||||||
|
const [form] = Form.useForm<RequestHeader>();
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [editIndex, setEditIndex] = useState<number | undefined>(-1)
|
||||||
|
|
||||||
|
const values = Form.useWatch([], form);
|
||||||
|
|
||||||
|
// 封装取消方法,添加关闭弹窗逻辑
|
||||||
|
const handleClose = () => {
|
||||||
|
setVisible(false);
|
||||||
|
form.resetFields();
|
||||||
|
setLoading(false)
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOpen = (index?: number, data?: RequestHeader) => {
|
||||||
|
if (data) {
|
||||||
|
setEditIndex(index)
|
||||||
|
form.setFieldsValue(data)
|
||||||
|
} else {
|
||||||
|
form.resetFields();
|
||||||
|
}
|
||||||
|
setVisible(true);
|
||||||
|
};
|
||||||
|
// 封装保存方法,添加提交逻辑
|
||||||
|
const handleSave = () => {
|
||||||
|
form
|
||||||
|
.validateFields()
|
||||||
|
.then(() => {
|
||||||
|
if (typeof editIndex === 'number' && editIndex > -1) {
|
||||||
|
refreshTable(prev => {
|
||||||
|
const newList = [...prev]
|
||||||
|
newList[editIndex] = values
|
||||||
|
return newList
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
refreshTable(prev => [...prev, values])
|
||||||
|
}
|
||||||
|
handleClose()
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
console.log('err', err)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 暴露给父组件的方法
|
||||||
|
useImperativeHandle(ref, () => ({
|
||||||
|
handleOpen,
|
||||||
|
handleClose
|
||||||
|
}));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<RbModal
|
||||||
|
title={typeof editIndex === 'number' ? t('tool.editRequestHeader') : t('tool.addRequestHeader')}
|
||||||
|
open={visible}
|
||||||
|
onCancel={handleClose}
|
||||||
|
okText={t('common.create')}
|
||||||
|
onOk={handleSave}
|
||||||
|
confirmLoading={loading}
|
||||||
|
>
|
||||||
|
<Form
|
||||||
|
form={form}
|
||||||
|
layout="vertical"
|
||||||
|
>
|
||||||
|
{/* 请求头名称 */}
|
||||||
|
<FormItem
|
||||||
|
name="key"
|
||||||
|
label={t('tool.requestHeaderName')}
|
||||||
|
rules={[{ required: true, message: t('common.pleaseEnter') }]}
|
||||||
|
>
|
||||||
|
<Input placeholder={t('common.enter')} />
|
||||||
|
</FormItem>
|
||||||
|
{/* 请求头值 */}
|
||||||
|
<FormItem
|
||||||
|
name="value"
|
||||||
|
label={t('tool.requestHeaderValue')}
|
||||||
|
rules={[{ required: true, message: t('common.pleaseEnter') }]}
|
||||||
|
>
|
||||||
|
<Input placeholder={t('common.enter',)} />
|
||||||
|
</FormItem>
|
||||||
|
</Form>
|
||||||
|
</RbModal>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
export default RequestHeaderModal;
|
||||||
205
web/src/views/ToolManagement/components/TimeToolModal.tsx
Normal file
205
web/src/views/ToolManagement/components/TimeToolModal.tsx
Normal file
@@ -0,0 +1,205 @@
|
|||||||
|
import { forwardRef, useImperativeHandle, useState, useEffect } from 'react';
|
||||||
|
import { Form, Input, Select, Row, Col, Button, Tabs } from 'antd';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import type { ToolItem, TimeToolModalRef } from '../types'
|
||||||
|
import RbModal from '@/components/RbModal';
|
||||||
|
import { execute } from '@/api/tools';
|
||||||
|
const FormItem = Form.Item;
|
||||||
|
|
||||||
|
const tabKeys = ['currentTime', 'timestampConversion', 'timeFormat']
|
||||||
|
const formatList = [
|
||||||
|
{ label: '%Y-%m-%d %H:%M:%S', value: '%Y-%m-%d %H:%M:%S' },
|
||||||
|
{ label: '%Y%m%d_%H%M%S', value: '%Y%m%d_%H%M%S' },
|
||||||
|
{ label: '%Y年%m月%d日 %H:%M', value: '%Y年%m月%d日 %H:%M' },
|
||||||
|
{ label: '%Y-%m-%d %H:%M:%S.%f', value: '%Y-%m-%d %H:%M:%S.%f' },
|
||||||
|
{ label: '%d/%m/%Y', value: '%d/%m/%Y' },
|
||||||
|
{ label: '%m/%d/%Y', value: '%m/%d/%Y' },
|
||||||
|
]
|
||||||
|
interface CurrentTimeObj {
|
||||||
|
datetime: string;
|
||||||
|
iso_format: string;
|
||||||
|
timestamp: string;
|
||||||
|
timestamp_ms: string;
|
||||||
|
}
|
||||||
|
const TimeToolModal = forwardRef<TimeToolModalRef>((_props, ref) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [visible, setVisible] = useState(false);
|
||||||
|
const [form] = Form.useForm<{ timestamp: string; formatType: string; }>();
|
||||||
|
const [data, setData] = useState<ToolItem>({} as ToolItem)
|
||||||
|
const [timeFormat, setTimeFormat] = useState<string | undefined>(undefined)
|
||||||
|
const [activeTab, setActiveTab] = useState('currentTime');
|
||||||
|
const values = Form.useWatch([], form)
|
||||||
|
const [currentTime, setCurrentTime] = useState<CurrentTimeObj>({} as CurrentTimeObj)
|
||||||
|
const [timestampFormat, setTimestampFormat] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const formatTabItems = () => {
|
||||||
|
return tabKeys.map(key => ({
|
||||||
|
key,
|
||||||
|
label: t(`tool.${key}`),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
const handleChangeTab = (key: string) => {
|
||||||
|
setActiveTab(key);
|
||||||
|
setTimestampFormat(null)
|
||||||
|
setTimeFormat(undefined)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 封装取消方法,添加关闭弹窗逻辑
|
||||||
|
const handleClose = () => {
|
||||||
|
setVisible(false);
|
||||||
|
form.resetFields();
|
||||||
|
setData({} as ToolItem)
|
||||||
|
setActiveTab('currentTime')
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOpen = (vo: ToolItem) => {
|
||||||
|
setData(vo)
|
||||||
|
setVisible(true);
|
||||||
|
getCurrentTime(vo)
|
||||||
|
};
|
||||||
|
|
||||||
|
const getCurrentTime = (vo: ToolItem) => {
|
||||||
|
if (!vo.id) return
|
||||||
|
execute({
|
||||||
|
tool_id: vo.id,
|
||||||
|
parameters: {
|
||||||
|
operation: 'now',
|
||||||
|
output_format: '%Y-%m-%d %H:%M:%S',
|
||||||
|
}
|
||||||
|
}).then(res => {
|
||||||
|
const response = res as { data: CurrentTimeObj}
|
||||||
|
setCurrentTime(response.data)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleFormat = () => {
|
||||||
|
const timestamp = form.getFieldValue('timestamp')
|
||||||
|
if (!timestamp || !data.id) return
|
||||||
|
execute({
|
||||||
|
"tool_id": data.id,
|
||||||
|
"parameters": {
|
||||||
|
"operation": "now",
|
||||||
|
"input_value": timestamp,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.then(res => {
|
||||||
|
const response = res as { data: CurrentTimeObj }
|
||||||
|
setTimestampFormat(response.data.datetime)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
const handleChangeFormatType = () => {
|
||||||
|
if (!data.id) return
|
||||||
|
execute({
|
||||||
|
tool_id: data.id,
|
||||||
|
parameters: {
|
||||||
|
operation: 'now',
|
||||||
|
output_format: values.formatType,
|
||||||
|
to_timezone: 'UTC'
|
||||||
|
}
|
||||||
|
}).then(res => {
|
||||||
|
const response = res as { data: CurrentTimeObj }
|
||||||
|
console.log('timeFormat', response.data.datetime)
|
||||||
|
setTimeFormat(response.data.datetime)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (values?.formatType) {
|
||||||
|
handleChangeFormatType()
|
||||||
|
}
|
||||||
|
}, [values?.formatType])
|
||||||
|
|
||||||
|
// 暴露给父组件的方法
|
||||||
|
useImperativeHandle(ref, () => ({
|
||||||
|
handleOpen,
|
||||||
|
handleClose
|
||||||
|
}));
|
||||||
|
|
||||||
|
|
||||||
|
return (
|
||||||
|
<RbModal
|
||||||
|
title={data.name}
|
||||||
|
open={visible}
|
||||||
|
onCancel={handleClose}
|
||||||
|
footer={null}
|
||||||
|
>
|
||||||
|
<Form
|
||||||
|
form={form}
|
||||||
|
layout="vertical"
|
||||||
|
initialValues={{
|
||||||
|
formatType: formatList[0].value
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* 当前时间、时间戳转换、时间格式化 */}
|
||||||
|
<Tabs
|
||||||
|
activeKey={activeTab}
|
||||||
|
items={formatTabItems()}
|
||||||
|
onChange={handleChangeTab}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 当前时间 */}
|
||||||
|
{activeTab === 'currentTime' &&
|
||||||
|
<>
|
||||||
|
<FormItem label={t('tool.currentTime')} >
|
||||||
|
<Input disabled value={currentTime?.datetime} />
|
||||||
|
</FormItem>
|
||||||
|
<FormItem label={t('tool.utcTime')} >
|
||||||
|
<Input disabled value={currentTime?.iso_format} />
|
||||||
|
</FormItem>
|
||||||
|
<FormItem label={t('tool.secondsTimestamp')} >
|
||||||
|
<Input disabled value={currentTime?.timestamp} />
|
||||||
|
</FormItem>
|
||||||
|
<FormItem label={t('tool.millisecondsTimestamp')} >
|
||||||
|
<Input disabled value={currentTime?.timestamp_ms} />
|
||||||
|
</FormItem>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
{/* 时间戳转换 */}
|
||||||
|
{activeTab === 'timestampConversion' &&
|
||||||
|
<>
|
||||||
|
<FormItem label={t('tool.enterTimestamp')} >
|
||||||
|
<Row gutter={24}>
|
||||||
|
<Col span={16}>
|
||||||
|
<FormItem name="timestamp">
|
||||||
|
<Input />
|
||||||
|
</FormItem>
|
||||||
|
</Col>
|
||||||
|
<Col span={8}>
|
||||||
|
<Button onClick={handleFormat}>{t('tool.conversion')}</Button>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
</FormItem>
|
||||||
|
{timestampFormat &&
|
||||||
|
<div className="rb:mt-3 rb:bg-[#F0F3F8] rb:px-3 rb:py-2.5 rb:rounded-md">
|
||||||
|
{t('tool.conversionResult')}
|
||||||
|
<div className="rb:font-medium rb:bg-white rb:px-3 rb:py-2.5 rb:rounded-md rb:mt-2">
|
||||||
|
{timestampFormat}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
{/* 时间格式化 */}
|
||||||
|
{activeTab === 'timeFormat' &&
|
||||||
|
<>
|
||||||
|
<FormItem label={t('tool.chooseFormatType')} name="formatType">
|
||||||
|
<Select
|
||||||
|
options={formatList}
|
||||||
|
onChange={handleChangeFormatType}
|
||||||
|
/>
|
||||||
|
</FormItem>
|
||||||
|
<div className="rb:mt-3 rb:bg-[#F0F3F8] rb:px-3 rb:py-2.5 rb:rounded-md">
|
||||||
|
{t('tool.conversionResult')}
|
||||||
|
<div className="rb:font-medium rb:bg-white rb:px-3 rb:py-2.5 rb:rounded-md rb:mt-2">
|
||||||
|
{timeFormat}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
</Form>
|
||||||
|
</RbModal>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
export default TimeToolModal;
|
||||||
190
web/src/views/ToolManagement/constant.ts
Normal file
190
web/src/views/ToolManagement/constant.ts
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
import type { InnerConfigItem } from './types';
|
||||||
|
export const InnerConfigData: Record<string, InnerConfigItem> = {
|
||||||
|
DateTimeTool: {
|
||||||
|
features: [
|
||||||
|
'timeFormat',
|
||||||
|
'timeZoneConversion',
|
||||||
|
'timestampConversion',
|
||||||
|
'timeCalculation'
|
||||||
|
],
|
||||||
|
},
|
||||||
|
JsonTool: {
|
||||||
|
features: [
|
||||||
|
'jsonFormat',
|
||||||
|
'jsonGzip',
|
||||||
|
'jsonCheck',
|
||||||
|
'jsonConversion'
|
||||||
|
],
|
||||||
|
eg: '{"name":"工具","tool_class":"内置"}'
|
||||||
|
},
|
||||||
|
BaiduSearchTool: {
|
||||||
|
link: 'https://ai.baidu.com/',
|
||||||
|
config: {
|
||||||
|
api_key: {
|
||||||
|
name: ['config', 'parameters', 'api_key'],
|
||||||
|
type: 'input',
|
||||||
|
desc: 'BaiduSearchTool_api_key_desc',
|
||||||
|
rules: [
|
||||||
|
{ required: true, message: 'common.pleaseEnter' }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
type: {
|
||||||
|
name: ['config', 'parameters', 'search_type'],
|
||||||
|
type: 'select',
|
||||||
|
options: [
|
||||||
|
{ label: 'webSearch', value: 'web' },
|
||||||
|
{ label: 'newsSearch', value: 'news' },
|
||||||
|
{ label: 'imageSearch', value: 'image' },
|
||||||
|
],
|
||||||
|
defaultValue: 'webSearch'
|
||||||
|
},
|
||||||
|
pagesize: {
|
||||||
|
name: ['config', 'parameters', 'pagesize'],
|
||||||
|
type: 'number',
|
||||||
|
range: {
|
||||||
|
web: [1, 50],
|
||||||
|
news: [1, 30],
|
||||||
|
image: [1, 10],
|
||||||
|
},
|
||||||
|
step: 1,
|
||||||
|
defaultValue: 10,
|
||||||
|
desc: 'pagesize_desc'
|
||||||
|
},
|
||||||
|
BaiduSearchTool_enable: {
|
||||||
|
name: ['config', 'is_enabled'],
|
||||||
|
type: 'checkbox',
|
||||||
|
defaultValue: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
features: [
|
||||||
|
'webSearch',
|
||||||
|
'newsSearch',
|
||||||
|
'imageSearch',
|
||||||
|
'realTimeResults'
|
||||||
|
],
|
||||||
|
},
|
||||||
|
MinerUTool: {
|
||||||
|
link: 'https://MinerUTool.ai/',
|
||||||
|
config: {
|
||||||
|
api_key: {
|
||||||
|
name: ['config', 'parameters', 'api_key'],
|
||||||
|
type: 'input',
|
||||||
|
desc: 'MinerUTool_api_key_desc',
|
||||||
|
rules: [
|
||||||
|
{ required: true, message: 'common.pleaseEnter' }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
api_address: {
|
||||||
|
name: ['config', 'parameters', 'api_address'],
|
||||||
|
type: 'input',
|
||||||
|
desc: 'MinerUTool_api_address_desc',
|
||||||
|
defaultValue: 'https://api.MinerUTool.ai/v1'
|
||||||
|
},
|
||||||
|
parsing_mode: {
|
||||||
|
name: ['config', 'parameters', 'parsing_mode'],
|
||||||
|
type: 'select',
|
||||||
|
options: [
|
||||||
|
{ label: 'auto_recognition', value: 'auto_recognition' },
|
||||||
|
{ label: 'pure_text_mode', value: 'pure_text_mode' },
|
||||||
|
{ label: 'table_priority', value: 'table_priority' },
|
||||||
|
{ label: 'image_priority', value: 'image_priority' },
|
||||||
|
],
|
||||||
|
defaultValue: 'auto_recognition'
|
||||||
|
},
|
||||||
|
timeout: {
|
||||||
|
name: ['config', 'parameters', 'timeout'],
|
||||||
|
type: 'number',
|
||||||
|
min: 10,
|
||||||
|
max: 300,
|
||||||
|
step: 1,
|
||||||
|
defaultValue: 60,
|
||||||
|
desc: 'MinerUTool_timeout_desc'
|
||||||
|
},
|
||||||
|
MinerUTool_enable: {
|
||||||
|
name: ['config', 'is_enabled'],
|
||||||
|
type: 'checkbox',
|
||||||
|
defaultValue: true,
|
||||||
|
},
|
||||||
|
MinerUTool_extract_images_enable: {
|
||||||
|
name: ['config', 'images_enable'],
|
||||||
|
type: 'checkbox',
|
||||||
|
defaultValue: true,
|
||||||
|
desc: 'MinerUTool_extract_images_enable_desc'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
features: [
|
||||||
|
'pdfParser',
|
||||||
|
'tableExtraction',
|
||||||
|
'imageRecognition',
|
||||||
|
'textExtraction'
|
||||||
|
],
|
||||||
|
},
|
||||||
|
TextInTool: {
|
||||||
|
link: 'https://www.TextInTool.com/',
|
||||||
|
config: {
|
||||||
|
app_id: {
|
||||||
|
name: ['config', 'parameters', 'app_id'],
|
||||||
|
type: 'input',
|
||||||
|
desc: 'TextInTool_app_id_desc',
|
||||||
|
rules: [
|
||||||
|
{ required: true, message: 'common.pleaseEnter' }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
secret_key: {
|
||||||
|
name: ['config', 'parameters', 'secret_key'],
|
||||||
|
type: 'input',
|
||||||
|
desc: 'TextInTool_secret_key_desc',
|
||||||
|
rules: [
|
||||||
|
{ required: true, message: 'common.pleaseEnter' }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
api_address: {
|
||||||
|
name: ['config', 'parameters', 'api_address'],
|
||||||
|
type: 'input',
|
||||||
|
desc: 'TextInTool_api_address_desc',
|
||||||
|
defaultValue: 'https://api.MinerUTool.ai/v1'
|
||||||
|
},
|
||||||
|
language_identification: {
|
||||||
|
name: ['config', 'parameters', 'language_identification'],
|
||||||
|
type: 'select',
|
||||||
|
options: [
|
||||||
|
{ label: 'automatic_detection', value: 'automatic_detection' },
|
||||||
|
{ label: 'simplified_chinese', value: 'simplified_chinese' },
|
||||||
|
{ label: 'traditional_chinese', value: 'traditional_chinese' },
|
||||||
|
{ label: 'english', value: 'english' },
|
||||||
|
{ label: 'japanese', value: 'japanese' },
|
||||||
|
{ label: 'korean_language', value: 'korean_language' },
|
||||||
|
],
|
||||||
|
defaultValue: 'automatic_detection'
|
||||||
|
},
|
||||||
|
pattern_recognition: {
|
||||||
|
name: ['config', 'parameters', 'pattern_recognition'],
|
||||||
|
type: 'select',
|
||||||
|
options: [
|
||||||
|
{ label: 'universal_identification', value: 'universal_identification' },
|
||||||
|
{ label: 'high_precision_identification', value: 'high_precision_identification' },
|
||||||
|
{ label: 'handwriting_recognition', value: 'handwriting_recognition' },
|
||||||
|
{ label: 'formula_recognition', value: 'formula_recognition' },
|
||||||
|
],
|
||||||
|
defaultValue: 'universal_identification'
|
||||||
|
},
|
||||||
|
TextInTool_enable: {
|
||||||
|
name: ['config', 'is_enabled'],
|
||||||
|
type: 'checkbox',
|
||||||
|
defaultValue: true,
|
||||||
|
},
|
||||||
|
return_text_position_enable: {
|
||||||
|
name: ['config', 'position_enable'],
|
||||||
|
type: 'checkbox',
|
||||||
|
defaultValue: true,
|
||||||
|
desc: 'return_text_position_enable_desc'
|
||||||
|
},
|
||||||
|
},
|
||||||
|
features: [
|
||||||
|
'universalOCR',
|
||||||
|
'handwritingRecognition',
|
||||||
|
'multilingualSupport',
|
||||||
|
'highPrecisionRecognition'
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}
|
||||||
52
web/src/views/ToolManagement/index.tsx
Normal file
52
web/src/views/ToolManagement/index.tsx
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { Tabs } from 'antd';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import Mcp from './Mcp';
|
||||||
|
import Inner from './Inner';
|
||||||
|
import Custom from './Custom';
|
||||||
|
import Tag from '@/components/Tag'
|
||||||
|
|
||||||
|
const tabKeys = ['mcp', 'inner', 'custom']
|
||||||
|
const ToolManagement: React.FC = () => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [activeTab, setActiveTab] = useState('mcp');
|
||||||
|
|
||||||
|
const formatTabItems = () => {
|
||||||
|
return tabKeys.map(key => ({
|
||||||
|
key,
|
||||||
|
label: t(`tool.${key}`),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
const handleChangeTab = (key: string) => {
|
||||||
|
setActiveTab(key);
|
||||||
|
}
|
||||||
|
// 获取状态标签
|
||||||
|
const getStatusTag = (status: string) => {
|
||||||
|
switch (status) {
|
||||||
|
case 'available':
|
||||||
|
return <Tag color="processing">{t('tool.status.available')}</Tag>;
|
||||||
|
case 'unconfigured':
|
||||||
|
return <Tag color="default">{t('tool.status.unconfigured')}</Tag>;
|
||||||
|
case 'configured_disabled':
|
||||||
|
return <Tag color="warning">{t('tool.status.configured_disabled')}</Tag>;
|
||||||
|
case 'error':
|
||||||
|
return <Tag color="error">{t('tool.status.error')}</Tag>;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rb:-mt-4">
|
||||||
|
<Tabs
|
||||||
|
activeKey={activeTab}
|
||||||
|
items={formatTabItems()}
|
||||||
|
onChange={handleChangeTab}
|
||||||
|
/>
|
||||||
|
{activeTab === 'mcp' && <Mcp getStatusTag={getStatusTag} />}
|
||||||
|
{activeTab === 'inner' && <Inner getStatusTag={getStatusTag} />}
|
||||||
|
{activeTab === 'custom' && <Custom getStatusTag={getStatusTag} />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ToolManagement;
|
||||||
138
web/src/views/ToolManagement/types.ts
Normal file
138
web/src/views/ToolManagement/types.ts
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
type ToolType = 'mcp' | 'builtin' | 'custom'
|
||||||
|
export interface Query {
|
||||||
|
name?: string;
|
||||||
|
tool_type: ToolType
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ApiKeyAuth {
|
||||||
|
key_name: string;
|
||||||
|
api_key: string;
|
||||||
|
}
|
||||||
|
interface BasicAuth {
|
||||||
|
username: string;
|
||||||
|
password: string;
|
||||||
|
}
|
||||||
|
interface BearerTokenAuth {
|
||||||
|
token: string;
|
||||||
|
}
|
||||||
|
export interface MCPToolItem {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
icon: string | null;
|
||||||
|
tool_type: ToolType;
|
||||||
|
config: {
|
||||||
|
server_url: string;
|
||||||
|
connection_config: {
|
||||||
|
auth_type: 'none' | 'api_key' | 'basic_auth' | 'bearer_token';
|
||||||
|
auth_config: ApiKeyAuth | BasicAuth | BearerTokenAuth;
|
||||||
|
timeout: number;
|
||||||
|
headers: Record<string, string>;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export interface InnerToolItem {
|
||||||
|
config: {
|
||||||
|
tool_class: string;
|
||||||
|
requires_config: boolean;
|
||||||
|
is_enabled: boolean;
|
||||||
|
parameters: {
|
||||||
|
api_key: string;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export interface CustomToolItem {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
icon?: string | null;
|
||||||
|
tool_type: string;
|
||||||
|
config: {
|
||||||
|
auth_type: 'none' | 'api_key' | 'basic_auth' | 'bearer_token';
|
||||||
|
auth_config: ApiKeyAuth | BasicAuth | BearerTokenAuth;
|
||||||
|
schema_content: string;
|
||||||
|
schema_url: null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export interface ToolItem {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
icon: string | null;
|
||||||
|
tool_type: ToolType;
|
||||||
|
version: string;
|
||||||
|
parameters: any[];
|
||||||
|
config_data: {
|
||||||
|
server_url: string;
|
||||||
|
connection_config: {
|
||||||
|
auth_type: 'none' | 'api_key' | 'basic_auth' | 'bearer_token';
|
||||||
|
auth_config: ApiKeyAuth | BasicAuth | BearerTokenAuth;
|
||||||
|
timeout: number;
|
||||||
|
headers: Record<string, string>;
|
||||||
|
};
|
||||||
|
last_health_check: number | null;
|
||||||
|
health_status: 'unknown' | 'healthy' | 'unhealthy';
|
||||||
|
available_tools: any[];
|
||||||
|
|
||||||
|
tool_class: string;
|
||||||
|
|
||||||
|
schema_content: string;
|
||||||
|
};
|
||||||
|
status: 'available' | 'unavailable';
|
||||||
|
tags: string[];
|
||||||
|
tenant_id: string;
|
||||||
|
created_at: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export interface McpServiceModalRef {
|
||||||
|
handleOpen: (data?: ToolItem) => void;
|
||||||
|
handleClose: () => void;
|
||||||
|
}
|
||||||
|
export interface TimeToolModalRef {
|
||||||
|
handleOpen: (data: ToolItem) => void;
|
||||||
|
}
|
||||||
|
export interface JsonToolModalRef {
|
||||||
|
handleOpen: (data: ToolItem) => void;
|
||||||
|
}
|
||||||
|
export interface InnerToolModalRef {
|
||||||
|
handleOpen: (data: ToolItem) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ConfigItem {
|
||||||
|
name: string | string[];
|
||||||
|
type: 'input' | 'select' | 'checkbox' | 'number';
|
||||||
|
desc?: string;
|
||||||
|
rules?: any[];
|
||||||
|
options?: { label: string; value: string }[];
|
||||||
|
range?: { [key: string]: number[]}
|
||||||
|
min?: number;
|
||||||
|
max?: number;
|
||||||
|
step?: number;
|
||||||
|
defaultValue?: any;
|
||||||
|
}
|
||||||
|
export interface InnerConfigItem {
|
||||||
|
link?: string;
|
||||||
|
config?: Record<string, ConfigItem>
|
||||||
|
features: string[];
|
||||||
|
eg?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExecuteData {
|
||||||
|
tool_id: string;
|
||||||
|
parameters: {
|
||||||
|
operation: string;
|
||||||
|
// 时间戳转换日期时间
|
||||||
|
input_value?: string;
|
||||||
|
output_format?: string;
|
||||||
|
to_timezone?: string;
|
||||||
|
input_format?: string;
|
||||||
|
from_timezone?: string;
|
||||||
|
indent?: number;
|
||||||
|
ensure_ascii?: boolean;
|
||||||
|
sort_keys?: boolean;
|
||||||
|
input_data?: string;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export interface CustomToolModalRef {
|
||||||
|
handleOpen: (data?: ToolItem) => void;
|
||||||
|
handleClose: () => void;
|
||||||
|
}
|
||||||
13
web/src/views/Workflow/components/Editor/commands/index.ts
Normal file
13
web/src/views/Workflow/components/Editor/commands/index.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { createCommand, type LexicalCommand } from 'lexical';
|
||||||
|
import type { Suggestion } from '../plugin/AutocompletePlugin';
|
||||||
|
|
||||||
|
|
||||||
|
export interface InsertVariableCommandPayload {
|
||||||
|
data: Suggestion;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const INSERT_VARIABLE_COMMAND: LexicalCommand<InsertVariableCommandPayload> = createCommand('INSERT_VARIABLE_COMMAND');
|
||||||
|
|
||||||
|
export const CLEAR_EDITOR_COMMAND: LexicalCommand<void> = createCommand('CLEAR_EDITOR_COMMAND');
|
||||||
|
|
||||||
|
export const FOCUS_EDITOR_COMMAND: LexicalCommand<void> = createCommand('FOCUS_EDITOR_COMMAND');
|
||||||
@@ -3,11 +3,18 @@ import { LexicalComposer } from '@lexical/react/LexicalComposer';
|
|||||||
import { RichTextPlugin } from '@lexical/react/LexicalRichTextPlugin';
|
import { RichTextPlugin } from '@lexical/react/LexicalRichTextPlugin';
|
||||||
import { ContentEditable } from '@lexical/react/LexicalContentEditable';
|
import { ContentEditable } from '@lexical/react/LexicalContentEditable';
|
||||||
import { HistoryPlugin } from '@lexical/react/LexicalHistoryPlugin';
|
import { HistoryPlugin } from '@lexical/react/LexicalHistoryPlugin';
|
||||||
import { AutoFocusPlugin } from '@lexical/react/LexicalAutoFocusPlugin';
|
// import { AutoFocusPlugin } from '@lexical/react/LexicalAutoFocusPlugin';
|
||||||
import { LexicalErrorBoundary } from '@lexical/react/LexicalErrorBoundary';
|
import { LexicalErrorBoundary } from '@lexical/react/LexicalErrorBoundary';
|
||||||
|
// import { HeadingNode, QuoteNode } from '@lexical/rich-text';
|
||||||
|
// import { ListItemNode, ListNode } from '@lexical/list';
|
||||||
|
// import { LinkNode } from '@lexical/link';
|
||||||
|
// import { CodeNode } from '@lexical/code';
|
||||||
|
|
||||||
import AutocompletePlugin, { type Suggestion } from './plugin/AutocompletePlugin'
|
import AutocompletePlugin, { type Suggestion } from './plugin/AutocompletePlugin'
|
||||||
import CharacterCountPlugin from './plugin/CharacterCountPlugin'
|
import CharacterCountPlugin from './plugin/CharacterCountPlugin'
|
||||||
import InitialValuePlugin from './plugin/InitialValuePlugin';
|
import InitialValuePlugin from './plugin/InitialValuePlugin';
|
||||||
|
import CommandPlugin from './plugin/CommandPlugin';
|
||||||
|
import { VariableNode } from './nodes/VariableNode'
|
||||||
|
|
||||||
interface LexicalEditorProps {
|
interface LexicalEditorProps {
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
@@ -30,10 +37,19 @@ const Editor: FC<LexicalEditorProps> =({
|
|||||||
onChange,
|
onChange,
|
||||||
suggestions,
|
suggestions,
|
||||||
}) => {
|
}) => {
|
||||||
const [count, setCount] = useState(0);
|
const [_count, setCount] = useState(0);
|
||||||
const initialConfig = {
|
const initialConfig = {
|
||||||
namespace: 'AutocompleteEditor',
|
namespace: 'AutocompleteEditor',
|
||||||
theme,
|
theme,
|
||||||
|
nodes: [
|
||||||
|
// HeadingNode,
|
||||||
|
// QuoteNode,
|
||||||
|
// ListItemNode,
|
||||||
|
// ListNode,
|
||||||
|
// LinkNode,
|
||||||
|
// CodeNode,
|
||||||
|
VariableNode
|
||||||
|
],
|
||||||
onError: (error: Error) => {
|
onError: (error: Error) => {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
},
|
},
|
||||||
@@ -74,10 +90,10 @@ const Editor: FC<LexicalEditorProps> =({
|
|||||||
ErrorBoundary={LexicalErrorBoundary}
|
ErrorBoundary={LexicalErrorBoundary}
|
||||||
/>
|
/>
|
||||||
<HistoryPlugin />
|
<HistoryPlugin />
|
||||||
<AutoFocusPlugin />
|
<CommandPlugin />
|
||||||
<AutocompletePlugin suggestions={suggestions} />
|
<AutocompletePlugin suggestions={suggestions} />
|
||||||
<CharacterCountPlugin setCount={(count) => { setCount(count) }} onChange={onChange} />
|
<CharacterCountPlugin setCount={(count) => { setCount(count) }} onChange={onChange} />
|
||||||
<InitialValuePlugin value={value} />
|
<InitialValuePlugin value={value} suggestions={suggestions} />
|
||||||
</div>
|
</div>
|
||||||
</LexicalComposer>
|
</LexicalComposer>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,113 +0,0 @@
|
|||||||
import {
|
|
||||||
$applyNodeReplacement,
|
|
||||||
DecoratorNode,
|
|
||||||
} from 'lexical';
|
|
||||||
import type { NodeKey, SerializedLexicalNode, Spread } from 'lexical';
|
|
||||||
import React from 'react';
|
|
||||||
|
|
||||||
export type SerializedTagNode = Spread<
|
|
||||||
{
|
|
||||||
label: string;
|
|
||||||
tagType: string;
|
|
||||||
},
|
|
||||||
SerializedLexicalNode
|
|
||||||
>;
|
|
||||||
|
|
||||||
export class TagNode extends DecoratorNode<JSX.Element> {
|
|
||||||
__label: string;
|
|
||||||
__type: string;
|
|
||||||
|
|
||||||
static getType(): string {
|
|
||||||
return 'tagNode';
|
|
||||||
}
|
|
||||||
|
|
||||||
static clone(node: TagNode): TagNode {
|
|
||||||
return new TagNode(node.__label, node.__type, node.__key);
|
|
||||||
}
|
|
||||||
|
|
||||||
constructor(label: string, type: string, key?: NodeKey) {
|
|
||||||
super(key);
|
|
||||||
this.__label = label;
|
|
||||||
this.__type = type;
|
|
||||||
}
|
|
||||||
|
|
||||||
createDOM(): HTMLElement {
|
|
||||||
return document.createElement('span');
|
|
||||||
}
|
|
||||||
|
|
||||||
updateDOM(): false {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
static importJSON(serializedNode: SerializedTagNode): TagNode {
|
|
||||||
const { label, tagType } = serializedNode;
|
|
||||||
return $createTagNode(label, tagType);
|
|
||||||
}
|
|
||||||
|
|
||||||
exportJSON(): SerializedTagNode {
|
|
||||||
return {
|
|
||||||
label: this.__label,
|
|
||||||
tagType: this.__type,
|
|
||||||
type: 'tagNode',
|
|
||||||
version: 1,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
getTextContent(): string {
|
|
||||||
return this.__label;
|
|
||||||
}
|
|
||||||
|
|
||||||
decorate(): JSX.Element {
|
|
||||||
const getIconAndColor = (type: string) => {
|
|
||||||
switch (type) {
|
|
||||||
case 'context':
|
|
||||||
return { icon: '📄', bgColor: '#722ed1' };
|
|
||||||
case 'system':
|
|
||||||
return { icon: 'x', bgColor: '#1890ff' };
|
|
||||||
default:
|
|
||||||
return { icon: 'x', bgColor: '#52c41a' };
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const { icon, bgColor } = getIconAndColor(this.__type);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
display: 'inline-flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: '4px',
|
|
||||||
background: '#f0f8ff',
|
|
||||||
border: '1px solid #d9d9d9',
|
|
||||||
borderRadius: '4px',
|
|
||||||
padding: '2px 6px',
|
|
||||||
fontSize: '14px',
|
|
||||||
margin: '0 2px',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
background: bgColor,
|
|
||||||
color: 'white',
|
|
||||||
padding: '1px 4px',
|
|
||||||
borderRadius: '2px',
|
|
||||||
fontSize: '10px',
|
|
||||||
minWidth: '12px',
|
|
||||||
textAlign: 'center',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{icon}
|
|
||||||
</span>
|
|
||||||
<span>{this.__label}</span>
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function $createTagNode(label: string, type: string): TagNode {
|
|
||||||
return new TagNode(label, type);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function $isTagNode(node: any): node is TagNode {
|
|
||||||
return node instanceof TagNode;
|
|
||||||
}
|
|
||||||
133
web/src/views/Workflow/components/Editor/nodes/VariableNode.tsx
Normal file
133
web/src/views/Workflow/components/Editor/nodes/VariableNode.tsx
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import clsx from 'clsx'
|
||||||
|
import type {
|
||||||
|
EditorConfig,
|
||||||
|
LexicalNode,
|
||||||
|
NodeKey,
|
||||||
|
SerializedLexicalNode,
|
||||||
|
Spread,
|
||||||
|
} from 'lexical';
|
||||||
|
import {
|
||||||
|
$applyNodeReplacement,
|
||||||
|
DecoratorNode,
|
||||||
|
} from 'lexical';
|
||||||
|
import { useLexicalNodeSelection } from '@lexical/react/useLexicalNodeSelection';
|
||||||
|
import type { Suggestion } from '../plugin/AutocompletePlugin';
|
||||||
|
|
||||||
|
export type SerializedVariableNode = Spread<
|
||||||
|
{
|
||||||
|
data: Suggestion;
|
||||||
|
},
|
||||||
|
SerializedLexicalNode
|
||||||
|
>;
|
||||||
|
|
||||||
|
const VariableComponent: React.FC<{ nodeKey: NodeKey; data: Suggestion }> = ({
|
||||||
|
nodeKey,
|
||||||
|
data,
|
||||||
|
}) => {
|
||||||
|
const [isSelected, setSelected] = useLexicalNodeSelection(nodeKey);
|
||||||
|
|
||||||
|
const handleClick = (e: React.MouseEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
setSelected(!isSelected);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
onClick={handleClick}
|
||||||
|
className={clsx('rb:border rb:rounded-md rb:bg-white rb:text-[12px] rb:inline-flex rb:items-center rb:py-0.5 rb:px-1.5 rb:mx-0.5 rb:cursor-pointer', {
|
||||||
|
'rb:border-[#155EEF]': isSelected,
|
||||||
|
'rb:border-[#DFE4ED]': !isSelected
|
||||||
|
})}
|
||||||
|
contentEditable={false}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={data.nodeData?.icon}
|
||||||
|
style={{ width: '12px', height: '12px', marginRight: '4px' }}
|
||||||
|
alt=""
|
||||||
|
/>
|
||||||
|
{data.nodeData?.name}
|
||||||
|
<span style={{ color: '#DFE4ED', margin: '0 2px' }}>/</span>
|
||||||
|
<span style={{ color: '#155EEF' }}>{data.label}</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export class VariableNode extends DecoratorNode<React.JSX.Element> {
|
||||||
|
__data: Suggestion;
|
||||||
|
|
||||||
|
static getType(): string {
|
||||||
|
return 'tag';
|
||||||
|
}
|
||||||
|
|
||||||
|
static clone(node: VariableNode): VariableNode {
|
||||||
|
return new VariableNode(node.__data, node.__key);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(data: Suggestion, key?: NodeKey) {
|
||||||
|
super(key);
|
||||||
|
this.__data = data;
|
||||||
|
}
|
||||||
|
|
||||||
|
createDOM(_config: EditorConfig): HTMLElement {
|
||||||
|
const element = document.createElement('span');
|
||||||
|
element.style.display = 'inline-block';
|
||||||
|
return element;
|
||||||
|
}
|
||||||
|
|
||||||
|
updateDOM(): false {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
decorate(): React.JSX.Element {
|
||||||
|
return <VariableComponent nodeKey={this.__key} data={this.__data} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
getTextContent(): string {
|
||||||
|
return `{{${this.__data?.value}}}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
static importJSON(serializedNode: SerializedVariableNode): VariableNode {
|
||||||
|
const { data } = serializedNode;
|
||||||
|
return $createVariableNode(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
exportJSON(): SerializedVariableNode {
|
||||||
|
return {
|
||||||
|
data: this.__data,
|
||||||
|
type: 'tag',
|
||||||
|
version: 1,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
canInsertTextBefore(): boolean {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
canInsertTextAfter(): boolean {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
canBeEmpty(): boolean {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
isInline(): true {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
isKeyboardSelectable(): boolean {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function $createVariableNode(data: Suggestion): VariableNode {
|
||||||
|
return $applyNodeReplacement(new VariableNode(data));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function $isVariableNode(
|
||||||
|
node: LexicalNode | null | undefined,
|
||||||
|
): node is VariableNode {
|
||||||
|
return node instanceof VariableNode;
|
||||||
|
}
|
||||||
@@ -1,7 +1,10 @@
|
|||||||
import { useEffect, useState, type FC } from 'react';
|
import { useEffect, useState, type FC } from 'react';
|
||||||
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
|
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
|
||||||
import { $getRoot, $createTextNode, $createParagraphNode, $setSelection, $createRangeSelection, $getSelection } from 'lexical';
|
import { $getRoot, $getSelection } from 'lexical';
|
||||||
|
|
||||||
|
import { INSERT_VARIABLE_COMMAND } from '../commands';
|
||||||
import type { NodeProperties } from '../../../types'
|
import type { NodeProperties } from '../../../types'
|
||||||
|
|
||||||
export interface Suggestion {
|
export interface Suggestion {
|
||||||
key: string;
|
key: string;
|
||||||
label: string;
|
label: string;
|
||||||
@@ -10,6 +13,7 @@ export interface Suggestion {
|
|||||||
value: string;
|
value: string;
|
||||||
nodeData: NodeProperties
|
nodeData: NodeProperties
|
||||||
}
|
}
|
||||||
|
|
||||||
const AutocompletePlugin: FC<{ suggestions: Suggestion[] }> = ({ suggestions }) => {
|
const AutocompletePlugin: FC<{ suggestions: Suggestion[] }> = ({ suggestions }) => {
|
||||||
const [editor] = useLexicalComposerContext();
|
const [editor] = useLexicalComposerContext();
|
||||||
const [showSuggestions, setShowSuggestions] = useState(false);
|
const [showSuggestions, setShowSuggestions] = useState(false);
|
||||||
@@ -32,19 +36,14 @@ const AutocompletePlugin: FC<{ suggestions: Suggestion[] }> = ({ suggestions })
|
|||||||
const range = domSelection.getRangeAt(0);
|
const range = domSelection.getRangeAt(0);
|
||||||
const rect = range.getBoundingClientRect();
|
const rect = range.getBoundingClientRect();
|
||||||
|
|
||||||
// Calculate popup dimensions
|
|
||||||
const popupWidth = 280;
|
const popupWidth = 280;
|
||||||
const popupHeight = 200;
|
const popupHeight = 200;
|
||||||
|
|
||||||
// Get viewport dimensions
|
|
||||||
const viewportWidth = window.innerWidth;
|
const viewportWidth = window.innerWidth;
|
||||||
const viewportHeight = window.innerHeight;
|
const viewportHeight = window.innerHeight;
|
||||||
|
|
||||||
// Calculate position with viewport constraints
|
|
||||||
let left = rect.left;
|
let left = rect.left;
|
||||||
let top = rect.top - 10;
|
let top = rect.top - 10;
|
||||||
|
|
||||||
// Adjust horizontal position if popup would overflow
|
|
||||||
if (left + popupWidth > viewportWidth) {
|
if (left + popupWidth > viewportWidth) {
|
||||||
left = viewportWidth - popupWidth - 10;
|
left = viewportWidth - popupWidth - 10;
|
||||||
}
|
}
|
||||||
@@ -52,9 +51,7 @@ const AutocompletePlugin: FC<{ suggestions: Suggestion[] }> = ({ suggestions })
|
|||||||
left = 10;
|
left = 10;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Adjust vertical position if popup would overflow
|
|
||||||
if (top - popupHeight < 10) {
|
if (top - popupHeight < 10) {
|
||||||
// Show below cursor if not enough space above
|
|
||||||
top = rect.bottom + 10;
|
top = rect.bottom + 10;
|
||||||
if (top + popupHeight > viewportHeight) {
|
if (top + popupHeight > viewportHeight) {
|
||||||
top = viewportHeight - popupHeight - 10;
|
top = viewportHeight - popupHeight - 10;
|
||||||
@@ -69,31 +66,8 @@ const AutocompletePlugin: FC<{ suggestions: Suggestion[] }> = ({ suggestions })
|
|||||||
});
|
});
|
||||||
}, [editor]);
|
}, [editor]);
|
||||||
|
|
||||||
const insertMention = (suggestion: any) => {
|
const insertMention = (suggestion: Suggestion) => {
|
||||||
editor.update(() => {
|
editor.dispatchCommand(INSERT_VARIABLE_COMMAND, { data: suggestion });
|
||||||
const root = $getRoot();
|
|
||||||
const text = root.getTextContent();
|
|
||||||
const lastSlashIndex = text.lastIndexOf('/');
|
|
||||||
const beforeSlash = text.slice(0, lastSlashIndex);
|
|
||||||
const afterSlash = text.slice(lastSlashIndex + 1);
|
|
||||||
const insertedText = `{{${suggestion.value}}} `;
|
|
||||||
const newText = beforeSlash + insertedText + afterSlash;
|
|
||||||
const cursorPosition = beforeSlash.length + insertedText.length;
|
|
||||||
|
|
||||||
root.clear();
|
|
||||||
const paragraph = $createParagraphNode();
|
|
||||||
paragraph.append($createTextNode(newText));
|
|
||||||
root.append(paragraph);
|
|
||||||
|
|
||||||
// Set cursor after the inserted text
|
|
||||||
const textNode = paragraph.getFirstChild();
|
|
||||||
if (textNode) {
|
|
||||||
const selection = $createRangeSelection();
|
|
||||||
selection.anchor.set(textNode.getKey(), cursorPosition, 'text');
|
|
||||||
selection.focus.set(textNode.getKey(), cursorPosition, 'text');
|
|
||||||
$setSelection(selection);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
setShowSuggestions(false);
|
setShowSuggestions(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -131,53 +105,53 @@ const AutocompletePlugin: FC<{ suggestions: Suggestion[] }> = ({ suggestions })
|
|||||||
const nodeName = nodeOptions[0]?.nodeData?.name || nodeId;
|
const nodeName = nodeOptions[0]?.nodeData?.name || nodeId;
|
||||||
return (
|
return (
|
||||||
<div key={nodeId}>
|
<div key={nodeId}>
|
||||||
{groupIndex > 0 && <div style={{ height: '1px', background: '#f0f0f0', margin: '4px 0' }} />}
|
{groupIndex > 0 && <div style={{ height: '1px', background: '#f0f0f0', margin: '4px 0' }} />}
|
||||||
<div style={{ padding: '4px 12px', fontSize: '12px', color: '#999', fontWeight: 'bold' }}>
|
<div style={{ padding: '4px 12px', fontSize: '12px', color: '#999', fontWeight: 'bold' }}>
|
||||||
{nodeName}
|
{nodeName}
|
||||||
</div>
|
</div>
|
||||||
{nodeOptions.map((option, index) => {
|
{nodeOptions.map((option, index) => {
|
||||||
const globalIndex = Object.values(groupedSuggestions).flat().indexOf(option);
|
const globalIndex = Object.values(groupedSuggestions).flat().indexOf(option);
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={option.key}
|
key={option.key}
|
||||||
style={{
|
style={{
|
||||||
padding: '8px 12px',
|
padding: '8px 12px',
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
background: selectedIndex === globalIndex ? '#f0f8ff' : 'white',
|
background: selectedIndex === globalIndex ? '#f0f8ff' : 'white',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'space-between',
|
justifyContent: 'space-between',
|
||||||
}}
|
}}
|
||||||
onClick={() => insertMention(option)}
|
onClick={() => insertMention(option)}
|
||||||
onMouseEnter={() => setSelectedIndex(globalIndex)}
|
onMouseEnter={() => setSelectedIndex(globalIndex)}
|
||||||
>
|
>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||||
<span
|
<span
|
||||||
style={{
|
style={{
|
||||||
background: option.type === 'context' ? '#722ed1' :
|
background: option.type === 'context' ? '#722ed1' :
|
||||||
option.type === 'system' ? '#1890ff' : '#52c41a',
|
option.type === 'system' ? '#1890ff' : '#52c41a',
|
||||||
color: 'white',
|
color: 'white',
|
||||||
padding: '2px 6px',
|
padding: '2px 6px',
|
||||||
borderRadius: '4px',
|
borderRadius: '4px',
|
||||||
fontSize: '12px',
|
fontSize: '12px',
|
||||||
minWidth: '16px',
|
minWidth: '16px',
|
||||||
textAlign: 'center',
|
textAlign: 'center',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{option.type === 'context' ? '📄' :
|
{option.type === 'context' ? '📄' :
|
||||||
option.type === 'system' ? 'x' : 'x'}
|
option.type === 'system' ? 'x' : 'x'}
|
||||||
</span>
|
</span>
|
||||||
<span style={{ fontSize: '14px' }}>{option.label}</span>
|
<span style={{ fontSize: '14px' }}>{option.label}</span>
|
||||||
|
</div>
|
||||||
|
{option.dataType && (
|
||||||
|
<span style={{ fontSize: '12px', color: '#999' }}>
|
||||||
|
{option.dataType}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{option.dataType && (
|
);
|
||||||
<span style={{ fontSize: '12px', color: '#999' }}>
|
})}
|
||||||
{option.dataType}
|
</div>
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import { $getRoot } from 'lexical';
|
import { $getRoot, $isParagraphNode } from 'lexical';
|
||||||
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
|
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
|
||||||
|
|
||||||
|
import { $isVariableNode } from '../nodes/VariableNode';
|
||||||
|
|
||||||
const CharacterCountPlugin = ({ setCount, onChange }: { setCount: (count: number) => void; onChange?: (value: string) => void }) => {
|
const CharacterCountPlugin = ({ setCount, onChange }: { setCount: (count: number) => void; onChange?: (value: string) => void }) => {
|
||||||
const [editor] = useLexicalComposerContext();
|
const [editor] = useLexicalComposerContext();
|
||||||
|
|
||||||
@@ -9,9 +11,23 @@ const CharacterCountPlugin = ({ setCount, onChange }: { setCount: (count: number
|
|||||||
return editor.registerUpdateListener(({ editorState }) => {
|
return editor.registerUpdateListener(({ editorState }) => {
|
||||||
editorState.read(() => {
|
editorState.read(() => {
|
||||||
const root = $getRoot();
|
const root = $getRoot();
|
||||||
const textContent = root.getTextContent();
|
let serializedContent = '';
|
||||||
setCount(textContent.length);
|
|
||||||
onChange?.(textContent);
|
// Traverse all nodes and serialize properly
|
||||||
|
root.getChildren().forEach(child => {
|
||||||
|
if ($isParagraphNode(child)) {
|
||||||
|
child.getChildren().forEach(node => {
|
||||||
|
if ($isVariableNode(node)) {
|
||||||
|
serializedContent += node.getTextContent();
|
||||||
|
} else {
|
||||||
|
serializedContent += node.getTextContent();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
setCount(serializedContent.length);
|
||||||
|
onChange?.(serializedContent);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}, [editor, setCount, onChange]);
|
}, [editor, setCount, onChange]);
|
||||||
|
|||||||
@@ -0,0 +1,127 @@
|
|||||||
|
import { useEffect } from 'react';
|
||||||
|
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
|
||||||
|
import {
|
||||||
|
$createParagraphNode,
|
||||||
|
$createTextNode,
|
||||||
|
$getRoot,
|
||||||
|
$setSelection,
|
||||||
|
$createRangeSelection,
|
||||||
|
$isParagraphNode,
|
||||||
|
$isTextNode,
|
||||||
|
} from 'lexical';
|
||||||
|
|
||||||
|
import { $createVariableNode } from '../nodes/VariableNode';
|
||||||
|
import {
|
||||||
|
INSERT_VARIABLE_COMMAND,
|
||||||
|
CLEAR_EDITOR_COMMAND,
|
||||||
|
FOCUS_EDITOR_COMMAND,
|
||||||
|
type InsertVariableCommandPayload,
|
||||||
|
} from '../commands';
|
||||||
|
|
||||||
|
const CommandPlugin = () => {
|
||||||
|
const [editor] = useLexicalComposerContext();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const unregisterInsertVariable = editor.registerCommand(
|
||||||
|
INSERT_VARIABLE_COMMAND,
|
||||||
|
(payload: InsertVariableCommandPayload) => {
|
||||||
|
editor.update(() => {
|
||||||
|
const root = $getRoot();
|
||||||
|
const text = root.getTextContent();
|
||||||
|
const lastSlashIndex = text.lastIndexOf('/');
|
||||||
|
|
||||||
|
// Find the paragraph and the position to insert
|
||||||
|
const paragraph = root.getFirstChild();
|
||||||
|
if (!paragraph || !$isParagraphNode(paragraph)) return;
|
||||||
|
|
||||||
|
const children = paragraph.getChildren();
|
||||||
|
let insertPosition = 0;
|
||||||
|
let currentTextLength = 0;
|
||||||
|
|
||||||
|
// Find where to insert the new tag
|
||||||
|
for (let i = 0; i < children.length; i++) {
|
||||||
|
const child = children[i];
|
||||||
|
const childText = child.getTextContent();
|
||||||
|
|
||||||
|
if (currentTextLength + childText.length > lastSlashIndex) {
|
||||||
|
// Split this text node if needed
|
||||||
|
if ($isTextNode(child)) {
|
||||||
|
const beforeSlash = childText.substring(0, lastSlashIndex - currentTextLength);
|
||||||
|
const afterSlash = childText.substring(lastSlashIndex - currentTextLength + 1);
|
||||||
|
|
||||||
|
if (beforeSlash) {
|
||||||
|
child.setTextContent(beforeSlash);
|
||||||
|
insertPosition = i + 1;
|
||||||
|
} else {
|
||||||
|
insertPosition = i;
|
||||||
|
child.remove();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Insert tag and space
|
||||||
|
const tagNode = $createVariableNode(payload.data);
|
||||||
|
const spaceNode = $createTextNode(' ');
|
||||||
|
|
||||||
|
if (insertPosition < paragraph.getChildrenSize()) {
|
||||||
|
paragraph.getChildAtIndex(insertPosition)?.insertBefore(tagNode);
|
||||||
|
tagNode.insertAfter(spaceNode);
|
||||||
|
} else {
|
||||||
|
paragraph.append(tagNode);
|
||||||
|
paragraph.append(spaceNode);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (afterSlash) {
|
||||||
|
spaceNode.insertAfter($createTextNode(afterSlash));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set cursor after space
|
||||||
|
const selection = $createRangeSelection();
|
||||||
|
selection.anchor.set(spaceNode.getKey(), 1, 'text');
|
||||||
|
selection.focus.set(spaceNode.getKey(), 1, 'text');
|
||||||
|
$setSelection(selection);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
currentTextLength += childText.length;
|
||||||
|
insertPosition = i + 1;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
1
|
||||||
|
);
|
||||||
|
|
||||||
|
const unregisterClearEditor = editor.registerCommand(
|
||||||
|
CLEAR_EDITOR_COMMAND,
|
||||||
|
() => {
|
||||||
|
editor.update(() => {
|
||||||
|
const root = $getRoot();
|
||||||
|
root.clear();
|
||||||
|
const paragraph = $createParagraphNode();
|
||||||
|
root.append(paragraph);
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
1
|
||||||
|
);
|
||||||
|
|
||||||
|
const unregisterFocusEditor = editor.registerCommand(
|
||||||
|
FOCUS_EDITOR_COMMAND,
|
||||||
|
() => {
|
||||||
|
editor.focus();
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
1
|
||||||
|
);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
unregisterInsertVariable();
|
||||||
|
unregisterClearEditor();
|
||||||
|
unregisterFocusEditor();
|
||||||
|
};
|
||||||
|
}, [editor]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CommandPlugin;
|
||||||
@@ -1,27 +1,49 @@
|
|||||||
import { useEffect } from 'react';
|
import { useEffect, useRef } from 'react';
|
||||||
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
|
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
|
||||||
import { $getRoot, $createParagraphNode, $createTextNode } from 'lexical';
|
import { $getRoot, $createParagraphNode, $createTextNode } from 'lexical';
|
||||||
|
|
||||||
|
import { $createVariableNode } from '../nodes/VariableNode';
|
||||||
|
import { type Suggestion } from '../plugin/AutocompletePlugin'
|
||||||
|
|
||||||
interface InitialValuePluginProps {
|
interface InitialValuePluginProps {
|
||||||
value: string;
|
value: string;
|
||||||
|
suggestions?: Suggestion[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const InitialValuePlugin: React.FC<InitialValuePluginProps> = ({ value }) => {
|
const InitialValuePlugin: React.FC<InitialValuePluginProps> = ({ value, suggestions = [] }) => {
|
||||||
const [editor] = useLexicalComposerContext();
|
const [editor] = useLexicalComposerContext();
|
||||||
|
const initializedRef = useRef(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (value) {
|
if (!initializedRef.current && value) {
|
||||||
editor.update(() => {
|
editor.update(() => {
|
||||||
const root = $getRoot();
|
const root = $getRoot();
|
||||||
if (root.getTextContent() === '') {
|
root.clear();
|
||||||
root.clear();
|
const paragraph = $createParagraphNode();
|
||||||
const paragraph = $createParagraphNode();
|
|
||||||
paragraph.append($createTextNode(value));
|
const parts = value.split(/(\{\{[^}]+\}\})/);
|
||||||
root.append(paragraph);
|
|
||||||
}
|
parts.forEach(part => {
|
||||||
|
const match = part.match(/^\{\{([^.]+)\.([^}]+)\}\}$/);
|
||||||
|
if (match) {
|
||||||
|
const [, nodeId, label] = match;
|
||||||
|
const suggestion = suggestions.find(s => s.nodeData.id === nodeId && s.label === label);
|
||||||
|
if (suggestion) {
|
||||||
|
paragraph.append($createVariableNode(suggestion));
|
||||||
|
} else {
|
||||||
|
paragraph.append($createTextNode(part));
|
||||||
|
}
|
||||||
|
} else if (part) {
|
||||||
|
paragraph.append($createTextNode(part));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
root.append(paragraph);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
initializedRef.current = true;
|
||||||
}
|
}
|
||||||
}, [editor, value]);
|
}, []);
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -85,7 +85,7 @@ const MessageEditor: FC<TextareaProps> = ({
|
|||||||
nodeData.config?.variables?.sys.forEach((variable: any) => {
|
nodeData.config?.variables?.sys.forEach((variable: any) => {
|
||||||
suggestions.push({
|
suggestions.push({
|
||||||
key: `${nodeId}_${variable.name}`,
|
key: `${nodeId}_${variable.name}`,
|
||||||
label: variable.name,
|
label: `sys.${variable.name}`,
|
||||||
type: 'variable',
|
type: 'variable',
|
||||||
dataType: variable.type,
|
dataType: variable.type,
|
||||||
value: `sys.${variable.name}`,
|
value: `sys.${variable.name}`,
|
||||||
|
|||||||
Reference in New Issue
Block a user