"use client"; /** * 工作流可视化编辑器。基于 @xyflow/react,样式套用 ai-video 设计 token。 * * 工具栏:添加节点、工作流设置、动态变量。全局设置、节点、条件边编辑与调试预览 * 复用画布右半覆盖层。 * 节点目录来自后端 /api/node-types。数据通过 value/onChange 与外部 graph 同步。 */ import { addEdge, Background, BackgroundVariant, type Connection, Controls, type Edge, type Node, type NodeChange, type OnConnectEnd, Panel, ReactFlow, ReactFlowProvider, useEdgesState, useNodesState, useReactFlow, } from "@xyflow/react"; import "@xyflow/react/dist/style.css"; import { AudioLines, Braces, Bot, Brain, Database, Flag, GitBranch, Loader2, MessageSquareText, PhoneForwarded, Play, Plus, Settings2, Sparkles, Tag, Trash2, Wrench, X, Zap, } from "lucide-react"; import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode, } from "react"; import { Button } from "@/components/ui/button"; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "@/components/ui/dialog"; import { SectionCard } from "@/components/editor/section-card"; import { KnowledgeRetrievalConfigDialog } from "@/components/editor/knowledge-retrieval-config-dialog"; import { Input } from "@/components/ui/input"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select"; import { Switch } from "@/components/ui/switch"; import type { KnowledgeRetrievalConfig, TurnConfig } from "@/lib/api"; import { TurnConfigEditor } from "@/components/turn-config-editor"; import { Textarea } from "@/components/ui/textarea"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, } from "@/components/ui/tooltip"; import { edgeTypes } from "./ConditionEdge"; import { ActiveNodeContext, EdgeActionContext, NodeActionContext, NodeSpecsContext, } from "./context"; import { nodeTypes } from "./GenericNode"; import { accentVar, defaultGraph, type NodeSpecMap, type RuntimeNodeSpec, type ExpressionRule, type WorkflowGraph, type WorkflowEdgeData, type WorkflowNodeData, type WorkflowNodeType, } from "./specs"; import { useNodeSpecs } from "./useNodeSpecs"; export type WorkflowSettings = { llm?: string; asr?: string; tts?: string; toolIds: string[]; knowledgeBaseId: string; knowledgeRetrievalConfig: KnowledgeRetrievalConfig; globalPrompt: string; allowInterrupt: boolean; turnConfig: TurnConfig; }; export type ModelOption = { value: string; label: string }; export type WorkflowEditorProps = { value?: WorkflowGraph; onChange?: (graph: WorkflowGraph) => void; settings: WorkflowSettings; onSettingsChange: (settings: WorkflowSettings) => void; modelOptions: { llm: ModelOption[]; asr: ModelOption[]; tts: ModelOption[] }; toolOptions?: ModelOption[]; knowledgeOptions?: ModelOption[]; onOpenDynamicVariables: () => void; editingNodeId: string | null; onEditingNodeIdChange: (nodeId: string | null) => void; editingEdgeId: string | null; onEditingEdgeIdChange: (edgeId: string | null) => void; settingsOpen: boolean; onSettingsOpenChange: (open: boolean) => void; debugOpen: boolean; onDebugOpenChange: (open: boolean) => void; debugPanel: ReactNode; /** 调试通话中当前激活的节点 id(用于高亮)。 */ activeNodeId?: string | null; }; let nodeSeq = 0; const NONE = "__none__"; function defaultNodeData(spec: RuntimeNodeSpec): WorkflowNodeData { const data: WorkflowNodeData = { name: spec.displayName, ...(spec.type === "agent" ? { contextPolicy: "inherit", inheritGlobalConfig: true, entryMode: "wait_user", entrySpeech: "", } : {}), }; for (const field of spec.fields) { if (field.default !== undefined) data[field.key] = field.default; } return data; } function toFlow(graph: WorkflowGraph): { nodes: Node[]; edges: Edge[] } { return { nodes: graph.nodes.map((n) => ({ id: n.id, type: n.type, position: n.position, data: n.data, })), edges: graph.edges.map((e) => ({ id: e.id, type: "condition", source: e.source, target: e.target, data: e.data ?? {}, })), }; } function fromFlow(nodes: Node[], edges: Edge[]): WorkflowGraph { return { specVersion: 3, settings: { globalPrompt: "", defaultLlmResourceId: "", defaultAsrResourceId: "", defaultTtsResourceId: "", toolIds: [], knowledgeBaseId: "", knowledgeMode: "automatic", knowledgeTopN: 5, knowledgeScoreThreshold: 0, }, nodes: nodes.map((n) => ({ id: n.id, type: n.type as WorkflowNodeType, position: n.position, data: n.data as WorkflowNodeData, })), edges: edges.map((e) => ({ id: e.id, source: e.source, target: e.target, data: (e.data ?? { mode: "always", priority: 10, }) as WorkflowGraph["edges"][number]["data"], })), }; } function Canvas({ value, onChange, settings, onSettingsChange, modelOptions, activeNodeId, onOpenDynamicVariables, editingNodeId, onEditingNodeIdChange, editingEdgeId, onEditingEdgeIdChange, settingsOpen, onSettingsOpenChange: setSettingsOpen, debugOpen, onDebugOpenChange, debugPanel, specsByType, toolOptions = [], knowledgeOptions = [], }: WorkflowEditorProps & { specsByType: NodeSpecMap }) { const initial = useMemo(() => toFlow(value ?? defaultGraph()), [value]); const [nodes, setNodes, onNodesChange] = useNodesState(initial.nodes); const [edges, setEdges, onEdgesChange] = useEdgesState(initial.edges); const [addOpen, setAddOpen] = useState(false); const [addSourceId, setAddSourceId] = useState(null); const [addPosition, setAddPosition] = useState<{ x: number; y: number } | null>(null); const { screenToFlowPosition } = useReactFlow(); // 回传画布状态给外部(助手 graph)。用 ref 避免把 onChange 放进依赖导致循环。 const onChangeRef = useRef(onChange); useEffect(() => { onChangeRef.current = onChange; }, [onChange]); useEffect(() => { const graph = fromFlow(nodes, edges); 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, }; onChangeRef.current?.(graph); }, [ nodes, edges, settings.globalPrompt, settings.llm, settings.asr, settings.tts, settings.toolIds, settings.knowledgeBaseId, settings.knowledgeRetrievalConfig, ]); const onConnect = useCallback( (connection: Connection) => { const sourceType = nodes.find((node) => node.id === connection.source)?.type; const priority = edges.filter((edge) => edge.source === connection.source).length * 10 + 10; setEdges((eds) => addEdge( { ...connection, id: `e-${connection.source}-${connection.target}-${Date.now()}`, type: "condition", animated: true, data: sourceType === "agent" ? { mode: "llm", priority, condition: "当前阶段任务已经完成", } : { mode: "always", priority }, }, eds, ), ); }, [nodes, edges, setEdges], ); // 连线约束:不能连入开始节点(无入边句柄),不能自连。 const isValidConnection = useCallback( (c: Connection | Edge) => { if (c.source === c.target) return false; const source = nodes.find((n) => n.id === c.source); const target = nodes.find((n) => n.id === c.target); if (!source || !target) return false; const sourceSpec = specsByType[source.type as string]; const targetSpec = specsByType[target.type as string]; if (!sourceSpec?.hasSource || !targetSpec?.hasTarget) return false; if (edges.some((e) => e.source === c.source && e.target === c.target)) { return false; } const sourceLimit = sourceSpec.constraints.maxOutgoing; if ( sourceLimit !== undefined && edges.filter((e) => e.source === c.source).length >= sourceLimit ) { return false; } const targetLimit = targetSpec.constraints.maxIncoming; if ( targetLimit !== undefined && edges.filter((e) => e.target === c.target).length >= targetLimit ) { return false; } return true; }, [edges, nodes, specsByType], ); const addNode = useCallback( (spec: RuntimeNodeSpec) => { nodeSeq += 1; const id = `${spec.type}-${Date.now()}-${nodeSeq}`; const source = addSourceId ? nodes.find((node) => node.id === addSourceId) : undefined; const position = addPosition ? { x: addPosition.x - 125, y: addPosition.y } : screenToFlowPosition({ x: window.innerWidth / 2, y: window.innerHeight / 2, }); const data = defaultNodeData(spec); setNodes((ns) => [...ns, { id, type: spec.type, position, data }]); if (source) { setEdges((currentEdges) => { const priority = currentEdges.filter((edge) => edge.source === source.id).length * 10 + 10; return addEdge( { id: `e-${source.id}-${id}-${Date.now()}`, source: source.id, target: id, type: "condition", animated: true, data: source.type === "agent" ? { mode: "llm", priority, condition: "当前阶段任务已经完成", } : { mode: "always", priority }, }, currentEdges, ); }); } setAddOpen(false); setAddSourceId(null); setAddPosition(null); setSettingsOpen(false); onDebugOpenChange(false); onEditingEdgeIdChange(null); onEditingNodeIdChange(id); }, [ addPosition, addSourceId, nodes, onDebugOpenChange, onEditingEdgeIdChange, onEditingNodeIdChange, screenToFlowPosition, setEdges, setNodes, setSettingsOpen, ], ); const updateNodeData = useCallback( (id: string, patch: Partial) => { setNodes((ns) => ns.map((n) => n.id === id ? { ...n, data: { ...n.data, ...patch } } : n, ), ); }, [setNodes], ); const deleteNode = useCallback( (id: string) => { if (nodes.find((node) => node.id === id)?.type === "start") return; setNodes((ns) => ns.filter((n) => n.id !== id)); setEdges((es) => es.filter((e) => e.source !== id && e.target !== id)); if (editingNodeId === id) onEditingNodeIdChange(null); }, [editingNodeId, nodes, onEditingNodeIdChange, setNodes, setEdges], ); const handleNodesChange = useCallback( (changes: NodeChange[]) => { const startIds = new Set( nodes.filter((node) => node.type === "start").map((node) => node.id), ); onNodesChange( changes.filter( (change) => change.type !== "remove" || !startIds.has(change.id), ), ); }, [nodes, onNodesChange], ); const updateEdgeData = useCallback( ( id: string, patch: WorkflowGraph["edges"][number]["data"], ) => { setEdges((es) => es.map((e) => e.id === id ? { ...e, data: { ...(e.data ?? {}), ...patch } } : e, ), ); }, [setEdges], ); const deleteEdge = useCallback( (id: string) => { setEdges((es) => es.filter((e) => e.id !== id)); if (editingEdgeId === id) onEditingEdgeIdChange(null); }, [editingEdgeId, onEditingEdgeIdChange, setEdges], ); const canCreateFromSource = useCallback( (id: string) => { const source = nodes.find((node) => node.id === id); if (!source) return false; const spec = specsByType[source.type as string]; if (!spec?.hasSource) return false; const outgoingCount = edges.filter((edge) => edge.source === id).length; if ( spec.constraints.maxOutgoing !== undefined && outgoingCount >= spec.constraints.maxOutgoing ) { return false; } return source.type === "agent" || outgoingCount === 0; }, [edges, nodes, specsByType], ); const onConnectEnd = useCallback( (event, connectionState) => { if ( connectionState.isValid || connectionState.toNode || connectionState.fromHandle?.type !== "source" || !connectionState.fromNode || !canCreateFromSource(connectionState.fromNode.id) ) { return; } const pointer = "changedTouches" in event ? event.changedTouches[0] : event; if (!pointer) return; setAddSourceId(connectionState.fromNode.id); setAddPosition( screenToFlowPosition({ x: pointer.clientX, y: pointer.clientY }), ); setAddOpen(true); }, [canCreateFromSource, screenToFlowPosition], ); const openSettings = useCallback(() => { onDebugOpenChange(false); onEditingNodeIdChange(null); onEditingEdgeIdChange(null); setSettingsOpen(true); }, [ onDebugOpenChange, onEditingEdgeIdChange, onEditingNodeIdChange, setSettingsOpen, ]); const nodeActions = useMemo( () => ({ edit: (id: string) => { setSettingsOpen(false); onDebugOpenChange(false); onEditingEdgeIdChange(null); onEditingNodeIdChange(id); }, remove: deleteNode, }), [ deleteNode, onDebugOpenChange, onEditingEdgeIdChange, onEditingNodeIdChange, setSettingsOpen, ], ); const edgeActions = useMemo( () => ({ edit: (id: string) => { setSettingsOpen(false); onDebugOpenChange(false); onEditingNodeIdChange(null); onEditingEdgeIdChange(id); }, remove: deleteEdge, }), [ deleteEdge, onDebugOpenChange, onEditingEdgeIdChange, onEditingNodeIdChange, setSettingsOpen, ], ); const editingNode = nodes.find((n) => n.id === editingNodeId); const editingSpec = editingNode ? specsByType[editingNode.type as string] : null; const editingEdge = edges.find((e) => e.id === editingEdgeId); const addableSpecs = Object.values(specsByType).filter((s) => s.addable); const canAddSpec = useCallback( (spec: RuntimeNodeSpec) => { const limit = spec.constraints.maxInstances; if (limit === undefined) return true; return nodes.filter((node) => node.type === spec.type).length < limit; }, [nodes], ); return (
{ onEditingEdgeIdChange(null); }} fitView proOptions={{ hideAttribution: true }} defaultEdgeOptions={{ type: "condition", animated: true }} >
添加节点 工作流设置 动态变量
{/* 添加节点弹窗 */} { setAddOpen(open); if (!open) { setAddSourceId(null); setAddPosition(null); } }} >
节点目录
添加节点 {addSourceId ? "选择节点类型,将在当前节点下方创建并自动连接。" : "选择节点类型并添加到画布中央,随后可编辑内容并建立连线。"}
{addableSpecs.length === 0 ? (

暂无可添加的节点类型。

) : null} {addableSpecs.map((spec) => { const Icon = spec.icon; const canAdd = canAddSpec(spec); return ( ); })}
{(debugOpen || settingsOpen || (editingNode && editingSpec) || editingEdge) && ( )}
); } function SettingsForm({ settings, onSettingsChange, modelOptions, toolOptions, knowledgeOptions, }: { settings: WorkflowSettings; onSettingsChange: (settings: WorkflowSettings) => void; modelOptions: WorkflowEditorProps["modelOptions"]; toolOptions: ModelOption[]; knowledgeOptions: ModelOption[]; }) { return (
} title="全局提示词" description="与所有选择继承全局配置的 Agent 节点提示词合并" >