Skip to content

第12章 工作流编辑器开发

作者

谭策 — 独立开发者 | AIOps 领域探索者

IT Online 微信公众号

许可证

MPL-2.0 © 谭策

本章导读

在 IT 运维自动化场景中,单一 Agent 的能力有限,复杂任务通常需要多个 Agent 按顺序协作完成。例如:先由"日志分析 Agent"排查错误原因,再由"修复 Agent"执行修复脚本,最后由"验证 Agent"确认修复结果。工作流编辑器通过可视化的拖拽方式,让运维人员可以像搭积木一样组合多个 Agent,形成可复用的自动化流程。

本章将以 ITOps Agent Platform 的 WorkflowEditor 页面为核心,深入讲解如何使用 @xyflow/react(React Flow)构建功能完整的工作流编辑器。你将学习节点类型定义、拖拽交互、画布操作、撤销/重做、工作流验证和导入导出等核心功能,掌握如何从零开发一个生产级的可视化编辑器。

学习目标

  • 理解 @xyflow/react 的核心概念和架构设计
  • 掌握自定义节点组件的开发方法
  • 学会实现拖拽创建节点和连线功能
  • 理解画布缩放/平移和 MiniMap 的实现原理
  • 掌握撤销/重做历史记录模式
  • 学会设计工作流验证逻辑
  • 掌握工作流的保存/加载/导入/导出模式
  • 理解前后端数据结构映射关系

12.1 @xyflow/react 基础

12.1.1 什么是 @xyflow/react

@xyflow/react 是 React Flow 的官方包(v12+ 从 reactflow 迁移而来),是一个基于 React 的节点图编辑器库。它提供了构建可视化编辑器的所有基础设施:

┌─────────────────────────────────────────────────────────┐
│                    @xyflow/react                          │
├─────────────────────────────────────────────────────────┤
│  ReactFlow 核心组件                                       │
│  ├── 节点渲染系统 (Node Types)                            │
│  ├── 连线渲染系统 (Edge Types)                            │
│  ├── 交互系统 (拖拽/缩放/平移/选择)                        │
│  └── 布局系统 (坐标转换/视口管理)                          │
├─────────────────────────────────────────────────────────┤
│  内置 UI 组件                                             │
│  ├── Controls      (缩放/适应/锁定)                        │
│  ├── MiniMap       (缩略导航图)                            │
│  ├── Background    (网格/点阵背景)                         │
│  └── Panel         (浮动面板)                              │
├─────────────────────────────────────────────────────────┤
│  工具函数                                                 │
│  ├── addEdge       (创建连线)                              │
│  ├── useNodesState (节点状态 Hook)                         │
│  ├── useEdgesState (连线状态 Hook)                         │
│  └── MarkerType    (箭头标记类型)                          │
└─────────────────────────────────────────────────────────┘

12.1.2 核心数据模型

React Flow 围绕三个核心概念构建:

概念说明数据结构
Node(节点)画布上的独立元素,代表一个工作步骤{ id, type, position, data }
Edge(连线)连接两个节点的有向边,代表执行顺序{ id, source, target, animated }
Handle(连接点)节点上的输入/输出锚点,用于连线<Handle type="target|source" />

数据结构示意

typescript
interface Node {
  id: string;             // 唯一标识
  type: string;           // 节点类型('agent' | 其他)
  position: { x: number; y: number };  // 画布坐标
  data: Record<string, any>;           // 自定义业务数据
  connectable: boolean;   // 是否可连接
  selected?: boolean;     // 是否选中
}

interface Edge {
  id: string;             // 唯一标识
  source: string;         // 起点节点 ID
  target: string;         // 终点节点 ID
  sourceHandle?: string;  // 起点连接点
  targetHandle?: string;  // 终点连接点
  animated?: boolean;     // 是否显示动画
  markerEnd?: object;     // 终点箭头样式
  style?: object;         // 连线样式
}

12.1.3 项目依赖

json
{
  "@xyflow/react": "^12.x",
  "@xyflow/react/dist/style.css"
}

WorkflowEditor.tsx 的导入清单:

typescript
import {
  ReactFlow,
  ReactFlowProvider,
  addEdge,
  useNodesState,
  useEdgesState,
  Controls,
  Background,
  MiniMap,
  Panel,
  MarkerType,
} from '@xyflow/react';
import '@xyflow/react/dist/style.css';
import { Handle, Position } from '@xyflow/react';

12.2 自定义节点开发

12.1.1 AgentNode 组件

项目定义了一个自定义 Agent 节点,用于在画布上展示 Agent 信息:

typescript
// frontend/src/pages/WorkflowEditor.tsx
import { Handle, Position } from '@xyflow/react';

