546 lines
24 KiB
TypeScript
546 lines
24 KiB
TypeScript
import { CopyOutlined, DownOutlined, MinusCircleOutlined, PlusOutlined, RightOutlined } from '@ant-design/icons';
|
||
import { App as AntApp, Button, Card, Form, Input, Modal, Select, Space } from 'antd';
|
||
import { useEffect, useRef, useState } from 'react';
|
||
import { AgentAPI, ExternalToolApi, ExternalToolApiRouting, ExternalToolPlugin, ExternalToolPluginPayload } from '../api';
|
||
|
||
interface Props {
|
||
open: boolean;
|
||
agentId: string;
|
||
plugin?: ExternalToolPlugin | null;
|
||
onClose: () => void;
|
||
onSaved?: () => void | Promise<void>;
|
||
}
|
||
|
||
interface ToolApiRoutingFormValue extends ExternalToolApiRouting {}
|
||
|
||
interface ToolApiFormValue extends Omit<ExternalToolApi, 'headers' | 'parametersSchema' | 'routing'> {
|
||
headers?: string;
|
||
parametersSchema: string;
|
||
routing: ToolApiRoutingFormValue;
|
||
}
|
||
|
||
interface ToolPluginFormValue extends Omit<ExternalToolPluginPayload, 'authConfig' | 'apis' | 'headers'> {
|
||
authConfig?: string;
|
||
headers?: string;
|
||
apis: ToolApiFormValue[];
|
||
}
|
||
|
||
const EMPTY_API: ToolApiFormValue = {
|
||
name: '',
|
||
description: '',
|
||
method: 'GET',
|
||
path: '',
|
||
headers: '{}',
|
||
parametersSchema: JSON.stringify({ type: 'object', properties: {} }, null, 2),
|
||
routing: {
|
||
summary: '',
|
||
useWhen: [''],
|
||
doNotUseWhen: [],
|
||
domains: [],
|
||
intents: [],
|
||
requiredSlots: [],
|
||
optionalSlots: [],
|
||
examples: [],
|
||
},
|
||
};
|
||
|
||
function parseJsonObject(value: string | undefined, fieldName: string, optional = false) {
|
||
const trimmedValue = value?.trim();
|
||
if (!trimmedValue) {
|
||
if (optional) return undefined;
|
||
throw new Error(`${fieldName}不能为空`);
|
||
}
|
||
|
||
try {
|
||
// 1. 优先尝试标准 JSON 解析
|
||
return JSON.parse(trimmedValue);
|
||
} catch (e) {
|
||
try {
|
||
// 2. 失败后尝试作为 JS 对象解析 (支持无引号键、单引号等)
|
||
// eslint-disable-next-line no-new-func
|
||
const parsed = new Function(`return (${trimmedValue})`)();
|
||
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
||
return parsed;
|
||
}
|
||
throw new Error();
|
||
} catch (e2) {
|
||
// 3. 尝试宽松解析 (处理类似 { Content-Type: application/json } 这种完全无引号的情况)
|
||
try {
|
||
const content = trimmedValue.replace(/^\{/, '').replace(/\}$/, '').trim();
|
||
const result: Record<string, any> = {};
|
||
const pairs = content.split(/[\n,]/);
|
||
let hasValidPair = false;
|
||
|
||
for (let pair of pairs) {
|
||
pair = pair.trim();
|
||
if (!pair) continue;
|
||
const colonIndex = pair.indexOf(':');
|
||
if (colonIndex > 0) {
|
||
const k = pair.substring(0, colonIndex).trim().replace(/^['"]|['"]$/g, '');
|
||
const v = pair.substring(colonIndex + 1).trim().replace(/^['"]|['"]$/g, '');
|
||
if (k) {
|
||
let finalVal: any = v;
|
||
if (v === 'true') finalVal = true;
|
||
else if (v === 'false') finalVal = false;
|
||
else if (v === 'null') finalVal = null;
|
||
else if (!isNaN(Number(v)) && v !== '') finalVal = Number(v);
|
||
result[k] = finalVal;
|
||
hasValidPair = true;
|
||
}
|
||
}
|
||
}
|
||
if (hasValidPair) return result;
|
||
} catch (e3) {
|
||
// ignore and fall through to error
|
||
}
|
||
throw new Error(`${fieldName}格式不正确,请确保是有效的 JSON 或对象格式`);
|
||
}
|
||
}
|
||
}
|
||
|
||
function normalizeStringList(values?: string[]) {
|
||
return Array.from(new Set((values ?? []).map((item) => item?.trim()).filter((item): item is string => Boolean(item))));
|
||
}
|
||
|
||
function validateTrimmedText(message: string) {
|
||
return async (_: unknown, value: string | undefined) => {
|
||
if (!value?.trim()) {
|
||
throw new Error(message);
|
||
}
|
||
};
|
||
}
|
||
|
||
function validateRoutingList(message: string, min = 0) {
|
||
return async (_: unknown, value: string[] | undefined) => {
|
||
const normalized = normalizeStringList(value);
|
||
if (normalized.length < min) {
|
||
throw new Error(message);
|
||
}
|
||
|
||
if ((value ?? []).some((item) => !item?.trim())) {
|
||
throw new Error('列表项不能为空');
|
||
}
|
||
};
|
||
}
|
||
|
||
function normalizeRouting(value: ToolApiRoutingFormValue | undefined, apiName: string): ExternalToolApiRouting {
|
||
if (!value) {
|
||
throw new Error(`API ${apiName} 的路由规则不能为空`);
|
||
}
|
||
|
||
const useWhen = normalizeStringList(value.useWhen);
|
||
if (useWhen.length < 1) {
|
||
throw new Error(`API ${apiName} 的 useWhen 至少保留一项`);
|
||
}
|
||
|
||
return {
|
||
summary: value.summary?.trim() || '',
|
||
useWhen,
|
||
doNotUseWhen: normalizeStringList(value.doNotUseWhen),
|
||
domains: normalizeStringList(value.domains),
|
||
intents: normalizeStringList(value.intents),
|
||
requiredSlots: normalizeStringList(value.requiredSlots),
|
||
optionalSlots: normalizeStringList(value.optionalSlots),
|
||
examples: normalizeStringList(value.examples),
|
||
};
|
||
}
|
||
|
||
export default function ExternalToolEditor({ open, agentId, plugin, onClose, onSaved }: Props) {
|
||
const { message } = AntApp.useApp();
|
||
const [form] = Form.useForm<ToolPluginFormValue>();
|
||
const isEditing = Boolean(plugin);
|
||
const apis = Form.useWatch('apis', form) || [];
|
||
const [expandedApiIndexes, setExpandedApiIndexes] = useState<number[]>([]);
|
||
const previousApiCountRef = useRef(0);
|
||
|
||
useEffect(() => {
|
||
if (!open) {
|
||
previousApiCountRef.current = 0;
|
||
setExpandedApiIndexes([]);
|
||
return;
|
||
}
|
||
|
||
const apiCount = apis.length;
|
||
if (apiCount < 1) {
|
||
previousApiCountRef.current = 0;
|
||
setExpandedApiIndexes([]);
|
||
return;
|
||
}
|
||
|
||
const previousApiCount = previousApiCountRef.current;
|
||
if (previousApiCount === 0) {
|
||
setExpandedApiIndexes(apiCount === 1 ? [0] : []);
|
||
} else if (previousApiCount === 1 && apiCount > 1) {
|
||
setExpandedApiIndexes([]);
|
||
} else if (apiCount === 1) {
|
||
setExpandedApiIndexes([0]);
|
||
} else {
|
||
setExpandedApiIndexes((current) => current.filter((index) => index < apiCount));
|
||
}
|
||
|
||
previousApiCountRef.current = apiCount;
|
||
}, [apis.length, open]);
|
||
|
||
const toggleApiCard = (index: number) => {
|
||
setExpandedApiIndexes((current) =>
|
||
current.includes(index) ? current.filter((item) => item !== index) : [...current, index],
|
||
);
|
||
};
|
||
|
||
const handleSubmit = async () => {
|
||
try {
|
||
const values = await form.validateFields();
|
||
const payload: ExternalToolPluginPayload = {
|
||
name: values.name.trim(),
|
||
description: values.description?.trim(),
|
||
baseUrl: values.baseUrl.trim(),
|
||
authType: values.authType,
|
||
authConfig: parseJsonObject(values.authConfig, '认证配置', values.authType === 'none') || {},
|
||
headers: parseJsonObject(values.headers, '统一请求头', true),
|
||
apis: values.apis.map((item) => ({
|
||
name: item.name.trim(),
|
||
description: item.description.trim(),
|
||
method: item.method,
|
||
path: item.path.trim(),
|
||
headers: parseJsonObject(item.headers, `API ${item.name} 的请求头`, true),
|
||
parametersSchema: parseJsonObject(item.parametersSchema, `API ${item.name} 的依赖参数`),
|
||
routing: normalizeRouting(item.routing, item.name.trim() || '未命名 API'),
|
||
})),
|
||
};
|
||
|
||
if (plugin) {
|
||
await AgentAPI.updatePlugin(agentId, plugin.id, payload);
|
||
message.success('外部工具更新成功');
|
||
} else {
|
||
await AgentAPI.bindPlugin(agentId, payload);
|
||
message.success('外部工具绑定成功');
|
||
}
|
||
await onSaved?.();
|
||
onClose();
|
||
} catch (error: any) {
|
||
if (error?.errorFields) return;
|
||
if (error instanceof SyntaxError) {
|
||
message.error('配置格式不正确,请检查认证配置、请求头或依赖参数(支持 JSON 或对象格式)');
|
||
return;
|
||
}
|
||
message.error(error?.message || '外部工具绑定失败');
|
||
}
|
||
};
|
||
|
||
return (
|
||
<Modal
|
||
open={open}
|
||
title={isEditing ? '编辑外部工具集' : '配置外部工具集'}
|
||
width={920}
|
||
okText={isEditing ? '保存修改' : '绑定工具'}
|
||
cancelText="取消"
|
||
onCancel={onClose}
|
||
onOk={handleSubmit}
|
||
destroyOnHidden
|
||
afterOpenChange={(visible) => {
|
||
if (visible) {
|
||
form.setFieldsValue(
|
||
plugin
|
||
? {
|
||
name: plugin.name,
|
||
description: plugin.description,
|
||
baseUrl: plugin.baseUrl,
|
||
authType: plugin.authType,
|
||
authConfig: JSON.stringify(plugin.authConfig || {}, null, 2),
|
||
headers: JSON.stringify(plugin.headers || {}, null, 2),
|
||
apis: plugin.apis.map((item) => ({
|
||
name: item.name,
|
||
description: item.description,
|
||
method: item.method,
|
||
path: item.path,
|
||
headers: JSON.stringify(item.headers || {}, null, 2),
|
||
parametersSchema: JSON.stringify(item.parametersSchema || { type: 'object', properties: {} }, null, 2),
|
||
routing: {
|
||
summary: item.routing?.summary || '',
|
||
useWhen: item.routing?.useWhen?.length ? item.routing.useWhen : [''],
|
||
doNotUseWhen: item.routing?.doNotUseWhen || [],
|
||
domains: item.routing?.domains || [],
|
||
intents: item.routing?.intents || [],
|
||
requiredSlots: item.routing?.requiredSlots || [],
|
||
optionalSlots: item.routing?.optionalSlots || [],
|
||
examples: item.routing?.examples || [],
|
||
},
|
||
})),
|
||
}
|
||
: {
|
||
name: '',
|
||
description: '',
|
||
baseUrl: '',
|
||
authType: 'bearer',
|
||
authConfig: JSON.stringify({ token: '' }, null, 2),
|
||
headers: JSON.stringify({}, null, 2),
|
||
apis: [{ ...EMPTY_API }],
|
||
},
|
||
);
|
||
}
|
||
}}
|
||
>
|
||
<Form form={form} layout="vertical" requiredMark="optional">
|
||
<div className="agent-editor-tool-grid">
|
||
<Form.Item label="工具集名称" name="name" rules={[{ required: true, message: '请输入工具集名称' }]}>
|
||
<Input placeholder="例如:Hoyidata 工具集" />
|
||
</Form.Item>
|
||
<Form.Item label="API 基础地址" name="baseUrl" rules={[{ required: true, message: '请输入 API 基础地址' }]}>
|
||
<Input placeholder="https://api.hoyidata.com" />
|
||
</Form.Item>
|
||
</div>
|
||
<Form.Item label="工具集描述" name="description">
|
||
<Input.TextArea rows={2} placeholder="说明该工具集提供的能力" />
|
||
</Form.Item>
|
||
<Form.Item label="统一请求头(JSON 或对象)" name="headers">
|
||
<Input.TextArea
|
||
rows={3}
|
||
className="agent-editor-code-input"
|
||
placeholder={'{\n "Authorization": "Bearer YOUR_TOKEN"\n}'}
|
||
/>
|
||
</Form.Item>
|
||
<div className="agent-editor-tool-grid">
|
||
<Form.Item label="认证方式" name="authType" rules={[{ required: true }]}>
|
||
<Select
|
||
options={[
|
||
{ value: 'none', label: '无需认证' },
|
||
{ value: 'bearer', label: 'Bearer Token' },
|
||
{ value: 'basic', label: 'Basic Auth' },
|
||
{ value: 'apiKey', label: 'API Key' },
|
||
]}
|
||
/>
|
||
</Form.Item>
|
||
<Form.Item label="认证配置(JSON 或对象)" name="authConfig">
|
||
<Input.TextArea rows={3} className="agent-editor-code-input" placeholder={'{\n token: "YOUR_API_KEY"\n}'} />
|
||
</Form.Item>
|
||
</div>
|
||
|
||
<Form.List name="apis">
|
||
{(fields, { add, remove }) => (
|
||
<Space direction="vertical" size={12} className="agent-editor-tool-list">
|
||
<div className="agent-editor-tool-list-header">
|
||
<div>
|
||
<strong>API 功能</strong>
|
||
<div className="agent-editor-tool-help">一个工具集可同时绑定多个 API 功能。</div>
|
||
</div>
|
||
<Button type="dashed" icon={<PlusOutlined />} onClick={() => add({ ...EMPTY_API })}>
|
||
添加 API
|
||
</Button>
|
||
</div>
|
||
|
||
{fields.map((field, index) => {
|
||
const isExpanded = expandedApiIndexes.includes(index);
|
||
const apiName = apis[index]?.name;
|
||
|
||
return (
|
||
<Card
|
||
key={field.key}
|
||
size="small"
|
||
title={
|
||
<Button
|
||
type="text"
|
||
size="small"
|
||
onClick={() => toggleApiCard(index)}
|
||
style={{ padding: 0, fontWeight: 500 }}
|
||
icon={isExpanded ? <DownOutlined /> : <RightOutlined />}
|
||
>
|
||
{apiName || `API-${index}`}
|
||
</Button>
|
||
}
|
||
className="agent-editor-tool-card"
|
||
extra={
|
||
<Space>
|
||
<Button
|
||
type="text"
|
||
size="small"
|
||
icon={<CopyOutlined />}
|
||
onClick={() => {
|
||
const currentApis = form.getFieldValue('apis') || [];
|
||
const apiToCopy = currentApis[field.name];
|
||
if (apiToCopy) {
|
||
add({
|
||
...apiToCopy,
|
||
name: `${apiToCopy.name}-copy`,
|
||
});
|
||
}
|
||
}}
|
||
>
|
||
复制
|
||
</Button>
|
||
{fields.length > 1 ? (
|
||
<Button type="text" danger size="small" icon={<MinusCircleOutlined />} onClick={() => remove(field.name)}>
|
||
删除
|
||
</Button>
|
||
) : null}
|
||
</Space>
|
||
}
|
||
>
|
||
{isExpanded ? (
|
||
<>
|
||
<div className="agent-editor-tool-grid">
|
||
<Form.Item
|
||
label="name(工具名)"
|
||
name={[field.name, 'name']}
|
||
rules={[
|
||
{ required: true, message: '请输入工具名' },
|
||
{ pattern: /^[A-Za-z0-9_]+$/, message: '仅支持字母、数字和下划线' },
|
||
]}
|
||
>
|
||
<Input placeholder="query_hot_selling_products" />
|
||
</Form.Item>
|
||
<Form.Item label="method(调用方法)" name={[field.name, 'method']} rules={[{ required: true }]}>
|
||
<Select options={['GET', 'POST', 'PUT', 'PATCH', 'DELETE'].map((value) => ({ value, label: value }))} />
|
||
</Form.Item>
|
||
</div>
|
||
<Form.Item
|
||
label="description(描述)"
|
||
name={[field.name, 'description']}
|
||
rules={[{ validator: validateTrimmedText('请输入描述') }]}
|
||
>
|
||
<Input.TextArea rows={2} placeholder="描述调用时机和工具能力" />
|
||
</Form.Item>
|
||
<Form.Item
|
||
label="path(API 地址)"
|
||
name={[field.name, 'path']}
|
||
rules={[{ validator: validateTrimmedText('请输入 API 地址') }]}
|
||
>
|
||
<Input placeholder="/v1/products/hot-selling" />
|
||
</Form.Item>
|
||
<div className="agent-editor-tool-grid">
|
||
<Form.Item label="headers(请求头 JSON 或对象)" name={[field.name, 'headers']}>
|
||
<Input.TextArea rows={7} className="agent-editor-code-input" placeholder={'{\n "X-Custom-Source": "aura-agent"\n}'} />
|
||
</Form.Item>
|
||
<Form.Item
|
||
label="parametersSchema(依赖参数 JSON 或对象)"
|
||
name={[field.name, 'parametersSchema']}
|
||
rules={[{ required: true, message: '请输入依赖参数 Schema' }]}
|
||
>
|
||
<Input.TextArea rows={7} className="agent-editor-code-input" />
|
||
</Form.Item>
|
||
</div>
|
||
<Card size="small" title="routing(路由规则)">
|
||
<Form.Item label="summary" name={[field.name, 'routing', 'summary']}>
|
||
<Input placeholder="查询商品维度数据" />
|
||
</Form.Item>
|
||
<Form.List name={[field.name, 'routing', 'useWhen']} rules={[{ validator: validateRoutingList('至少添加一条 useWhen', 1) }]}>
|
||
{(routingFields, { add: addUseWhen, remove: removeUseWhen }, { errors }) => (
|
||
<Space direction="vertical" size={8} className="agent-editor-tool-list">
|
||
<div className="agent-editor-tool-list-header">
|
||
<div>
|
||
<strong>useWhen</strong>
|
||
<div className="agent-editor-tool-help">至少一项,描述什么情况下应该调用这个 API。</div>
|
||
</div>
|
||
<Button type="dashed" icon={<PlusOutlined />} onClick={() => addUseWhen('')}>
|
||
添加条件
|
||
</Button>
|
||
</div>
|
||
{routingFields.map((routingField) => (
|
||
<Space key={routingField.key} align="start" className="agent-editor-tool-list">
|
||
<Form.Item
|
||
name={routingField.name}
|
||
className="flex-1 mb-0"
|
||
rules={[{ validator: validateTrimmedText('条件不能为空') }]}
|
||
>
|
||
<Input placeholder="用户明确要求商品数据" style={{ width: 600 }} />
|
||
</Form.Item>
|
||
<Button
|
||
danger
|
||
type="text"
|
||
icon={<MinusCircleOutlined />}
|
||
onClick={() => removeUseWhen(routingField.name)}
|
||
disabled={routingFields.length <= 1}
|
||
/>
|
||
</Space>
|
||
))}
|
||
<Form.ErrorList errors={errors} />
|
||
</Space>
|
||
)}
|
||
</Form.List>
|
||
<Form.List name={[field.name, 'routing', 'doNotUseWhen']} rules={[{ validator: validateRoutingList('列表项不能为空') }]}>
|
||
{(routingFields, { add: addDoNotUseWhen, remove: removeDoNotUseWhen }, { errors }) => (
|
||
<Space direction="vertical" size={8} className="agent-editor-tool-list" style={{ marginTop: 16 }}>
|
||
<div className="agent-editor-tool-list-header">
|
||
<div>
|
||
<strong>doNotUseWhen</strong>
|
||
<div className="agent-editor-tool-help">可为空,描述什么情况下不要调用这个 API。</div>
|
||
</div>
|
||
<Button type="dashed" icon={<PlusOutlined />} onClick={() => addDoNotUseWhen('')}>
|
||
添加条件
|
||
</Button>
|
||
</div>
|
||
{routingFields.map((routingField) => (
|
||
<Space key={routingField.key} align="start" className="agent-editor-tool-list">
|
||
<Form.Item
|
||
name={routingField.name}
|
||
className="flex-1 mb-0"
|
||
rules={[{ validator: validateTrimmedText('条件不能为空') }]}
|
||
>
|
||
<Input placeholder="当前问题只需要其他维度数据" style={{ width: 600 }} />
|
||
</Form.Item>
|
||
<Button danger type="text" icon={<MinusCircleOutlined />} onClick={() => removeDoNotUseWhen(routingField.name)} />
|
||
</Space>
|
||
))}
|
||
<Form.ErrorList errors={errors} />
|
||
</Space>
|
||
)}
|
||
</Form.List>
|
||
|
||
<div className="agent-editor-tool-grid" style={{ marginTop: 16 }}>
|
||
<Form.Item label="domains(领域)" name={[field.name, 'routing', 'domains']}>
|
||
<Select mode="tags" placeholder="例如:e-commerce, logistics" />
|
||
</Form.Item>
|
||
<Form.Item label="intents(意图)" name={[field.name, 'routing', 'intents']}>
|
||
<Select mode="tags" placeholder="例如:query_order, cancel_order" />
|
||
</Form.Item>
|
||
</div>
|
||
|
||
<div className="agent-editor-tool-grid">
|
||
<Form.Item label="requiredSlots(必填槽位)" name={[field.name, 'routing', 'requiredSlots']}>
|
||
<Select mode="tags" placeholder="例如:order_id, user_id" />
|
||
</Form.Item>
|
||
<Form.Item label="optionalSlots(可选槽位)" name={[field.name, 'routing', 'optionalSlots']}>
|
||
<Select mode="tags" placeholder="例如:start_date, end_date" />
|
||
</Form.Item>
|
||
</div>
|
||
|
||
<Form.List name={[field.name, 'routing', 'examples']}>
|
||
{(exampleFields, { add: addExample, remove: removeExample }) => (
|
||
<Space direction="vertical" size={8} className="agent-editor-tool-list" style={{ marginTop: 16 }}>
|
||
<div className="agent-editor-tool-list-header">
|
||
<div>
|
||
<strong>examples(示例)</strong>
|
||
<div className="agent-editor-tool-help">描述用户可能的提问方式。</div>
|
||
</div>
|
||
<Button type="dashed" icon={<PlusOutlined />} onClick={() => addExample('')}>
|
||
添加示例
|
||
</Button>
|
||
</div>
|
||
{exampleFields.map((exampleField) => (
|
||
<Space key={exampleField.key} align="start" className="agent-editor-tool-list">
|
||
<Form.Item
|
||
name={exampleField.name}
|
||
className="flex-1 mb-0"
|
||
rules={[{ validator: validateTrimmedText('示例不能为空') }]}
|
||
>
|
||
<Input placeholder="我想查一下最近的订单" style={{ width: 600 }} />
|
||
</Form.Item>
|
||
<Button danger type="text" icon={<MinusCircleOutlined />} onClick={() => removeExample(exampleField.name)} />
|
||
</Space>
|
||
))}
|
||
</Space>
|
||
)}
|
||
</Form.List>
|
||
</Card>
|
||
</>
|
||
) : null}
|
||
</Card>
|
||
);
|
||
})}
|
||
</Space>
|
||
)}
|
||
</Form.List>
|
||
</Form>
|
||
</Modal>
|
||
);
|
||
}
|