- Introduced `manual_opener_tool_calls` field in the Assistant model to support custom tool calls. - Updated AssistantBase and AssistantUpdate schemas to include the new field. - Implemented normalization and migration logic for handling manual opener tool calls in the API. - Enhanced runtime metadata to include manual opener tool calls in responses. - Updated tests to validate the new functionality and ensure proper handling of tool calls. - Refactored tool ID normalization to support legacy tool names for backward compatibility.
952 lines
41 KiB
TypeScript
952 lines
41 KiB
TypeScript
import React, { useEffect, useState } from 'react';
|
||
import { Search, Filter, Plus, Wrench, Terminal, Globe, Camera, CameraOff, Image, Images, CloudSun, Calendar, TrendingUp, Coins, Trash2, Edit2, Box, Volume2 } from 'lucide-react';
|
||
import { Button, Input, Badge, Dialog, Drawer } from '../components/UI';
|
||
import { Tool } from '../types';
|
||
import { createTool, deleteTool, fetchTools, updateTool } from '../services/backendApi';
|
||
|
||
const iconMap: Record<string, React.ReactNode> = {
|
||
Camera: <Camera className="w-5 h-5" />,
|
||
CameraOff: <CameraOff className="w-5 h-5" />,
|
||
Image: <Image className="w-5 h-5" />,
|
||
Images: <Images className="w-5 h-5" />,
|
||
CloudSun: <CloudSun className="w-5 h-5" />,
|
||
Calendar: <Calendar className="w-5 h-5" />,
|
||
TrendingUp: <TrendingUp className="w-5 h-5" />,
|
||
Coins: <Coins className="w-5 h-5" />,
|
||
Terminal: <Terminal className="w-5 h-5" />,
|
||
Globe: <Globe className="w-5 h-5" />,
|
||
Wrench: <Wrench className="w-5 h-5" />,
|
||
Box: <Box className="w-5 h-5" />,
|
||
Volume2: <Volume2 className="w-5 h-5" />,
|
||
};
|
||
|
||
type ToolParameterType = 'string' | 'number' | 'integer' | 'boolean' | 'object' | 'array';
|
||
type ToolParameterDraft = {
|
||
id: string;
|
||
key: string;
|
||
sourceKey: string;
|
||
type: ToolParameterType;
|
||
required: boolean;
|
||
description: string;
|
||
hasDefault: boolean;
|
||
defaultValueText: string;
|
||
};
|
||
|
||
const TOOL_PARAMETER_TYPES: ToolParameterType[] = ['string', 'number', 'integer', 'boolean', 'object', 'array'];
|
||
|
||
const newParameterDraft = (): ToolParameterDraft => ({
|
||
id: `param_${Date.now()}_${Math.random().toString(16).slice(2, 8)}`,
|
||
key: '',
|
||
sourceKey: '',
|
||
type: 'string',
|
||
required: false,
|
||
description: '',
|
||
hasDefault: false,
|
||
defaultValueText: '',
|
||
});
|
||
|
||
const schemaTypeOrDefault = (value: any): ToolParameterType =>
|
||
TOOL_PARAMETER_TYPES.includes(value as ToolParameterType) ? (value as ToolParameterType) : 'string';
|
||
|
||
const stringifyDefaultValue = (value: any): string => {
|
||
if (value === null || value === undefined) return '';
|
||
if (typeof value === 'object') {
|
||
try {
|
||
return JSON.stringify(value);
|
||
} catch {
|
||
return '';
|
||
}
|
||
}
|
||
return String(value);
|
||
};
|
||
|
||
const parseDefaultValueByType = (type: ToolParameterType, valueText: string): { value?: any; error?: string } => {
|
||
const text = valueText.trim();
|
||
if (type === 'string') return { value: valueText };
|
||
if (type === 'number') {
|
||
const n = Number(text);
|
||
if (!Number.isFinite(n)) return { error: `参数默认值不是合法 number: ${valueText}` };
|
||
return { value: n };
|
||
}
|
||
if (type === 'integer') {
|
||
const n = Number(text);
|
||
if (!Number.isInteger(n)) return { error: `参数默认值不是合法 integer: ${valueText}` };
|
||
return { value: n };
|
||
}
|
||
if (type === 'boolean') {
|
||
const lowered = text.toLowerCase();
|
||
if (lowered === 'true') return { value: true };
|
||
if (lowered === 'false') return { value: false };
|
||
return { error: `参数默认值不是合法 boolean(true/false): ${valueText}` };
|
||
}
|
||
if (type === 'object' || type === 'array') {
|
||
try {
|
||
const parsed = JSON.parse(text || (type === 'array' ? '[]' : '{}'));
|
||
if (type === 'object' && (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed))) {
|
||
return { error: `参数默认值不是合法 object JSON: ${valueText}` };
|
||
}
|
||
if (type === 'array' && !Array.isArray(parsed)) {
|
||
return { error: `参数默认值不是合法 array JSON: ${valueText}` };
|
||
}
|
||
return { value: parsed };
|
||
} catch {
|
||
return { error: `参数默认值不是合法 JSON: ${valueText}` };
|
||
}
|
||
}
|
||
return { value: valueText };
|
||
};
|
||
|
||
const draftsFromSchema = (schema: Record<string, any> | undefined, defaults: Record<string, any> | undefined): ToolParameterDraft[] => {
|
||
const safeSchema = schema && typeof schema === 'object' ? schema : {};
|
||
const safeDefaults = defaults && typeof defaults === 'object' ? defaults : {};
|
||
const properties = safeSchema.properties && typeof safeSchema.properties === 'object' ? safeSchema.properties : {};
|
||
const requiredSet = new Set<string>(Array.isArray(safeSchema.required) ? safeSchema.required.map((item) => String(item)) : []);
|
||
|
||
const drafts: ToolParameterDraft[] = Object.entries(properties).map(([key, spec]) => {
|
||
const typedSpec: Record<string, any> = spec && typeof spec === 'object' ? (spec as Record<string, any>) : {};
|
||
const hasDefault = Object.prototype.hasOwnProperty.call(safeDefaults, key);
|
||
return {
|
||
id: `param_${key}_${Math.random().toString(16).slice(2, 8)}`,
|
||
key,
|
||
sourceKey: key,
|
||
type: schemaTypeOrDefault(typedSpec.type),
|
||
required: requiredSet.has(key),
|
||
description: String(typedSpec.description || ''),
|
||
hasDefault,
|
||
defaultValueText: hasDefault ? stringifyDefaultValue(safeDefaults[key]) : '',
|
||
};
|
||
});
|
||
|
||
for (const [key, value] of Object.entries(safeDefaults)) {
|
||
if (drafts.some((item) => item.key === key)) continue;
|
||
drafts.push({
|
||
id: `param_${key}_${Math.random().toString(16).slice(2, 8)}`,
|
||
key,
|
||
sourceKey: key,
|
||
type: typeof value === 'number' && Number.isInteger(value) ? 'integer' : (typeof value as ToolParameterType) || 'string',
|
||
required: false,
|
||
description: '',
|
||
hasDefault: true,
|
||
defaultValueText: stringifyDefaultValue(value),
|
||
});
|
||
}
|
||
return drafts;
|
||
};
|
||
|
||
const normalizeObjectSchema = (value: any): Record<string, any> => {
|
||
const schema = value && typeof value === 'object' && !Array.isArray(value) ? { ...value } : {};
|
||
const properties = schema.properties && typeof schema.properties === 'object' && !Array.isArray(schema.properties)
|
||
? schema.properties
|
||
: {};
|
||
const required = Array.isArray(schema.required) ? schema.required.map((item: any) => String(item)) : [];
|
||
return {
|
||
...schema,
|
||
type: 'object',
|
||
properties,
|
||
required,
|
||
};
|
||
};
|
||
|
||
const normalizeDefaultsObject = (value: any): Record<string, any> =>
|
||
value && typeof value === 'object' && !Array.isArray(value) ? { ...value } : {};
|
||
|
||
const buildToolParameterConfig = (
|
||
drafts: ToolParameterDraft[],
|
||
baseSchemaInput?: Record<string, any>
|
||
): { schema: Record<string, any>; defaults: Record<string, any>; error?: string } => {
|
||
const baseSchema = normalizeObjectSchema(baseSchemaInput);
|
||
const baseProperties = baseSchema.properties && typeof baseSchema.properties === 'object' && !Array.isArray(baseSchema.properties)
|
||
? (baseSchema.properties as Record<string, any>)
|
||
: {};
|
||
const properties: Record<string, any> = {};
|
||
const required: string[] = [];
|
||
const defaults: Record<string, any> = {};
|
||
const seen = new Set<string>();
|
||
|
||
for (const draft of drafts) {
|
||
const key = draft.key.trim();
|
||
if (!key) continue;
|
||
if (seen.has(key)) {
|
||
return { schema: {}, defaults: {}, error: `参数标识重复: ${key}` };
|
||
}
|
||
seen.add(key);
|
||
|
||
const sourceKey = String(draft.sourceKey || '').trim();
|
||
const preservedRaw = (
|
||
(sourceKey && Object.prototype.hasOwnProperty.call(baseProperties, sourceKey) ? baseProperties[sourceKey] : undefined)
|
||
?? baseProperties[key]
|
||
);
|
||
const preservedSpec = preservedRaw && typeof preservedRaw === 'object' && !Array.isArray(preservedRaw)
|
||
? { ...(preservedRaw as Record<string, any>) }
|
||
: {};
|
||
properties[key] = preservedSpec;
|
||
properties[key].type = draft.type;
|
||
if (draft.description.trim()) {
|
||
properties[key].description = draft.description.trim();
|
||
} else {
|
||
delete properties[key].description;
|
||
}
|
||
if (draft.required) required.push(key);
|
||
if (draft.hasDefault) {
|
||
const parsed = parseDefaultValueByType(draft.type, draft.defaultValueText);
|
||
if (parsed.error) {
|
||
return { schema: {}, defaults: {}, error: parsed.error };
|
||
}
|
||
defaults[key] = parsed.value;
|
||
properties[key].default = parsed.value;
|
||
} else {
|
||
delete properties[key].default;
|
||
}
|
||
}
|
||
|
||
return {
|
||
schema: {
|
||
...baseSchema,
|
||
type: 'object',
|
||
properties,
|
||
required,
|
||
},
|
||
defaults,
|
||
};
|
||
};
|
||
|
||
const defaultsFromSchema = (schema: Record<string, any>): Record<string, any> => {
|
||
const defaults: Record<string, any> = {};
|
||
const properties = schema?.properties;
|
||
if (!properties || typeof properties !== 'object' || Array.isArray(properties)) return defaults;
|
||
for (const [key, spec] of Object.entries(properties)) {
|
||
if (!spec || typeof spec !== 'object' || Array.isArray(spec)) continue;
|
||
if (Object.prototype.hasOwnProperty.call(spec, 'default')) {
|
||
defaults[key] = (spec as Record<string, any>).default;
|
||
}
|
||
}
|
||
return defaults;
|
||
};
|
||
|
||
export const ToolLibraryPage: React.FC = () => {
|
||
const [tools, setTools] = useState<Tool[]>([]);
|
||
const [searchTerm, setSearchTerm] = useState('');
|
||
const [categoryFilter, setCategoryFilter] = useState<'all' | 'system' | 'query'>('all');
|
||
const [isLoading, setIsLoading] = useState(true);
|
||
const [isToolModalOpen, setIsToolModalOpen] = useState(false);
|
||
const [editingTool, setEditingTool] = useState<Tool | null>(null);
|
||
|
||
const [toolName, setToolName] = useState('');
|
||
const [toolId, setToolId] = useState('');
|
||
const [toolDesc, setToolDesc] = useState('');
|
||
const [toolCategory, setToolCategory] = useState<'system' | 'query'>('system');
|
||
const [toolIcon, setToolIcon] = useState('Wrench');
|
||
const [toolEnabled, setToolEnabled] = useState(true);
|
||
const [toolWaitForResponse, setToolWaitForResponse] = useState(false);
|
||
const [toolHttpMethod, setToolHttpMethod] = useState<'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'>('GET');
|
||
const [toolHttpUrl, setToolHttpUrl] = useState('');
|
||
const [toolHttpHeadersText, setToolHttpHeadersText] = useState('{}');
|
||
const [toolHttpTimeoutMs, setToolHttpTimeoutMs] = useState(10000);
|
||
const [toolSchema, setToolSchema] = useState<Record<string, any>>({ type: 'object', properties: {}, required: [] });
|
||
const [toolParameterDefaults, setToolParameterDefaults] = useState<Record<string, any>>({});
|
||
const [toolParameters, setToolParameters] = useState<ToolParameterDraft[]>([]);
|
||
const [schemaDrawerOpen, setSchemaDrawerOpen] = useState(false);
|
||
const [schemaEditorText, setSchemaEditorText] = useState('');
|
||
const [schemaEditorError, setSchemaEditorError] = useState('');
|
||
const [saving, setSaving] = useState(false);
|
||
|
||
const loadTools = async () => {
|
||
setIsLoading(true);
|
||
try {
|
||
setTools(await fetchTools());
|
||
} catch (error) {
|
||
console.error(error);
|
||
setTools([]);
|
||
} finally {
|
||
setIsLoading(false);
|
||
}
|
||
};
|
||
|
||
useEffect(() => {
|
||
loadTools();
|
||
}, []);
|
||
|
||
const openAdd = () => {
|
||
setEditingTool(null);
|
||
setToolName('');
|
||
setToolId('');
|
||
setToolDesc('');
|
||
setToolCategory('system');
|
||
setToolIcon('Wrench');
|
||
setToolEnabled(true);
|
||
setToolWaitForResponse(false);
|
||
setToolHttpMethod('GET');
|
||
setToolHttpUrl('');
|
||
setToolHttpHeadersText('{}');
|
||
setToolHttpTimeoutMs(10000);
|
||
const emptySchema = { type: 'object', properties: {}, required: [] };
|
||
setToolSchema(emptySchema);
|
||
setToolParameterDefaults({});
|
||
setToolParameters([]);
|
||
setSchemaEditorError('');
|
||
setSchemaEditorText(JSON.stringify(emptySchema, null, 2));
|
||
setSchemaDrawerOpen(false);
|
||
setIsToolModalOpen(true);
|
||
};
|
||
|
||
const openEdit = (tool: Tool) => {
|
||
setEditingTool(tool);
|
||
setToolName(tool.name);
|
||
setToolId(tool.id);
|
||
setToolDesc(tool.description || '');
|
||
setToolCategory(tool.category);
|
||
setToolIcon(tool.icon || 'Wrench');
|
||
setToolEnabled(tool.enabled ?? true);
|
||
setToolWaitForResponse(Boolean(tool.waitForResponse));
|
||
setToolHttpMethod((tool.httpMethod || 'GET') as 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE');
|
||
setToolHttpUrl(tool.httpUrl || '');
|
||
setToolHttpHeadersText(JSON.stringify(tool.httpHeaders || {}, null, 2));
|
||
setToolHttpTimeoutMs(tool.httpTimeoutMs || 10000);
|
||
const normalizedSchema = normalizeObjectSchema(tool.parameterSchema);
|
||
const mergedDefaults = {
|
||
...defaultsFromSchema(normalizedSchema),
|
||
...normalizeDefaultsObject(tool.parameterDefaults),
|
||
};
|
||
setToolSchema(normalizedSchema);
|
||
setToolParameterDefaults(mergedDefaults);
|
||
setToolParameters(draftsFromSchema(normalizedSchema, mergedDefaults));
|
||
setSchemaEditorError('');
|
||
setSchemaEditorText(
|
||
JSON.stringify(
|
||
normalizedSchema,
|
||
null,
|
||
2
|
||
)
|
||
);
|
||
setSchemaDrawerOpen(false);
|
||
setIsToolModalOpen(true);
|
||
};
|
||
|
||
const openSchemaDrawer = () => {
|
||
const parameterConfig = buildToolParameterConfig(toolParameters, toolSchema);
|
||
if (!parameterConfig.error) {
|
||
setToolSchema(parameterConfig.schema);
|
||
setToolParameterDefaults(parameterConfig.defaults);
|
||
setToolParameters((prev) => prev.map((item) => ({ ...item, sourceKey: item.key.trim() || item.sourceKey })));
|
||
setSchemaEditorText(JSON.stringify(parameterConfig.schema, null, 2));
|
||
} else if (toolSchema && typeof toolSchema === 'object') {
|
||
setSchemaEditorText(JSON.stringify(toolSchema, null, 2));
|
||
} else if (!schemaEditorText.trim()) {
|
||
setSchemaEditorText(JSON.stringify({ type: 'object', properties: {}, required: [] }, null, 2));
|
||
}
|
||
setSchemaEditorError('');
|
||
setSchemaDrawerOpen(true);
|
||
};
|
||
|
||
const formatSchemaEditor = () => {
|
||
try {
|
||
const parsed = JSON.parse(schemaEditorText || '{}');
|
||
setSchemaEditorText(JSON.stringify(parsed, null, 2));
|
||
setSchemaEditorError('');
|
||
} catch {
|
||
setSchemaEditorError('Schema 不是合法 JSON,无法格式化。');
|
||
}
|
||
};
|
||
|
||
const applySchemaEditor = () => {
|
||
let parsedSchema: Record<string, any>;
|
||
try {
|
||
const parsed = JSON.parse(schemaEditorText || '{}');
|
||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
||
setSchemaEditorError('Schema 必须是 JSON 对象。');
|
||
return;
|
||
}
|
||
parsedSchema = parsed as Record<string, any>;
|
||
} catch {
|
||
setSchemaEditorError('Schema 不是合法 JSON。');
|
||
return;
|
||
}
|
||
|
||
if (parsedSchema.type !== undefined && parsedSchema.type !== 'object') {
|
||
setSchemaEditorError("Schema.type 必须为 'object'。");
|
||
return;
|
||
}
|
||
if (
|
||
parsedSchema.properties !== undefined
|
||
&& (!parsedSchema.properties || typeof parsedSchema.properties !== 'object' || Array.isArray(parsedSchema.properties))
|
||
) {
|
||
setSchemaEditorError('Schema.properties 必须是对象。');
|
||
return;
|
||
}
|
||
if (parsedSchema.required !== undefined && !Array.isArray(parsedSchema.required)) {
|
||
setSchemaEditorError('Schema.required 必须是数组。');
|
||
return;
|
||
}
|
||
|
||
const normalizedSchema = normalizeObjectSchema(parsedSchema);
|
||
const mergedDefaults = { ...normalizeDefaultsObject(toolParameterDefaults), ...defaultsFromSchema(normalizedSchema) };
|
||
|
||
setToolSchema(normalizedSchema);
|
||
setToolParameterDefaults(mergedDefaults);
|
||
setToolParameters(draftsFromSchema(normalizedSchema, mergedDefaults));
|
||
setSchemaEditorError('');
|
||
setSchemaEditorText(JSON.stringify(normalizedSchema, null, 2));
|
||
setSchemaDrawerOpen(false);
|
||
};
|
||
|
||
const filteredTools = tools.filter((tool) => {
|
||
const q = searchTerm.toLowerCase();
|
||
const matchesSearch =
|
||
tool.name.toLowerCase().includes(q) ||
|
||
(tool.description || '').toLowerCase().includes(q) ||
|
||
tool.id.toLowerCase().includes(q);
|
||
const matchesCategory = categoryFilter === 'all' || tool.category === categoryFilter;
|
||
return matchesSearch && matchesCategory;
|
||
});
|
||
const systemTools = filteredTools.filter((tool) => tool.category === 'system');
|
||
const queryTools = filteredTools.filter((tool) => tool.category === 'query');
|
||
|
||
const renderToolCard = (tool: Tool) => (
|
||
<div
|
||
key={tool.id}
|
||
className="p-5 rounded-xl border transition-all relative bg-card/30 border-white/5 hover:bg-white/5 hover:border-white/10 hover:shadow-lg"
|
||
>
|
||
<div className="flex items-start space-x-4">
|
||
<div className={`p-3 rounded-lg shrink-0 transition-colors ${tool.category === 'system' ? 'bg-primary/10 text-primary' : 'bg-blue-500/10 text-blue-400'}`}>
|
||
{iconMap[tool.icon] || <Box className="w-5 h-5" />}
|
||
</div>
|
||
<div className="flex-1 min-w-0">
|
||
<div className="flex items-center justify-between mb-1 gap-2">
|
||
<span className="text-base font-bold text-white truncate">{tool.name}</span>
|
||
{tool.isSystem ? <Badge variant="outline" className="text-[9px] h-4 px-1">SYSTEM</Badge> : <Badge variant="outline" className="text-[9px] h-4 px-1">CUSTOM</Badge>}
|
||
</div>
|
||
<div className="flex items-center gap-2 mb-2">
|
||
<Badge variant="outline" className={`text-[10px] border-0 px-0 ${tool.category === 'system' ? 'text-primary' : 'text-blue-400'}`}>
|
||
{tool.category === 'system' ? 'SYSTEM' : 'QUERY'}
|
||
</Badge>
|
||
{tool.category === 'system' && (
|
||
<Badge
|
||
variant="outline"
|
||
className={`text-[10px] h-4 px-1.5 border ${tool.waitForResponse ? 'border-amber-400/40 text-amber-300' : 'border-white/20 text-muted-foreground'}`}
|
||
title={tool.waitForResponse ? 'wait for response: ON' : 'wait for response: OFF'}
|
||
>
|
||
{tool.waitForResponse ? 'WAIT' : 'NO-WAIT'}
|
||
</Badge>
|
||
)}
|
||
<span className="text-[10px] text-muted-foreground font-mono opacity-50 truncate">ID: {tool.id}</span>
|
||
</div>
|
||
<p className="text-xs text-muted-foreground line-clamp-2 leading-relaxed opacity-80">{tool.description}</p>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="mt-4 pt-3 border-t border-white/10 flex items-center justify-between">
|
||
<span className="text-[11px] text-muted-foreground">system/query 仅表示执行类型</span>
|
||
<div className="flex space-x-1">
|
||
<button
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
openEdit(tool);
|
||
}}
|
||
title="编辑工具"
|
||
className="p-1.5 rounded-md transition-colors hover:bg-primary/20 text-muted-foreground hover:text-primary"
|
||
>
|
||
<Edit2 className="w-4 h-4" />
|
||
</button>
|
||
<button
|
||
onClick={(e) => handleDeleteTool(e, tool)}
|
||
title="删除工具"
|
||
className="p-1.5 rounded-md transition-colors hover:bg-destructive/20 text-muted-foreground hover:text-destructive"
|
||
>
|
||
<Trash2 className="w-4 h-4" />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
|
||
const addToolParameter = () => {
|
||
setToolParameters((prev) => [...prev, newParameterDraft()]);
|
||
};
|
||
|
||
const updateToolParameter = (parameterId: string, patch: Partial<ToolParameterDraft>) => {
|
||
setToolParameters((prev) => prev.map((item) => (item.id === parameterId ? { ...item, ...patch } : item)));
|
||
};
|
||
|
||
const removeToolParameter = (parameterId: string) => {
|
||
setToolParameters((prev) => prev.filter((item) => item.id !== parameterId));
|
||
};
|
||
|
||
const handleSaveTool = async () => {
|
||
if (!toolName.trim()) {
|
||
alert('请填写工具名称');
|
||
return;
|
||
}
|
||
if (!editingTool && toolId.trim() && !/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(toolId.trim())) {
|
||
alert('工具 ID 不合法,请使用字母/数字/下划线,且不能以数字开头');
|
||
return;
|
||
}
|
||
|
||
try {
|
||
setSaving(true);
|
||
let parsedHeaders: Record<string, string> = {};
|
||
const parameterConfig = buildToolParameterConfig(toolParameters, toolSchema);
|
||
if (parameterConfig.error) {
|
||
alert(parameterConfig.error);
|
||
setSaving(false);
|
||
return;
|
||
}
|
||
const parsedParameterSchema = parameterConfig.schema;
|
||
const parsedParameterDefaults = parameterConfig.defaults;
|
||
setToolSchema(parsedParameterSchema);
|
||
setToolParameterDefaults(parsedParameterDefaults);
|
||
setToolParameters((prev) => prev.map((item) => ({ ...item, sourceKey: item.key.trim() || item.sourceKey })));
|
||
setSchemaEditorText(JSON.stringify(parsedParameterSchema, null, 2));
|
||
|
||
if (toolCategory === 'query') {
|
||
if (
|
||
!toolHttpUrl.trim()
|
||
&& editingTool?.id !== 'calculator'
|
||
&& editingTool?.id !== 'code_interpreter'
|
||
&& editingTool?.id !== 'current_time'
|
||
) {
|
||
alert('信息查询工具请填写 HTTP URL');
|
||
setSaving(false);
|
||
return;
|
||
}
|
||
try {
|
||
const parsed = JSON.parse(toolHttpHeadersText || '{}');
|
||
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
||
parsedHeaders = parsed as Record<string, string>;
|
||
} else {
|
||
throw new Error('headers must be object');
|
||
}
|
||
} catch {
|
||
alert('HTTP Headers 必须是合法 JSON 对象');
|
||
setSaving(false);
|
||
return;
|
||
}
|
||
}
|
||
|
||
if (editingTool) {
|
||
const updated = await updateTool(editingTool.id, {
|
||
name: toolName.trim(),
|
||
description: toolDesc,
|
||
category: toolCategory,
|
||
icon: toolIcon,
|
||
httpMethod: toolHttpMethod,
|
||
httpUrl: toolHttpUrl.trim(),
|
||
httpHeaders: parsedHeaders,
|
||
httpTimeoutMs: toolHttpTimeoutMs,
|
||
parameterSchema: parsedParameterSchema,
|
||
parameterDefaults: parsedParameterDefaults,
|
||
waitForResponse: toolCategory === 'system' ? toolWaitForResponse : false,
|
||
enabled: toolEnabled,
|
||
});
|
||
setTools((prev) => prev.map((item) => (item.id === updated.id ? updated : item)));
|
||
} else {
|
||
const created = await createTool({
|
||
id: toolId.trim() || undefined,
|
||
name: toolName.trim(),
|
||
description: toolDesc,
|
||
category: toolCategory,
|
||
icon: toolIcon,
|
||
httpMethod: toolHttpMethod,
|
||
httpUrl: toolHttpUrl.trim(),
|
||
httpHeaders: parsedHeaders,
|
||
httpTimeoutMs: toolHttpTimeoutMs,
|
||
parameterSchema: parsedParameterSchema,
|
||
parameterDefaults: parsedParameterDefaults,
|
||
waitForResponse: toolCategory === 'system' ? toolWaitForResponse : false,
|
||
enabled: toolEnabled,
|
||
});
|
||
setTools((prev) => [created, ...prev]);
|
||
}
|
||
setIsToolModalOpen(false);
|
||
} catch (error: any) {
|
||
alert(error?.message || '保存工具失败');
|
||
} finally {
|
||
setSaving(false);
|
||
}
|
||
};
|
||
|
||
const handleDeleteTool = async (e: React.MouseEvent, tool: Tool) => {
|
||
e.stopPropagation();
|
||
if (!confirm('确认删除该工具吗?')) return;
|
||
|
||
try {
|
||
await deleteTool(tool.id);
|
||
setTools((prev) => prev.filter((item) => item.id !== tool.id));
|
||
} catch (error: any) {
|
||
alert(error?.message || '删除失败');
|
||
}
|
||
};
|
||
|
||
return (
|
||
<div className="space-y-6 animate-in fade-in py-4 pb-10">
|
||
<div className="flex items-center justify-between">
|
||
<h1 className="text-2xl font-bold tracking-tight text-white">工具与插件</h1>
|
||
<Button onClick={openAdd} className="shadow-[0_0_15px_rgba(6,182,212,0.4)]">
|
||
<Plus className="mr-2 h-4 w-4" /> 添加工具
|
||
</Button>
|
||
</div>
|
||
|
||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 bg-card/50 p-4 rounded-lg border border-white/5 shadow-sm">
|
||
<div className="relative col-span-1 md:col-span-2">
|
||
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
||
<Input
|
||
placeholder="搜索工具名称..."
|
||
className="pl-9 border-0 bg-white/5"
|
||
value={searchTerm}
|
||
onChange={(e) => setSearchTerm(e.target.value)}
|
||
/>
|
||
</div>
|
||
<div className="flex items-center space-x-2">
|
||
<Filter className="h-4 w-4 text-muted-foreground" />
|
||
<select
|
||
className="flex h-9 w-full rounded-md border-0 bg-white/5 px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary/50 [&>option]:bg-card text-foreground"
|
||
value={categoryFilter}
|
||
onChange={(e) => setCategoryFilter(e.target.value as 'all' | 'system' | 'query')}
|
||
>
|
||
<option value="all">所有类型</option>
|
||
<option value="system">系统指令 (System)</option>
|
||
<option value="query">信息查询 (Query)</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
|
||
{isLoading ? (
|
||
<div className="py-12 flex flex-col items-center justify-center text-muted-foreground opacity-70">
|
||
<Wrench className="w-12 h-12 mb-4 stroke-1 animate-pulse" />
|
||
<p>加载中...</p>
|
||
</div>
|
||
) : filteredTools.length === 0 ? (
|
||
<div className="py-12 flex flex-col items-center justify-center text-muted-foreground opacity-50">
|
||
<Wrench className="w-12 h-12 mb-4 stroke-1" />
|
||
<p>未找到相关工具</p>
|
||
</div>
|
||
) : (
|
||
<div className="space-y-8">
|
||
{(categoryFilter === 'all' || categoryFilter === 'system') && (
|
||
<section className="space-y-3">
|
||
<div className="flex items-center justify-between">
|
||
<h2 className="text-sm font-black uppercase tracking-wider text-primary">System Command</h2>
|
||
<Badge variant="outline" className="text-[10px]">{systemTools.length} tools</Badge>
|
||
</div>
|
||
{systemTools.length === 0 ? (
|
||
<div className="rounded-lg border border-white/10 bg-black/20 p-4 text-xs text-muted-foreground">当前筛选条件下无系统指令工具。</div>
|
||
) : (
|
||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||
{systemTools.map(renderToolCard)}
|
||
</div>
|
||
)}
|
||
</section>
|
||
)}
|
||
|
||
{(categoryFilter === 'all' || categoryFilter === 'query') && (
|
||
<section className="space-y-3">
|
||
<div className="flex items-center justify-between">
|
||
<h2 className="text-sm font-black uppercase tracking-wider text-blue-300">Information Query</h2>
|
||
<Badge variant="outline" className="text-[10px]">{queryTools.length} tools</Badge>
|
||
</div>
|
||
{queryTools.length === 0 ? (
|
||
<div className="rounded-lg border border-white/10 bg-black/20 p-4 text-xs text-muted-foreground">当前筛选条件下无信息查询工具。</div>
|
||
) : (
|
||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||
{queryTools.map(renderToolCard)}
|
||
</div>
|
||
)}
|
||
</section>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
<Dialog
|
||
isOpen={isToolModalOpen}
|
||
onClose={() => {
|
||
setIsToolModalOpen(false);
|
||
setSchemaDrawerOpen(false);
|
||
setSchemaEditorError('');
|
||
}}
|
||
title={editingTool ? '编辑自定义工具' : '添加自定义工具'}
|
||
contentClassName="max-w-4xl"
|
||
footer={
|
||
<>
|
||
<Button
|
||
variant="ghost"
|
||
onClick={() => {
|
||
setIsToolModalOpen(false);
|
||
setSchemaDrawerOpen(false);
|
||
setSchemaEditorError('');
|
||
}}
|
||
>
|
||
取消
|
||
</Button>
|
||
<Button onClick={handleSaveTool} disabled={saving}>{saving ? '保存中...' : (editingTool ? '保存修改' : '确认添加')}</Button>
|
||
</>
|
||
}
|
||
>
|
||
<div className="space-y-4 max-h-[68vh] overflow-y-auto pr-1">
|
||
<div className="space-y-1.5">
|
||
<label className="text-[10px] font-black text-muted-foreground uppercase tracking-widest block">工具类型</label>
|
||
<div className="flex bg-white/5 p-1 rounded-lg border border-white/10">
|
||
<button
|
||
onClick={() => {
|
||
setToolCategory('system');
|
||
if (!toolIcon) setToolIcon('Terminal');
|
||
}}
|
||
className={`flex-1 flex items-center justify-center py-2 text-xs font-bold rounded-md transition-all ${toolCategory === 'system' ? 'bg-primary text-primary-foreground shadow-lg' : 'text-muted-foreground hover:text-foreground'}`}
|
||
>
|
||
<Terminal className="w-3.5 h-3.5 mr-2" /> 系统指令
|
||
</button>
|
||
<button
|
||
onClick={() => {
|
||
setToolCategory('query');
|
||
if (!toolIcon) setToolIcon('Globe');
|
||
}}
|
||
className={`flex-1 flex items-center justify-center py-2 text-xs font-bold rounded-md transition-all ${toolCategory === 'query' ? 'bg-blue-500 text-white shadow-lg' : 'text-muted-foreground hover:text-foreground'}`}
|
||
>
|
||
<Globe className="w-3.5 h-3.5 mr-2" /> 信息查询
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="space-y-1.5">
|
||
<label className="text-[10px] font-black text-muted-foreground uppercase tracking-widest block">图标 (Icon)</label>
|
||
<select
|
||
className="flex h-9 w-full rounded-md border-0 bg-white/5 px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary/50 [&>option]:bg-card text-foreground"
|
||
value={toolIcon}
|
||
onChange={(e) => setToolIcon(e.target.value)}
|
||
>
|
||
{Object.keys(iconMap).map((icon) => (
|
||
<option key={icon} value={icon}>{icon}</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
|
||
<div className="space-y-1.5">
|
||
<label className="text-[10px] font-black text-muted-foreground uppercase tracking-widest block">工具名称</label>
|
||
<Input
|
||
value={toolName}
|
||
onChange={(e) => setToolName(e.target.value)}
|
||
placeholder="例如: 智能家居控制"
|
||
autoFocus
|
||
/>
|
||
</div>
|
||
|
||
<div className="space-y-1.5">
|
||
<label className="text-[10px] font-black text-muted-foreground uppercase tracking-widest block">工具 ID (函数名)</label>
|
||
<Input
|
||
value={toolId}
|
||
onChange={(e) => setToolId(e.target.value)}
|
||
placeholder="例如: voice_msg_prompt(留空自动生成)"
|
||
disabled={Boolean(editingTool)}
|
||
/>
|
||
<p className="text-[11px] text-muted-foreground">
|
||
{editingTool ? '已创建工具的 ID 不可修改。' : '建议客户端工具填写稳定 ID,避免随机 tool_xxx 导致前端无法识别。'}
|
||
</p>
|
||
</div>
|
||
|
||
<div className="space-y-1.5">
|
||
<label className="text-[10px] font-black text-muted-foreground uppercase tracking-widest block">工具描述 (给 AI 的说明)</label>
|
||
<textarea
|
||
className="flex min-h-[100px] w-full rounded-md border border-white/10 bg-white/5 px-3 py-2 text-sm shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary/50 text-white"
|
||
value={toolDesc}
|
||
onChange={(e) => setToolDesc(e.target.value)}
|
||
placeholder="描述该工具的功能,以及 AI 应该在什么情况下调用它..."
|
||
/>
|
||
</div>
|
||
|
||
{toolCategory === 'system' && (
|
||
<div className="rounded-md border border-white/10 bg-black/20 p-3 space-y-1.5">
|
||
<label className="flex items-center gap-2 text-xs text-muted-foreground">
|
||
<input
|
||
type="checkbox"
|
||
checked={toolWaitForResponse}
|
||
onChange={(e) => setToolWaitForResponse(e.target.checked)}
|
||
/>
|
||
等待工具完成 (wait for response)
|
||
</label>
|
||
<p className="text-[11px] text-muted-foreground">
|
||
勾选后,模型会在客户端工具回传结果后再继续回复;不勾选则可立即继续回复。
|
||
</p>
|
||
</div>
|
||
)}
|
||
|
||
<div className="space-y-4 rounded-md border border-white/10 bg-white/5 p-3">
|
||
<div className="flex items-center justify-between">
|
||
<div className="text-[10px] font-black uppercase tracking-widest text-emerald-300">Tool Parameters</div>
|
||
<div className="flex items-center gap-2">
|
||
<Button type="button" variant="outline" size="sm" onClick={openSchemaDrawer}>
|
||
Schema 抽屉
|
||
</Button>
|
||
<Button type="button" variant="outline" size="sm" onClick={addToolParameter}>
|
||
<Plus className="w-3.5 h-3.5 mr-1" /> 添加参数
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
{toolParameters.length === 0 ? (
|
||
<div className="rounded-md border border-white/10 bg-black/20 p-3 text-xs text-muted-foreground">
|
||
当前无参数。可以逐个添加参数,无需直接编辑 JSON Schema。
|
||
</div>
|
||
) : (
|
||
<div className="space-y-3">
|
||
{toolParameters.map((param) => (
|
||
<div key={param.id} className="rounded-md border border-white/10 bg-black/20 p-3 space-y-3">
|
||
<div className="grid grid-cols-1 md:grid-cols-5 gap-2">
|
||
<div className="md:col-span-2 space-y-1.5">
|
||
<label className="text-[10px] font-black text-muted-foreground uppercase tracking-widest block">Identifier</label>
|
||
<Input
|
||
value={param.key}
|
||
onChange={(e) => updateToolParameter(param.id, { key: e.target.value })}
|
||
placeholder="city"
|
||
/>
|
||
</div>
|
||
<div className="space-y-1.5">
|
||
<label className="text-[10px] font-black text-muted-foreground uppercase tracking-widest block">Type</label>
|
||
<select
|
||
className="flex h-9 w-full rounded-md border-0 bg-white/5 px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary/50 [&>option]:bg-card text-foreground"
|
||
value={param.type}
|
||
onChange={(e) => updateToolParameter(param.id, { type: e.target.value as ToolParameterType })}
|
||
>
|
||
{TOOL_PARAMETER_TYPES.map((type) => (
|
||
<option key={type} value={type}>{type}</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
<label className="flex items-center gap-2 text-xs text-muted-foreground md:mt-7">
|
||
<input
|
||
type="checkbox"
|
||
checked={param.required}
|
||
onChange={(e) => updateToolParameter(param.id, { required: e.target.checked })}
|
||
/>
|
||
必填
|
||
</label>
|
||
<div className="flex items-end justify-end md:mt-0">
|
||
<button
|
||
type="button"
|
||
onClick={() => removeToolParameter(param.id)}
|
||
className="h-9 px-3 rounded-md border border-white/10 text-xs text-muted-foreground hover:text-destructive hover:border-destructive/40"
|
||
>
|
||
删除参数
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<div className="space-y-1.5">
|
||
<label className="text-[10px] font-black text-muted-foreground uppercase tracking-widest block">Description</label>
|
||
<Input
|
||
value={param.description}
|
||
onChange={(e) => updateToolParameter(param.id, { description: e.target.value })}
|
||
placeholder="Parameter description for model"
|
||
/>
|
||
</div>
|
||
<div className="space-y-1.5">
|
||
<label className="flex items-center gap-2 text-xs text-muted-foreground">
|
||
<input
|
||
type="checkbox"
|
||
checked={param.hasDefault}
|
||
onChange={(e) => updateToolParameter(param.id, { hasDefault: e.target.checked })}
|
||
/>
|
||
设置默认值
|
||
</label>
|
||
{param.hasDefault && (
|
||
<Input
|
||
value={param.defaultValueText}
|
||
onChange={(e) => updateToolParameter(param.id, { defaultValueText: e.target.value })}
|
||
placeholder={param.type === 'object' || param.type === 'array' ? 'JSON value' : 'Default value'}
|
||
/>
|
||
)}
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
<p className="text-[11px] text-muted-foreground">
|
||
支持系统指令和信息查询两类工具。复杂 JSON Schema(如 items/anyOf/minItems)请在 Schema 抽屉编辑,参数表单修改时会保留这些扩展字段。
|
||
</p>
|
||
</div>
|
||
|
||
{toolCategory === 'query' && (
|
||
<div className="space-y-4 rounded-md border border-blue-500/20 bg-blue-500/5 p-3">
|
||
<div className="text-[10px] font-black uppercase tracking-widest text-blue-300">HTTP Request Config</div>
|
||
<div className="grid grid-cols-1 md:grid-cols-3 gap-2">
|
||
<div className="space-y-1.5">
|
||
<label className="text-[10px] font-black text-muted-foreground uppercase tracking-widest block">Method</label>
|
||
<select
|
||
className="flex h-9 w-full rounded-md border-0 bg-white/5 px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary/50 [&>option]:bg-card text-foreground"
|
||
value={toolHttpMethod}
|
||
onChange={(e) => setToolHttpMethod(e.target.value as 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE')}
|
||
>
|
||
<option value="GET">GET</option>
|
||
<option value="POST">POST</option>
|
||
<option value="PUT">PUT</option>
|
||
<option value="PATCH">PATCH</option>
|
||
<option value="DELETE">DELETE</option>
|
||
</select>
|
||
</div>
|
||
<div className="space-y-1.5 md:col-span-2">
|
||
<label className="text-[10px] font-black text-muted-foreground uppercase tracking-widest block">URL</label>
|
||
<Input value={toolHttpUrl} onChange={(e) => setToolHttpUrl(e.target.value)} placeholder="https://api.example.com/endpoint" />
|
||
</div>
|
||
</div>
|
||
<div className="space-y-1.5">
|
||
<label className="text-[10px] font-black text-muted-foreground uppercase tracking-widest block">Headers (JSON)</label>
|
||
<textarea
|
||
className="flex min-h-[90px] w-full rounded-md border border-white/10 bg-white/5 px-3 py-2 text-sm shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary/50 text-white font-mono"
|
||
value={toolHttpHeadersText}
|
||
onChange={(e) => setToolHttpHeadersText(e.target.value)}
|
||
placeholder='{"Authorization":"Bearer ..."}'
|
||
/>
|
||
</div>
|
||
<div className="space-y-1.5">
|
||
<label className="text-[10px] font-black text-muted-foreground uppercase tracking-widest block">Timeout (ms)</label>
|
||
<Input
|
||
type="number"
|
||
min={1000}
|
||
value={toolHttpTimeoutMs}
|
||
onChange={(e) => setToolHttpTimeoutMs(Math.max(1000, Number(e.target.value || 10000)))}
|
||
/>
|
||
</div>
|
||
<p className="text-[11px] text-muted-foreground">
|
||
Query tools send model arguments as request params for GET/DELETE, and JSON body for POST/PUT/PATCH.
|
||
</p>
|
||
</div>
|
||
)}
|
||
|
||
<label className="flex items-center space-x-2 text-xs text-muted-foreground">
|
||
<input type="checkbox" checked={toolEnabled} onChange={(e) => setToolEnabled(e.target.checked)} />
|
||
<span>启用该工具</span>
|
||
</label>
|
||
</div>
|
||
</Dialog>
|
||
<Drawer
|
||
isOpen={schemaDrawerOpen}
|
||
onClose={() => {
|
||
setSchemaDrawerOpen(false);
|
||
setSchemaEditorError('');
|
||
}}
|
||
title="Tool Schema Editor"
|
||
>
|
||
<div className="space-y-3">
|
||
<p className="text-xs text-muted-foreground">
|
||
可在此粘贴/编辑 JSON Schema。点击「应用到参数表单」后会同步到上方参数编辑区。
|
||
</p>
|
||
<textarea
|
||
className="flex min-h-[58vh] w-full rounded-md border border-white/10 bg-white/5 px-3 py-2 text-xs shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary/50 text-white font-mono"
|
||
value={schemaEditorText}
|
||
onChange={(e) => {
|
||
setSchemaEditorText(e.target.value);
|
||
if (schemaEditorError) setSchemaEditorError('');
|
||
}}
|
||
placeholder='{"type":"object","properties":{},"required":[]}'
|
||
/>
|
||
{schemaEditorError && (
|
||
<div className="rounded-md border border-red-500/30 bg-red-500/10 px-3 py-2 text-xs text-red-300">
|
||
{schemaEditorError}
|
||
</div>
|
||
)}
|
||
<div className="flex items-center justify-end gap-2">
|
||
<Button variant="ghost" onClick={() => setSchemaDrawerOpen(false)}>关闭</Button>
|
||
<Button variant="outline" onClick={formatSchemaEditor}>格式化</Button>
|
||
<Button onClick={applySchemaEditor}>应用到参数表单</Button>
|
||
</div>
|
||
</div>
|
||
</Drawer>
|
||
</div>
|
||
);
|
||
};
|