const AgentNode = ({ data, selected }: { data: any; selected: boolean }) => {
  return (
    <div
      className={`
        px-4 py-3 rounded-lg shadow-md border-2 min-w-[200px]
        ${selected 
          ? 'border-primary bg-primary/10 ring-2 ring-primary/30' 
          : 'border-border bg-surface'
        }
        transition-all duration-200
      `}
    >
      {/* 输入连接点(左侧) */}
      <Handle 
        type="target" 
        position={Position.Left} 
        className="w-3 h-3 bg-primary" 
      />
      
      {/* Agent 头像和名称 */}
      <div className="flex items-center gap-2 mb-2">
        <span className="text-2xl">{data.avatar || '🤖'}</span>
        <span className="font-semibold text-text-primary text-sm">
          {data.label || 'Agent'}
        </span>
      </div>
      
      {/* Agent 描述 */}
      {data.description && (
        <div className="text-xs text-text-secondary mb-2 line-clamp-2">
          {data.description}
        </div>
      )}
      
      {/* 输入输出键显示 */}
      <div className="space-y-1 mb-2">
        {data.inputKey && (
          <div className="text-xs text-blue-600 bg-blue-50 px-2 py-1 rounded border border-blue-200">
输入: {data.inputKey}
          </div>
        )}
        {data.outputKey && (
          <div className="text-xs text-green-600 bg-green-50 px-2 py-1 rounded border border-green-200">
输出: {data.outputKey}
          </div>
        )}
      </div>
      
      {/* Prompt 配置状态 */}
      {data.prompt && (
        <div className="text-xs text-text-secondary bg-background px-2 py-1 rounded border border-border">
          已配置Prompt
        </div>
      )}
      
      {/* 输出连接点(右侧) */}
      <Handle 
        type="source" 
        position={Position.Right} 
        className="w-3 h-3 bg-primary" 
      />
    </div>
  );
};

节点设计说明

┌─────────────────────────────────────┐
│  ● ┌─ 输入连接点 (Handle target)    │
│    │                                 │
│    │  🤖  日志分析 Agent             │
│    │                                 │
│    │  分析服务器日志,找出错误原因     │  ← data.description
│    │                                 │
│    │  ← 输入: log_data               │  ← data.inputKey
│    │  → 输出: analysis_result        │  ← data.outputKey
│    │                                 │
│    │  已配置Prompt                   │  ← data.prompt (可选)
│    │                                 │
│                                  ● ──┤ ─ 输出连接点 (Handle source)
└─────────────────────────────────────┘

连接点设计

  • Target(输入):位于左侧,接收来自上游节点的数据流
  • Source(输出):位于右侧,将数据传递给下游节点
  • 数据流向:左 → 右,符合从左到右的阅读习惯

12.1.2 节点类型注册

自定义节点必须注册到 React Flow 的节点类型映射表中:

typescript
const nodeTypes: NodeTypes = {
  agent: AgentNode,  // 类型名 → 组件映射
};

// 在 ReactFlow 组件中使用
<ReactFlow
  nodeTypes={nodeTypes}  // 传入类型映射
  // ...
/>

当节点的 type: 'agent' 时,React Flow 会自动使用 AgentNode 组件渲染。

12.3 工作流编辑器架构

12.3.1 整体布局

工作流编辑器采用三栏布局:

┌────────────────────────────────────────────────────────────────┐
│  ← 返回  │  编辑工作流  │  [撤销] [重做] │ [导入] [导出] │ 保存  │
├────────────────────────────────────────────────────────────────┤
│  工作流名称: [____________]  描述: [____________]  ☑ 设为模板     │
│  ┌────────────────────────────────────────────────────────────┐│
│  │ ⚠ 发现问题                                                   ││
│  │ • 请至少添加一个节点                                          ││
│  └────────────────────────────────────────────────────────────┘│
├──────────────┬─────────────────────────────────────────────────┤
│ 可用 Agent   │                                                  │
│ ┌──────────┐ │  ┌─────────┐    ┌─────────┐    ┌─────────┐      │
│ │🤖 日志分析│ │  │         │───►│         │───►│         │      │
│ └──────────┘ │  └─────────┘    └─────────┘    └─────────┘      │
│ ┌──────────┐ │                                    ● MiniMap     │
│ │🔧 修复   │ │                                                  │
│ └──────────┘ │                                                  │
│ ┌──────────┐ │           [Background 网格背景]                   │
│ │✅ 验证   │ │                                                  │
│ └──────────┘ │  [Controls 缩放控件]    [Panel 操作提示]          │
├──────────────┤                                                  │
│ 节点配置     │                                                  │
│ ┌──────────┐ │                                                  │
│ │ 显示名称  │ │                                                  │
│ │ 节点描述  │ │                                                  │
│ │ 输入键名  │ │                                                  │
│ │ 输出键名  │ │                                                  │
│ │ 自定义Prompt│ │                                                │
│ └──────────┘ │                                                  │
└──────────────┴─────────────────────────────────────────────────┘
   左侧面板              中间画布 (ReactFlow)

12.3.2 状态管理

编辑器管理了 8 个状态:

