Files
MemoryBear/web/src/views/ToolManagement/components/McpServiceModal.tsx
2026-03-13 18:10:06 +08:00

422 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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'
import { stringRegExp } from '@/utils/validator';
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', 'bearer_token']
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 abortControllerRef = useRef<AbortController | null>(null)
const formatTabItems = () => {
return tabKeys.map(key => ({
key,
label: t(`tool.${key}`),
}))
}
const handleChangeTab = (key: string) => {
setActiveTab(key);
}
// 封装取消方法,添加关闭弹窗逻辑
const handleClose = () => {
// 如果有正在进行的请求,取消它
if (abortControllerRef.current) {
abortControllerRef.current.abort();
abortControllerRef.current = null;
}
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 if (data) {
const { config_data, name, description, icon } = data
form.setFieldsValue({
name, description, icon,
...(config_data ? { config: { ...config_data } } : {})
})
// 如果是从 Market 组件传来的数据(包含 market_id保存完整的 data 用于后续提交
if ((data as any).market_id) {
setEditVo(data)
}
} else {
form.resetFields();
}
setVisible(true);
};
// 封装保存方法,添加提交逻辑
const handleSave = () => {
form
.validateFields()
.then(() => {
setLoading(true);
// 创建 AbortController 用于取消请求
abortControllerRef.current = new AbortController();
// 创建新服务对象
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
}, {})
}
}
}
// 如果是从 Market 组件传来的数据,添加市场相关字段
if ((editVo as any)?.market_id) {
(newService.config as any).source_channel = (editVo as any).source_channel;
(newService.config as any).market_id = (editVo as any).market_id;
(newService.config as any).market_config_id = (editVo as any).market_config_id;
(newService.config as any).mcp_service_id = (editVo as any).mcp_service_id;
}
const request = editVo?.id
? updateTool(editVo.id, newService, { signal: abortControllerRef.current.signal })
: addTool(newService, { signal: abortControllerRef.current.signal })
request.then((res: any) => {
// 清除 AbortController
abortControllerRef.current = null;
message.success(t('common.saveSuccess'));
setLoading(false);
handleClose();
refresh();
// 在后台测试连接,不阻塞用户操作
testConnection(res.tool_id || editVo?.id).catch((err) => {
console.error('测试连接失败:', err);
});
})
.catch((error) => {
// 清除 AbortController
abortControllerRef.current = null;
// 如果是用户主动取消,不显示错误提示
if (error.name === 'AbortError' || error.code === 'ERR_CANCELED') {
console.log('请求已取消');
} else {
message.error(t('common.saveFailed'));
}
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}
okButtonProps={{ loading: loading }}
footer={(_, { OkBtn, CancelBtn }) => (
<>
<CancelBtn />
<OkBtn />
</>
)}
>
<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') },
{ max: 500 },
{ pattern: /^https?:\/\/\S+$/, message: t('tool.serverUrlInvalid') },
]}
>
<Input placeholder={t('tool.serviceEndpointPlaceholder')} />
</FormItem>
<Form.Item
name="name"
label={t('tool.name')}
rules={[
{ required: true, message: t('common.pleaseEnter') },
{ max: 50 },
{ pattern: stringRegExp, message: t('common.nameInvalid') },
]}
>
<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')}
rules={[{ max: 500 }]}
>
<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',
width: 120,
},
{
title: t('tool.requestHeaderValue'),
dataIndex: 'value',
key: 'value',
render: (value) => {
return <div className="rb:break-all">{value}</div>
}
},
{
title: t('common.operation'),
key: 'action',
width: 80,
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}
scroll={{ x: 'max-content' }}
/>
}
</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;