feat: improve tool configuration UX
This commit is contained in:
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user