状态类型用途
nodesNode[]画布上的所有节点
edgesEdge[]节点之间的连线
reactFlowInstanceanyReact Flow 实例,用于坐标转换等
namestring工作流名称
descriptionstring工作流描述
selectedNodeNode | null当前选中的节点
isTemplateboolean是否设为模板
historyArray<{nodes, edges}>撤销/重做历史
historyIndexnumber当前历史位置
validationErrorsstring[]验证错误列表
typescript
const [nodes, setNodes, onNodesChange] = useNodesState<Node>([]);
const [edges, setEdges, onEdgesChange] = useEdgesState<Edge>([]);
const [reactFlowInstance, setReactFlowInstance] = useState<any>(null);
const [name, setName] = useState('');
const [description, setDescription] = useState('');
const [selectedNode, setSelectedNode] = useState<Node | null>(null);
const [isTemplate, setIsTemplate] = useState(false);
const [history, setHistory] = useState<{ nodes: Node[]; edges: Edge[] }[]>([]);
const [historyIndex, setHistoryIndex] = useState(-1);
const [validationErrors, setValidationErrors] = useState<string[]>([]);

12.3.3 ReactFlowProvider 上下文

@xyflow/react 的许多 Hook 和组件依赖 ReactFlowProvider 提供的上下文。编辑器将内容包裹在 Provider 中:

typescript
export default function WorkflowEditor() {
  return (
    <ReactFlowProvider>
      <WorkflowEditorContent />
    </ReactFlowProvider>
  );
}

为什么需要 Provider

┌─────────────────────────────────────┐
│         ReactFlowProvider            │
│                                      │
│  提供:                                │
│  ├── ReactFlow 实例上下文              │
│  ├── 坐标转换函数                      │
│  ├── 节点/边状态共享                   │
│  └── 视口状态 (缩放/平移)              │
│                                      │
│  ┌───────────────────────────────┐  │
│  │      WorkflowEditorContent     │  │
│  │                                │  │
│  │  可以使用:                      │  │
│  │  ├── useReactFlow()            │  │
│  │  ├── useStore()                │  │
│  │  └── MiniMap / Controls 等     │  │
│  └───────────────────────────────┘  │
└─────────────────────────────────────┘

12.4 数据加载与保存

12.4.1 加载已有工作流

使用 React Query 从后端获取工作流数据:

typescript
const { data: workflow } = useQuery({
  queryKey: ['workflow', id],
  queryFn: async () => {
    const res = await api.get(`/api/workflows/${id}`);
    return res.data.data;
  },
  enabled: !!id && id !== 'new',  // 新建时不请求
});

// 数据加载完成后初始化状态
useEffect(() => {
  if (workflow) {
    setName(workflow.name);
    setDescription(workflow.description);
    setIsTemplate(workflow.is_template === 1);
    if (workflow.nodes && workflow.nodes.length > 0) {
      setNodes(workflow.nodes);   // 后端存储的 JSON 已解析为 Node[]
    }
    if (workflow.edges && workflow.edges.length > 0) {
      setEdges(workflow.edges);   // 后端存储的 JSON 已解析为 Edge[]
    }
    // 初始化历史
    setHistory([{ nodes: workflow.nodes || [], edges: workflow.edges || [] }]);
    setHistoryIndex(0);
  }
}, [workflow, setNodes, setEdges]);

12.4.2 后端存储格式

工作流数据以 JSON 序列化后存储在 SQLite 中:

sql
CREATE TABLE workflows (
  id TEXT PRIMARY KEY,
  name TEXT,
  description TEXT,
  nodes TEXT,           -- JSON 字符串: [{"id":"node-1","type":"agent",...}]
  edges TEXT,           -- JSON 字符串: [{"id":"e1","source":"node-1",...}]
  agent_configs TEXT,   -- JSON 字符串: 节点配置
  is_template INTEGER,
  created_at TIMESTAMP,
  updated_at TIMESTAMP
);

后端路由处理

typescript
// backend/src/routes/workflowRoutes.ts

// 创建:序列化后存储
router.post('/', requireRole('admin', 'operator'), (req, res) => {
  const { name, description, nodes, edges, agent_configs, is_template } = req.body;
  const id = randomUUID();
  
  db.prepare(`
    INSERT INTO workflows (id, name, description, nodes, edges, agent_configs, is_template)
    VALUES (?, ?, ?, ?, ?, ?, ?)
  `).run(
    id, name, description,
    JSON.stringify(nodes || []),    // 序列化
    JSON.stringify(edges || []),
    JSON.stringify(agent_configs || {}),
    is_template ? 1 : 0
  );
});

// 查询:反序列化后返回
router.get('/:id', (req, res) => {
  const workflow = db.prepare('SELECT * FROM workflows WHERE id = ?').get(req.params.id);
  const w = workflow as Record<string, unknown>;
  if (w.nodes) w.nodes = JSON.parse(w.nodes as string);   // 反序列化
  if (w.edges) w.edges = JSON.parse(w.edges as string);
  if (w.agent_configs) w.agent_configs = JSON.parse(w.agent_configs as string);
  res.json({ success: true, data: workflow });
});

