Ai Agent编排多叉树下的位置冲突解决方案

520 阅读2分钟

背景

在Ai Agent的项目开发中,为了编排画图的美观,采用了reactflow实现,可以自定义节点和边,但是没有提供免费的布局优化算法,笔者看了下付费情况,开通会员 $269/每月

思来想去决定自己开发,当然不是心疼会员费,而是自己开发更有性价比。

要饭三连.webp

不浪费时间,上干货

分析问题

先看看reactflow的节点元素

interface Node {
    id: string;
    data: Record<string, any>;
    position: {
        x: number;
        y: number;
    }
}

我们发现,这里是要求提前写好节点的位置!

我们的终极目标是实现类似下图的多叉树,root节点为初始节点,只有A一个入口节点

mindmap
      Root
          A
            B
             B1
             B2
            C
             C1
             C2
             C3
            D
             D1
             D2
             D3
    

首先笔者采用的从左到右的布局方式,理由如下:

  • PC一般屏幕宽度大于高度
  • PC上的用户习惯是横向为主

根据上面的思考,最终的布局大致为下图所示

image.png

布局确定后,我们发现编排的几种方式,难度由浅到深

  1. 横向类似链表
  2. 只有最后一层节点为多节点
  3. 随机每层都可能为多节点

类似链表布局的实现

这个很简单了,直接平铺节点:

  1. 我们只需要确定初始节点的坐标:x和y,确定每个节点的宽度:比如为160px
  2. 每次插入节点的时候,x在父节点的基础上+节点宽度:160+ 一段距离:80,y用父节点的y

这样解决了链表的布局

import { produce } from "immer";

const nodeWidth: number = 160;

const rootNode: Node = {
    id: 'root',
    data: {
        name: 'root'
    },
    position: {
        x: 100,
        y: 100
    }
};

const nodes: Node[] = [rootNode];

function addNode(nodes: Node[], parentNode: Node): Node[] {
    const newNode: Node = {
        id: 'node1',
        data: {
            name: 'node1'
        },
        position: {
            x: parentNode.x + nodeWidth + 80,
            y: parentNode.y
        }
    };
    
    const newNodes: Node[] = produce(nodes, (state: Node[]) => {
        return [...state, newNode];
    });
    
    return newNodes;
}

最终效果如下:

image.png

最后一层为多节点的情况

这种和链表的区别是,需要解决最后一层的布局:

  1. 除了最后一层仍然使用之前的布局方案,初始节点默认level层级为0
  2. 最后一层的元素,x在父节点的基础上+节点宽度:160+ 一段距离:80,第一个子节点的y用父节点的y,其余子节点 y = 父节点的y + 最后一层数组下标 * (节点高度:80 + 一段距离:40)
  3. 数组下标可以在每次插入节点标记level=父节点level+1
import { produce } from "immer";

const nodeWidth: number = 160;
const nodeHeight: number = 80;

const rootNode: Node = {
    id: 'root',
    data: {
        name: 'root',
        level: 0
    },
    position: {
        x: 100,
        y: 100
    }
};

const nodes: Node[] = [rootNode];

function addNode(nodes: Node[], parentNode: Node): Node[] {
    // 假想可能每层都为末尾节点
    const lastChildren: Node[] = nodes.filter((node: Node) => {
        return node.data.level = parentNode.level + 1;
    });
    const i = lastChildren.length;
    
    const newNode: Node = {
        id: 'node1',
        data: {
            name: 'node1',
            level: parentNode.level + 1,
        },
        position: {
            x: parentNode.x + nodeWidth + 80,
            y: parentNode.y + i * (nodeHeight + 40)
        }
    };
    
    const newNodes: Node[] = produce(nodes, (state: Node[]) => {
        return [...state, newNode];
    });
    
    return newNodes;
}

最终效果如下:

image.png

随机每层都是多节点

需要引入多叉树的数据结构了,帮我们记录整个编排的结构

