fix: 修复 JSON 编辑器同步时对象字段类型错误,并拆分模块控制文件行数

main
sp mac bookpro 2605 2026-07-29 13:42:11 +08:00
parent 49b48df182
commit 67e69d4d3b
8 changed files with 831 additions and 604 deletions

View File

@ -10,5 +10,6 @@ alwaysApply: true
4. **语言** - 永远使用中文跟用户沟通,专有名词可以用英文。
5. **文档** - 在用户没有明确要求先提供文档时,禁止创建文档。避免生产垃圾文件。用户明确要求提供文档时,才创建文档。
6. **注释** - 你生成的代码尽可能完善中文注释。注释的格式需要按照Go语言的注释规范。要描述清楚代码的功能参数返回值异常等。
7. **代码格式** - .go文件.ts, .tsx 文件单文件代码不超过300行当超过300行时需要做拆分按功能模块拆分同模块在同一个文件夹下文件夹名要语义化。

View File

@ -60,7 +60,7 @@ export interface ExternalToolPluginPayload {
name: string;
description?: string;
baseUrl: string;
authType: 'none' | 'bearer' | 'basic' | 'apiKey';
authType: 'none' | 'bearer' | 'basic' | 'apiKey' | 'custom';
authConfig: Record<string, unknown>;
headers?: Record<string, string> | null;
apis: ExternalToolApi[];

View File

@ -1,603 +0,0 @@
import { CopyOutlined, DownOutlined, MinusCircleOutlined, PlusOutlined, RightOutlined } from '@ant-design/icons';
import { App as AntApp, Button, Card, Form, Input, Modal, Select, Space, Tabs } 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 allValues = Form.useWatch([], form);
const [expandedApiIndexes, setExpandedApiIndexes] = useState<number[]>([]);
const [jsonContent, setJsonContent] = useState('');
const [activeTab, setActiveTab] = useState('visual');
const [isSyncing, setIsSyncing] = useState(false);
const previousApiCountRef = useRef(0);
useEffect(() => {
if (allValues && !isSyncing) {
setJsonContent(JSON.stringify(allValues, null, 2));
}
}, [allValues, isSyncing]);
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 handleJsonChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
const val = e.target.value;
setJsonContent(val);
try {
const parsed = JSON.parse(val);
if (parsed && typeof parsed === 'object') {
setIsSyncing(true);
form.setFieldsValue(parsed);
setTimeout(() => setIsSyncing(false), 0);
}
} catch (e) {
// 格式不正确时不更新表单
}
};
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" preserve={true}>
<Tabs
activeKey={activeTab}
onChange={setActiveTab}
items={[
{
key: 'visual',
label: '可视化编辑',
children: (
<>
<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>
</>
),
},
{
key: 'json',
label: 'JSON 编辑',
children: (
<div style={{ padding: '4px 0 16px' }}>
<div style={{ marginBottom: 8, color: '#666', fontSize: 12 }}>
JSON
</div>
<Input.TextArea
value={jsonContent}
onChange={handleJsonChange}
rows={25}
className="agent-editor-code-input"
placeholder="请输入完整的工具集配置 JSON"
style={{ fontFamily: 'monospace' }}
/>
</div>
),
},
]}
/>
</Form>
</Modal>
);
}

View File

@ -0,0 +1,31 @@
import { Input } from 'antd';
import React from 'react';
interface JsonEditorProps {
/** JSON 文本内容 */
value: string;
/** 内容变化回调 */
onChange: (e: React.ChangeEvent<HTMLTextAreaElement>) => void;
}
/**
* JSON
* JSON
*/
export default function JsonEditor({ value, onChange }: JsonEditorProps) {
return (
<div style={{ padding: '4px 0 16px' }}>
<div style={{ marginBottom: 8, color: '#666', fontSize: 12 }}>
JSON
</div>
<Input.TextArea
value={value}
onChange={onChange}
rows={25}
className="agent-editor-code-input"
placeholder="请输入完整的工具集配置 JSON"
style={{ fontFamily: 'monospace' }}
/>
</div>
);
}

View File

@ -0,0 +1,281 @@
import { CopyOutlined, DownOutlined, MinusCircleOutlined, PlusOutlined, RightOutlined } from '@ant-design/icons';
import { Button, Card, Form, Input, Select, Space } from 'antd';
import { ToolApiFormValue, ToolPluginFormValue, EMPTY_API } from './types';
import { validateTrimmedText, validateRoutingList } from './utils';
interface VisualEditorProps {
form: ReturnType<typeof Form.useForm<ToolPluginFormValue>>[0];
apis: ToolApiFormValue[];
expandedApiIndexes: number[];
onToggleApiCard: (index: number) => void;
}
/**
*
* API
*/
export default function VisualEditor({ form, apis, expandedApiIndexes, onToggleApiCard }: VisualEditorProps) {
return (
<>
<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' },
{ value: 'custom', label: '自定义' },
]}
/>
</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={() => onToggleApiCard(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>
</>
);
}