前后端数据流

前端 Node[] / Edge[] (JS 对象)

         │  JSON.stringify (保存)

后端 TEXT 字段 (JSON 字符串)

         │  存储到 SQLite

    SQLite 数据库

         │  读取

后端 TEXT 字段 (JSON 字符串)

         │  JSON.parse (查询)

前端 Node[] / Edge[] (JS 对象)

12.4.3 保存工作流

使用 React Query mutation 保存到后端:

typescript
const saveMutation = useMutation({
  mutationFn: async (data: WorkflowData) => {
    if (!validateWorkflow()) {
      throw new Error('工作流验证失败');
    }
    
    if (id && id !== 'new') {
      await api.put(`/api/workflows/${id}`, data);    // 更新
    } else {
      await api.post('/api/workflows', data);          // 创建
    }
  },
  onSuccess: () => {
    queryClient.invalidateQueries({ queryKey: ['workflows'] });
    navigate('/workflows');
    alert('保存成功!');
  },
  onError: (error: any) => {
    alert(error.message || '保存失败,请重试');
  },
});

const handleSave = useCallback(() => {
  if (!validateWorkflow()) {
    alert('工作流验证失败:\n' + validationErrors.join('\n'));
    return;
  }

  saveMutation.mutate({
    name,
    description,
    nodes,
    edges,
    is_template: isTemplate ? 1 : 0,
  });
}, [name, description, nodes, edges, isTemplate, saveMutation, validateWorkflow]);

12.5 拖拽与连线交互

12.5.1 从侧边栏拖拽创建节点

左侧面板的 Agent 列表项支持 HTML5 拖拽 API:

typescript
// 拖拽开始:写入 dataTransfer
<div
  draggable
  onDragStart={(event) => {
    event.dataTransfer.setData('application/reactflow/agentId', agent.id);
    event.dataTransfer.setData('application/reactflow/agentName', agent.name);
    event.dataTransfer.setData('application/reactflow/agentAvatar', agent.avatar || '🤖');
    event.dataTransfer.setData('application/reactflow/agentDescription', agent.description || '');
    event.dataTransfer.setData('application/reactflow/agentSystemPrompt', agent.system_prompt || '');
    event.dataTransfer.effectAllowed = 'move';
  }}
  className="p-3 rounded-lg border border-border bg-background hover:border-primary cursor-move"
>
  <span className="text-xl">{agent.avatar || '🤖'}</span>
  <span>{agent.name}</span>
</div>

拖拽释放处理

typescript
const onDragOver = useCallback((event: React.DragEvent) => {
  event.preventDefault();        // 必须阻止默认行为才能触发 onDrop
  event.dataTransfer.dropEffect = 'move';
}, []);

const onDrop = useCallback(
  (event: React.DragEvent) => {
    event.preventDefault();

    // 读取拖拽数据
    const agentId = event.dataTransfer.getData('application/reactflow/agentId');
    const agentName = event.dataTransfer.getData('application/reactflow/agentName');
    const agentAvatar = event.dataTransfer.getData('application/reactflow/agentAvatar');
    const agentDescription = event.dataTransfer.getData('application/reactflow/agentDescription');
    const agentSystemPrompt = event.dataTransfer.getData('application/reactflow/agentSystemPrompt');

    if (typeof agentId !== 'string') return;

    // 将屏幕坐标转换为 React Flow 画布坐标
    const position = reactFlowInstance.screenToFlowPosition({
      x: event.clientX,
      y: event.clientY,
    });

    // 创建新节点
    const newNode: Node = {
      id: `node-${Date.now()}`,   // 使用时间戳生成唯一 ID
      type: 'agent',
      position,
      data: {
        label: agentName,
        agentId,
        avatar: agentAvatar,
        description: agentDescription,
        prompt: agentSystemPrompt,
      },
      connectable: true,
    };

    setNodes((nds) => nds.concat(newNode));
  },
  [reactFlowInstance, setNodes]
);

坐标转换说明

屏幕坐标 (clientX/Y)          画布坐标 (position)
                               (考虑缩放和平移)
┌─────────────────┐          ┌─────────────────┐
│                 │          │                 │
│    (200, 300)   │          │    (150, 250)   │
│       ● ────────┼──screenToFlowPosition──►●   │
│                 │          │                 │
│  React Flow 画布 │          │  缩放 1.5x        │
│  缩放: 1.5x      │          │  平移: x=50,y=50  │
│  平移: x=50,y=50 │          │                 │
└─────────────────┘          └─────────────────┘

12.5.2 节点连线

onConnect 回调处理节点间的连线创建:

typescript
const onConnect = useCallback(
  (params: Connection) => {
    setEdges((eds) => addEdge({
      ...params,
      animated: true,                        // 流动动画
      markerEnd: { type: MarkerType.ArrowClosed },  // 闭合箭头
      style: { stroke: '#3b82f6', strokeWidth: 2 }, // 蓝色线条
    }, eds));
  },
  [setEdges]
);

连线视觉设计

┌──────────┐                         ┌──────────┐
│ Node A   │─────● source ────────●── target ───│ Node B   │
│          │         \           /              │          │
│          │          \         /               │          │
│          │           \       /                │          │
│          │            ──────▶                 │          │
│          │         蓝色箭头 + 流动动画          │          │
└──────────┘                         └──────────┘

12.6 画布操作

12.6.1 缩放和平移

React Flow 内置缩放和平移支持,通过鼠标操作:

操作效果
鼠标滚轮以光标为中心缩放
Ctrl + 拖拽框选区域放大
鼠标拖拽空白区域平移画布
点击 Controls 按钮放大/缩小/适应视图
typescript
<ReactFlow
  fitView           // 初始时自动适应所有节点在视口内
  // ...
>
  <Controls />      // 内置缩放控件 (+/-/fit/lock)
</ReactFlow>

12.6.2 MiniMap 缩略图

MiniMap 提供画布的缩略预览,支持点击导航:

typescript
<MiniMap
  nodeColor={(node) => {
    return node.selected ? '#3b82f6' : '#475569';
  }}
  className="border border-border rounded-lg overflow-hidden"
/>

MiniMap 布局

┌─────────────────────────────┐
│                             │
│        主画布区域            │
│                             │
│    ┌─────────┐              │
│    │ MiniMap │              │  ← 右下角
│    │ 缩略图   │              │
│    │ ▓▓░░▓▓  │              │  ← 节点分布
│    │  ░▓▓░   │              │  ← 蓝色=选中节点
│    └─────────┘              │
└─────────────────────────────┘

12.6.3 背景网格

typescript
<Background gap={16} size={1} />
参数说明
gap16网格间距(像素)
size1点的大小(1=点阵,更大=网格线)

12.7 撤销/重做历史

12.7.1 历史存储

使用数组存储每次操作后的节点和边快照:

typescript
const [history, setHistory] = useState<{ nodes: Node[]; edges: Edge[] }[]>([]);
const [historyIndex, setHistoryIndex] = useState(-1);

// 保存当前状态到历史
const saveHistory = useCallback(() => {
  // 删除当前索引之后的所有历史(如果用户在中间状态做了新操作)
  const newHistory = history.slice(0, historyIndex + 1);
  newHistory.push({ nodes: [...nodes], edges: [...edges] });
  
  // 限制历史长度,防止内存增长
  if (newHistory.length > 50) newHistory.shift();
  
  setHistory(newHistory);
  setHistoryIndex(newHistory.length - 1);
}, [nodes, edges, history, historyIndex]);

12.7.2 防抖保存

直接监听 nodes/edges 变化会导致每次拖动都保存历史。使用防抖优化:

typescript
const saveHistoryRef = useRef(saveHistory);
useEffect(() => {
  saveHistoryRef.current = saveHistory;
}, [saveHistory]);

useEffect(() => {
  if (nodes.length > 0 || edges.length > 0) {
    const timer = setTimeout(() => {
      saveHistoryRef.current();
    }, 500);  // 500ms 防抖
    return () => clearTimeout(timer);
  }
}, [nodes.length, edges.length]);  // 只监听长度变化,而非内容

为什么监听长度而非内容

  • 拖动节点时 nodes 内容变化但长度不变,不需要保存历史
  • 只在添加/删除节点时保存,减少历史记录噪音

12.7.3 撤销和重做实现

typescript
const handleUndo = useCallback(() => {
  if (historyIndex > 0) {
    const newIndex = historyIndex - 1;
    const state = history[newIndex];
    setNodes(state.nodes);
    setEdges(state.edges);
    setHistoryIndex(newIndex);
  }
}, [history, historyIndex, setNodes, setEdges]);

const handleRedo = useCallback(() => {
  if (historyIndex < history.length - 1) {
    const newIndex = historyIndex + 1;
    const state = history[newIndex];
    setNodes(state.nodes);
    setEdges(state.edges);
    setHistoryIndex(newIndex);
  }
}, [history, historyIndex, setNodes, setEdges]);

历史操作模型

历史数组:
[State0] [State1] [State2] [State3]
  │        │        │        │
  └────────┴────────┴────────┘
       撤销/重做 游标

撤销: 游标左移,恢复前一个状态
重做: 游标右移,恢复后一个状态
新操作: 删除游标右侧所有状态,追加新状态

12.8 节点选择与编辑

12.8.1 节点选择

typescript
const onNodeClick = useCallback((_: React.MouseEvent, node: Node) => {
  setSelectedNode(node);
}, []);

