Refactor workflow agent and routing components for improved functionality

- Introduce WorkflowAgentStage to manage agent stage configurations and enhance interaction with the workflow engine.
- Implement WorkflowEdgeEvaluator for priority-aware edge evaluation, improving routing decisions based on conditions and user turns.
- Update WorkflowBrain to handle user turns and routing more effectively, ensuring agents cannot have only one default path.
- Enhance CallEndCoordinator to track speech events and manage call termination based on queued speech.
- Add new models and output handling for workflow interactions, improving clarity and maintainability.
- Update tests to validate the new routing logic and agent behavior under various scenarios.
This commit is contained in:
Xin Wang
2026-07-17 22:37:15 +08:00
parent 162a3d8bec
commit bdf3d3dd9c
23 changed files with 1374 additions and 475 deletions
@@ -0,0 +1,116 @@
"use client";
import { Plus } from "lucide-react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { accentVar, type RuntimeNodeSpec } from "./specs";
export function AddNodeDialog({
open,
connectingFromSource,
specs,
canAdd,
onOpenChange,
onSelect,
}: {
open: boolean;
connectingFromSource: boolean;
specs: RuntimeNodeSpec[];
canAdd: (spec: RuntimeNodeSpec) => boolean;
onOpenChange: (open: boolean) => void;
onSelect: (spec: RuntimeNodeSpec) => void;
}) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="gap-0 overflow-hidden border border-hairline bg-card p-0 shadow-2xl sm:max-w-[500px]">
<DialogHeader className="relative overflow-hidden border-b border-hairline px-6 py-6 pr-16">
<div
aria-hidden
className="pointer-events-none absolute -right-14 -top-16 h-40 w-40 rounded-full opacity-40 blur-3xl"
style={{
background:
"radial-gradient(circle, var(--gradient-sky), transparent 68%)",
}}
/>
<div className="relative flex items-start gap-4">
<div className="flex h-11 w-11 shrink-0 items-center justify-center rounded-full bg-surface-strong text-foreground">
<Plus size={18} />
</div>
<div>
<div className="caption-label text-muted-soft">节点目录</div>
<DialogTitle className="font-display display-sm mt-1 text-ink">
添加节点
</DialogTitle>
<DialogDescription className="mt-2 leading-6">
{connectingFromSource
? "选择节点类型,将在当前节点下方创建并自动连接。"
: "选择节点类型并添加到画布中央,随后可编辑内容并建立连线。"}
</DialogDescription>
</div>
</div>
</DialogHeader>
<div className="flex max-h-[440px] flex-col gap-3 overflow-y-auto bg-canvas-soft/70 p-4">
{specs.length === 0 && (
<p className="rounded-2xl border border-dashed border-hairline-strong bg-card px-4 py-8 text-center text-sm text-muted-soft">
暂无可添加的节点类型。
</p>
)}
{specs.map((spec) => {
const Icon = spec.icon;
const allowed = canAdd(spec);
return (
<button
key={spec.type}
type="button"
disabled={!allowed}
className="group relative flex items-start gap-4 overflow-hidden rounded-2xl border border-hairline bg-card p-4 text-left shadow-sm transition-[border-color,box-shadow,transform] hover:-translate-y-0.5 hover:border-hairline-strong hover:shadow-md disabled:cursor-not-allowed disabled:opacity-55 disabled:hover:translate-y-0 disabled:hover:border-hairline disabled:hover:shadow-sm"
onClick={() => onSelect(spec)}
>
<span
aria-hidden
className="absolute left-5 right-5 top-0 h-px"
style={{
background: `linear-gradient(90deg, transparent, var(${accentVar(spec.accent)}), transparent)`,
}}
/>
<div
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full text-foreground transition-transform group-hover:scale-105"
style={{
background: `color-mix(in srgb, var(${accentVar(spec.accent)}) 28%, var(--surface-strong))`,
}}
>
<Icon size={17} />
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 text-sm font-medium text-foreground">
<span>{spec.displayName}</span>
{!allowed && (
<span className="rounded-full bg-surface-strong px-2 py-0.5 text-[10px] font-normal text-muted-foreground">
已添加
</span>
)}
</div>
<div className="mt-1 text-xs leading-5 text-muted-foreground">
{spec.description}
</div>
</div>
<Plus
size={15}
className="mt-1 shrink-0 text-muted-soft transition-colors group-hover:text-foreground"
/>
</button>
);
})}
</div>
</DialogContent>
</Dialog>
);
}
@@ -16,7 +16,7 @@ import {
useNodesState,
useReactFlow,
} from "@xyflow/react";
import { Braces, Plus, Settings2, X } from "lucide-react";
import { Braces, Plus, Settings2 } from "lucide-react";
import {
useCallback,
useEffect,
@@ -26,13 +26,6 @@ import {
} from "react";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
Tooltip,
TooltipContent,
@@ -40,6 +33,7 @@ import {
TooltipTrigger,
} from "@/components/ui/tooltip";
import { AddNodeDialog } from "./AddNodeDialog";
import { edgeTypes } from "./ConditionEdge";
import { hasDefaultPath, newEdgeData } from "./edge-rules";
import {
@@ -52,8 +46,8 @@ import { nodeTypes } from "./GenericNode";
import { EdgeSettingsPanel } from "./panels/EdgeSettingsPanel";
import { GlobalSettingsPanel } from "./panels/GlobalSettingsPanel";
import { NodeSettingsPanel } from "./panels/NodeSettingsPanel";
import { WorkflowSidePanel } from "./WorkflowSidePanel";
import {
accentVar,
defaultGraph,
type NodeSpecMap,
type RuntimeNodeSpec,
@@ -195,7 +189,9 @@ export function WorkflowCanvas({
settings.tts,
settings.toolIds,
settings.knowledgeBaseId,
settings.knowledgeRetrievalConfig,
settings.knowledgeRetrievalConfig.mode,
settings.knowledgeRetrievalConfig.topN,
settings.knowledgeRetrievalConfig.scoreThreshold,
settings.allowInterrupt,
settings.turnConfig,
]);
@@ -203,6 +199,9 @@ export function WorkflowCanvas({
const onConnect = useCallback(
(connection: Connection) => {
if (!connection.source || !connection.target) return;
const sourceType = nodes.find(
(node) => node.id === connection.source,
)?.type;
setEdges((eds) =>
addEdge(
{
@@ -210,13 +209,13 @@ export function WorkflowCanvas({
id: `e-${connection.source}-${connection.target}-${Date.now()}`,
type: "condition",
animated: true,
data: newEdgeData(eds, connection.source),
data: newEdgeData(eds, connection.source, sourceType),
},
eds,
),
);
},
[setEdges],
[nodes, setEdges],
);
// 连线约束:不能连入开始节点(无入边句柄),不能自连。
@@ -277,7 +276,7 @@ export function WorkflowCanvas({
target: id,
type: "condition",
animated: true,
data: newEdgeData(currentEdges, source.id),
data: newEdgeData(currentEdges, source.id, source.type),
},
currentEdges,
);
@@ -577,9 +576,11 @@ export function WorkflowCanvas({
</ReactFlow>
</section>
{/* 添加节点弹窗 */}
<Dialog
<AddNodeDialog
open={addOpen}
connectingFromSource={Boolean(addSourceId)}
specs={addableSpecs}
canAdd={canAddSpec}
onOpenChange={(open) => {
setAddOpen(open);
if (!open) {
@@ -587,131 +588,43 @@ export function WorkflowCanvas({
setAddPosition(null);
}
}}
>
<DialogContent className="gap-0 overflow-hidden border border-hairline bg-card p-0 shadow-2xl sm:max-w-[500px]">
<DialogHeader className="relative overflow-hidden border-b border-hairline px-6 py-6 pr-16">
<div
aria-hidden
className="pointer-events-none absolute -right-14 -top-16 h-40 w-40 rounded-full opacity-40 blur-3xl"
style={{
background:
"radial-gradient(circle, var(--gradient-sky), transparent 68%)",
}}
/>
<div className="relative flex items-start gap-4">
<div className="flex h-11 w-11 shrink-0 items-center justify-center rounded-full bg-surface-strong text-foreground">
<Plus size={18} />
</div>
<div>
<div className="caption-label text-muted-soft">
节点目录
</div>
<DialogTitle className="font-display display-sm mt-1 text-ink">
添加节点
</DialogTitle>
<DialogDescription className="mt-2 leading-6">
{addSourceId
? "选择节点类型,将在当前节点下方创建并自动连接。"
: "选择节点类型并添加到画布中央,随后可编辑内容并建立连线。"}
</DialogDescription>
</div>
</div>
</DialogHeader>
<div className="flex max-h-[440px] flex-col gap-3 overflow-y-auto bg-canvas-soft/70 p-4">
{addableSpecs.length === 0 ? (
<p className="rounded-2xl border border-dashed border-hairline-strong bg-card px-4 py-8 text-center text-sm text-muted-soft">
暂无可添加的节点类型。
</p>
) : null}
{addableSpecs.map((spec) => {
const Icon = spec.icon;
const canAdd = canAddSpec(spec);
return (
<button
key={spec.type}
type="button"
disabled={!canAdd}
className="group relative flex items-start gap-4 overflow-hidden rounded-2xl border border-hairline bg-card p-4 text-left shadow-sm transition-[border-color,box-shadow,transform] hover:-translate-y-0.5 hover:border-hairline-strong hover:shadow-md disabled:cursor-not-allowed disabled:opacity-55 disabled:hover:translate-y-0 disabled:hover:border-hairline disabled:hover:shadow-sm"
onClick={() => addNode(spec)}
>
<span
aria-hidden
className="absolute left-5 right-5 top-0 h-px"
style={{
background: `linear-gradient(90deg, transparent, var(${accentVar(spec.accent)}), transparent)`,
}}
/>
<div
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full text-foreground transition-transform group-hover:scale-105"
style={{
background: `color-mix(in srgb, var(${accentVar(spec.accent)}) 28%, var(--surface-strong))`,
}}
>
<Icon size={17} />
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 text-sm font-medium text-foreground">
<span>{spec.displayName}</span>
{!canAdd && (
<span className="rounded-full bg-surface-strong px-2 py-0.5 text-[10px] font-normal text-muted-foreground">
已添加
</span>
)}
</div>
<div className="mt-1 text-xs leading-5 text-muted-foreground">
{spec.description}
</div>
</div>
<Plus
size={15}
className="mt-1 shrink-0 text-muted-soft transition-colors group-hover:text-foreground"
/>
</button>
);
})}
</div>
</DialogContent>
</Dialog>
onSelect={addNode}
/>
{(debugOpen ||
settingsOpen ||
(editingNode && editingSpec) ||
editingEdge) && (
<aside className="absolute inset-y-0 right-0 z-40 flex w-1/2 flex-col overflow-hidden rounded-r-2xl border-l border-hairline bg-card shadow-2xl">
<WorkflowSidePanel
title={
debugOpen
? undefined
: settingsOpen
? "工作流设置"
: editingEdge
? "编辑连接条件"
: `编辑${editingSpec?.displayName ?? "节点"}`
}
closeLabel={
settingsOpen
? "关闭工作流设置"
: editingEdge
? "关闭边编辑"
: "关闭节点编辑"
}
onClose={
debugOpen
? undefined
: () => {
if (settingsOpen) setSettingsOpen(false);
else if (editingEdge) onEditingEdgeIdChange(null);
else onEditingNodeIdChange(null);
}
}
>
{debugOpen ? (
debugPanel
) : settingsOpen ||
(editingNode && editingSpec) ||
editingEdge ? (
<>
<div className="flex min-h-14 shrink-0 items-center gap-3 border-b border-hairline px-4 py-3">
<button
type="button"
aria-label={
settingsOpen
? "关闭工作流设置"
: editingEdge
? "关闭边编辑"
: "关闭节点编辑"
}
title="关闭"
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full border border-hairline-strong bg-card text-muted-foreground shadow-sm transition-colors hover:text-foreground"
onClick={() => {
if (settingsOpen) setSettingsOpen(false);
else if (editingEdge) onEditingEdgeIdChange(null);
else onEditingNodeIdChange(null);
}}
>
<X size={16} />
</button>
<h2 className="min-w-0 truncate text-sm font-medium text-foreground">
{settingsOpen
? "工作流设置"
: editingEdge
? "编辑连接条件"
: `编辑${editingSpec?.displayName ?? "节点"}`}
</h2>
</div>
) : (
<div className="scrollbar-subtle min-h-0 flex-1 overflow-y-auto bg-canvas-soft px-4 pb-5 pt-4">
{settingsOpen ? (
<GlobalSettingsPanel
@@ -741,6 +654,16 @@ export function WorkflowCanvas({
<EdgeSettingsPanel
key={editingEdge.id}
edge={editingEdge}
sourceType={
nodes.find(
(node) => node.id === editingEdge.source,
)?.type as string | undefined
}
isOnlyOutgoing={
edges.filter(
(edge) => edge.source === editingEdge.source,
).length === 1
}
hasOtherDefaultPath={hasDefaultPath(
edges,
editingEdge.source,
@@ -752,9 +675,8 @@ export function WorkflowCanvas({
/>
) : null}
</div>
</>
) : null}
</aside>
)}
</WorkflowSidePanel>
)}
</div>
</EdgeActionContext.Provider>
@@ -0,0 +1,38 @@
"use client";
import { X } from "lucide-react";
import type { ReactNode } from "react";
export function WorkflowSidePanel({
title,
closeLabel,
onClose,
children,
}: {
title?: string;
closeLabel?: string;
onClose?: () => void;
children: ReactNode;
}) {
return (
<aside className="absolute inset-y-0 right-0 z-40 flex w-1/2 flex-col overflow-hidden rounded-r-2xl border-l border-hairline bg-card shadow-2xl">
{title && onClose && (
<div className="flex min-h-14 shrink-0 items-center gap-3 border-b border-hairline px-4 py-3">
<button
type="button"
aria-label={closeLabel ?? "关闭面板"}
title="关闭"
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full border border-hairline-strong bg-card text-muted-foreground shadow-sm transition-colors hover:text-foreground"
onClick={onClose}
>
<X size={16} />
</button>
<h2 className="min-w-0 truncate text-sm font-medium text-foreground">
{title}
</h2>
</div>
)}
{children}
</aside>
);
}
+11 -3
View File
@@ -31,11 +31,19 @@ export function nextEdgePriority(edges: Edge[], sourceId: string): number {
}
/**
* The first path from a node is deterministic. Further paths start as an
* incomplete LLM condition so the graph never silently gains two defaults.
* Agent nodes need a real condition before they can leave; a lone default path
* would consume the next user turn without letting the Agent answer it.
* Automatic nodes may use a default as their first deterministic path.
*/
export function newEdgeData(edges: Edge[], sourceId: string): WorkflowEdgeData {
export function newEdgeData(
edges: Edge[],
sourceId: string,
sourceType?: string,
): WorkflowEdgeData {
const priority = nextEdgePriority(edges, sourceId);
if (sourceType === "agent" && !hasDefaultPath(edges, sourceId)) {
return { mode: "llm", priority, condition: "" };
}
return hasDefaultPath(edges, sourceId)
? { mode: "llm", priority, condition: "" }
: { mode: "always", priority };
@@ -27,10 +27,14 @@ import type { ExpressionRule, WorkflowEdgeData } from "../specs";
export function EdgeSettingsPanel({
edge,
sourceType,
isOnlyOutgoing,
hasOtherDefaultPath,
onChange,
}: {
edge: Edge;
sourceType?: string;
isOnlyOutgoing: boolean;
hasOtherDefaultPath: boolean;
onChange: (patch: WorkflowEdgeData) => void;
}) {
@@ -109,7 +113,10 @@ export function EdgeSettingsPanel({
{
value: "always",
label: "默认路径",
disabled: mode !== "always" && hasOtherDefaultPath,
disabled:
mode !== "always" &&
(hasOtherDefaultPath ||
(sourceType === "agent" && isOnlyOutgoing)),
},
{ value: "llm", label: "大模型判断" },
{ value: "expression", label: "表达式" },
@@ -127,24 +134,31 @@ export function EdgeSettingsPanel({
当前节点已经有一条默认路径;其它出边需要使用大模型判断或表达式。
</span>
)}
<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">
条件路径按数字从小到大判断,默认路径始终作为兜底。
{sourceType === "agent" && isOnlyOutgoing && mode === "always" && (
<span className="-mt-1 block text-xs text-destructive">
Agent 不能只有默认路径;请改为条件路径,或删除连接以持续对话。
</span>
</label>
)}
{mode !== "always" && (
<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">
条件路径按数字从小到大判断;默认路径不参与优先级。
</span>
</label>
)}
</SectionCard>
<SectionCard