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

View File

@@ -1,14 +1,19 @@
"use client";
import { useState } from "react";
import { useCallback, useMemo, useState } from "react";
import type { WorkflowSettings } from "@/components/workflow/types";
import { defaultGraph, type WorkflowGraph } from "@/components/workflow/specs";
import type { DynamicVariableDefinition } from "@/lib/api";
import { defaultTurnConfig } from "@/lib/turn-config";
function initialWorkflowSettings(): WorkflowSettings {
const graph = defaultGraph();
type WorkflowPanel =
| { type: "debug" }
| { type: "settings" }
| { type: "node"; nodeId: string }
| { type: "edge"; edgeId: string }
| null;
function settingsFromGraph(graph: WorkflowGraph): WorkflowSettings {
return {
globalPrompt: graph.settings.globalPrompt,
llm: graph.settings.defaultLlmResourceId,
@@ -21,8 +26,8 @@ function initialWorkflowSettings(): WorkflowSettings {
topN: graph.settings.knowledgeTopN,
scoreThreshold: graph.settings.knowledgeScoreThreshold,
},
allowInterrupt: true,
turnConfig: defaultTurnConfig(),
allowInterrupt: graph.settings.enableInterrupt,
turnConfig: graph.settings.turnConfig,
};
}
@@ -32,19 +37,67 @@ export function useWorkflowEditorState() {
const [workflowGraph, setWorkflowGraph] = useState<WorkflowGraph>(() =>
defaultGraph(),
);
const [workflowSettings, setWorkflowSettings] = useState<WorkflowSettings>(
initialWorkflowSettings,
const workflowSettings = useMemo(
() => settingsFromGraph(workflowGraph),
[workflowGraph],
);
const setWorkflowSettings = useCallback((settings: WorkflowSettings) => {
setWorkflowGraph((graph) => ({
...graph,
settings: {
globalPrompt: settings.globalPrompt,
defaultLlmResourceId: settings.llm ?? "",
defaultAsrResourceId: settings.asr ?? "",
defaultTtsResourceId: settings.tts ?? "",
toolIds: settings.toolIds,
knowledgeBaseId: settings.knowledgeBaseId,
knowledgeMode: settings.knowledgeRetrievalConfig.mode,
knowledgeTopN: settings.knowledgeRetrievalConfig.topN,
knowledgeScoreThreshold:
settings.knowledgeRetrievalConfig.scoreThreshold,
enableInterrupt: settings.allowInterrupt,
turnConfig: settings.turnConfig,
},
}));
}, []);
const [workflowDynamicVariableDefinitions, setWorkflowDynamicVariableDefinitions] =
useState<Record<string, DynamicVariableDefinition>>({});
const [workflowDebugOpen, setWorkflowDebugOpen] = useState(false);
const [workflowSettingsOpen, setWorkflowSettingsOpen] = useState(false);
const [workflowEditingNodeId, setWorkflowEditingNodeId] = useState<string | null>(
null,
);
const [workflowEditingEdgeId, setWorkflowEditingEdgeId] = useState<string | null>(
null,
);
const [workflowPanel, setWorkflowPanel] = useState<WorkflowPanel>(null);
const workflowDebugOpen = workflowPanel?.type === "debug";
const workflowSettingsOpen = workflowPanel?.type === "settings";
const workflowEditingNodeId =
workflowPanel?.type === "node" ? workflowPanel.nodeId : null;
const workflowEditingEdgeId =
workflowPanel?.type === "edge" ? workflowPanel.edgeId : null;
const setWorkflowDebugOpen = useCallback((open: boolean) => {
setWorkflowPanel((panel) =>
open ? { type: "debug" } : panel?.type === "debug" ? null : panel,
);
}, []);
const setWorkflowSettingsOpen = useCallback((open: boolean) => {
setWorkflowPanel((panel) =>
open ? { type: "settings" } : panel?.type === "settings" ? null : panel,
);
}, []);
const setWorkflowEditingNodeId = useCallback((nodeId: string | null) => {
setWorkflowPanel((panel) =>
nodeId
? { type: "node", nodeId }
: panel?.type === "node"
? null
: panel,
);
}, []);
const setWorkflowEditingEdgeId = useCallback((edgeId: string | null) => {
setWorkflowPanel((panel) =>
edgeId
? { type: "edge", edgeId }
: panel?.type === "edge"
? null
: panel,
);
}, []);
const [activeNodeId, setActiveNodeId] = useState<string | null>(null);
const [dynamicVariablesOpen, setDynamicVariablesOpen] = useState(false);
@@ -65,6 +118,7 @@ export function useWorkflowEditorState() {
setWorkflowEditingNodeId,
workflowEditingEdgeId,
setWorkflowEditingEdgeId,
workflowPanel,
activeNodeId,
setActiveNodeId,
dynamicVariablesOpen,

View File

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

View File

@@ -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>

View File

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

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

View File

@@ -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