const onPaneClick = useCallback(() => {
  setSelectedNode(null);  // 点击空白区域取消选择
}, []);

12.8.2 节点配置面板

选中节点后,右侧面板展示可编辑的配置项:

tsx
{selectedNode && (
  <div className="border-t border-border p-4 bg-background/50 overflow-y-auto max-h-96">
    <div className="flex items-center justify-between mb-4">
      <h3 className="font-semibold">节点配置</h3>
      <div className="flex gap-1">
        <button onClick={duplicateSelectedNode}>复制</button>
        <button onClick={deleteSelectedNode}>删除</button>
      </div>
    </div>
    
    {/* 显示名称 */}
    <input
      value={(selectedNode.data?.label as string) || ''}
      onChange={(e) => {
        setNodes((nds) =>
          nds.map((n) =>
            n.id === selectedNode.id
              ? { ...n, data: { ...n.data, label: e.target.value } }
              : n
          )
        );
      }}
    />
    
    {/* 节点描述 */}
    <textarea value={selectedNode.data?.description as string || ''} ... />
    
    {/* 输入/输出键配置 */}
    <input value={selectedNode.data?.inputKey as string || ''} ... />
    <input value={selectedNode.data?.outputKey as string || ''} ... />
    
    {/* 自定义 Prompt */}
    <textarea value={selectedNode.data?.prompt as string || ''} ... />
  </div>
)}

状态同步模式

修改输入框 ──► setNodes (更新画布数据)

                   ├──► ReactFlow 重新渲染节点

                   └──► setSelectedNode (同步选中节点引用)

                            └──► 配置面板显示最新值

12.8.3 复制与删除节点

typescript
const deleteSelectedNode = useCallback(() => {
  if (!selectedNode) return;
  setNodes((nds) => nds.filter((n) => n.id !== selectedNode.id));
  setEdges((eds) => eds.filter(
    (e) => e.source !== selectedNode.id && e.target !== selectedNode.id
  ));
  setSelectedNode(null);
}, [selectedNode, setNodes, setEdges]);

const duplicateSelectedNode = useCallback(() => {
  if (!selectedNode) return;
  const newNode: Node = {
    ...selectedNode,
    id: `node-${Date.now()}`,  // 新 ID
    position: { 
      x: selectedNode.position.x + 50, 
      y: selectedNode.position.y + 50  // 偏移 50px 避免重叠
    },
  };
  setNodes((nds) => nds.concat(newNode));
}, [selectedNode, setNodes]);

12.9 工作流验证

12.9.1 验证规则

保存前对工作流进行结构验证,确保可以正常执行:

typescript
const validateWorkflow = useCallback(() => {
  const errors: string[] = [];
  
  // 1. 名称验证
  if (!name.trim()) {
    errors.push('请输入工作流名称');
  }
  
  // 2. 节点数量验证
  if (nodes.length === 0) {
    errors.push('请至少添加一个节点');
  }
  
  // 3. 孤立节点检测
  if (nodes.length > 1) {
    const connectedNodes = new Set<string>();
    edges.forEach(e => {
      connectedNodes.add(e.source);
      connectedNodes.add(e.target);
    });
    
    const orphanNodes = nodes.filter(n => !connectedNodes.has(n.id));
    if (orphanNodes.length > 0) {
      errors.push(`发现 ${orphanNodes.length} 个孤立节点,请连接或删除`);
    }
  }
  
  setValidationErrors(errors);
  return errors.length === 0;
}, [name, nodes, edges]);

12.9.2 验证错误展示

tsx
{validationErrors.length > 0 && (
  <div className="mt-4 p-3 bg-red-500/10 border border-red-500/30 rounded-lg">
    <div className="flex items-center gap-2 text-red-500 mb-2">
      <AlertCircle className="w-4 h-4" />
      <span className="font-medium">发现问题</span>
    </div>
    <ul className="text-sm text-red-500 space-y-1">
      {validationErrors.map((err, i) => (
        <li key={i}>• {err}</li>
      ))}
    </ul>
  </div>
)}

12.9.3 验证场景分析

验证规则触发条件错误信息严重程度
名称为空name.trim() === ''请输入工作流名称阻塞保存
无节点nodes.length === 0请至少添加一个节点阻塞保存
孤立节点多节点但存在未连线节点发现 N 个孤立节点阻塞保存
环检测 (简化)TODO存在循环依赖阻塞保存

孤立节点检测算法

节点: [A, B, C, D]
边:   [A→B, B→C]

connectedNodes = {A, B, C}   (所有出现在边中的节点)
orphanNodes = [D]             (不在 connectedNodes 中的节点)
→ 错误: "发现 1 个孤立节点"

12.10 导入与导出

12.10.1 导出为 JSON 文件