View File

@ -0,0 +1,223 @@
import { App as AntApp, Form, Modal, Tabs } from 'antd';
import { useEffect, useRef, useState } from 'react';
import { AgentAPI, ExternalToolPlugin } from '../../api';
import { EMPTY_API, ExternalToolEditorProps, ToolApiFormValue, ToolPluginFormValue } from './types';
import { formValueToJson, formValueToPayload, jsonToFormValue } from './utils';
import VisualEditor from './VisualEditor';
import JsonEditor from './JsonEditor';
/**
*
* JSON
*/
export default function ExternalToolEditor({ open, agentId, plugin, onClose, onSaved }: ExternalToolEditorProps) {
const { message } = AntApp.useApp();
const [form] = Form.useForm<ToolPluginFormValue>();
const isEditing = Boolean(plugin);
const apis = Form.useWatch('apis', form) || [];
const allValues = Form.useWatch([], form) as ToolPluginFormValue | undefined;
// 展开的 API 卡片索引
const [expandedApiIndexes, setExpandedApiIndexes] = useState<number[]>([]);
const previousApiCountRef = useRef(0);
// JSON 编辑相关状态
const [jsonContent, setJsonContent] = useState('');
const [activeTab, setActiveTab] = useState('visual');
// 是否正在由 JSON 侧同步表单,用于避免表单变更又反向写回 JSON 造成的循环
const isSyncingRef = useRef(false);
/**
* -> JSON
* JSON JSON
*/
useEffect(() => {
if (!allValues || isSyncingRef.current) return;
try {
const jsonObj = formValueToJson(allValues);
setJsonContent(JSON.stringify(jsonObj, null, 2));
} catch (e) {
// 表单值不合法时不更新 JSON
}
}, [allValues]);
/**
* API
*/
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]);
/**
* JSON
*/
const handleJsonChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
const val = e.target.value;
setJsonContent(val);
try {
const parsed = JSON.parse(val);
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
isSyncingRef.current = true;
form.setFieldsValue(jsonToFormValue(parsed));
// 下一个事件循环解除标记,恢复表单 -> JSON 的同步
setTimeout(() => {
isSyncingRef.current = false;
}, 0);
}
} catch (e) {
// JSON 格式不正确时不更新表单
}
};
/**
* API /
*/
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 = formValueToPayload(values);
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 || '外部工具绑定失败');
}
};
/**
*
*/
const handleAfterOpenChange = (visible: boolean) => {
if (!visible) return;
const initial: ToolPluginFormValue = 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): ToolApiFormValue => ({
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 }],
};
isSyncingRef.current = true;
form.setFieldsValue(initial);
setTimeout(() => {
isSyncingRef.current = false;
}, 0);
};
return (
<Modal
open={open}
title={isEditing ? '编辑外部工具集' : '配置外部工具集'}
width={920}
okText={isEditing ? '保存修改' : '绑定工具'}
cancelText="取消"
onCancel={onClose}
onOk={handleSubmit}
destroyOnHidden
afterOpenChange={handleAfterOpenChange}
>
<Form form={form} layout="vertical" requiredMark="optional" preserve={true}>
<Tabs
activeKey={activeTab}
onChange={setActiveTab}
items={[
{
key: 'visual',
label: '可视化编辑',
children: (
<VisualEditor
form={form}
apis={apis}
expandedApiIndexes={expandedApiIndexes}
onToggleApiCard={toggleApiCard}
/>
),
},
{
key: 'json',
label: 'JSON 编辑',
children: <JsonEditor value={jsonContent} onChange={handleJsonChange} />,
},
]}
/>
</Form>
</Modal>
);
}
// 保持原类型 ExternalToolPlugin 的引用,避免 tree-shaking 丢失(其他地方可能依赖该类型重导出)
export type { ExternalToolPlugin };

View File

@ -0,0 +1,63 @@
import { ExternalToolApi, ExternalToolApiRouting, ExternalToolPlugin, ExternalToolPluginPayload } from '../../api';
/** 组件 Props 定义 */
export interface ExternalToolEditorProps {
/** 弹窗是否可见 */
open: boolean;
/** 智能体 ID */
agentId: string;
/** 已有的外部工具插件,传入时为编辑模式 */
plugin?: ExternalToolPlugin | null;
/** 关闭弹窗回调 */
onClose: () => void;
/** 保存成功后的回调 */
onSaved?: () => void | Promise<void>;
}
/** 单个 API 路由规则的表单值(与 API 定义一致,仅做引用) */
export interface ToolApiRoutingFormValue extends ExternalToolApiRouting {}
/** API
* - headers / parametersSchema Input.TextArea
* - parseJsonObject
*/
export interface ToolApiFormValue extends Omit<ExternalToolApi, 'headers' | 'parametersSchema' | 'routing'> {
/** 请求头 JSON 字符串 */
headers?: string;
/** 依赖参数 Schema JSON 字符串 */
parametersSchema: string;
/** 路由规则 */
routing: ToolApiRoutingFormValue;
}
/**
* - authConfig / headers
*/
export interface ToolPluginFormValue extends Omit<ExternalToolPluginPayload, 'authConfig' | 'apis' | 'headers'> {
/** 认证配置 JSON 字符串 */
authConfig?: string;
/** 统一请求头 JSON 字符串 */
headers?: string;
/** API 列表 */
apis: ToolApiFormValue[];
}
/** 空 API 模板 */
export 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: [],
},
};

