2026-07-14 12:59:41 +08:00
|
|
|
|
"use client";
|
|
|
|
|
|
|
|
|
|
|
|
import type { Edge } from "@xyflow/react";
|
|
|
|
|
|
import {
|
|
|
|
|
|
Braces,
|
|
|
|
|
|
GitBranch,
|
|
|
|
|
|
MessageSquareText,
|
|
|
|
|
|
Plus,
|
|
|
|
|
|
Trash2,
|
|
|
|
|
|
} from "lucide-react";
|
|
|
|
|
|
import { useState } from "react";
|
|
|
|
|
|
|
|
|
|
|
|
import { SectionCard } from "@/components/editor/section-card";
|
|
|
|
|
|
import { Button } from "@/components/ui/button";
|
|
|
|
|
|
import { Input } from "@/components/ui/input";
|
|
|
|
|
|
import {
|
|
|
|
|
|
Select,
|
|
|
|
|
|
SelectContent,
|
|
|
|
|
|
SelectItem,
|
|
|
|
|
|
SelectTrigger,
|
|
|
|
|
|
SelectValue,
|
|
|
|
|
|
} from "@/components/ui/select";
|
|
|
|
|
|
import { Textarea } from "@/components/ui/textarea";
|
|
|
|
|
|
|
|
|
|
|
|
import { NodeSelect } from "./controls";
|
|
|
|
|
|
import type { ExpressionRule, WorkflowEdgeData } from "../specs";
|
|
|
|
|
|
|
|
|
|
|
|
export function EdgeSettingsPanel({
|
|
|
|
|
|
edge,
|
2026-07-17 22:01:42 +08:00
|
|
|
|
hasOtherDefaultPath,
|
2026-07-14 12:59:41 +08:00
|
|
|
|
onChange,
|
|
|
|
|
|
}: {
|
|
|
|
|
|
edge: Edge;
|
2026-07-17 22:01:42 +08:00
|
|
|
|
hasOtherDefaultPath: boolean;
|
2026-07-14 12:59:41 +08:00
|
|
|
|
onChange: (patch: WorkflowEdgeData) => void;
|
|
|
|
|
|
}) {
|
|
|
|
|
|
const data = (edge.data ?? { mode: "always", priority: 10 }) as WorkflowEdgeData;
|
|
|
|
|
|
const [mode, setMode] = useState(data.mode ?? "always");
|
|
|
|
|
|
const [priority, setPriority] = useState(data.priority ?? 10);
|
|
|
|
|
|
const [label, setLabel] = useState(data.label ?? "");
|
|
|
|
|
|
const [condition, setCondition] = useState(data.condition ?? "");
|
|
|
|
|
|
const [transitionSpeech, setTransitionSpeech] = useState(data.transitionSpeech ?? "");
|
|
|
|
|
|
const [combinator, setCombinator] = useState<"and" | "or">(
|
|
|
|
|
|
data.expression?.combinator ?? "and",
|
|
|
|
|
|
);
|
|
|
|
|
|
const [rules, setRules] = useState<ExpressionRule[]>(
|
|
|
|
|
|
data.expression?.rules?.length
|
|
|
|
|
|
? data.expression.rules
|
|
|
|
|
|
: [{ variable: "", operator: "eq", value: "" }],
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
const publish = ({
|
|
|
|
|
|
nextMode = mode,
|
|
|
|
|
|
nextPriority = priority,
|
|
|
|
|
|
nextLabel = label,
|
|
|
|
|
|
nextCondition = condition,
|
|
|
|
|
|
nextTransitionSpeech = transitionSpeech,
|
|
|
|
|
|
nextCombinator = combinator,
|
|
|
|
|
|
nextRules = rules,
|
|
|
|
|
|
}: {
|
|
|
|
|
|
nextMode?: WorkflowEdgeData["mode"];
|
|
|
|
|
|
nextPriority?: number;
|
|
|
|
|
|
nextLabel?: string;
|
|
|
|
|
|
nextCondition?: string;
|
|
|
|
|
|
nextTransitionSpeech?: string;
|
|
|
|
|
|
nextCombinator?: "and" | "or";
|
|
|
|
|
|
nextRules?: ExpressionRule[];
|
|
|
|
|
|
}) =>
|
|
|
|
|
|
onChange({
|
|
|
|
|
|
mode: nextMode,
|
|
|
|
|
|
priority: nextPriority,
|
|
|
|
|
|
label: nextLabel.trim() ? nextLabel : undefined,
|
|
|
|
|
|
condition: nextMode === "llm" ? nextCondition : undefined,
|
|
|
|
|
|
expression:
|
|
|
|
|
|
nextMode === "expression"
|
|
|
|
|
|
? { combinator: nextCombinator, rules: nextRules }
|
|
|
|
|
|
: undefined,
|
|
|
|
|
|
transitionSpeech: nextTransitionSpeech.trim()
|
|
|
|
|
|
? nextTransitionSpeech
|
|
|
|
|
|
: undefined,
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
const setRule = (index: number, patch: Partial<ExpressionRule>) => {
|
|
|
|
|
|
const nextRules = rules.map((rule, ruleIndex) =>
|
|
|
|
|
|
ruleIndex === index ? { ...rule, ...patch } : rule,
|
|
|
|
|
|
);
|
|
|
|
|
|
setRules(nextRules);
|
|
|
|
|
|
publish({ nextRules });
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const parseValue = (value: string): unknown => {
|
|
|
|
|
|
if (value === "true") return true;
|
|
|
|
|
|
if (value === "false") return false;
|
|
|
|
|
|
if (value !== "" && Number.isFinite(Number(value))) return Number(value);
|
|
|
|
|
|
return value;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div className="space-y-3">
|
|
|
|
|
|
<SectionCard
|
|
|
|
|
|
icon={<GitBranch size={15} />}
|
|
|
|
|
|
title="路由方式"
|
2026-07-17 22:01:42 +08:00
|
|
|
|
description="每个节点最多一条默认路径,也可以配置多条条件路径"
|
2026-07-14 12:59:41 +08:00
|
|
|
|
>
|
|
|
|
|
|
<NodeSelect
|
2026-07-17 22:01:42 +08:00
|
|
|
|
label="条件类型"
|
2026-07-14 12:59:41 +08:00
|
|
|
|
value={mode}
|
|
|
|
|
|
options={[
|
2026-07-17 22:01:42 +08:00
|
|
|
|
{
|
|
|
|
|
|
value: "always",
|
|
|
|
|
|
label: "默认路径",
|
|
|
|
|
|
disabled: mode !== "always" && hasOtherDefaultPath,
|
|
|
|
|
|
},
|
|
|
|
|
|
{ value: "llm", label: "大模型判断" },
|
|
|
|
|
|
{ value: "expression", label: "表达式" },
|
2026-07-14 12:59:41 +08:00
|
|
|
|
]}
|
|
|
|
|
|
onChange={(value) => {
|
|
|
|
|
|
const nextMode =
|
|
|
|
|
|
(value as WorkflowEdgeData["mode"]) || "always";
|
|
|
|
|
|
setMode(nextMode);
|
|
|
|
|
|
publish({ nextMode });
|
|
|
|
|
|
}}
|
|
|
|
|
|
allowNone={false}
|
|
|
|
|
|
/>
|
2026-07-17 22:01:42 +08:00
|
|
|
|
{mode !== "always" && hasOtherDefaultPath && (
|
|
|
|
|
|
<span className="-mt-1 block text-xs text-muted-foreground">
|
|
|
|
|
|
当前节点已经有一条默认路径;其它出边需要使用大模型判断或表达式。
|
|
|
|
|
|
</span>
|
|
|
|
|
|
)}
|
2026-07-14 12:59:41 +08:00
|
|
|
|
<label className="block">
|
|
|
|
|
|
<div className="mb-1.5 text-sm font-medium text-foreground">
|
|
|
|
|
|
优先级
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<Input
|
|
|
|
|
|
type="number"
|
|
|
|
|
|
value={priority}
|
|
|
|
|
|
onChange={(event) => {
|
|
|
|
|
|
const nextPriority = Number(event.target.value) || 0;
|
|
|
|
|
|
setPriority(nextPriority);
|
|
|
|
|
|
publish({ nextPriority });
|
|
|
|
|
|
}}
|
|
|
|
|
|
className="border-hairline-strong bg-background text-foreground"
|
|
|
|
|
|
/>
|
|
|
|
|
|
<span className="mt-1.5 block text-xs text-muted-foreground">
|
2026-07-17 22:01:42 +08:00
|
|
|
|
条件路径按数字从小到大判断,默认路径始终作为兜底。
|
2026-07-14 12:59:41 +08:00
|
|
|
|
</span>
|
|
|
|
|
|
</label>
|
|
|
|
|
|
</SectionCard>
|
|
|
|
|
|
|
|
|
|
|
|
<SectionCard
|
|
|
|
|
|
icon={<Braces size={15} />}
|
|
|
|
|
|
title="触发条件"
|
|
|
|
|
|
description="配置画布标签以及这条连接被命中的条件"
|
|
|
|
|
|
>
|
|
|
|
|
|
<label className="block">
|
|
|
|
|
|
<div className="mb-1.5 text-sm font-medium text-foreground">
|
|
|
|
|
|
条件标签
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<Input
|
|
|
|
|
|
value={label}
|
|
|
|
|
|
maxLength={64}
|
|
|
|
|
|
placeholder="例如:用户想转人工"
|
|
|
|
|
|
onChange={(event) => {
|
|
|
|
|
|
const nextLabel = event.target.value;
|
|
|
|
|
|
setLabel(nextLabel);
|
|
|
|
|
|
publish({ nextLabel });
|
|
|
|
|
|
}}
|
|
|
|
|
|
className="border-hairline-strong bg-background text-foreground placeholder:text-muted-soft"
|
|
|
|
|
|
/>
|
|
|
|
|
|
<span className="mt-1.5 block text-xs text-muted-foreground">
|
|
|
|
|
|
用于画布和日志中识别该路径,{label.length}/64
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</label>
|
|
|
|
|
|
|
|
|
|
|
|
{mode === "llm" && (
|
|
|
|
|
|
<label className="block">
|
|
|
|
|
|
<div className="mb-1.5 text-sm font-medium text-foreground">
|
|
|
|
|
|
自然语言条件 <span className="text-destructive">*</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<Textarea
|
|
|
|
|
|
rows={4}
|
|
|
|
|
|
value={condition}
|
|
|
|
|
|
placeholder="例如:用户已经明确表示需要人工客服。"
|
|
|
|
|
|
onChange={(event) => {
|
|
|
|
|
|
const nextCondition = event.target.value;
|
|
|
|
|
|
setCondition(nextCondition);
|
|
|
|
|
|
publish({ nextCondition });
|
|
|
|
|
|
}}
|
|
|
|
|
|
className="field-sizing-fixed min-h-24 resize-y border-hairline-strong bg-background text-sm text-foreground placeholder:text-muted-soft"
|
|
|
|
|
|
/>
|
|
|
|
|
|
{!condition.trim() && (
|
|
|
|
|
|
<span className="mt-1.5 block text-xs text-destructive">
|
|
|
|
|
|
LLM 判断需要填写触发条件。
|
|
|
|
|
|
</span>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</label>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
{mode === "expression" && (
|
|
|
|
|
|
<div className="space-y-3">
|
|
|
|
|
|
<NodeSelect
|
|
|
|
|
|
label="规则组合"
|
|
|
|
|
|
value={combinator}
|
|
|
|
|
|
options={[
|
|
|
|
|
|
{ value: "and", label: "全部满足(AND)" },
|
|
|
|
|
|
{ value: "or", label: "任一满足(OR)" },
|
|
|
|
|
|
]}
|
|
|
|
|
|
onChange={(value) => {
|
|
|
|
|
|
const nextCombinator = value === "or" ? "or" : "and";
|
|
|
|
|
|
setCombinator(nextCombinator);
|
|
|
|
|
|
publish({ nextCombinator });
|
|
|
|
|
|
}}
|
|
|
|
|
|
allowNone={false}
|
|
|
|
|
|
/>
|
|
|
|
|
|
{rules.map((rule, index) => (
|
|
|
|
|
|
<div
|
|
|
|
|
|
key={index}
|
|
|
|
|
|
className="space-y-2 rounded-xl border border-hairline bg-canvas-soft p-3"
|
|
|
|
|
|
>
|
|
|
|
|
|
<Input
|
|
|
|
|
|
value={rule.variable}
|
|
|
|
|
|
placeholder="动态变量名"
|
|
|
|
|
|
onChange={(event) => setRule(index, { variable: event.target.value })}
|
|
|
|
|
|
className="border-hairline-strong bg-background text-foreground placeholder:text-muted-soft"
|
|
|
|
|
|
/>
|
|
|
|
|
|
<div className="grid grid-cols-[minmax(0,1fr)_minmax(0,1fr)_auto] gap-2">
|
|
|
|
|
|
<Select
|
|
|
|
|
|
value={rule.operator}
|
|
|
|
|
|
onValueChange={(value) =>
|
|
|
|
|
|
setRule(index, {
|
|
|
|
|
|
operator: value as ExpressionRule["operator"],
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
>
|
|
|
|
|
|
<SelectTrigger className="border-hairline-strong bg-background">
|
|
|
|
|
|
<SelectValue />
|
|
|
|
|
|
</SelectTrigger>
|
|
|
|
|
|
<SelectContent>
|
|
|
|
|
|
{[
|
|
|
|
|
|
"eq",
|
|
|
|
|
|
"neq",
|
|
|
|
|
|
"gt",
|
|
|
|
|
|
"gte",
|
|
|
|
|
|
"lt",
|
|
|
|
|
|
"lte",
|
|
|
|
|
|
"contains",
|
|
|
|
|
|
"in",
|
|
|
|
|
|
"exists",
|
|
|
|
|
|
].map((operator) => (
|
|
|
|
|
|
<SelectItem key={operator} value={operator}>
|
|
|
|
|
|
{operator}
|
|
|
|
|
|
</SelectItem>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</SelectContent>
|
|
|
|
|
|
</Select>
|
|
|
|
|
|
<Input
|
|
|
|
|
|
disabled={rule.operator === "exists"}
|
|
|
|
|
|
value={rule.value == null ? "" : String(rule.value)}
|
|
|
|
|
|
placeholder="比较值"
|
|
|
|
|
|
onChange={(event) =>
|
|
|
|
|
|
setRule(index, {
|
|
|
|
|
|
value: parseValue(event.target.value),
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
className="border-hairline-strong bg-background text-foreground placeholder:text-muted-soft"
|
|
|
|
|
|
/>
|
|
|
|
|
|
<Button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
size="icon"
|
|
|
|
|
|
variant="outline"
|
|
|
|
|
|
disabled={rules.length === 1}
|
|
|
|
|
|
aria-label={`删除第 ${index + 1} 条规则`}
|
|
|
|
|
|
onClick={() => {
|
|
|
|
|
|
const nextRules = rules.filter(
|
|
|
|
|
|
(_, ruleIndex) => ruleIndex !== index,
|
|
|
|
|
|
);
|
|
|
|
|
|
setRules(nextRules);
|
|
|
|
|
|
publish({ nextRules });
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
|
|
|
|
|
<Trash2 size={14} />
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
{!rule.variable.trim() && (
|
|
|
|
|
|
<span className="text-xs text-destructive">
|
|
|
|
|
|
请输入动态变量名。
|
|
|
|
|
|
</span>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
))}
|
|
|
|
|
|
<Button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
variant="outline"
|
|
|
|
|
|
className="w-full gap-2 border-hairline-strong"
|
|
|
|
|
|
onClick={() => {
|
|
|
|
|
|
const nextRules = [
|
|
|
|
|
|
...rules,
|
|
|
|
|
|
{ variable: "", operator: "eq", value: "" } as ExpressionRule,
|
|
|
|
|
|
];
|
|
|
|
|
|
setRules(nextRules);
|
|
|
|
|
|
publish({ nextRules });
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
|
|
|
|
|
<Plus size={14} />
|
|
|
|
|
|
添加规则
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
{mode === "always" && (
|
|
|
|
|
|
<p className="rounded-xl border border-hairline bg-canvas-soft px-3.5 py-3 text-sm leading-6 text-muted-foreground">
|
|
|
|
|
|
当前节点没有其它条件命中时,将沿这条默认路径继续执行。
|
|
|
|
|
|
</p>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</SectionCard>
|
|
|
|
|
|
|
|
|
|
|
|
<SectionCard
|
|
|
|
|
|
icon={<MessageSquareText size={15} />}
|
|
|
|
|
|
title="过渡语"
|
|
|
|
|
|
description="命中连接后、进入下一节点前播放的固定内容"
|
|
|
|
|
|
>
|
|
|
|
|
|
<label className="block">
|
|
|
|
|
|
<div className="mb-1.5 text-sm font-medium text-foreground">
|
|
|
|
|
|
固定过渡语(可选)
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<Textarea
|
|
|
|
|
|
rows={3}
|
|
|
|
|
|
value={transitionSpeech}
|
|
|
|
|
|
placeholder="例如:好的,正在为你转接。"
|
|
|
|
|
|
onChange={(event) => {
|
|
|
|
|
|
const nextTransitionSpeech = event.target.value;
|
|
|
|
|
|
setTransitionSpeech(nextTransitionSpeech);
|
|
|
|
|
|
publish({ nextTransitionSpeech });
|
|
|
|
|
|
}}
|
|
|
|
|
|
className="field-sizing-fixed min-h-24 resize-y border-hairline-strong bg-background text-sm text-foreground placeholder:text-muted-soft"
|
|
|
|
|
|
/>
|
|
|
|
|
|
<span className="mt-1.5 block text-xs text-muted-foreground">
|
|
|
|
|
|
会显示在调试与完整聊天记录中,同时使用当前 TTS 播放。
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</label>
|
|
|
|
|
|
</SectionCard>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|