Implement tool parameter management in ToolLibrary, including types, default value handling, and schema generation. Introduce functions for creating, updating, and removing tool parameters, enhancing the configuration capabilities for tools. Update state management to reflect new parameter structure.

This commit is contained in:
Xin Wang
2026-02-27 15:04:52 +08:00
parent 5f768edf68
commit 487634c494

View File

@@ -20,11 +20,155 @@ const iconMap: Record<string, React.ReactNode> = {
Volume2: <Volume2 className="w-5 h-5" />,
};
const DEFAULT_PARAMETER_SCHEMA_TEXT = JSON.stringify(
{ type: 'object', properties: {}, required: [] },
null,
2
);
type ToolParameterType = 'string' | 'number' | 'integer' | 'boolean' | 'object' | 'array';
type ToolParameterDraft = {
id: string;
key: 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: '',
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,
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,
type: typeof value === 'number' && Number.isInteger(value) ? 'integer' : (typeof value as ToolParameterType) || 'string',
required: false,
description: '',
hasDefault: true,
defaultValueText: stringifyDefaultValue(value),
});
}
return drafts;
};
const buildToolParameterConfig = (drafts: ToolParameterDraft[]): { schema: Record<string, any>; defaults: Record<string, any>; error?: string } => {
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 (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(key)) {
return { schema: {}, defaults: {}, error: `参数标识不合法: ${key}` };
}
if (seen.has(key)) {
return { schema: {}, defaults: {}, error: `参数标识重复: ${key}` };
}
seen.add(key);
properties[key] = {
type: draft.type,
...(draft.description.trim() ? { description: draft.description.trim() } : {}),
};
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;
}
}
return {
schema: {
type: 'object',
properties,
required,
},
defaults,
};
};
export const ToolLibraryPage: React.FC = () => {
const [tools, setTools] = useState<Tool[]>([]);
@@ -43,8 +187,7 @@ export const ToolLibraryPage: React.FC = () => {
const [toolHttpUrl, setToolHttpUrl] = useState('');
const [toolHttpHeadersText, setToolHttpHeadersText] = useState('{}');
const [toolHttpTimeoutMs, setToolHttpTimeoutMs] = useState(10000);
const [toolParameterSchemaText, setToolParameterSchemaText] = useState(DEFAULT_PARAMETER_SCHEMA_TEXT);
const [toolParameterDefaultsText, setToolParameterDefaultsText] = useState('{}');
const [toolParameters, setToolParameters] = useState<ToolParameterDraft[]>([]);
const [saving, setSaving] = useState(false);
const loadTools = async () => {
@@ -74,8 +217,7 @@ export const ToolLibraryPage: React.FC = () => {
setToolHttpUrl('');
setToolHttpHeadersText('{}');
setToolHttpTimeoutMs(10000);
setToolParameterSchemaText(DEFAULT_PARAMETER_SCHEMA_TEXT);
setToolParameterDefaultsText('{}');
setToolParameters([]);
setIsToolModalOpen(true);
};
@@ -90,8 +232,7 @@ export const ToolLibraryPage: React.FC = () => {
setToolHttpUrl(tool.httpUrl || '');
setToolHttpHeadersText(JSON.stringify(tool.httpHeaders || {}, null, 2));
setToolHttpTimeoutMs(tool.httpTimeoutMs || 10000);
setToolParameterSchemaText(JSON.stringify(tool.parameterSchema || { type: 'object', properties: {}, required: [] }, null, 2));
setToolParameterDefaultsText(JSON.stringify(tool.parameterDefaults || {}, null, 2));
setToolParameters(draftsFromSchema(tool.parameterSchema, tool.parameterDefaults));
setIsToolModalOpen(true);
};
@@ -156,6 +297,18 @@ export const ToolLibraryPage: React.FC = () => {
</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('请填写工具名称');
@@ -165,44 +318,14 @@ export const ToolLibraryPage: React.FC = () => {
try {
setSaving(true);
let parsedHeaders: Record<string, string> = {};
let parsedParameterSchema: Record<string, any> = {};
let parsedParameterDefaults: Record<string, any> = {};
try {
const parsed = JSON.parse(toolParameterSchemaText || '{}');
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
throw new Error('schema must be object');
}
parsedParameterSchema = parsed as Record<string, any>;
} catch {
alert('参数 Schema 必须是合法 JSON 对象');
setSaving(false);
return;
}
if (parsedParameterSchema.type && parsedParameterSchema.type !== 'object') {
alert("参数 Schema 的 type 必须是 'object'");
setSaving(false);
return;
}
if (!parsedParameterSchema.type) parsedParameterSchema.type = 'object';
if (!parsedParameterSchema.properties || typeof parsedParameterSchema.properties !== 'object' || Array.isArray(parsedParameterSchema.properties)) {
parsedParameterSchema.properties = {};
}
if (!Array.isArray(parsedParameterSchema.required)) {
parsedParameterSchema.required = [];
}
try {
const parsed = JSON.parse(toolParameterDefaultsText || '{}');
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
throw new Error('defaults must be object');
}
parsedParameterDefaults = parsed as Record<string, any>;
} catch {
alert('参数默认值必须是合法 JSON 对象');
const parameterConfig = buildToolParameterConfig(toolParameters);
if (parameterConfig.error) {
alert(parameterConfig.error);
setSaving(false);
return;
}
const parsedParameterSchema = parameterConfig.schema;
const parsedParameterDefaults = parameterConfig.defaults;
if (toolCategory === 'query') {
if (
@@ -370,7 +493,7 @@ export const ToolLibraryPage: React.FC = () => {
</>
}
>
<div className="space-y-4">
<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">
@@ -429,25 +552,88 @@ export const ToolLibraryPage: React.FC = () => {
</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="space-y-1.5">
<label className="text-[10px] font-black text-muted-foreground uppercase tracking-widest block">Schema (JSON Schema)</label>
<textarea
className="flex min-h-[110px] w-full rounded-md border border-white/10 bg-black/20 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={toolParameterSchemaText}
onChange={(e) => setToolParameterSchemaText(e.target.value)}
placeholder={DEFAULT_PARAMETER_SCHEMA_TEXT}
<Button type="button" variant="outline" size="sm" onClick={addToolParameter}>
<Plus className="w-3.5 h-3.5 mr-1" />
</Button>
</div>
{toolParameters.length === 0 ? (
<div className="rounded-md border border-white/10 bg-black/20 p-3 text-xs text-muted-foreground">
ElevenLabs 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">Default Args (JSON)</label>
<textarea
className="flex min-h-[90px] w-full rounded-md border border-white/10 bg-black/20 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={toolParameterDefaultsText}
onChange={(e) => setToolParameterDefaultsText(e.target.value)}
placeholder='{"step": 1}'
<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">
Default Args
</p>