"use client"; /** * 工作流可视化编辑器。基于 @xyflow/react,样式套用 ai-video 设计 token。 * * 工具栏:添加节点(弹窗选类型)、工作流设置(ASR/LLM/TTS、是否允许打断)。 * 节点与条件边:悬停/选中出现「编辑 / 删除」按钮,编辑统一用弹出对话框。 * 节点目录来自后端 /api/node-types。数据通过 value/onChange 与外部 graph 同步。 */ import { addEdge, Background, BackgroundVariant, type Connection, Controls, type Edge, MiniMap, type Node, Panel, ReactFlow, ReactFlowProvider, useEdgesState, useNodesState, useReactFlow, } from "@xyflow/react"; import "@xyflow/react/dist/style.css"; import { Loader2, Plus, Settings2, Trash2 } from "lucide-react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Button } from "@/components/ui/button"; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "@/components/ui/dialog"; 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 { edgeTypes } from "./ConditionEdge"; import { EdgeActionContext, NodeActionContext, NodeSpecsContext, } from "./context"; import { nodeTypes } from "./GenericNode"; import { accentVar, defaultGraph, type NodeSpecMap, type RuntimeNodeSpec, type WorkflowGraph, type WorkflowNodeData, type WorkflowNodeType, } from "./specs"; import { useNodeSpecs } from "./useNodeSpecs"; export type WorkflowSettings = { llm?: string; asr?: string; tts?: string; allowInterrupt: boolean; }; 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[] }; }; let nodeSeq = 0; const NONE = "__none__"; 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 { 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 as { condition?: string; label?: string }) ?? {}, })), }; } function Canvas({ value, onChange, settings, onSettingsChange, modelOptions, specsByType, }: 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 [editingId, setEditingId] = useState(null); const [editingEdgeId, setEditingEdgeId] = useState(null); const [addOpen, setAddOpen] = useState(false); const [settingsOpen, setSettingsOpen] = useState(false); const { screenToFlowPosition } = useReactFlow(); // 回传画布状态给外部(助手 graph)。用 ref 避免把 onChange 放进依赖导致循环。 const onChangeRef = useRef(onChange); useEffect(() => { onChangeRef.current = onChange; }, [onChange]); useEffect(() => { onChangeRef.current?.(fromFlow(nodes, edges)); }, [nodes, edges]); const onConnect = useCallback( (connection: Connection) => { setEdges((eds) => addEdge( { ...connection, id: `e-${connection.source}-${connection.target}-${Date.now()}`, type: "condition", animated: true, data: {}, }, eds, ), ); }, [setEdges], ); // 连线约束:不能连入开始节点(无入边句柄),不能自连。 const isValidConnection = useCallback( (c: Connection | Edge) => { if (c.source === c.target) return false; const target = nodes.find((n) => n.id === c.target); if (!target) return false; const spec = specsByType[target.type as string]; return !!spec?.hasTarget; }, [nodes, specsByType], ); const addNode = useCallback( (spec: RuntimeNodeSpec) => { nodeSeq += 1; const id = `${spec.type}-${Date.now()}-${nodeSeq}`; const position = screenToFlowPosition({ x: window.innerWidth / 2, y: window.innerHeight / 2, }); const data: WorkflowNodeData = { name: spec.displayName }; if (spec.type === "agentNode") { data.allowInterrupt = true; data.prompt = ""; } setNodes((ns) => [...ns, { id, type: spec.type, position, data }]); setAddOpen(false); setEditingId(id); }, [screenToFlowPosition, setNodes], ); 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) => { setNodes((ns) => ns.filter((n) => n.id !== id)); setEdges((es) => es.filter((e) => e.source !== id && e.target !== id)); setEditingId((cur) => (cur === id ? null : cur)); }, [setNodes, setEdges], ); const updateEdgeData = useCallback( (id: string, patch: { condition?: string; label?: string }) => { 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)); setEditingEdgeId((cur) => (cur === id ? null : cur)); }, [setEdges], ); const nodeActions = useMemo( () => ({ edit: setEditingId, remove: deleteNode }), [deleteNode], ); const edgeActions = useMemo( () => ({ edit: setEditingEdgeId, remove: deleteEdge }), [deleteEdge], ); const editingNode = nodes.find((n) => n.id === editingId); 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); return (
{ setEditingId(null); setEditingEdgeId(null); }} fitView proOptions={{ hideAttribution: true }} defaultEdgeOptions={{ type: "condition", animated: true }} className="bg-canvas-soft" > {/* 添加节点弹窗 */} 添加节点 选择要添加到画布的节点类型。
{addableSpecs.length === 0 && (

暂无可添加的节点类型。

)} {addableSpecs.map((spec) => { const Icon = spec.icon; return ( ); })}
{/* 工作流设置弹窗 */} 工作流设置 配置整个工作流使用的语音与大模型,以及交互策略。
onSettingsChange({ ...settings, llm: v })} /> onSettingsChange({ ...settings, asr: v })} /> onSettingsChange({ ...settings, tts: v })} />
{/* 节点编辑弹窗 */} !open && setEditingId(null)} > {editingNode && editingSpec && ( { updateNodeData(editingNode.id, patch); setEditingId(null); }} onDelete={ editingSpec.addable ? () => deleteNode(editingNode.id) : undefined } /> )} {/* 条件编辑弹窗 */} !open && setEditingEdgeId(null)} > {editingEdge && ( { updateEdgeData(editingEdge.id, patch); setEditingEdgeId(null); }} onDelete={() => deleteEdge(editingEdge.id)} /> )}
); } function ModelSelect({ label, value, options, onChange, }: { label: string; value?: string; options: ModelOption[]; onChange: (value: string | undefined) => void; }) { return (
); } function NodeForm({ spec, data, onSave, onDelete, }: { spec: RuntimeNodeSpec; data: WorkflowNodeData; onSave: (patch: WorkflowNodeData) => void; onDelete?: () => void; }) { const [draft, setDraft] = useState({ ...data }); const set = (key: string, val: unknown) => setDraft((d) => ({ ...d, [key]: val })); return ( <> 编辑{spec.displayName} {spec.description}
{spec.fields.map((field) => { const raw = draft[field.key]; if (field.type === "switch") { return ( ); } return (
{field.type === "textarea" ? (