typescript
const handleExport = useCallback(() => {
  const data = {
    name,
    description,
    nodes,
    edges,
    is_template: isTemplate ? 1 : 0,
    exportedAt: new Date().toISOString(),
  };
  
  const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
  const url = URL.createObjectURL(blob);
  const a = document.createElement('a');
  a.href = url;
  a.download = `${name || 'workflow'}-${Date.now()}.json`;
  a.click();
  URL.revokeObjectURL(url);  // 释放 Blob URL,防止内存泄漏
}, [name, description, nodes, edges, isTemplate]);

导出文件示例

json
{
  "name": "服务器CPU告警自动排查",
  "description": "当收到CPU告警时自动排查",
  "nodes": [
    {
      "id": "node-1680000001",
      "type": "agent",
      "position": { "x": 100, "y": 200 },
      "data": {
        "label": "日志分析Agent",
        "agentId": "agent-001",
        "avatar": "🤖",
        "description": "分析日志找出原因"
      }
    }
  ],
  "edges": [],
  "is_template": 0,
  "exportedAt": "2026-05-27T10:30:00.000Z"
}

12.10.2 从 JSON 文件导入

typescript
const fileInputRef = useRef<HTMLInputElement>(null);

const handleImport = useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
  const file = event.target.files?.[0];
  if (!file) return;

  const reader = new FileReader();
  reader.onload = (e) => {
    try {
      const data = JSON.parse(e.target?.result as string);
      if (data.nodes && data.edges) {
        setName(data.name || '导入的工作流');
        setDescription(data.description || '');
        setIsTemplate(data.is_template === 1);
        setNodes(data.nodes);
        setEdges(data.edges);
        alert('导入成功!');
      } else {
        alert('无效的工作流文件');
      }
    } catch {
      alert('导入失败:无效的JSON格式');
    }
  };
  reader.readAsText(file);
  event.target.value = '';  // 重置 file input,允许重复选择同一文件
}, [setNodes, setEdges]);

触发导入的隐藏文件输入

tsx
<input
  type="file"
  ref={fileInputRef}
  onChange={handleImport}
  accept=".json"
  className="hidden"
/>
<button onClick={() => fileInputRef.current?.click()}>
  <Upload /> 导入
</button>

12.11 工作流执行

12.11.1 执行入口

工作流编辑器本身不执行工作流,而是导航到任务页面触发执行:

typescript
const handleExecute = useCallback(() => {
  if (!id || id === 'new') {
    alert('请先保存工作流再执行');
    return;
  }
  navigate(`/tasks?workflowId=${id}`);
}, [id, navigate]);

12.11.2 执行流程概览

WorkflowEditor              任务页面              后端 API              Agent 执行
     │                        │                     │                     │
     │── navigate ───────────►│                     │                     │
     │   /tasks?workflowId=X  │                     │                     │
     │                        │── POST /api/tasks ──►│                     │
     │                        │   {workflowId: X}    │                     │
     │                        │                     │── load workflow ───►│
     │                        │                     │   (nodes + edges)    │
     │                        │                     │                     │
     │                        │                     │── execute node 1 ──►│
     │                        │◄── task:created ────│                     │
     │                        │   (WebSocket)        │                     │
     │                        │                     │── execute node 2 ──►│
     │                        │◄── task:progress ───│                     │
     │                        │   (WebSocket)        │                     │
     │                        │                     │── execute node N ──►│
     │                        │◄── task:completed ──│                     │
     │                        │   (WebSocket)        │                     │

12.12 画布操作与布局

12.12.1 清空画布

typescript
const handleClear = useCallback(() => {
  if (confirm('确定要清空画布吗?此操作不可撤销。')) {
    setNodes([]);
    setEdges([]);
    setSelectedNode(null);
  }
}, [setNodes, setEdges]);

使用 Panel 组件在画布左下角放置清空按钮:

tsx
<Panel position="bottom-left">
  <div className="bg-surface/95 backdrop-blur-sm p-2 rounded-lg border border-border">
    <button onClick={handleClear} className="text-red-500">
      <Trash2 /> 清空画布
    </button>
  </div>
</Panel>

12.12.2 操作提示面板

在画布顶部中央展示实时统计信息:

tsx
<Panel position="top-center">
  <div className="bg-surface/95 backdrop-blur-sm px-4 py-2 rounded-lg border border-border">
    <span>从左侧拖拽Agent到画布创建节点</span>
    <span>•</span>
    <span>{nodes.length} 个节点</span>
    <span>•</span>
    <span>{edges.length} 条连接</span>
  </div>
</Panel>

12.13 完整的 ReactFlow 配置

将所有组件组合在一起的最终配置:

typescript
<ReactFlow
  nodes={nodes}
  edges={edges}
  onNodesChange={onNodesChange}     // 节点移动/选中/删除
  onEdgesChange={onEdgesChange}     // 连线删除
  onConnect={onConnect}             // 新连线创建
  onInit={setReactFlowInstance}     // 实例初始化
  onDrop={onDrop}                   // 拖拽释放
  onDragOver={onDragOver}           // 拖拽经过
  onNodeClick={onNodeClick}         // 节点点击
  onPaneClick={onPaneClick}         // 空白区域点击
  nodeTypes={nodeTypes}             // 自定义节点类型
  proOptions={proOptions}           // 隐藏 attribution
  fitView                           // 自动适应视图
