feat: improve tool configuration UX

This commit is contained in:
Xin Wang
2026-08-14 08:48:03 +08:00
parent 5be21f5ccc
commit 36253de864
4 changed files with 1355 additions and 363 deletions
@@ -1,11 +1,25 @@
"use client";
import { useState } from "react";
import { ChevronDown, ChevronUp, Plus, Settings2, Trash2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea";
import type { BuiltInTools, DynamicVariableDefinition } from "@/lib/api";
import type {
BuiltInTools,
DynamicVariableDefinition,
StateExpression,
StateUpdateRule,
} from "@/lib/api";
const DEFAULTS = {
endConversation: {
@@ -20,41 +34,337 @@ const DEFAULTS = {
requestHumanHandoff: { description: "提交人工接管请求。" },
};
type BuiltInKey = keyof BuiltInTools;
type SimpleExpressionType = "llm" | "constant" | "variable" | "advanced";
const ITEMS: Array<{
key: BuiltInKey;
label: string;
result: string;
}> = [
{ key: "endConversation", label: "结束对话", result: "播报结束语后结束当前会话" },
{ key: "updateState", label: "更新状态", result: "写入当前助手已声明的动态变量" },
{ key: "skipTurn", label: "跳过本轮", result: "本轮保持静默,不生成回复" },
{ key: "requestHumanHandoff", label: "转接人工", result: "发送人工接管请求并记录状态" },
];
function expressionType(expression: StateExpression): SimpleExpressionType {
if (
expression.type === "llm" ||
expression.type === "constant" ||
expression.type === "variable"
) {
return expression.type;
}
return "advanced";
}
function defaultExpression(
source: Exclude<SimpleExpressionType, "advanced">,
variableName: string,
definition: DynamicVariableDefinition,
): StateExpression {
if (source === "constant") {
return {
type: "constant",
value:
definition.type === "boolean"
? false
: definition.type === "number"
? 0
: "",
};
}
if (source === "variable") {
return { type: "variable", variableName };
}
return {
type: "llm",
parameterName: variableName,
valueType: definition.type,
description: `从当前对话中提取并确认 ${variableName}`,
};
}
function constantValue(
raw: string,
definition: DynamicVariableDefinition,
): string | number | boolean {
if (definition.type === "number") {
const parsed = Number(raw);
return Number.isFinite(parsed) ? parsed : 0;
}
if (definition.type === "boolean") return raw === "true";
return raw;
}
function UpdateRulesEditor({
value,
variables,
onChange,
}: {
value: StateUpdateRule[];
variables: Record<string, DynamicVariableDefinition>;
onChange: (value: StateUpdateRule[]) => void;
}) {
const variableNames = Object.keys(variables);
function updateRule(index: number, next: StateUpdateRule) {
onChange(value.map((rule, itemIndex) => (itemIndex === index ? next : rule)));
}
function addRule() {
const variableName =
variableNames.find((name) => !value.some((rule) => rule.variableName === name)) ??
variableNames[0];
if (!variableName) return;
onChange([
...value,
{
variableName,
expression: defaultExpression("llm", variableName, variables[variableName]),
},
]);
}
if (!variableNames.length) {
return (
<div className="rounded-xl border border-dashed border-hairline-strong bg-canvas-soft p-4 text-sm text-muted-foreground">
请先在“动态变量”中声明可写变量,再配置状态更新规则。
</div>
);
}
return (
<div className="space-y-3">
<div className="flex items-start justify-between gap-3">
<div>
<div className="text-sm font-medium text-foreground">状态更新规则</div>
<p className="mt-0.5 text-xs leading-5 text-muted-foreground">
为目标变量选择数据来源;保存时仍会由后端校验变量与表达式。
</p>
</div>
<Button type="button" size="sm" variant="outline" onClick={addRule}>
<Plus size={14} />
添加规则
</Button>
</div>
{value.length === 0 && (
<div className="rounded-xl border border-dashed border-hairline-strong p-4 text-center text-sm text-muted-foreground">
还没有规则。添加后,模型可在一次调用中原子更新这些变量。
</div>
)}
{value.map((rule, index) => {
const definition = variables[rule.variableName] ?? variables[variableNames[0]];
const sourceType = expressionType(rule.expression);
return (
<div
key={`${rule.variableName}-${index}`}
className="space-y-3 rounded-xl border border-hairline bg-canvas-soft p-3"
>
<div className="grid gap-2 sm:grid-cols-[minmax(0,1fr)_9rem_auto] sm:items-end">
<label className="space-y-1.5">
<span className="text-xs font-medium text-muted-foreground">目标变量</span>
<Select
value={rule.variableName}
onValueChange={(variableName) =>
updateRule(index, {
variableName,
expression: defaultExpression(
sourceType === "advanced" ? "llm" : sourceType,
variableName,
variables[variableName],
),
})
}
>
<SelectTrigger aria-label={`规则 ${index + 1} 目标变量`} className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{variableNames.map((name) => (
<SelectItem key={name} value={name}>
{name}
</SelectItem>
))}
</SelectContent>
</Select>
</label>
<label className="space-y-1.5">
<span className="text-xs font-medium text-muted-foreground">值来源</span>
<Select
value={sourceType}
onValueChange={(source: SimpleExpressionType) => {
if (source === "advanced") return;
updateRule(index, {
...rule,
expression: defaultExpression(
source,
rule.variableName,
definition,
),
});
}}
>
<SelectTrigger aria-label={`规则 ${index + 1} 值来源`} className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="llm">模型提取</SelectItem>
<SelectItem value="constant">固定值</SelectItem>
<SelectItem value="variable">其他变量</SelectItem>
{sourceType === "advanced" && (
<SelectItem value="advanced">高级表达式</SelectItem>
)}
</SelectContent>
</Select>
</label>
<Button
type="button"
size="icon-sm"
variant="ghost"
aria-label={`删除规则 ${index + 1}`}
onClick={() => onChange(value.filter((_, itemIndex) => itemIndex !== index))}
>
<Trash2 size={14} />
</Button>
</div>
{rule.expression.type === "llm" && (
<label className="block space-y-1.5">
<span className="text-xs font-medium text-muted-foreground">模型提取说明</span>
<Input
aria-label={`规则 ${index + 1} 模型提取说明`}
value={rule.expression.description}
onChange={(event) => {
if (rule.expression.type !== "llm") return;
updateRule(index, {
...rule,
expression: {
type: "llm",
parameterName: rule.expression.parameterName,
valueType: rule.expression.valueType,
description: event.target.value,
},
});
}}
/>
</label>
)}
{rule.expression.type === "variable" && (
<label className="block space-y-1.5">
<span className="text-xs font-medium text-muted-foreground">读取变量</span>
<Select
value={rule.expression.variableName}
onValueChange={(variableName) =>
updateRule(index, {
...rule,
expression: { type: "variable", variableName },
})
}
>
<SelectTrigger aria-label={`规则 ${index + 1} 读取变量`} className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{variableNames.map((name) => (
<SelectItem key={name} value={name}>
{name}
</SelectItem>
))}
</SelectContent>
</Select>
</label>
)}
{rule.expression.type === "constant" &&
(definition.type === "boolean" ? (
<label className="block space-y-1.5">
<span className="text-xs font-medium text-muted-foreground">固定值</span>
<Select
value={String(rule.expression.value)}
onValueChange={(raw) =>
updateRule(index, {
...rule,
expression: { type: "constant", value: raw === "true" },
})
}
>
<SelectTrigger aria-label={`规则 ${index + 1} 固定值`} className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="true">true</SelectItem>
<SelectItem value="false">false</SelectItem>
</SelectContent>
</Select>
</label>
) : (
<label className="block space-y-1.5">
<span className="text-xs font-medium text-muted-foreground">固定值</span>
<Input
aria-label={`规则 ${index + 1} 固定值`}
type={definition.type === "number" ? "number" : "text"}
value={String(rule.expression.value)}
onChange={(event) =>
updateRule(index, {
...rule,
expression: {
type: "constant",
value: constantValue(event.target.value, definition),
},
})
}
/>
</label>
))}
{sourceType === "advanced" && (
<AdvancedExpressionEditor
value={rule.expression}
onChange={(expression) => updateRule(index, { ...rule, expression })}
/>
)}
</div>
);
})}
</div>
);
}
function AdvancedExpressionEditor({
value,
onChange,
}: {
value: NonNullable<BuiltInTools["updateState"]>["updates"];
onChange: (value: NonNullable<BuiltInTools["updateState"]>["updates"]) => void;
value: StateExpression;
onChange: (value: StateExpression) => void;
}) {
const [draft, setDraft] = useState(JSON.stringify(value, null, 2));
const [error, setError] = useState("");
function commit() {
try {
const parsed = JSON.parse(draft) as unknown;
if (!Array.isArray(parsed)) throw new Error();
const parsed = JSON.parse(draft) as StateExpression;
if (!parsed || typeof parsed !== "object" || !("type" in parsed)) throw new Error();
setError("");
onChange(parsed);
} catch {
setError("更新规则必须是 JSON 数组");
setError("高级表达式必须是包含 type 的 JSON 对象");
}
}
return (
<div className="mt-3 space-y-1.5">
<div className="text-xs font-medium text-muted-foreground">
更新规则(JSON,支持 constant / variable / llm / if / 运算表达式)
</div>
<label className="block space-y-1.5">
<span className="text-xs font-medium text-muted-foreground">高级表达式</span>
<Textarea
rows={8}
rows={7}
className="font-mono text-xs"
value={draft}
onChange={(event) => setDraft(event.target.value)}
onBlur={commit}
/>
{error && <p className="text-xs text-destructive">{error}</p>}
</div>
{error && <span className="block text-xs text-destructive">{error}</span>}
</label>
);
}
@@ -69,27 +379,18 @@ export function BuiltInToolsEditor({
onChange: (value: BuiltInTools) => void;
allowedKeys?: Array<keyof BuiltInTools>;
}) {
const items = [
["endConversation", "结束对话"],
["updateState", "更新状态"],
["skipTurn", "跳过本轮"],
["requestHumanHandoff", "转接人工"],
] as const;
const [openKey, setOpenKey] = useState<BuiltInKey | null>(null);
function toggle(key: keyof BuiltInTools, enabled: boolean) {
function toggle(key: BuiltInKey, enabled: boolean) {
if (!enabled) {
if (openKey === key) setOpenKey(null);
onChange({ ...value, [key]: null });
return;
}
if (key === "updateState") {
const updates = Object.entries(variables).map(([name, definition]) => ({
variableName: name,
expression: {
type: "llm" as const,
parameterName: name,
valueType: definition.type,
description: `从当前对话中提取并确认 ${name}`,
},
expression: defaultExpression("llm", name, definition),
}));
onChange({
...value,
@@ -102,104 +403,148 @@ export function BuiltInToolsEditor({
return (
<div className="space-y-3">
{items
.filter(([key]) => !allowedKeys || allowedKeys.includes(key))
.map(([key, label]) => {
<div className="rounded-xl border border-hairline bg-canvas-soft px-4 py-3 text-xs leading-5 text-muted-foreground">
内建能力由平台直接执行,不需要创建工具资源。先启用能力,需要调整调用条件或行为时再打开配置。
</div>
{ITEMS.filter(({ key }) => !allowedKeys || allowedKeys.includes(key)).map(
({ key, label, result }) => {
const config = value[key];
const expanded = openKey === key && Boolean(config);
return (
<div key={key} className="rounded-xl border border-hairline p-3">
<div className="flex items-center justify-between gap-3">
<div>
<div className="text-sm font-medium text-foreground">{label}</div>
<div className="text-xs text-muted-foreground">平台内建能力,不创建工具资源。</div>
</div>
<Switch checked={Boolean(config)} onCheckedChange={(enabled) => toggle(key, enabled)} />
</div>
{config && (
<Input
className="mt-3"
value={config.description}
onChange={(event) =>
onChange({ ...value, [key]: { ...config, description: event.target.value } })
}
placeholder="模型何时调用该能力"
/>
)}
{key === "endConversation" && value.endConversation && (
<div className="mt-3 space-y-3 text-sm text-foreground">
<label className="flex items-center justify-between gap-3">
<span>播报自定义结束语</span>
<Switch
checked={value.endConversation.messageType === "custom"}
onCheckedChange={(enabled) =>
onChange({
...value,
endConversation: {
...value.endConversation!,
messageType: enabled ? "custom" : "none",
},
})
}
/>
</label>
{value.endConversation.messageType === "custom" && (
<Input
value={value.endConversation.customMessage}
onChange={(event) =>
onChange({
...value,
endConversation: {
...value.endConversation!,
customMessage: event.target.value,
},
})
}
placeholder="例如:感谢您的时间,再见。"
/>
<div key={key} className="rounded-xl border border-hairline bg-background">
<div className="flex items-center gap-3 p-3">
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<span className="text-sm font-medium text-foreground">{label}</span>
<span className="text-xs text-muted-foreground">
{config ? "已启用" : "未启用"}
</span>
</div>
<p className="mt-0.5 text-xs leading-5 text-muted-foreground">{result}</p>
</div>
{config && (
<Button
type="button"
size="sm"
variant="ghost"
aria-expanded={expanded}
aria-label={`${expanded ? "收起" : "配置"}${label}`}
onClick={() => setOpenKey(expanded ? null : key)}
>
<Settings2 size={14} />
配置
{expanded ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
</Button>
)}
<label className="flex items-center justify-between gap-3">
<span>记录结束原因</span>
<Switch
checked={value.endConversation.captureReason}
onCheckedChange={(captureReason) =>
onChange({
...value,
endConversation: { ...value.endConversation!, captureReason },
})
}
/>
</label>
<label className="flex items-center justify-between gap-3">
<span>结束语允许用户打断</span>
<Switch
checked={value.endConversation.allowEndInterruption}
onCheckedChange={(allowEndInterruption) =>
onChange({
...value,
endConversation: {
...value.endConversation!,
allowEndInterruption,
},
})
}
/>
</label>
<Switch
checked={Boolean(config)}
aria-label={`${config ? "关闭" : "启用"}${label}`}
onCheckedChange={(enabled) => toggle(key, enabled)}
/>
</div>
)}
{key === "updateState" && value.updateState && (
<UpdateRulesEditor
value={value.updateState.updates}
onChange={(updates) =>
onChange({
...value,
updateState: { ...value.updateState!, updates },
})
}
/>
)}
</div>
{expanded && config && (
<div className="space-y-4 border-t border-hairline p-4">
<label className="block space-y-1.5">
<span className="text-xs font-medium text-muted-foreground">调用条件</span>
<Input
aria-label={`${label}调用条件`}
value={config.description}
onChange={(event) =>
onChange({
...value,
[key]: { ...config, description: event.target.value },
})
}
placeholder="说明模型应在什么情况下调用"
/>
</label>
{key === "endConversation" && value.endConversation && (
<div className="space-y-3 rounded-xl border border-hairline bg-canvas-soft p-3 text-sm text-foreground">
<label className="flex items-center justify-between gap-3">
<span>播报自定义结束语</span>
<Switch
aria-label="播报自定义结束语"
checked={value.endConversation.messageType === "custom"}
onCheckedChange={(enabled) =>
onChange({
...value,
endConversation: {
...value.endConversation!,
messageType: enabled ? "custom" : "none",
},
})
}
/>
</label>
{value.endConversation.messageType === "custom" && (
<Input
aria-label="自定义结束语"
value={value.endConversation.customMessage}
onChange={(event) =>
onChange({
...value,
endConversation: {
...value.endConversation!,
customMessage: event.target.value,
},
})
}
placeholder="例如:感谢您的时间,再见。"
/>
)}
<label className="flex items-center justify-between gap-3">
<span>记录结束原因</span>
<Switch
aria-label="记录结束原因"
checked={value.endConversation.captureReason}
onCheckedChange={(captureReason) =>
onChange({
...value,
endConversation: { ...value.endConversation!, captureReason },
})
}
/>
</label>
<label className="flex items-center justify-between gap-3">
<span>结束语允许用户打断</span>
<Switch
aria-label="结束语允许用户打断"
checked={value.endConversation.allowEndInterruption}
onCheckedChange={(allowEndInterruption) =>
onChange({
...value,
endConversation: {
...value.endConversation!,
allowEndInterruption,
},
})
}
/>
</label>
</div>
)}
{key === "updateState" && value.updateState && (
<UpdateRulesEditor
value={value.updateState.updates}
variables={variables}
onChange={(updates) =>
onChange({
...value,
updateState: { ...value.updateState!, updates },
})
}
/>
)}
</div>
)}
</div>
);
})}
},
)}
</div>
);
}
@@ -5,6 +5,7 @@ import {
AnalysisConfigEditor,
} from "@/components/assistant-editor/analysis-config";
import {
AlertTriangle,
Braces,
Bot,
Brain,
@@ -42,6 +43,14 @@ import { BuiltInToolsEditor } from "@/components/assistant-editor/built-in-tools
import { ToolBindingEditor } from "@/components/tools/tool-binding-editor";
import { TurnConfigEditor } from "@/components/turn-config-editor";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
DropdownMenu,
DropdownMenuContent,
@@ -69,6 +78,19 @@ type ResourceOption = { value: string; label: string };
type PromptOpeningMode = StartupConfig["openingMode"];
type PromptEntryMode = StartupConfig["entryMode"];
const BUILT_IN_TOOL_LABELS: Record<keyof BuiltInTools, string> = {
endConversation: "结束对话",
updateState: "更新状态",
skipTurn: "跳过本轮",
requestHumanHandoff: "转接人工",
};
const EXTERNAL_TOOL_TYPE_LABELS: Record<Tool["type"], string> = {
http: "HTTP",
mcp: "MCP",
client: "Client",
};
const OPENING_MODE_OPTIONS: Array<{
value: PromptOpeningMode;
label: string;
@@ -177,6 +199,31 @@ export function PromptEditor({
useState<PromptSectionId>("conversation");
const [debugOpen, setDebugOpen] = useState(true);
const [deleting, setDeleting] = useState(false);
const [cleanupDialogOpen, setCleanupDialogOpen] = useState(false);
const selectedRealtimeModel = realtimeOptions.find(
(option) => option.value === form.realtimeModel,
);
const allowedRealtimeBuiltIns = new Set(
realtimeToolFeatures?.builtIns ?? [],
);
const unsupportedBuiltIns = (
Object.keys(form.builtInTools) as Array<keyof BuiltInTools>
).filter(
(key) =>
Boolean(form.builtInTools[key]) && !allowedRealtimeBuiltIns.has(key),
);
const allowedRealtimeExternalTypes = new Set(
realtimeToolFeatures?.externalTypes ?? [],
);
const unsupportedToolBindings = form.toolBindings.filter((binding) => {
const tool = tools.find((item) => item.id === binding.toolId);
return !tool || !allowedRealtimeExternalTypes.has(tool.type);
});
const unsupportedToolIds = new Set(
unsupportedToolBindings.map((binding) => binding.toolId),
);
const incompatibleToolCount =
unsupportedBuiltIns.length + unsupportedToolBindings.length;
useEffect(() => {
const container = scrollContainerRef.current;
@@ -719,82 +766,111 @@ export function PromptEditor({
title="Realtime 工具"
description="当前 Realtime 接口尚未开放 Prompt 工具调用"
>
<p className="text-sm text-muted-foreground">
已有配置会被保留,但保存前需要切换到支持工具的模型或清理这些配置。
</p>
{form.realtimeModel ? (
<div className="space-y-2 rounded-xl border border-hairline bg-canvas-soft p-4">
<p className="text-sm font-medium text-foreground">
{selectedRealtimeModel?.label ?? "当前 Realtime 模型"} 不支持工具调用
</p>
<p className="text-xs leading-5 text-muted-foreground">
已有配置会被保留。保存前请选择支持 Prompt 工具的 Realtime 模型,或查看并移除不兼容项。
</p>
</div>
) : (
<div className="rounded-xl border border-dashed border-hairline-strong p-4">
<p className="text-sm font-medium text-foreground">请先选择 Realtime 模型</p>
<p className="mt-1 text-xs leading-5 text-muted-foreground">
选择模型后,这里会显示其支持的内建能力与外部工具类型。
</p>
</div>
)}
</SectionCard>
)}
{form.runtimeMode === "realtime" && (() => {
const allowedBuiltIns = new Set(
realtimeToolFeatures?.builtIns ?? [],
);
const unsupportedBuiltIns = (
Object.keys(form.builtInTools) as Array<keyof BuiltInTools>
).filter(
(key) =>
Boolean(form.builtInTools[key]) &&
!allowedBuiltIns.has(key),
);
const allowedExternal = new Set(
realtimeToolFeatures?.externalTypes ?? [],
);
const unsupportedToolIds = new Set(
form.toolBindings
.filter((binding) => {
const tool = tools.find((item) => item.id === binding.toolId);
return !tool || !allowedExternal.has(tool.type);
})
.map((binding) => binding.toolId),
);
if (!unsupportedBuiltIns.length && !unsupportedToolIds.size) {
return null;
}
return (
<SectionCard
icon={<Wrench size={15} />}
title="存在不兼容工具配置"
description="这些配置不会被自动删除,后端也会阻止保存"
>
<div className="flex items-center justify-between gap-3">
<p className="text-sm text-muted-foreground">
{unsupportedBuiltIns.length} 个内建工具,
{unsupportedToolIds.size} 个外部工具不受当前接口支持。
</p>
{form.runtimeMode === "realtime" &&
Boolean(realtimeToolFeatures) && (
<div className="rounded-xl border border-hairline bg-canvas-soft px-4 py-3">
<div className="text-sm font-medium text-foreground">
当前模型支持的工具
</div>
<p className="mt-1 text-xs leading-5 text-muted-foreground">
外部工具:
{realtimeToolFeatures?.externalTypes
.map((type) => EXTERNAL_TOOL_TYPE_LABELS[type])
.join("、") || "无"}
;内建能力:
{realtimeToolFeatures?.builtIns
.map((key) => BUILT_IN_TOOL_LABELS[key])
.join("、") || "无"}
。
</p>
</div>
)}
{form.runtimeMode === "realtime" && incompatibleToolCount > 0 && (
<SectionCard
icon={<AlertTriangle size={15} />}
title={`存在 ${incompatibleToolCount} 项不兼容工具配置`}
description="配置不会被自动删除;保存前需要更换模型或手动移除"
>
<div className="space-y-3">
<ul className="space-y-2">
{unsupportedBuiltIns.map((key) => (
<li
key={key}
className="flex items-start justify-between gap-3 rounded-xl border border-hairline bg-canvas-soft px-3 py-2.5"
>
<div>
<div className="text-sm font-medium text-foreground">
{BUILT_IN_TOOL_LABELS[key]}
</div>
<div className="mt-0.5 text-xs text-muted-foreground">
内建能力 · 当前 Realtime 模型不支持
</div>
</div>
</li>
))}
{unsupportedToolBindings.map((binding) => {
const tool = tools.find((item) => item.id === binding.toolId);
return (
<li
key={binding.toolId}
className="flex items-start justify-between gap-3 rounded-xl border border-hairline bg-canvas-soft px-3 py-2.5"
>
<div>
<div className="text-sm font-medium text-foreground">
{tool?.name ?? `已失效工具 ${binding.toolId}`}
</div>
<div className="mt-0.5 text-xs text-muted-foreground">
{tool
? `${EXTERNAL_TOOL_TYPE_LABELS[tool.type]} 工具 · 当前 Realtime 模型不支持此类型`
: "工具资源已不存在"}
</div>
</div>
</li>
);
})}
</ul>
<div className="flex justify-end">
<Button
type="button"
variant="outline"
onClick={() => {
updateForm(
"builtInTools",
Object.fromEntries(
Object.entries(form.builtInTools).filter(([key]) =>
allowedBuiltIns.has(key as keyof BuiltInTools),
),
) as BuiltInTools,
);
updateForm(
"toolBindings",
form.toolBindings.filter(
(binding) => !unsupportedToolIds.has(binding.toolId),
),
);
}}
onClick={() => setCleanupDialogOpen(true)}
>
清理不兼容配置
查看并移除 {incompatibleToolCount} 项
</Button>
</div>
</SectionCard>
);
})()}
</div>
</SectionCard>
)}
<SectionCard
icon={<Wrench size={15} />}
title="外部工具"
description="选择可复用资源,并配置当前助手的参数来源与结果映射"
description="从资源库添加工具,并配置当前助手的参数来源与结果写入"
>
<ToolBindingEditor
tools={
tools={tools}
selectableTools={
form.runtimeMode === "realtime"
? tools.filter((tool) =>
realtimeToolFeatures?.externalTypes.includes(tool.type),
@@ -899,6 +975,72 @@ export function PromptEditor({
/>
)}
</div>
<Dialog open={cleanupDialogOpen} onOpenChange={setCleanupDialogOpen}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>移除不兼容工具配置?</DialogTitle>
<DialogDescription>
下面 {incompatibleToolCount} 项配置将从当前助手中移除。工具资源本身不会被删除。
</DialogDescription>
</DialogHeader>
<ul className="max-h-64 space-y-2 overflow-y-auto">
{unsupportedBuiltIns.map((key) => (
<li
key={key}
className="rounded-xl border border-hairline bg-canvas-soft px-3 py-2 text-sm"
>
{BUILT_IN_TOOL_LABELS[key]}
<span className="ml-2 text-xs text-muted-foreground">内建能力</span>
</li>
))}
{unsupportedToolBindings.map((binding) => {
const tool = tools.find((item) => item.id === binding.toolId);
return (
<li
key={binding.toolId}
className="rounded-xl border border-hairline bg-canvas-soft px-3 py-2 text-sm"
>
{tool?.name ?? binding.toolId}
<span className="ml-2 text-xs text-muted-foreground">外部工具</span>
</li>
);
})}
</ul>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => setCleanupDialogOpen(false)}
>
取消
</Button>
<Button
type="button"
variant="destructive"
onClick={() => {
updateForm(
"builtInTools",
Object.fromEntries(
Object.entries(form.builtInTools).filter(([key]) =>
allowedRealtimeBuiltIns.has(key as keyof BuiltInTools),
),
) as BuiltInTools,
);
updateForm(
"toolBindings",
form.toolBindings.filter(
(binding) => !unsupportedToolIds.has(binding.toolId),
),
);
setCleanupDialogOpen(false);
}}
>
确认移除
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}
@@ -644,7 +644,7 @@ export function ComponentsToolsPage() {
>
<DropdownMenuItem className="rounded-lg" onSelect={openCreate}>
<Plus size={15} />
添加普通工具
添加 HTTP / Client Tool
</DropdownMenuItem>
<DropdownMenuItem
className="rounded-lg"
@@ -704,7 +704,7 @@ export function ComponentsToolsPage() {
<DialogHeader>
<DialogTitle>{editing ? "编辑工具资源" : "添加工具资源"}</DialogTitle>
<DialogDescription>
左侧配置工具的固定信息,右侧参数会随工具类型更新。
定义可复用的连接与默认运行策略;当前助手的参数值和结果写入在助手绑定中配置。
</DialogDescription>
</DialogHeader>
@@ -1,8 +1,27 @@
"use client";
import { useMemo, useState } from "react";
import {
Check,
ChevronDown,
ChevronUp,
CircleHelp,
Plus,
Search,
Settings2,
Trash2,
Wrench,
} from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import {
Select,
@@ -35,6 +54,97 @@ const SYSTEM_VARIABLE_NAMES = [
"system__handoff_status",
];
const TOOL_TYPE_LABELS: Record<Tool["type"], string> = {
http: "HTTP",
mcp: "MCP",
client: "Client",
};
type ResultPathSuggestion = {
path: string;
label: string;
};
const CLIENT_RESULT_PATHS: Record<string, ResultPathSuggestion[]> = {
set_photo_button_visible: [
{ path: "visible", label: "显示状态 visible" },
],
show_message: [{ path: "action", label: "用户操作 action" }],
};
function resultPathSuggestions(tool: Tool): ResultPathSuggestion[] {
if (tool.type === "client") {
return CLIENT_RESULT_PATHS[tool.functionName] ?? [];
}
if (tool.type === "mcp") {
return [
{ path: "text", label: "文本结果 text" },
{
path: "structuredContent.status",
label: "结构化字段示例",
},
{ path: "content.0.text", label: "第一段文本" },
];
}
return [];
}
function resultPathExample(tool: Tool): {
response: string;
path: string;
explanation: string;
} {
if (tool.type === "client") {
if (tool.functionName === "show_message") {
return {
response: '{ "action": "confirm" }',
path: "action",
explanation: "Client handler 返回的对象就是路径根节点。",
};
}
return {
response: '{ "visible": true }',
path: "visible",
explanation: "Client handler 返回的对象就是路径根节点。",
};
}
if (tool.type === "mcp") {
return {
response: '{ "structuredContent": { "status": "shipped" } }',
path: "structuredContent.status",
explanation: "MCP 的 text、structuredContent 和 content 都位于路径根节点。",
};
}
return {
response: '{ "order": { "status": "paid" } }',
path: "order.status",
explanation: "HTTP 响应 JSON 就是路径根节点,不需要填写 data 前缀。",
};
}
function resultMappingPurpose(tool: Tool): string {
if (tool.type === "client" && tool.functionName === "set_photo_button_visible") {
return "该工具无需结果映射也会立即显示或隐藏拍照按钮。只有后续对话还需要读取当前显示状态时,才把返回的 visible 写入动态变量。";
}
if (tool.type === "client" && tool.functionName === "show_message") {
return "该工具无需结果映射也会正常显示确认弹窗。只有后续对话需要判断用户点击了什么操作时,才把 action 写入动态变量。";
}
return "不配置结果写入不会影响工具执行。只有后续提示词或其他工具需要复用某个返回字段时,才添加映射。";
}
function suggestedResultVariable(tool: Tool): {
name: string;
type: DynamicVariableDefinition["type"];
} {
if (tool.type === "client" && tool.functionName === "set_photo_button_visible") {
return { name: "photo_button_visible", type: "boolean" };
}
if (tool.type === "client" && tool.functionName === "show_message") {
return { name: "last_message_action", type: "string" };
}
return { name: "tool_result", type: "string" };
}
function parametersFor(tool: Tool): ToolParameter[] {
if (tool.definition.type === "mcp") {
const schema = tool.definition.config.inputSchema as {
@@ -60,39 +170,246 @@ function templateInputsFor(tool: Tool): ToolParameter[] {
return [];
}
function configuredParameterCount(tool: Tool, binding: ToolBinding): number {
const parameters = parametersFor(tool);
const templates = templateInputsFor(tool);
return [...parameters, ...templates].filter((parameter) => {
const source = parameters.includes(parameter)
? binding.argumentBindings[parameter.name]
: binding.templateBindings[parameter.name];
if (!source) return false;
if (source.source === "literal" || source.source === "template") {
return String(source.value ?? "").trim().length > 0 || !parameter.required;
}
if (source.source === "variable") return Boolean(source.variableName);
return source.source === "model";
}).length;
}
function ResultAssignmentsEditor({
tool,
value,
variables,
onChange,
}: {
tool: Tool;
value: Record<string, string>;
variables: Record<string, DynamicVariableDefinition>;
onChange: (value: Record<string, string>) => void;
}) {
const [draft, setDraft] = useState(JSON.stringify(value));
const [error, setError] = useState("");
const variableNames = Object.keys(variables);
const assignments = Object.entries(value);
const suggestions = resultPathSuggestions(tool);
const example = resultPathExample(tool);
const purpose = resultMappingPurpose(tool);
const suggestedVariable = suggestedResultVariable(tool);
const [helpOpen, setHelpOpen] = useState(false);
function commit() {
try {
const parsed = JSON.parse(draft) as unknown;
if (!parsed || Array.isArray(parsed) || typeof parsed !== "object") {
throw new Error();
}
if (Object.values(parsed).some((path) => typeof path !== "string")) {
throw new Error();
}
setError("");
onChange(parsed as Record<string, string>);
} catch {
setError("结果映射必须是“变量名 → 字符串路径”的 JSON 对象");
}
function updateAssignment(
index: number,
nextVariableName: string,
nextPath: string,
) {
const next = Object.fromEntries(
assignments.map(([variableName, path], itemIndex) =>
itemIndex === index ? [nextVariableName, nextPath] : [variableName, path],
),
);
onChange(next);
}
function addAssignment() {
const variableName =
variableNames.find((name) => !(name in value)) ?? variableNames[0];
if (!variableName) return;
const initialPath =
tool.type === "client" && suggestions.length === 1
? suggestions[0].path
: "";
onChange({ ...value, [variableName]: initialPath });
}
return (
<div>
<div className="mb-1 text-xs font-medium text-muted-foreground">
结果映射(JSON:变量名 → 结果路径)
<div className="space-y-3 border-t border-hairline pt-4">
<div className="flex items-start justify-between gap-3">
<div>
<div className="text-sm font-medium text-foreground">结果写入(可选)</div>
<p className="mt-0.5 text-xs leading-5 text-muted-foreground">
可选。选择目标变量,再填写要从工具业务结果中读取的字段路径。
</p>
</div>
<Button
type="button"
size="sm"
variant="outline"
disabled={!variableNames.length}
onClick={addAssignment}
>
<Plus size={14} />
添加映射
</Button>
</div>
<Input value={draft} onChange={(event) => setDraft(event.target.value)} onBlur={commit} />
{error && <p className="mt-1 text-xs text-destructive">{error}</p>}
{!variableNames.length && (
<div className="rounded-xl border border-dashed border-hairline-strong p-3 text-xs leading-5 text-muted-foreground">
<p className="font-medium text-foreground">当前助手还没有可写入的动态变量</p>
<p className="mt-1">
如果确实需要保存结果,请先在下方“动态变量”分区声明
<code className="mx-1 rounded bg-surface-strong px-1.5 py-0.5 text-foreground">
{suggestedVariable.name}
</code>
,类型选择
<code className="mx-1 rounded bg-surface-strong px-1.5 py-0.5 text-foreground">
{suggestedVariable.type}
</code>
。
</p>
</div>
)}
<div className="rounded-xl border border-hairline bg-canvas-soft p-3">
<div className="flex items-start justify-between gap-3">
<div className="flex min-w-0 gap-2">
<CircleHelp
size={15}
className="mt-0.5 shrink-0 text-muted-foreground"
/>
<div>
<p className="text-xs font-medium text-foreground">
工具效果与结果写入是两件事
</p>
<p className="mt-1 text-xs leading-5 text-muted-foreground">
{purpose}
</p>
</div>
</div>
<Button
type="button"
size="sm"
variant="ghost"
aria-expanded={helpOpen}
onClick={() => setHelpOpen((current) => !current)}
>
{helpOpen ? "收起示例" : "查看示例"}
{helpOpen ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
</Button>
</div>
{helpOpen && (
<div className="mt-3 grid gap-2 border-t border-hairline pt-3 text-xs sm:grid-cols-2">
<div>
<div className="text-muted-foreground">工具业务返回值</div>
<code className="mt-1 block overflow-x-auto rounded-lg bg-background px-3 py-2 text-foreground">
{example.response}
</code>
</div>
<div>
<div className="text-muted-foreground">应填写的字段路径</div>
<code className="mt-1 block rounded-lg bg-background px-3 py-2 text-foreground">
{example.path}
</code>
</div>
<p className="leading-5 text-muted-foreground sm:col-span-2">
{example.explanation} 使用点号进入嵌套字段,数组使用数字下标;例如
<code className="mx-1 rounded bg-surface-strong px-1.5 py-0.5 text-foreground">
items.0.id
</code>
。
</p>
</div>
)}
</div>
{assignments.length > 0 && (
<div className="hidden grid-cols-[minmax(0,1fr)_1.5rem_minmax(0,1fr)_auto] gap-2 px-1 text-xs font-medium text-muted-foreground sm:grid">
<span>写入助手变量</span>
<span />
<span>从工具结果读取</span>
<span className="w-8" />
</div>
)}
{assignments.map(([variableName, path], index) => (
<div
key={`${variableName}-${index}`}
className="grid gap-2 sm:grid-cols-[minmax(0,1fr)_1.5rem_minmax(0,1fr)_auto] sm:items-center"
>
<Select
value={variableName}
onValueChange={(nextVariableName) =>
updateAssignment(index, nextVariableName, path)
}
>
<SelectTrigger aria-label={`结果映射 ${index + 1} 目标变量`} className="w-full">
<SelectValue placeholder="选择目标变量" />
</SelectTrigger>
<SelectContent>
{variableNames.map((name) => (
<SelectItem key={name} value={name}>
{name} · {variables[name].type}
</SelectItem>
))}
</SelectContent>
</Select>
<span className="hidden text-center text-muted-foreground sm:block">←</span>
<div>
<div className="flex overflow-hidden rounded-3xl bg-input/50 focus-within:ring-3 focus-within:ring-ring/30">
<span className="flex shrink-0 items-center border-r border-hairline px-3 font-mono text-xs text-muted-foreground">
结果.
</span>
<Input
aria-label={`结果映射 ${index + 1} 工具返回字段路径`}
value={path}
onChange={(event) =>
updateAssignment(index, variableName, event.target.value)
}
placeholder={`例如 ${example.path}`}
className="rounded-none bg-transparent focus-visible:ring-0"
/>
</div>
{!path.trim() && (
<p className="mt-1 text-xs text-destructive">
请填写字段路径,否则不会写入变量。
</p>
)}
</div>
<Button
type="button"
size="icon-sm"
variant="ghost"
aria-label={`删除结果映射 ${index + 1}`}
onClick={() =>
onChange(
Object.fromEntries(
assignments.filter((_, itemIndex) => itemIndex !== index),
),
)
}
>
<Trash2 size={14} />
</Button>
</div>
))}
{assignments.length > 0 && suggestions.length > 0 && (
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
<span>快捷填入:</span>
{suggestions.map((suggestion) => (
<button
key={suggestion.path}
type="button"
className="rounded-full border border-hairline-strong bg-background px-2.5 py-1 font-mono text-foreground transition-colors hover:bg-surface-strong"
onClick={() => {
const firstEmptyIndex = assignments.findIndex(([, path]) => !path.trim());
const targetIndex = firstEmptyIndex >= 0 ? firstEmptyIndex : 0;
const [variableName] = assignments[targetIndex];
updateAssignment(targetIndex, variableName, suggestion.path);
}}
>
{suggestion.label} · {suggestion.path}
</button>
))}
</div>
)}
</div>
);
}
@@ -113,8 +430,106 @@ export function defaultToolBinding(tool: Tool): ToolBinding {
};
}
function ToolPicker({
open,
tools,
selectedIds,
maxBindings,
onOpenChange,
onSelect,
}: {
open: boolean;
tools: Tool[];
selectedIds: Set<string>;
maxBindings?: number;
onOpenChange: (open: boolean) => void;
onSelect: (tool: Tool) => void;
}) {
const [query, setQuery] = useState("");
const filtered = tools.filter((tool) => {
const normalizedQuery = query.trim().toLowerCase();
return (
!normalizedQuery ||
[tool.name, tool.functionName, tool.description, TOOL_TYPE_LABELS[tool.type]]
.join(" ")
.toLowerCase()
.includes(normalizedQuery)
);
});
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-h-[min(42rem,calc(100vh-3rem))] overflow-hidden sm:max-w-2xl">
<DialogHeader>
<DialogTitle>添加外部工具</DialogTitle>
<DialogDescription>
从工具资源库选择。添加后再配置当前助手的参数来源与结果写入。
</DialogDescription>
</DialogHeader>
<label className="relative block">
<Search
size={15}
className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground"
/>
<Input
aria-label="搜索外部工具"
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="搜索名称、函数名或类型"
className="pl-9"
/>
</label>
<div className="scrollbar-subtle -mx-1 space-y-2 overflow-y-auto px-1">
{filtered.map((tool) => {
const selected = selectedIds.has(tool.id);
const limitReached =
maxBindings === 1 && selectedIds.size >= 1 && !selected;
return (
<button
key={tool.id}
type="button"
aria-pressed={selected}
disabled={selected || limitReached}
className="flex w-full items-start gap-3 rounded-xl border border-hairline bg-background p-3 text-left transition-colors hover:bg-surface-strong disabled:cursor-default disabled:opacity-60"
onClick={() => onSelect(tool)}
>
<span className="mt-0.5 flex size-8 shrink-0 items-center justify-center rounded-full bg-surface-strong text-foreground">
{selected ? <Check size={15} /> : <Wrench size={15} />}
</span>
<span className="min-w-0 flex-1">
<span className="flex flex-wrap items-center gap-2">
<span className="font-medium text-foreground">{tool.name}</span>
<Badge variant="secondary">{TOOL_TYPE_LABELS[tool.type]}</Badge>
</span>
<span className="mt-1 block font-mono text-xs text-muted-foreground">
{tool.functionName}
</span>
{tool.description && (
<span className="mt-1 block text-xs leading-5 text-muted-foreground">
{tool.description}
</span>
)}
</span>
<span className="mt-1 text-xs text-muted-foreground">
{selected ? "已添加" : "添加"}
</span>
</button>
);
})}
{filtered.length === 0 && (
<div className="rounded-xl border border-dashed border-hairline-strong p-8 text-center text-sm text-muted-foreground">
没有找到匹配的工具资源。
</div>
)}
</div>
</DialogContent>
</Dialog>
);
}
export function ToolBindingEditor({
tools,
selectableTools,
value,
variables,
onChange,
@@ -122,24 +537,27 @@ export function ToolBindingEditor({
maxBindings,
}: {
tools: Tool[];
selectableTools?: Tool[];
value: ToolBinding[];
variables: Record<string, DynamicVariableDefinition>;
onChange: (value: ToolBinding[]) => void;
allowModel?: boolean;
maxBindings?: number;
}) {
const [pickerOpen, setPickerOpen] = useState(false);
const [openToolId, setOpenToolId] = useState<string | null>(null);
const activeTools = useMemo(
() => tools.filter((tool) => tool.status === "active"),
[tools],
);
const selected = new Set(value.map((binding) => binding.toolId));
const pickerTools = useMemo(
() => (selectableTools ?? tools).filter((tool) => tool.status === "active"),
[selectableTools, tools],
);
const selectedIds = new Set(value.map((binding) => binding.toolId));
const variableNames = [...Object.keys(variables), ...SYSTEM_VARIABLE_NAMES];
function toggle(tool: Tool) {
if (selected.has(tool.id)) {
onChange(value.filter((binding) => binding.toolId !== tool.id));
return;
}
function addTool(tool: Tool) {
const binding = defaultToolBinding(tool);
if (!allowModel) {
binding.argumentBindings = Object.fromEntries(
@@ -150,6 +568,8 @@ export function ToolBindingEditor({
);
}
onChange(maxBindings === 1 ? [binding] : [...value, binding]);
setOpenToolId(tool.id);
setPickerOpen(false);
}
function updateBinding(index: number, next: ToolBinding) {
@@ -158,182 +578,267 @@ export function ToolBindingEditor({
return (
<div className="space-y-3">
<div className="flex flex-wrap gap-2">
{activeTools.map((tool) => (
<Button
key={tool.id}
type="button"
size="sm"
variant={selected.has(tool.id) ? "default" : "outline"}
disabled={
maxBindings === 1 && value.length >= 1 && !selected.has(tool.id)
}
onClick={() => toggle(tool)}
>
{tool.name}
</Button>
))}
<div className="flex items-start justify-between gap-3">
<div>
<div className="text-sm font-medium text-foreground">
已添加 {value.length} 个工具
</div>
<p className="mt-0.5 text-xs leading-5 text-muted-foreground">
工具资源定义连接方式;这里仅配置当前助手如何传参与接收结果。
</p>
</div>
<Button
type="button"
size="sm"
variant="outline"
disabled={
pickerTools.length === 0 ||
(maxBindings === 1 && value.length >= 1)
}
onClick={() => setPickerOpen(true)}
>
<Plus size={14} />
添加外部工具
</Button>
</div>
{value.length === 0 && (
<button
type="button"
className="flex w-full flex-col items-center rounded-xl border border-dashed border-hairline-strong p-6 text-center transition-colors hover:bg-canvas-soft"
disabled={pickerTools.length === 0}
onClick={() => setPickerOpen(true)}
>
<span className="flex size-9 items-center justify-center rounded-full bg-surface-strong text-foreground">
<Wrench size={16} />
</span>
<span className="mt-3 text-sm font-medium text-foreground">还没有添加外部工具</span>
<span className="mt-1 text-xs leading-5 text-muted-foreground">
从资源库选择 HTTP、MCP 或 Client Tool。
</span>
</button>
)}
{value.map((binding, index) => {
const tool = activeTools.find((item) => item.id === binding.toolId);
if (!tool) return null;
const parameterCount =
parametersFor(tool).length + templateInputsFor(tool).length;
const configuredCount = configuredParameterCount(tool, binding);
const expanded = openToolId === binding.toolId;
return (
<div key={binding.toolId} className="space-y-3 rounded-xl border border-hairline p-4">
<div>
<div className="text-sm font-medium text-foreground">{tool.name}</div>
<div className="text-xs text-muted-foreground">{tool.description}</div>
<div key={binding.toolId} className="rounded-xl border border-hairline bg-background">
<div className="flex items-center gap-3 p-3">
<span className="flex size-9 shrink-0 items-center justify-center rounded-full bg-surface-strong text-foreground">
<Wrench size={15} />
</span>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<span className="truncate text-sm font-medium text-foreground">{tool.name}</span>
<Badge variant="secondary">{TOOL_TYPE_LABELS[tool.type]}</Badge>
</div>
<p className="mt-0.5 text-xs text-muted-foreground">
{parameterCount
? `已配置 ${configuredCount}/${parameterCount} 个输入参数`
: "无需输入参数"}
{Object.keys(binding.resultAssignments).length
? ` · ${Object.keys(binding.resultAssignments).length} 个结果映射`
: ""}
</p>
</div>
<Button
type="button"
size="sm"
variant="ghost"
aria-expanded={expanded}
aria-label={`${expanded ? "收起" : "配置"}${tool.name}`}
onClick={() => setOpenToolId(expanded ? null : binding.toolId)}
>
<Settings2 size={14} />
配置
{expanded ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
</Button>
<Button
type="button"
size="icon-sm"
variant="ghost"
aria-label={`移除${tool.name}`}
onClick={() =>
onChange(value.filter((item) => item.toolId !== binding.toolId))
}
>
<Trash2 size={14} />
</Button>
</div>
{parametersFor(tool).map((parameter) => {
const source = binding.argumentBindings[parameter.name] ?? {
source: allowModel ? "model" : "literal",
...(allowModel ? {} : { value: "" }),
} as ValueSource;
return (
<div key={parameter.name} className="grid gap-2 md:grid-cols-[1fr_10rem_1fr] md:items-center">
<div className="text-sm text-foreground">
{parameter.name}{parameter.required ? " *" : ""}
{expanded && (
<div className="space-y-4 border-t border-hairline p-4">
<div>
<div className="font-mono text-xs text-muted-foreground">
{tool.functionName}
</div>
<Select
value={source.source}
onValueChange={(sourceType) => {
const nextSource: ValueSource =
sourceType === "model"
? { source: "model" }
: sourceType === "variable"
? { source: "variable", variableName: variableNames[0] }
: sourceType === "template"
? { source: "template", value: "" }
: { source: "literal", value: "" };
updateBinding(index, {
...binding,
argumentBindings: {
...binding.argumentBindings,
[parameter.name]: nextSource,
},
});
}}
>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
{allowModel && <SelectItem value="model">模型填写</SelectItem>}
<SelectItem value="variable">动态变量</SelectItem>
<SelectItem value="literal">固定值</SelectItem>
<SelectItem value="template">模板值</SelectItem>
</SelectContent>
</Select>
{source.source === "variable" ? (
<Select
value={source.variableName}
onValueChange={(variableName) =>
{tool.description && (
<p className="mt-1 text-xs leading-5 text-muted-foreground">
{tool.description}
</p>
)}
</div>
{parametersFor(tool).map((parameter) => {
const source = (binding.argumentBindings[parameter.name] ?? {
source: allowModel ? "model" : "literal",
...(allowModel ? {} : { value: "" }),
}) as ValueSource;
return (
<ParameterBindingRow
key={parameter.name}
parameter={parameter}
source={source}
variableNames={variableNames}
allowModel={allowModel}
onChange={(nextSource) =>
updateBinding(index, {
...binding,
argumentBindings: {
...binding.argumentBindings,
[parameter.name]: { source: "variable", variableName },
[parameter.name]: nextSource,
},
})
}
>
<SelectTrigger><SelectValue placeholder="选择变量" /></SelectTrigger>
<SelectContent>
{variableNames.map((name) => (
<SelectItem key={name} value={name}>{name}</SelectItem>
))}
</SelectContent>
</Select>
) : source.source === "literal" || source.source === "template" ? (
<Input
value={String(source.value ?? "")}
onChange={(event) =>
/>
);
})}
{templateInputsFor(tool).map((parameter) => {
const source = binding.templateBindings[parameter.name] ?? {
source: "literal" as const,
value: "",
};
return (
<ParameterBindingRow
key={`template-${parameter.name}`}
parameter={{ ...parameter, name: `模板 · ${parameter.name}` }}
source={source}
variableNames={variableNames}
allowModel={false}
onChange={(nextSource) => {
if (nextSource.source === "model") return;
updateBinding(index, {
...binding,
argumentBindings: {
...binding.argumentBindings,
[parameter.name]: { ...source, value: event.target.value },
templateBindings: {
...binding.templateBindings,
[parameter.name]: nextSource,
},
})
}
});
}}
/>
) : (
<span className="text-xs text-muted-foreground">调用时由模型提供</span>
)}
</div>
);
})}
{templateInputsFor(tool).map((parameter) => {
const source = binding.templateBindings[parameter.name] ?? {
source: "literal" as const,
value: "",
};
return (
<div key={`template-${parameter.name}`} className="grid gap-2 md:grid-cols-[1fr_10rem_1fr] md:items-center">
<div className="text-sm text-foreground">
模板 · {parameter.name}{parameter.required ? " *" : ""}
</div>
<Select
value={source.source}
onValueChange={(sourceType) => {
const nextSource = sourceType === "variable"
? { source: "variable" as const, variableName: variableNames[0] }
: sourceType === "template"
? { source: "template" as const, value: "" }
: { source: "literal" as const, value: "" };
updateBinding(index, {
...binding,
templateBindings: { ...binding.templateBindings, [parameter.name]: nextSource },
});
}}
>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="variable">动态变量</SelectItem>
<SelectItem value="literal">固定值</SelectItem>
<SelectItem value="template">模板值</SelectItem>
</SelectContent>
</Select>
{source.source === "variable" ? (
<Select
value={source.variableName}
onValueChange={(variableName) => updateBinding(index, {
...binding,
templateBindings: {
...binding.templateBindings,
[parameter.name]: { source: "variable", variableName },
},
})}
>
<SelectTrigger><SelectValue placeholder="选择变量" /></SelectTrigger>
<SelectContent>
{variableNames.map((name) => (
<SelectItem key={name} value={name}>{name}</SelectItem>
))}
</SelectContent>
</Select>
) : (
<Input
value={String(source.value ?? "")}
onChange={(event) => updateBinding(index, {
...binding,
templateBindings: {
...binding.templateBindings,
[parameter.name]: { ...source, value: event.target.value },
},
})}
/>
)}
</div>
);
})}
<ResultAssignmentsEditor
value={binding.resultAssignments}
onChange={(resultAssignments) =>
updateBinding(index, { ...binding, resultAssignments })
}
/>
);
})}
<ResultAssignmentsEditor
tool={tool}
value={binding.resultAssignments}
variables={variables}
onChange={(resultAssignments) =>
updateBinding(index, { ...binding, resultAssignments })
}
/>
</div>
)}
</div>
);
})}
<ToolPicker
open={pickerOpen}
tools={pickerTools}
selectedIds={selectedIds}
maxBindings={maxBindings}
onOpenChange={setPickerOpen}
onSelect={addTool}
/>
</div>
);
}
function ParameterBindingRow({
parameter,
source,
variableNames,
allowModel,
onChange,
}: {
parameter: ToolParameter;
source: ValueSource;
variableNames: string[];
allowModel: boolean;
onChange: (source: ValueSource) => void;
}) {
return (
<div className="rounded-xl border border-hairline bg-canvas-soft p-3">
<div className="grid gap-3 md:grid-cols-[minmax(0,1fr)_9rem_minmax(0,1fr)] md:items-center">
<div className="min-w-0">
<div className="text-sm font-medium text-foreground">
{parameter.name}
{parameter.required ? <span className="text-destructive"> *</span> : null}
</div>
<p className="mt-0.5 text-xs leading-5 text-muted-foreground">
{parameter.description || `${parameter.type} · ${parameter.location}`}
</p>
</div>
<Select
value={source.source}
onValueChange={(sourceType) => {
const nextSource: ValueSource =
sourceType === "model"
? { source: "model" }
: sourceType === "variable"
? { source: "variable", variableName: variableNames[0] ?? "" }
: sourceType === "template"
? { source: "template", value: "" }
: { source: "literal", value: "" };
onChange(nextSource);
}}
>
<SelectTrigger aria-label={`${parameter.name} 值来源`} className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{allowModel && <SelectItem value="model">模型生成</SelectItem>}
<SelectItem value="variable">动态变量</SelectItem>
<SelectItem value="literal">固定值</SelectItem>
<SelectItem value="template">模板值</SelectItem>
</SelectContent>
</Select>
{source.source === "variable" ? (
<Select
value={source.variableName}
onValueChange={(variableName) => onChange({ source: "variable", variableName })}
>
<SelectTrigger aria-label={`${parameter.name} 动态变量`} className="w-full">
<SelectValue placeholder="选择变量" />
</SelectTrigger>
<SelectContent>
{variableNames.map((name) => (
<SelectItem key={name} value={name}>
{name}
</SelectItem>
))}
</SelectContent>
</Select>
) : source.source === "literal" || source.source === "template" ? (
<Input
aria-label={`${parameter.name} ${source.source === "literal" ? "固定值" : "模板值"}`}
value={String(source.value ?? "")}
onChange={(event) => onChange({ ...source, value: event.target.value })}
placeholder={source.source === "template" ? "例如 {{input__tenant}}" : "输入固定值"}
/>
) : (
<span className="text-xs leading-5 text-muted-foreground">
调用时由模型根据上下文提供
</span>
)}
</div>
</div>
);
}