aura-web/src/components/ExternalToolEditor.tsx

546 lines
24 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

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="pathAPI 地址)"
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>
);
}