class CustomTree {
  id: number;
  parentId: number;
  level: number;
  chlidren: CustomTree[];

  constructor(
    data: any,
    parentId: string | undefined = undefined,
    children: any[] = []
  ) {
    this.id = `${Math.random().toString(16).slice(2, 10)}`;
    this.data = data;
    this.parentId = parentId;
    this.level = 0;
    this.children = children;
  }

  addNode(id: string, node: CustomTree) {
      // TODO
  }

  findNodeById(id: string): CustomTree | null {
      // TODO
  }
}

整体思路:

  1. 每次创建节点的时候同步创建一个多叉树节点,在多叉树中保存相关状态
  2. 用二维数组levelArr描述整个布局方案,第一层表示每一列,第二层表示每一列的每一行
  3. level初始为0,每次注入节点的时候,子节点level=父节点level+1
  4. 根据levelArr的某一层下标给 x 添加 节点宽度 * 某一层下标,根据某一层的数组长度给 y 添加 节点高度 * 某一层的数组长度
import { produce } from "immer";

const nodeWidth: number = 160;
const nodeHeight: number = 80;

const rootNode: Node = {
    id: 'root',
    data: {
        name: 'root',
        level: 0
    },
    position: {
        x: 100,
        y: 100
    }
};

const nodes: Node[] = [rootNode];

function layoutSort(nodes: Node[], tree: CustomTree): Node[] {
  const levelArr: string[][] = [];
  const newNodes = produce(nodes, (state) => {
    state.forEach((node) => {
      const item = tree.findNodeById(node.id);

      if (item) {
        const index = item.level;
        if (Array.isArray(levelArr[index])) {
          levelArr[index].push(node.id);
        } else {
          levelArr[index] = [node.id];
        }

        node.position.x = rootNode.x + nodeWidth * index;
        node.position.y = rootNode.y + nodeHeight * levelArr[index].length;
      }
    });
  });

  return newNodes;
}

最终效果如下:

image.png

优化

看起来好像解决问题了,但是如果上图所示,在最后一层节点靠下方的节点 后面继续追加节点会咋样?

image.png

因为在levelArr中,新的一层没有元素,所以 当前节点的 y又被重置为初始节点的 y 了,我们期望新的子节点 y坐标和父节点的 y坐标继续保持一致

优化如下:

function layoutSort(nodes: Node[], tree: CustomTree): Node[] {
  const levelArr: string[][] = [];
  const newNodes = produce(nodes, (state) => {
    state.forEach((node) => {
      const item = tree.findNodeById(node.id);

      if (item) {
        const index = item.level;
        levelArr[index] = Array.isArray(levelArr[index]) ? levelArr[index] : [];

        // 是否需要重置 y坐标
        const parentItem = tree.findNodeById(item.parentId || "");

        if (parentItem) {
          const parentIndex = levelArr[parentItem?.level].indexOf(
            parentItem.id
          );
          const isNeedResetPos =
            index > 1 && parentIndex > levelArr[index].length;

          const repeatArr = isNeedResetPos
            ? Array(
                parentIndex - levelArr[index].length > 0
                  ? parentIndex - levelArr[index].length
                  : 0
              ).map((v: string, i: number) => i)
            : [];
          levelArr[index] = [...levelArr[index], ...repeatArr, node.id];
        } else {
          levelArr[index] = [...levelArr[index], node.id];
        }

        node.position.x = 100 + agentOffsetWidth * index;
        node.position.y = 100 + agentOffsetHeight * levelArr[index].length;
      }
    });
  });
  

  return newNodes;
}

最终效果如下:

image.png

总结

试问还能优化吗???

能,布局算法只是解决尽可能不冲突,而真正的布局优化是用户手动去拖拽生成的位置

如果要真正达到用户心目中满意的程度,在用户未操作前用布局算法实现,用户不满意接着拖拽后存储每次拖拽的位置,把每个节点的位置都记录下,下一次的布局直接使用用户手动调整的位置,而不是布局算法了!

以上,欢迎留言和评论