From 44aac44a05c2f6471f5a670465e1661f7760864b Mon Sep 17 00:00:00 2001 From: zhaoying Date: Fri, 26 Dec 2025 11:57:50 +0800 Subject: [PATCH 1/4] feat(web): add tool management --- web/src/api/tools.ts | 31 ++ web/src/i18n/en.ts | 27 +- web/src/i18n/zh.ts | 27 +- web/src/routes/index.tsx | 1 + web/src/routes/routes.json | 1 + web/src/store/menu.json | 13 + web/src/views/ToolManagement/Custom.tsx | 141 +++++++ web/src/views/ToolManagement/Inner.tsx | 168 +++++++++ web/src/views/ToolManagement/Mcp.tsx | 167 ++++++++ .../components/CustomToolModal.tsx | 278 ++++++++++++++ .../components/InnerToolModal.tsx | 155 ++++++++ .../components/JsonToolModal.tsx | 161 ++++++++ .../components/McpServiceModal.tsx | 355 ++++++++++++++++++ .../components/RequestHeaderModal.tsx | 102 +++++ .../components/TimeToolModal.tsx | 205 ++++++++++ web/src/views/ToolManagement/constant.ts | 190 ++++++++++ web/src/views/ToolManagement/index.tsx | 52 +++ web/src/views/ToolManagement/types.ts | 138 +++++++ 18 files changed, 2193 insertions(+), 19 deletions(-) create mode 100644 web/src/api/tools.ts create mode 100644 web/src/views/ToolManagement/Custom.tsx create mode 100644 web/src/views/ToolManagement/Inner.tsx create mode 100644 web/src/views/ToolManagement/Mcp.tsx create mode 100644 web/src/views/ToolManagement/components/CustomToolModal.tsx create mode 100644 web/src/views/ToolManagement/components/InnerToolModal.tsx create mode 100644 web/src/views/ToolManagement/components/JsonToolModal.tsx create mode 100644 web/src/views/ToolManagement/components/McpServiceModal.tsx create mode 100644 web/src/views/ToolManagement/components/RequestHeaderModal.tsx create mode 100644 web/src/views/ToolManagement/components/TimeToolModal.tsx create mode 100644 web/src/views/ToolManagement/constant.ts create mode 100644 web/src/views/ToolManagement/index.tsx create mode 100644 web/src/views/ToolManagement/types.ts diff --git a/web/src/api/tools.ts b/web/src/api/tools.ts new file mode 100644 index 00000000..142d4c41 --- /dev/null +++ b/web/src/api/tools.ts @@ -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) => { + return request.post(`/tools/parse_schema`, data) + +} \ No newline at end of file diff --git a/web/src/i18n/en.ts b/web/src/i18n/en.ts index eacf0437..229636d5 100644 --- a/web/src/i18n/en.ts +++ b/web/src/i18n/en.ts @@ -1348,13 +1348,19 @@ Memory Bear: After the rebellion, regional warlordism intensified for several re addService: 'Add MCP Service', addServiceSuccess: 'Service added successfully', server_url: 'Service URL', - lastConnection: 'Last Connection', + last_health_check: 'Last Connection', responseTime: 'Response Time', status: { - active: 'Active', - inactive: 'Inactive', + available: '可用', + 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', serviceEndpointPlaceholder: 'URL of the service endpoint', serviceEndpointExtra: 'Complete access address of the MCP service', @@ -1369,12 +1375,13 @@ Memory Bear: After the rebellion, regional warlordism intensified for several re requestHeader: 'Request Headers', config: 'Configuration', authType: 'Authentication Type', - noAuth: 'No Authentication', - apiKey: 'API Key', - basicAuth: 'Basic Auth', - bearerToken: 'Bearer Token', + none: 'No Authentication', + api_key: 'API Key', + basic_auth: 'Basic Auth', + bearer_token: 'Bearer Token', username: 'Username', password: 'Password', + key_name: 'Key Name', requestHeaderDesc: 'Additional HTTP request headers sent to MCP server', addRequestHeader: 'Add Request Header', editRequestHeader: 'Edit Request Header', @@ -1448,7 +1455,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.', TextInTool_config_desc: 'TextIn provides intelligent OCR text recognition service with multi-language support.', link: 'Application URL', - api_key: 'API Key', BaiduSearchTool_api_key_desc: 'API Key obtained from Baidu Open Platform', MinerUTool_api_key_desc: 'API Key obtained from MinerU platform', secret_key: 'Secret Key', @@ -1505,7 +1511,8 @@ Memory Bear: After the rebellion, regional warlordism intensified for several re desc: 'Description', method: 'Method', path: 'Path', - viewDetail: 'View Details' + viewDetail: 'View Details', + noResult: 'Processing results will be displayed here' }, workflow: { coreNode: 'Core Nodes', diff --git a/web/src/i18n/zh.ts b/web/src/i18n/zh.ts index 43388b55..1573f698 100644 --- a/web/src/i18n/zh.ts +++ b/web/src/i18n/zh.ts @@ -1447,12 +1447,19 @@ export const zh = { addService: '添加MCP服务', addServiceSuccess: '服务添加成功', server_url: '服务地址', - lastConnection: '最后连接', + last_health_check: '最后连接', responseTime: '响应时间', status: { - active: '活跃', - inactive: '不活跃', + available: '可用', + unconfigured: '未配置', + configured_disabled: '已配置未启用', + error: '链接异常' }, + available_desc: 'API 已配置并启用', + unconfigured_desc: '需要配置 API Key', + configured_disabled_desc: 'API 已配置但未启用', + error_desc: 'API 已配置但链接异常', + testConnectionSuccess: '测试连接成功', serviceEndpoint: '服务端点 URL', serviceEndpointPlaceholder: '服务端点的 URL', @@ -1468,12 +1475,13 @@ export const zh = { requestHeader: '请求头', config: '配置', authType: '认证方式', - noAuth: '无需认证', - apiKey: 'API Key', - basicAuth: 'Basic Auth', - bearerToken: 'Bearer Token', + none: '无需认证', + api_key: 'API Key', + basic_auth: 'Basic Auth', + bearer_token: 'Bearer Token', username: '用户名', password: '密码', + key_name: 'Key Name', requestHeaderDesc: '发送到 MCP 服务器的额外 HTTP 请求头', addRequestHeader: '添加请求头', editRequestHeader: '编辑请求头', @@ -1547,7 +1555,6 @@ export const zh = { MinerUTool_config_desc: 'MinerU是高精度PDF文档解析工具,需要API Key才能使用。', TextInTool_config_desc: 'TextIn提供智能OCR文字识别服务,支持多语言识别。', link: '申请地址', - api_key: 'API Key', BaiduSearchTool_api_key_desc: '从百度开放平台获取的API Key', MinerUTool_api_key_desc: '从MinerU平台获取的API Key', secret_key: 'Secret Key', @@ -1604,7 +1611,9 @@ export const zh = { desc: '描述', method: '方法', path: '路径', - viewDetail: '查看详情' + viewDetail: '查看详情', + textLink: '测试连接', + noResult: '处理结果将显示在这里' }, workflow: { coreNode: '核心节点', diff --git a/web/src/routes/index.tsx b/web/src/routes/index.tsx index e7624994..a4c20163 100644 --- a/web/src/routes/index.tsx +++ b/web/src/routes/index.tsx @@ -61,6 +61,7 @@ const componentMap: Record>> = OrderPayment: lazy(() => import('@/views/OrderPayment')), OrderHistory: lazy(() => import('@/views/OrderHistory')), Pricing: lazy(() => import('@/views/Pricing')), + ToolManagement: lazy(() => import('@/views/ToolManagement')), Login: lazy(() => import('@/views/Login')), InviteRegister: lazy(() => import('@/views/InviteRegister')), NoPermission: lazy(() => import('@/views/NoPermission')), diff --git a/web/src/routes/routes.json b/web/src/routes/routes.json index 45e187d6..a414185d 100644 --- a/web/src/routes/routes.json +++ b/web/src/routes/routes.json @@ -5,6 +5,7 @@ { "path": "/user-management", "element": "UserManagement" }, { "path": "/model", "element": "ModelManagement" }, { "path": "/space", "element": "SpaceManagement" }, + { "path": "/tool", "element": "ToolManagement" }, { "path": "/pricing", "element": "Pricing" }, { "path": "/order-pay", "element": "OrderPayment" }, { "path": "/orders", "element": "OrderHistory" }, diff --git a/web/src/store/menu.json b/web/src/store/menu.json index a8232531..1c4cec9d 100644 --- a/web/src/store/menu.json +++ b/web/src/store/menu.json @@ -26,6 +26,19 @@ "sort": 0, "subs": [] }, + { + "id": 7, + "parent": 0, + "code": "tool", + "label": "工具管理", + "i18nKey": "menu.toolManagement", + "path": "/tool", + "enable": true, + "display": true, + "level": 1, + "sort": 0, + "subs": [] + }, { "id": 6, "parent": 0, diff --git a/web/src/views/ToolManagement/Custom.tsx b/web/src/views/ToolManagement/Custom.tsx new file mode 100644 index 00000000..3b5a3f33 --- /dev/null +++ b/web/src/views/ToolManagement/Custom.tsx @@ -0,0 +1,141 @@ +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([]); + const [query, setQuery] = useState({ name: undefined, tool_type: 'custom' }); + const customToolModalRef = useRef(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 ( +
+ + + + + + + + + + ( + + + // {item.name[0]} + //
+ // } + title={ +
+ {item.name}
+ {/*
xx个工具
*/} +
+ } + extra={getStatusTag(item.status)} + > +
+ {['auth_type', 'tag', 'created_at'].map(key => ( +
+
{t(`tool.${key}`)}
+
+ {key === 'created_at' && item[key] ? dayjs(item[key]).format('YYYY-MM-DD HH:mm:ss') : (item.config_data as any)?.[key] || '-'} +
+
+ ))} +
+ +
handleEdit(item)} + >
+
handleDeleteService(item)} + >
+
+
+
+ + + )} + className="rb:h-[calc(100vh-178px)] rb:overflow-y-auto rb:overflow-x-hidden" + /> + + + {/* 添加服务弹窗组件 */} + + + ); +}; + +export default Custom; \ No newline at end of file diff --git a/web/src/views/ToolManagement/Inner.tsx b/web/src/views/ToolManagement/Inner.tsx new file mode 100644 index 00000000..8c507a46 --- /dev/null +++ b/web/src/views/ToolManagement/Inner.tsx @@ -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([]); + const [query, setQuery] = useState({ name: undefined, tool_type: 'builtin' }); + const [curTime, setCurTime] = useState(dayjs()) + const timeToolModalRef = useRef(null) + const jsonToolModalRef = useRef(null) + const innerToolModalRef = useRef(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 ( +
+ + + + + + + ( + + + // {item.name[0]} + //
+ // } + title={item.name} + extra={getStatusTag(item.status)} + bodyClassName='rb:h-[calc(100%-40px)]' + > +
+
+ {t(`tool.${item.config_data.tool_class}_features`)}
+ + {InnerConfigData[item.config_data.tool_class].features.map(vo => { t(`tool.${vo}`) }) } + + + {item.config_data.tool_class === 'DateTimeTool' + ?
+ {t('tool.currentTime')} +
+ {curTime.format('YYYY-MM-DD HH:mm:ss')} +
+ {t('tool.timestamp')} +
+ {curTime.unix()} +
+
+ :item.config_data.tool_class === 'JsonTool' + ?
+ {t('tool.jsonEg')} +
+ {InnerConfigData[item.config_data.tool_class].eg} +
+
+ :
+ {t('tool.configStatus')} +
+ {t(`tool.${item.status}_desc`)} +
+
+ } +
+ +
+ {item.config_data.tool_class === 'DateTimeTool' || item.config_data.tool_class === 'JsonTool' ? + handleEdit(item)} /> + :
handleEdit(item)} + >
+ } +
+
+ + + )} + className="rb:h-[calc(100vh-178px)] rb:overflow-y-auto rb:overflow-x-hidden" + /> + + + + + + + ); +}; + +export default Inner; \ No newline at end of file diff --git a/web/src/views/ToolManagement/Mcp.tsx b/web/src/views/ToolManagement/Mcp.tsx new file mode 100644 index 00000000..30e2614f --- /dev/null +++ b/web/src/views/ToolManagement/Mcp.tsx @@ -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([]); + const [query, setQuery] = useState({ name: undefined, tool_type: 'mcp' }); + const addServiceModalRef = useRef(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 ( +
+ + + + + + + + + + ( + + + // {item.name[0]} + //
+ // } + title={item.name} + extra={getStatusTag(item.status)} + > +
+ {[ + '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 ( +
+
{t(`tool.${key}`)}
+ {displayValue} +
+ ); + })} +
+ +
handleEdit(item)} + >
+ +
handleDeleteService(item)} + >
+
+
+
+ + + )} + className="rb:h-[calc(100vh-178px)] rb:overflow-y-auto rb:overflow-x-hidden" + /> + + + {/* 添加服务弹窗组件 */} + + + ); +}; + +export default Mcp; \ No newline at end of file diff --git a/web/src/views/ToolManagement/components/CustomToolModal.tsx b/web/src/views/ToolManagement/components/CustomToolModal.tsx new file mode 100644 index 00000000..e51f9ddc --- /dev/null +++ b/web/src/views/ToolManagement/components/CustomToolModal.tsx @@ -0,0 +1,278 @@ +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> + request_body: null | string; + responses: Record> + tags: string[] + }> +} +const authTypeList = ['none', 'api_key', 'basic_auth'] +const CustomToolModal = forwardRef(({ + refresh +}, ref) => { + const { t } = useTranslation(); + const { message } = App.useApp(); + const [visible, setVisible] = useState(false); + const [form] = Form.useForm(); + const [loading, setLoading] = useState(false); + const [editVo, setEditVo] = useState(null) + const values = Form.useWatch([], form) + const [parseSchemaData, setParseSchemaData] = useState({} 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 ( + +
+ {/* 名称和图标 */} + + + + + + + + + + + + + + formatSchema(e.target.value)} /> + + + ( + {summary ?? parseSchemaData.title} + ) + }, + { + 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} + /> + + + <> + {/* 认证方式 */} + + + + + + + } + + {/* API Key: 认证方式 = bearer_token 展示 */} + {values?.config?.auth_type === 'bearer_token' && + + + + } + + {/* API Key: 认证方式 = basic_auth 展示 */} + {values?.config?.auth_type === 'basic_auth' && + <> + + + + + + + + } + + + + : config[key].type === 'number' + ? + : config[key].type === 'checkbox' + ? {t(`tool.${key}`)} + : config[key].type === 'select' && config[key].options + ? + + + + + {/* 名称和图标 */} + {/* + + + + + + + + + + + */} + + {/* 描述 */} + + + + + + {/* 认证、请求头、配置 */} + + {/* 认证模块 */} + <> + {/* 认证方式 */} + + + } + + {/* API Key: 认证方式 = bearer_token 展示 */} + {values?.config?.connection_config?.auth_type === 'bearer_token' && + + } + + {/* API Key: 认证方式 = basic_auth 展示 */} + {values?.config?.connection_config?.auth_type === 'basic_auth' && + <> + + + + } + + {/* 请求头模块 */} +
+
+
{t('tool.requestHeader')}
+ +
+
{t('tool.requestHeaderDesc')}
+ + {requestHeaderList.length === 0 + ? + : +
( + + + + + ), + }, + ]} + initialData={requestHeaderList} + emptySize={88} + /> + } + + {/* 配置模块 */} + <> + + + + + + + ); +}); + +export default McpServiceModal; diff --git a/web/src/views/ToolManagement/components/RequestHeaderModal.tsx b/web/src/views/ToolManagement/components/RequestHeaderModal.tsx new file mode 100644 index 00000000..5e20120d --- /dev/null +++ b/web/src/views/ToolManagement/components/RequestHeaderModal.tsx @@ -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>; +} + +const RequestHeaderModal = forwardRef(({ + refreshTable +}, ref) => { + const { t } = useTranslation(); + const [visible, setVisible] = useState(false); + const [form] = Form.useForm(); + const [loading, setLoading] = useState(false) + const [editIndex, setEditIndex] = useState(-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 ( + +
+ {/* 请求头名称 */} + + + + {/* 请求头值 */} + + + + +
+ ); +}); + +export default RequestHeaderModal; \ No newline at end of file diff --git a/web/src/views/ToolManagement/components/TimeToolModal.tsx b/web/src/views/ToolManagement/components/TimeToolModal.tsx new file mode 100644 index 00000000..af64a222 --- /dev/null +++ b/web/src/views/ToolManagement/components/TimeToolModal.tsx @@ -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((_props, ref) => { + const { t } = useTranslation(); + const [visible, setVisible] = useState(false); + const [form] = Form.useForm<{ timestamp: string; formatType: string; }>(); + const [data, setData] = useState({} as ToolItem) + const [timeFormat, setTimeFormat] = useState(undefined) + const [activeTab, setActiveTab] = useState('currentTime'); + const values = Form.useWatch([], form) + const [currentTime, setCurrentTime] = useState({} as CurrentTimeObj) + const [timestampFormat, setTimestampFormat] = useState(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 ( + +
+ {/* 当前时间、时间戳转换、时间格式化 */} + + + {/* 当前时间 */} + {activeTab === 'currentTime' && + <> + + + + + + + + + + + + + + } + {/* 时间戳转换 */} + {activeTab === 'timestampConversion' && + <> + + +
+ + + + + + + + + + {timestampFormat && +
+ {t('tool.conversionResult')} +
+ {timestampFormat} +
+
+ } + + } + {/* 时间格式化 */} + {activeTab === 'timeFormat' && + <> + + + {/* 名称和图标 */} - + {/* (({ - + */} (({ {/* 认证方式 */} Date: Fri, 26 Dec 2025 12:29:46 +0800 Subject: [PATCH 4/4] =?UTF-8?q?feat(web):=20workflow=E2=80=99s=20Editor=20?= =?UTF-8?q?Variable=20support=20Tag?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- web/package.json | 4 + .../components/Editor/commands/index.ts | 13 ++ .../Workflow/components/Editor/index.tsx | 24 +++- .../components/Editor/nodes/TagNode.tsx | 113 --------------- .../components/Editor/nodes/VariableNode.tsx | 133 ++++++++++++++++++ .../Editor/plugin/AutocompletePlugin.tsx | 132 +++++++---------- .../Editor/plugin/CharacterCountPlugin.tsx | 24 +++- .../Editor/plugin/CommandPlugin.tsx | 127 +++++++++++++++++ .../Editor/plugin/InitialValuePlugin.tsx | 42 ++++-- .../components/Properties/MessageEditor.tsx | 2 +- 10 files changed, 403 insertions(+), 211 deletions(-) create mode 100644 web/src/views/Workflow/components/Editor/commands/index.ts delete mode 100644 web/src/views/Workflow/components/Editor/nodes/TagNode.tsx create mode 100644 web/src/views/Workflow/components/Editor/nodes/VariableNode.tsx create mode 100644 web/src/views/Workflow/components/Editor/plugin/CommandPlugin.tsx diff --git a/web/package.json b/web/package.json index d6642ac8..e6c7483d 100644 --- a/web/package.json +++ b/web/package.json @@ -17,7 +17,11 @@ "@dnd-kit/modifiers": "^9.0.0", "@dnd-kit/sortable": "^10.0.0", "@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/rich-text": "^0.39.0", "antd": "^5.27.4", "axios": "^1.12.2", "clsx": "^2.1.1", diff --git a/web/src/views/Workflow/components/Editor/commands/index.ts b/web/src/views/Workflow/components/Editor/commands/index.ts new file mode 100644 index 00000000..7f30c46a --- /dev/null +++ b/web/src/views/Workflow/components/Editor/commands/index.ts @@ -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 = createCommand('INSERT_VARIABLE_COMMAND'); + +export const CLEAR_EDITOR_COMMAND: LexicalCommand = createCommand('CLEAR_EDITOR_COMMAND'); + +export const FOCUS_EDITOR_COMMAND: LexicalCommand = createCommand('FOCUS_EDITOR_COMMAND'); \ No newline at end of file diff --git a/web/src/views/Workflow/components/Editor/index.tsx b/web/src/views/Workflow/components/Editor/index.tsx index ac96dd73..9575b8fa 100644 --- a/web/src/views/Workflow/components/Editor/index.tsx +++ b/web/src/views/Workflow/components/Editor/index.tsx @@ -3,11 +3,18 @@ import { LexicalComposer } from '@lexical/react/LexicalComposer'; import { RichTextPlugin } from '@lexical/react/LexicalRichTextPlugin'; import { ContentEditable } from '@lexical/react/LexicalContentEditable'; 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 { 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 CharacterCountPlugin from './plugin/CharacterCountPlugin' import InitialValuePlugin from './plugin/InitialValuePlugin'; +import CommandPlugin from './plugin/CommandPlugin'; +import { VariableNode } from './nodes/VariableNode' interface LexicalEditorProps { placeholder?: string; @@ -30,10 +37,19 @@ const Editor: FC =({ onChange, suggestions, }) => { - const [count, setCount] = useState(0); + const [_count, setCount] = useState(0); const initialConfig = { namespace: 'AutocompleteEditor', theme, + nodes: [ + // HeadingNode, + // QuoteNode, + // ListItemNode, + // ListNode, + // LinkNode, + // CodeNode, + VariableNode + ], onError: (error: Error) => { console.error(error); }, @@ -74,10 +90,10 @@ const Editor: FC =({ ErrorBoundary={LexicalErrorBoundary} /> - + { setCount(count) }} onChange={onChange} /> - + ); diff --git a/web/src/views/Workflow/components/Editor/nodes/TagNode.tsx b/web/src/views/Workflow/components/Editor/nodes/TagNode.tsx deleted file mode 100644 index f4264bfe..00000000 --- a/web/src/views/Workflow/components/Editor/nodes/TagNode.tsx +++ /dev/null @@ -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 { - __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 ( - - - {icon} - - {this.__label} - - ); - } -} - -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; -} \ No newline at end of file diff --git a/web/src/views/Workflow/components/Editor/nodes/VariableNode.tsx b/web/src/views/Workflow/components/Editor/nodes/VariableNode.tsx new file mode 100644 index 00000000..4b53c217 --- /dev/null +++ b/web/src/views/Workflow/components/Editor/nodes/VariableNode.tsx @@ -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 ( + + + {data.nodeData?.name} + / + {data.label} + + ); +}; + +export class VariableNode extends DecoratorNode { + __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 ; + } + + 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; +} \ No newline at end of file diff --git a/web/src/views/Workflow/components/Editor/plugin/AutocompletePlugin.tsx b/web/src/views/Workflow/components/Editor/plugin/AutocompletePlugin.tsx index 79bd857b..4a86332f 100644 --- a/web/src/views/Workflow/components/Editor/plugin/AutocompletePlugin.tsx +++ b/web/src/views/Workflow/components/Editor/plugin/AutocompletePlugin.tsx @@ -1,7 +1,10 @@ import { useEffect, useState, type FC } from 'react'; 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' + export interface Suggestion { key: string; label: string; @@ -10,6 +13,7 @@ export interface Suggestion { value: string; nodeData: NodeProperties } + const AutocompletePlugin: FC<{ suggestions: Suggestion[] }> = ({ suggestions }) => { const [editor] = useLexicalComposerContext(); const [showSuggestions, setShowSuggestions] = useState(false); @@ -32,19 +36,14 @@ const AutocompletePlugin: FC<{ suggestions: Suggestion[] }> = ({ suggestions }) const range = domSelection.getRangeAt(0); const rect = range.getBoundingClientRect(); - // Calculate popup dimensions const popupWidth = 280; const popupHeight = 200; - - // Get viewport dimensions const viewportWidth = window.innerWidth; const viewportHeight = window.innerHeight; - // Calculate position with viewport constraints let left = rect.left; let top = rect.top - 10; - // Adjust horizontal position if popup would overflow if (left + popupWidth > viewportWidth) { left = viewportWidth - popupWidth - 10; } @@ -52,9 +51,7 @@ const AutocompletePlugin: FC<{ suggestions: Suggestion[] }> = ({ suggestions }) left = 10; } - // Adjust vertical position if popup would overflow if (top - popupHeight < 10) { - // Show below cursor if not enough space above top = rect.bottom + 10; if (top + popupHeight > viewportHeight) { top = viewportHeight - popupHeight - 10; @@ -69,31 +66,8 @@ const AutocompletePlugin: FC<{ suggestions: Suggestion[] }> = ({ suggestions }) }); }, [editor]); - const insertMention = (suggestion: any) => { - editor.update(() => { - 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); - } - }); + const insertMention = (suggestion: Suggestion) => { + editor.dispatchCommand(INSERT_VARIABLE_COMMAND, { data: suggestion }); setShowSuggestions(false); }; @@ -131,53 +105,53 @@ const AutocompletePlugin: FC<{ suggestions: Suggestion[] }> = ({ suggestions }) const nodeName = nodeOptions[0]?.nodeData?.name || nodeId; return (
- {groupIndex > 0 &&
} -
- {nodeName} -
- {nodeOptions.map((option, index) => { - const globalIndex = Object.values(groupedSuggestions).flat().indexOf(option); - return ( -
insertMention(option)} - onMouseEnter={() => setSelectedIndex(globalIndex)} - > -
- - {option.type === 'context' ? '📄' : - option.type === 'system' ? 'x' : 'x'} - - {option.label} + {groupIndex > 0 &&
} +
+ {nodeName} +
+ {nodeOptions.map((option, index) => { + const globalIndex = Object.values(groupedSuggestions).flat().indexOf(option); + return ( +
insertMention(option)} + onMouseEnter={() => setSelectedIndex(globalIndex)} + > +
+ + {option.type === 'context' ? '📄' : + option.type === 'system' ? 'x' : 'x'} + + {option.label} +
+ {option.dataType && ( + + {option.dataType} + + )}
- {option.dataType && ( - - {option.dataType} - - )} -
- ); - })} -
+ ); + })} +
); })}
diff --git a/web/src/views/Workflow/components/Editor/plugin/CharacterCountPlugin.tsx b/web/src/views/Workflow/components/Editor/plugin/CharacterCountPlugin.tsx index 911eff3d..963f824b 100644 --- a/web/src/views/Workflow/components/Editor/plugin/CharacterCountPlugin.tsx +++ b/web/src/views/Workflow/components/Editor/plugin/CharacterCountPlugin.tsx @@ -1,7 +1,9 @@ import { useEffect } from 'react'; -import { $getRoot } from 'lexical'; +import { $getRoot, $isParagraphNode } from 'lexical'; import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'; +import { $isVariableNode } from '../nodes/VariableNode'; + const CharacterCountPlugin = ({ setCount, onChange }: { setCount: (count: number) => void; onChange?: (value: string) => void }) => { const [editor] = useLexicalComposerContext(); @@ -9,9 +11,23 @@ const CharacterCountPlugin = ({ setCount, onChange }: { setCount: (count: number return editor.registerUpdateListener(({ editorState }) => { editorState.read(() => { const root = $getRoot(); - const textContent = root.getTextContent(); - setCount(textContent.length); - onChange?.(textContent); + let serializedContent = ''; + + // 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]); diff --git a/web/src/views/Workflow/components/Editor/plugin/CommandPlugin.tsx b/web/src/views/Workflow/components/Editor/plugin/CommandPlugin.tsx new file mode 100644 index 00000000..7393130e --- /dev/null +++ b/web/src/views/Workflow/components/Editor/plugin/CommandPlugin.tsx @@ -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; \ No newline at end of file diff --git a/web/src/views/Workflow/components/Editor/plugin/InitialValuePlugin.tsx b/web/src/views/Workflow/components/Editor/plugin/InitialValuePlugin.tsx index 912801d8..05043436 100644 --- a/web/src/views/Workflow/components/Editor/plugin/InitialValuePlugin.tsx +++ b/web/src/views/Workflow/components/Editor/plugin/InitialValuePlugin.tsx @@ -1,27 +1,49 @@ -import { useEffect } from 'react'; +import { useEffect, useRef } from 'react'; import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'; import { $getRoot, $createParagraphNode, $createTextNode } from 'lexical'; +import { $createVariableNode } from '../nodes/VariableNode'; +import { type Suggestion } from '../plugin/AutocompletePlugin' + interface InitialValuePluginProps { value: string; + suggestions?: Suggestion[]; } -const InitialValuePlugin: React.FC = ({ value }) => { +const InitialValuePlugin: React.FC = ({ value, suggestions = [] }) => { const [editor] = useLexicalComposerContext(); + const initializedRef = useRef(false); useEffect(() => { - if (value) { + if (!initializedRef.current && value) { editor.update(() => { const root = $getRoot(); - if (root.getTextContent() === '') { - root.clear(); - const paragraph = $createParagraphNode(); - paragraph.append($createTextNode(value)); - root.append(paragraph); - } + root.clear(); + const paragraph = $createParagraphNode(); + + const parts = value.split(/(\{\{[^}]+\}\})/); + + 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; }; diff --git a/web/src/views/Workflow/components/Properties/MessageEditor.tsx b/web/src/views/Workflow/components/Properties/MessageEditor.tsx index dfb3ccdb..879f5072 100644 --- a/web/src/views/Workflow/components/Properties/MessageEditor.tsx +++ b/web/src/views/Workflow/components/Properties/MessageEditor.tsx @@ -85,7 +85,7 @@ const MessageEditor: FC = ({ nodeData.config?.variables?.sys.forEach((variable: any) => { suggestions.push({ key: `${nodeId}_${variable.name}`, - label: variable.name, + label: `sys.${variable.name}`, type: 'variable', dataType: variable.type, value: `sys.${variable.name}`,