feat: ExternalToolEditor 支持 JSON 全量编辑与双向同步

main
sp mac bookpro 2605 2026-07-27 23:28:06 +08:00
parent f1553be178
commit 49b48df182
1 changed files with 315 additions and 257 deletions

View File

@ -1,5 +1,5 @@
import { CopyOutlined, DownOutlined, MinusCircleOutlined, PlusOutlined, RightOutlined } from '@ant-design/icons';
import { App as AntApp, Button, Card, Form, Input, Modal, Select, Space } from 'antd';
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';
@ -150,9 +150,19 @@ export default function ExternalToolEditor({ open, agentId, plugin, onClose, onS
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;
@ -181,6 +191,21 @@ export default function ExternalToolEditor({ open, agentId, plugin, onClose, onS
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],
@ -280,265 +305,298 @@ export default function ExternalToolEditor({ open, agentId, plugin, onClose, onS
}
}}
>
<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 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)}>
<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>
) : null}
</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>
}
>
{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.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>
);