解密 Dify:使用 React Flow 打造生成式 AI 流程编排引擎底层交互和渲染逻辑

使用 React Flow 打造生成式 AI 流程编排引擎底层交互和渲染逻辑

本文将带你逐行解析一个基于 React Flow 的类似于Dify平台的流程编辑器渲染示例,重点讲解如何在节点和连线(Edge)中添加“+”按钮,一键动态生成节点以及交互效果实现。文中包含核心代码示例与思路剖析,帮助你快速理解流程编排如何渲染和交互。

✨ 技术栈

  • React(18+)
  • Ant Design
  • TypeScript
  • React Flow
  • SVG

主要实现思路

  • 使用了 React Flow 作为底层可视化库
  • 结合 Ant DesignDropdownMenu,在节点与连线上添加浮层按钮
  • 应用SVGpathforeignObject 元素实现自定义边
  • 通过React Flow 内置 API getOutgoersdfs算法找出所有需要移动的节点,防止当前链条有节点重叠
  • 支持两种“新增”场景:
    • 在节点右侧新增子节点
    • 在连线中途插入新节点并拆分原有连线

自定义节点(Node)实现

开始节点(StartNode)

const StartNode: React.FC<NodeProps> = ({ id, data }) => {
  const handleClick = ({ key, domEvent }) => {
    domEvent.stopPropagation();
    data.onAdd?.(id, key, menuItems.find(e => e.key === key)?.label, "node");
  };
  return (
    <div style={...}>
      开始节点
      <Handle type="source" position={Position.Right} style={{ background: "#555" }} />
      <Dropdown overlay={<Menu items={menuItems} onClick={handleClick} />} trigger={['click']}>
        <div style={{...}}><PlusOutlined /></div>
      </Dropdown>
    </div>
  );
};
  • 使用 暴露连线接口
  • 右侧添加圆形“+”图标,通过 Dropdown 弹出“中间节点 / 结束节点”选项
  • 只展示右侧Handle

中间 & 结束节点(MiddleNode / EndNode)

  • MiddleNode 同时有左右 Handle
  • EndNode 只展示左侧 Handle
  • 样式与 StartNode 类似

➰自定义连线(Edge)实现

const CustomEdge: React.FC<EdgeProps> = ({ id, sourceX, sourceY, targetX, targetY, sourcePosition, targetPosition, style, data }) => {
  const [path, labelX, labelY] = getBezierPath({ sourceX, sourceY, sourcePosition, targetX, targetY, targetPosition });
  const onClick = ({ key, domEvent }) => {
    domEvent.stopPropagation();
    data.onAdd?.(data.source, data.target, key, menuItems.find(e => e.key === key)?.label, "edge");
  };
  return (
    <>
      <path id={id} d={path} className="react-flow__edge-path" style={style} />
      <foreignObject x={labelX-15} y={labelY-15} width={30} height={30} requiredExtensions="http://www.w3.org/1999/xhtml">
        <Dropdown overlay={<Menu items={menuItems} onClick={onClick} />} trigger={['click']}>
          <div style={{...}}><PlusOutlined /></div>
        </Dropdown>
      </foreignObject>
    </>
  );
};
  • 通过 getBezierPath 计算曲线和中点坐标
  • 使用 SVG 中嵌入 HTML “+”按钮

⚙️动态新增节点与连线的核心逻辑

➕基本新增:addNodeToRight

const addNodeToRight = (sourceId, nodeType, label, clickType) => {
  const sourceNode = nodes.find(n => n.id === sourceId);
  if (!sourceNode) return;
  const newId = getId();
  const newNode = { id: newId, type: nodeType, position: {...}, data: { label: `${label}${newId}`, onAdd: addNodeToRight } };
  setNodes(nds => [...nds, newNode]);
  const newEdge = { id: `edge_${sourceId}_${newId}`, source: sourceId, target: newId, type: "custom", data: { onAdd: addNodeToRight } };
  setEdges(eds => addEdge(newEdge, eds));
};

在连线上插入节点:addNodeToLineRight

const addNodeToLineRight = (srcId, tgtId, nodeType, label) => {
  // 1. 在源节点后生成新节点,同 addNodeToRight
  // 2. 删除原有 src->tgt 连线
  // 3. 新增两条连线:src->new, new->tgt
  // 4. 调整新节点所在“链”之后所有节点的位置(findLineNode + updateLineNode)
};