>
  <Background gap={16} size={1} />
  <Controls />
  <MiniMap
    nodeColor={(node) => node.selected ? '#3b82f6' : '#475569'}
  />
  <Panel position="top-center">
    {/* 操作提示 */}
  </Panel>
  <Panel position="bottom-left">
    {/* 清空画布 */}
  </Panel>
</ReactFlow>

12.14 工作流编辑器最佳实践

12.14.1 节点 ID 生成

方案优点缺点
Date.now()简单、几乎不冲突极快操作可能冲突
crypto.randomUUID()完全不冲突需要 Web Crypto API
递增计数器简单有序需要同步状态

项目使用 node-${Date.now()} 方案,在正常使用场景下足够安全。

12.14.2 状态更新模式

React Flow 推荐使用函数式更新,避免闭包问题:

typescript
// 正确:函数式更新
setNodes((nds) => nds.concat(newNode));
setEdges((eds) => eds.filter(e => e.source !== nodeId));

// 错误:直接使用当前状态
setNodes([...nodes, newNode]);  // 可能使用过时的 nodes

12.14.3 性能优化建议

场景优化策略
大量节点使用 useCallback 缓存事件处理器
频繁更新使用 useMemo 缓存派生数据
自定义节点使用 React.memo 避免不必要的重渲染
历史保存使用防抖,只监听关键状态变化

本章小结

本章系统讲解了使用 @xyflow/react 构建工作流编辑器的完整流程,核心要点包括:

  1. 自定义节点:通过 Handle 组件定义输入/输出连接点,通过 nodeTypes 映射注册自定义组件
  2. 拖拽交互:使用 HTML5 Drag & Drop API 配合 screenToFlowPosition 实现侧边栏拖拽创建
  3. 连线系统:通过 onConnectaddEdge 实现带箭头和动画的可视化连线
  4. 画布操作:内置 Controls、MiniMap、Background 组件提供缩放/导航/背景功能
  5. 撤销/重做:使用历史数组 + 游标模式,配合防抖优化减少无效记录
  6. 工作流验证:保存前检查名称、节点数量、孤立节点,确保工作流可执行
  7. 导入/导出:通过 Blob API 实现 JSON 文件下载,FileReader 实现文件上传解析
  8. 后端集成:JSON 序列化存储到 SQLite,查询时反序列化为前端可用格式

工作流编辑器是可视化编程的典型应用,React Flow 提供了强大的基础设施,业务逻辑围绕节点数据模型展开。

本章练习

基础练习

  1. 阅读 WorkflowEditor.tsx 中的 AgentNode 组件,解释 Handle 组件的 typeposition 属性的作用。如果把 Position.Left 改为 Position.Top,节点的外观和行为会有什么变化?

  2. onDrop 回调中,reactFlowInstance.screenToFlowPosition 的作用是什么?如果不进行坐标转换,直接将 event.clientX/Y 赋值给节点位置,会出现什么问题?

  3. 分析 saveHistory 函数中的 history.slice(0, historyIndex + 1) 操作。为什么需要在保存新状态前删除游标之后的历史?请举例说明。

进阶练习

  1. 当前的 validateWorkflow 函数只做了简化版的环检测(注释为 TODO)。请实现一个完整的环检测算法(可以使用深度优先搜索或拓扑排序),检测工作流中是否存在循环依赖。

  2. 项目使用 nodes.lengthedges.length 作为 useEffect 的依赖来决定是否保存历史。这个策略存在什么问题?请分析一个场景:拖动节点改变了位置但长度不变,此时是否应该保存历史?提出你的优化方案。

  3. 当前的撤销/重做使用完整快照模式(存储整个 nodes/edges 数组)。当工作流包含 100 个节点和 200 条边时,每次保存会产生大量数据。请设计一个差异(diff)模式的实现方案,只记录每次操作的变化量。

思考题

  1. 项目中的 Agent 节点通过 data.agentId 关联到后端 Agent。如果 Agent 被删除,工作流中引用该 Agent 的节点应该如何处理?请提出至少两种策略(静默处理、标记无效、自动修复)并比较优缺点。

  2. @xyflow/react 支持自定义 Edge 类型。如果要在连线上显示数据流转的实时状态(如"执行中"、"成功"、"失败"),你会如何设计和实现?请描述数据结构、组件设计和与 WebSocket 的集成方式。

  3. 当前工作流编辑器使用 React Flow 的单画布模式。如果需要支持"子工作流"(一个节点代表嵌套的子工作流,点击可下钻),你会如何设计数据结构和 UI 交互?请画出页面层级关系图和数据结构示例。

延伸阅读

基于 MPL-2.0 许可证发布