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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user