完整代码示例

import React, { useCallback, useEffect, useRef, useState } from "react";
import ReactFlow, {
  Background,
  Controls,
  MiniMap,
  addEdge,
  useNodesState,
  useEdgesState,
  Connection,
  Edge,
  Node,
  Handle,
  Position,
  NodeProps,
  NodeTypes,
  MarkerType,
  getBezierPath,
  EdgeTypes,
  EdgeProps,
  getOutgoers,
  getIncomers,
} from "reactflow";
import { Dropdown, Menu } from "antd";
import { PlusOutlined } from "@ant-design/icons";
import "reactflow/dist/style.css";
const menuItems = [
  {
    key: "middle",
    label: "中间节点",
  },
  {
    key: "end",
    label: "结束节点",
  },
];
// --- Custom Edge Component with Add Button at Midpoint ---
const CustomEdge: React.FC<EdgeProps> = ({
  id,
  sourceX,
  sourceY,
  targetX,
  targetY,
  sourcePosition,
  targetPosition,
  style,
  source,
  target,
  data,
}) => {
  const [edgePath] = getBezierPath({
    sourceX,
    sourceY,
    sourcePosition,
    targetX,
    targetY,
    targetPosition,
  });

  const [, labelX, labelY] = getBezierPath({
    sourceX,
    sourceY,
    sourcePosition,
    targetX,
    targetY,
    targetPosition,
  });
  const menuItems = [
    { key: "middle", label: "中间节点" },
    { key: "end", label: "结束节点" },
  ];
  const handleClick = ({ item, key, keyPath, domEvent }) => {
    const labelObj = menuItems.find((e) => e.key === key);
    domEvent.stopPropagation();
    data.onAdd?.(source, target, key, labelObj?.label, "edge");
  };
  const menu = <Menu items={menuItems} onClick={handleClick} />;

  return (
    <>
      <path
        id={id}
        style={style}
        className="react-flow__edge-path"
        d={edgePath}
      />
      <foreignObject
        width={30}
        height={30}
        x={labelX - 15}
        y={labelY - 15}
        requiredExtensions="http://www.w3.org/1999/xhtml"
      >
        <Dropdown key="edge" overlay={menu} trigger={["click"]}>
          <div
            style={{
              width: 30,
              height: 30,
              borderRadius: "50%",
              backgroundColor: "#1890ff",
              color: "#fff",
              display: "flex",
              alignItems: "center",
              justifyContent: "center",
              cursor: "pointer",
              boxShadow: "0 2px 6px rgba(0,0,0,0.2)",
            }}
          >
            <PlusOutlined />
          </div>
        </Dropdown>
      </foreignObject>
    </>
  );
};

const StartNode: React.FC<NodeProps> = ({ id, data }) => {
  const handleClick = ({ item, key, keyPath, domEvent }) => {
    const labelObj = menuItems.find((e) => e.key === key);
    domEvent.stopPropagation();
    data.onAdd?.(id, key, labelObj?.label, "node");
  };
  const menu = <Menu items={menuItems} onClick={handleClick} />;

  return (
    <div
      style={{
        position: "relative",
        padding: 10,
        background: "#fff",
        border: "1px solid #ddd",
        borderRadius: 4,
        color: "black",
      }}
    >
      开始节点
      <Handle
        type="source"
        position={Position.Right}
        id="right"
        style={{ background: "#555" }}
      />
      <Dropdown key="StartNode" overlay={menu} trigger={["click"]}>
        <div
          style={{
            width: 24,
            height: 24,
            borderRadius: "50%",
            backgroundColor: "#1890ff",
            color: "#fff",
            display: "flex",
            alignItems: "center",
            justifyContent: "center",
            position: "absolute",
            right: -14,
            top: "50%",
            transform: "translateY(-50%)",
            boxShadow: "0 2px 6px rgba(0, 0, 0, 0.2)",
            cursor: "pointer",
            zIndex: 10,
          }}
        >
          <PlusOutlined style={{ fontSize: 12 }} />
        </div>
      </Dropdown>
    </div>
  );
};