View File

@ -0,0 +1,231 @@
import { ExternalToolApiRouting, ExternalToolPluginPayload } from '../../api';
import { ToolApiFormValue, ToolApiRoutingFormValue, ToolPluginFormValue } from './types';
/** 合法的认证类型枚举 */
const VALID_AUTH_TYPES = new Set(['none', 'bearer', 'basic', 'apiKey', 'custom']);
/**
* JSON JS
* - JSON
* - JS
* -
* @param value
* @param fieldName
* @param optional undefined
* @returns
* @throws Error
*/
export 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 或对象格式`);
}
}
}
/**
* trim
* @param values
* @returns
*/
export function normalizeStringList(values?: string[]) {
return Array.from(new Set((values ?? []).map((item) => item?.trim()).filter((item): item is string => Boolean(item))));
}
/**
* trim
* @param message
* @returns
*/
export function validateTrimmedText(message: string) {
return async (_: unknown, value: string | undefined) => {
if (!value?.trim()) {
throw new Error(message);
}
};
}
/**
*
* @param message
* @param min
* @returns
*/
export 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('列表项不能为空');
}
};
}
/**
* API
* @param value
* @param apiName API
* @returns
*/
export 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),
};
}
/**
* Payload
* @param values
* @returns Payload
*/
export function formValueToPayload(values: ToolPluginFormValue): ExternalToolPluginPayload {
return {
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'),
})),
};
}
/**
* JSON
* JSON JSON Form
* @param json JSON
* @returns
*/
export function jsonToFormValue(json: Record<string, any>): ToolPluginFormValue {
const apisRaw: any[] = Array.isArray(json.apis) ? json.apis : [];
const apis: ToolApiFormValue[] = apisRaw.map((item) => ({
name: typeof item.name === 'string' ? item.name : '',
description: typeof item.description === 'string' ? item.description : '',
method: typeof item.method === 'string' ? item.method : 'GET',
path: typeof item.path === 'string' ? item.path : '',
headers: typeof item.headers === 'string' ? item.headers : JSON.stringify(item.headers || {}, null, 2),
parametersSchema:
typeof item.parametersSchema === 'string'
? item.parametersSchema
: JSON.stringify(item.parametersSchema || { type: 'object', properties: {} }, null, 2),
routing: {
summary: typeof item.routing?.summary === 'string' ? item.routing.summary : '',
useWhen: Array.isArray(item.routing?.useWhen) ? item.routing.useWhen : [''],
doNotUseWhen: Array.isArray(item.routing?.doNotUseWhen) ? item.routing.doNotUseWhen : [],
domains: Array.isArray(item.routing?.domains) ? item.routing.domains : [],
intents: Array.isArray(item.routing?.intents) ? item.routing.intents : [],
requiredSlots: Array.isArray(item.routing?.requiredSlots) ? item.routing.requiredSlots : [],
optionalSlots: Array.isArray(item.routing?.optionalSlots) ? item.routing.optionalSlots : [],
examples: Array.isArray(item.routing?.examples) ? item.routing.examples : [],
},
}));
return {
name: typeof json.name === 'string' ? json.name : '',
description: typeof json.description === 'string' ? json.description : '',
baseUrl: typeof json.baseUrl === 'string' ? json.baseUrl : '',
authType: VALID_AUTH_TYPES.has(json.authType) ? json.authType : 'bearer',
authConfig: typeof json.authConfig === 'string' ? json.authConfig : JSON.stringify(json.authConfig || {}, null, 2),
headers: typeof json.headers === 'string' ? json.headers : JSON.stringify(json.headers || {}, null, 2),
apis: apis.length ? apis : [],
};
}
/**
* JSON
* JSON
* @param values
* @returns JSON
*/
export function formValueToJson(values: ToolPluginFormValue): Record<string, any> {
return {
name: values.name,
description: values.description,
baseUrl: values.baseUrl,
authType: values.authType,
authConfig: parseJsonObject(values.authConfig, '认证配置', true) || {},
headers: parseJsonObject(values.headers, '统一请求头', true) || {},
apis: (values.apis || []).map((item) => ({
name: item.name,
description: item.description,
method: item.method,
path: item.path,
headers: parseJsonObject(item.headers, `API ${item.name || ''} 的请求头`, true) || {},
parametersSchema: parseJsonObject(item.parametersSchema, `API ${item.name || ''} 的依赖参数`, true) || {},
routing: item.routing || {},
})),
};
}