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>
</>
);
}