const EndNode: React.FC<NodeProps> = ({ id, data }) => {
  return (
    <div
      style={{
        position: "relative",
        padding: 10,
        background: "#fff",
        border: "1px solid #ddd",
        borderRadius: 4,
        color: "black",
      }}
    >
      {data.label}
      <Handle
        type="target"
        position={Position.Left}
        id="left"
        style={{ background: "#555" }}
      />
    </div>
  );
};
const MiddleNode: React.FC<NodeProps> = ({ id, data }) => {
  const handleClick = ({ item, key, keyPath, domEvent }) => {
    const labelObj = menuItems.find((e) => e.key === key);
    domEvent.stopPropagation();
    data.onAdd?.(id, key, labelObj?.label, "node");
  };
  const menu = <Menu items={menuItems} onClick={handleClick} />;
  return (
    <div
      style={{
        position: "relative",
        padding: 10,
        background: "#fff",
        border: "1px solid #ddd",
        borderRadius: 4,
        color: "black",
      }}
    >
      {data.label}
      <Handle
        type="source"
        position={Position.Right}
        id="right"
        style={{ background: "#555" }}
      />
      <Handle
        type="target"
        position={Position.Left}
        id="right"
        style={{ background: "#555" }}
      />
      <Dropdown key="MiddleNode" overlay={menu} trigger={["click"]}>
        <div
          style={{
            width: 24,
            height: 24,
            borderRadius: "50%",
            backgroundColor: "#1890ff",
            color: "#fff",
            display: "flex",
            alignItems: "center",
            justifyContent: "center",
            position: "absolute",
            right: -14,
            top: "50%",
            transform: "translateY(-50%)",
            boxShadow: "0 2px 6px rgba(0, 0, 0, 0.2)",
            cursor: "pointer",
            zIndex: 10,
          }}
        >
          <PlusOutlined style={{ fontSize: 12 }} />
        </div>
      </Dropdown>
    </div>
  );
};

const nodeTypes: NodeTypes = {
  start: StartNode,
  end: EndNode,
  middle: MiddleNode,
};
const edgeTypes: EdgeTypes = { custom: CustomEdge };

const initialNodes: Node[] = [
  {
    id: "1",
    type: "start",
    data: { onSelect: () => {} },
    position: { x: 100, y: 100 },
  },
];

const initialEdges: Edge[] = [];

let id = 1;
const getId = () => `node_${++id}`;

const FlowDemo: React.FC = () => {
  const lastNodeIdRef = useRef<string | null>("0");
  // const [lineNode, setLineNode] = useState([]);
  const [nodes, setNodes, onNodesChange] = useNodesState<Node[]>(initialNodes);
  const [edges, setEdges, onEdgesChange] = useEdgesState<Edge[]>(
    initialEdges.map((e) => ({ ...e, type: "custom" }))
  );

  const onConnect = useCallback((params: Edge | Connection) => {
    setEdges((eds) => addEdge(params, eds));
  }, []);
  const updateEdge = (edgeId: string, edge: Edge) => {
    setEdges((eds) => eds.map((e) => (e.id === edgeId ? edge : e)));
  };

  const updateLineNode = (edgeIds: string[]) => {
    setNodes((nds) =>
      nds.map((e) => {
        if (edgeIds.includes(e.id)) {
          return {
            ...e,
            position: {
              x: e.position.x + 400,
              y: e.position.y,
            },
          };
        } else return e;
      })
    );
  };
  const addNodeToRight = useCallback(
    (
      sourceNodeId: string,
      nodeType: string,
      label: string,
      clickType: string
    ) => {
      const sourceNode = nodes.find((n) => n.id === sourceNodeId);

      const sourceEdge = edges.find((e) => e.source === sourceNodeId);
      const outgoers = getOutgoers(sourceNode, nodes, edges);
      //寻找已有出边节点
      // const findTargeNodes = edges
      // .reduce((prev, current, index) => {
      //   if (current.source === sourceNodeId) {
      //     const currentNode = nodes.find((n) => n.id === current.target);
      //     prev.push(currentNode);
      //   } else prev = prev;
      //   return prev;
      // }, [])
      const findTargeNodes =outgoers
      .sort((a, b) => { 
        const A = a.position.y;
        const B = b.position.y;
        return B - A;
      });
      
      const hasChildren = findTargeNodes.length > 0;
      if (!sourceNode) return;
      const newId = getId();
      //保证新节点 往下移 不与已有的出边节点 重叠
      const newNodePositionY = !hasChildren
        ? sourceNode.position.y
        : findTargeNodes[0].position.y + 120;
      const newNode = {
        id: newId,
        type: nodeType,
        position: {
          x: sourceNode.position.x + 400,
          y: newNodePositionY,
        },
        data: {
          label: `${label}${newId}`,
          nodeType,
          onAdd: addNodeToRight,
        },
      };

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

      const newEdge = {
        id: `edge_${sourceNodeId}_${newId}`,
        type: "custom",
        source: sourceNodeId,
        target: newId,
        data: { onAdd: addNodeToRight },
      };

      setEdges((eds) => addEdge(newEdge, eds));
    },
    [nodes, setNodes, setEdges, edges]
  );
  const findLineNode = (
    nodes: Node[],
    edges: Edge[],
    startNodeId: string
  ) => {
    const visited = new Set();
    //目标是从 触发事件边的target节点开始 所以 得包含target节点 本身
    const result:string[] = [startNodeId];
  
    function dfs(currentId:string) {
      if (visited.has(currentId)) return;
      visited.add(currentId);
  
      // 拿到当前节点对象
      const node = nodes.find(n => n.id === currentId);
      if (!node) return;
  
      // 找所有直接出边指向的下游节点
      const outgoers = getOutgoers(node, nodes, edges);
       const incomers = getIncomers(node, nodes, edges);
      outgoers.forEach(n => {
        if (!visited.has(n.id)) {
          result.push(n.id);
          dfs(n.id);
        }
      });
    }
  
    dfs(startNodeId);
    return result;
  };
  const addNodeToLineRight = useCallback(
    (
      sourceNodeId: string,
      targetNodeId: string,
      nodeType: string,
      label: string,
      clickType: string
    ) => {
      const sourceNode = nodes.find((n) => n.id === sourceNodeId);

      const sourceEdge = edges.find((e) => e.source === sourceNodeId);
      const targetNode = nodes.find((n) => n.id === targetNodeId);

      if (!sourceNode) return;

      const newId = getId();
      const newNode = {
        id: newId,
        type: nodeType,
        position: {
          x: sourceNode.position.x + 400,
          y: targetNode ? targetNode.position.y : sourceNode.position.y,
        },
        data: {
          label: `${label}${newId}`,
          nodeType,
          onAdd: addNodeToRight,
        },
      };

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

      const newEdge = {
        id: `edge_${sourceNodeId}_${newId}`,
        type: "custom",
        source: sourceNodeId,
        target: newId,
        data: { onAdd: addNodeToLineRight },
      };
      const newSourceEdge = {
        id: `edge_${newId}_${targetNodeId}`,
        type: "custom",
        source: newId,
        target: targetNodeId,
        data: { onAdd: addNodeToLineRight },
      };
      //删掉原来的边
      setEdges((eds) => {
       return eds.filter(e=>e.id!==`edge_${sourceNodeId}_${targetNodeId}`)
      });
      //新增 节点的出边和入边
      setEdges((eds) => addEdge(newEdge, eds));
      setEdges((eds) => addEdge(newSourceEdge, eds));

      const lineNode = findLineNode(nodes, edges,targetNodeId);
      updateLineNode(lineNode);
    },
    [edges, nodes, setNodes, updateEdge, setEdges, updateLineNode]
  );
  return (
    <div style={{ width: "100vw", height: "100vh" }}>
      <ReactFlow
        nodes={nodes.map((n) => ({
          ...n,
          data: { ...n.data, onAdd: addNodeToRight },
        }))}
        edges={edges.map((e) => ({
          ...e,
          data: { ...e.data, onAdd: addNodeToLineRight },
        }))}
        onNodesChange={onNodesChange}
        onEdgesChange={onEdgesChange}
        onConnect={onConnect}
        fitView
        nodeTypes={nodeTypes}
        edgeTypes={edgeTypes}
      >
        <MiniMap />
        <Controls />
        <Background variant="dots" gap={12} size={1} />
      </ReactFlow>
    </div>
  );
};

export default FlowDemo;


效果展示

  • 节点和连线处都可一键新增自定义节点
  • 节点处新增列式堆叠
  • 连线处新增水平式向后移动

总结

  • React Flow的底层数据协议为有向图,根据这个逻辑去理解节点的交互逻辑会更简单一点。

参考资料

  • Ant Design 官方文档
  • React 官方文档
  • MDN SVG 教程
  • Dify.AI

如果你有其他问题,欢迎留言讨论,或者查看我的其他文章。希望本篇文章对你有所帮助!✨

你可能感兴趣的:(react.js,交互,前端)