从「流程编排×AI生图小工具」看LogicFlow的高可定制性

1,449 阅读11分钟

引言

在AI逐渐成为潮流的现在,各行各业的开发者都在积极探索如何将AI与自身项目有机结合。LogicFlow 是一款高度可定制的流程图编辑框架,支持开发者根据具体需求构建专属的流程图编排应用,作为维护者的我们也在思考和探索着流程图在AI领域的可能性。

本次,我们将分享近期探索实现的一个“流程编排 × AI生图”的小工具,带大家了解其功能背后 LogicFlow 高可定制性所提供的可能性。

先一起看看效果:

demo-preview.gif

项目源码传送门:github.com/DymoneLewis… 大家克隆下来起本地服务可以直接试用~

一起来实现这个「流程编排 × AI生图」工具

背景

说起AI能力,最早出现在我们眼前的除了 ChatGPT 这样沟通工具就是一些 AI 生成的图片了,再加上后面看到了 Open AI 发布的 Sora 的预告,让编者突然产生了个流程图和 AI 结合使用的想法

「 有没有可能做这样一个工具: 用户在它上面像画流程图一样拖拖拽拽,通过画出一个流程图作为一个场景/故事的描述,流程引擎执行这个流程图,把内容转化为prompt,再给到AI,生成一个故事性的图片/视频呢? 」

于是编者优先尝试了文生视频,去找了市面上的一些可以 文生视频 且 支持API调用 的AI服务尝试生成 LogicFlow运行原理 为主题的视频,编者接触到的AI文生视频应用大概分成下面这三类,但是结果不是很尽人意:

  • 文案+固有视频片段生成视频:表现上像是根据文案描述,从现有视频内容中找到有一些关联的视频片段返回(代表:Pictory和inVideo)
  • 根据文案创作视频:根据文案描述生成视频内容,但生成的质量参差不齐,没有办法避免有扭曲的地方(代表:Luma AI、可灵 AI)
  • 文案+模板生成视频:需要先选择一个模板,再根据文案描述,生成一个ppt视频(代表:Lumen 5)

这里小小地放一个编者耗尽所有力气用Luma + Pictory做出来的一个视频片段:

LLLLOGICFLOW-smaller-high.gif

再加上探索视频的过程中看到现在市面上AI图片的质量看起来还不错,所以我们退求其次,有了这次实践。

功能设计

AI生成图片有一个必要条件是 prompt(提示词)

我们都知道,一个句子主要由 主语 + 谓语 + 宾语 + 定语/状语/补语/表语 组成。那一个prompt都由什么组成呢?chatgpt是这样说的:

如何写作-11-20-2024_11_11_AM.png

不同的AI平台提供的prompt使用指南则更具体一些:

通义万相文字作画—使用指南-·-钉钉文档-11-19-2024_01_17_PM.png

通义万相提示词指南截图

可灵-AI-使用指南-图片生成-轻雀文档-11-19-2024_01_19_PM.png

可灵AI提示词指南截图

大概懂了,就是要描述什么人在什么场景下做什么,所以我们要传给AI的内容大概可以符合这个公式:

「 某个场景 + 某个主体 + 在做某个动作 」

再根据公式反推得出这个工具里工具需要三种类型的节点:描述场景的节点描述主体的节点描述动作节点,后续流程图转换成文本时需要根据不同的节点做不同的转换处理,并按顺序组装起来。 在页面上,这个工具大概会分为三个模块:画布、左侧拖拽面板 和 右侧图片生成面板。

logicflow-vue2-demo-11-18-2024_11_50_AM.png

使用流程大概是这样:

  1. 从左侧拖出各个图片描述的节点到画布上
  2. 在画布上给各个节点填写描述内容,通过连线标明节点关系
  3. 然后在生成面板上点击生成按钮,生成图片描述和图片内容

实现思路

lf-ai-tool-plan.png

创建画布&引入拖拽面板

首先我们创建一个容器DOM、创建一个LogicFlow实例, 并引入拖拽面板插件

<template>
  <div id="app">
    <div class="lf-container" ref="container"></div>
  </div>
</template>

<script>
import LogicFlow from "@logicflow/core";
import { DndPanel } from "@logicflow/extension";
// ...
  $_initLf() {
    // 画布配置
    const lf = new LogicFlow({
      grid: {
        enabled: true,
        size: 20,
      },
      allowResize: true, // 全局的节点缩放配置
      allowRotate: true, // 全局的节点旋转配置
      keyboard: {
        enabled: true,
      },
      edgeType: "bezier",
      container: this.$refs.container,
      plugins: [DndPanel],
    });
    // ...
    this.lf = lf;
    this.lf.render({});
  }

在这个阶段我们就有一张空白画布和一个空白的拖拽面板了

logicflow-vue2-demo-11-19-2024_11_47_AM.png

然后我们来增加拖拽项配置,在这个工具里主要有三个拖拽项:场景、主体和行为,三个拖拽项分别对应下面会创建的三类自定义节点: 场景节点(sceneNode)、 主体节点(subjectNode)、 行为节点(behviorNode)。

LogicFlow拖拽面板的拖拽项配置的定义是这样的:

type PatternItem = {
  type?: string; // 拖拽面板对应的节点类型
  text?: string; // 拖拽生成节点上的文本内容
  label?: string; // 拖拽项标签
  icon?: string; // 拖拽项图标
  className?: string; // 拖拽项类名
  properties?: object; // 拖拽生成节点的 properties
  callback?: () => void; // 鼠标按下拖拽项后触发的回调
}

所以我们可以为每个拖拽项事先写好配置和节点类型的映射,遍历注册节点。

在这个项目中,编者单独开辟了一个config.js文件用于存储静态的配置,现在先向config中增加映射

// config.js
export const nodeType = [
  // 节点类型
  "sceneNode",
  "subjectNode",
  "behaviorNode",
];

export const nodeTypeZhMap = {
  // 节点类型转换中文
  sceneNode: "场景",
  subjectNode: "主体",
  behaviorNode: "行为",
};

export const nodeTypeIcon = {
  // 不同的节点类型在拖拽面板中的icon映射
  sceneNode: '...',
  subjectNode: '...',
  behaviorNode: '...',
};

export const nodeTypeProperties = {
  // 节点默认样式映射
  sceneNode: {
    width: 240, // 节点宽度
    height: 240, // 节点高度
    style: { // 节点样式
      background: "#fff",
      border: "3px solid #f57170",
    },
  },
  subjectNode: {...},
  behaviorNode: {...},
};

 

// App.vue
import {
  nodeType,
  nodeTypeZhMap,
  nodeTypeIcon,
  nodeTypeProperties,
} from "./config";

getNodePatternConfig(type) {
  // 生成节点在拖拽面板上的映射
  return {
    type,
    label: nodeTypeZhMap[type],
    icon: nodeTypeIcon[type],
    properties: nodeTypeProperties[type],
  };
},

$_initLf() {
  // 画布配置
  const lf = new LogicFlow({...});

  const pattern = [];
  nodeType.forEach((type) => {
    pattern.push(this.getNodePatternConfig(type));
  });
  lf.setPatternItems(pattern);
  this.lf = lf;
  this.lf.render({});
  // ...
},

这时我们就能获得带拖拽面板的画布了

logicflow-vue2-demo-11-19-2024_12_03_PM.png

制作自定义组件节点

组件定义

我们定义一下这个组件,因为三个节点看起来比较相似,所以编者抽象了一个这样的配置化组件模块:

info-card-node.png

整个模块由一个展示组件 InfoCardNode、一个静态配置文件 config 和 LogicFlow 实例组成。

组件 InfoCardNode 的设计大概是这样:

  • 模板内容主要由一个标题和一个动态表单组成:
    • 标题内容根据实际的节点类型展示不同内容,判断展示内容的配置从config中读取
    • 动态表单中展示的表单项则是根据静态配置 nodeFormConfig 中不同节点类型设置的表单内容生成
  • 模板之外设置了一些辅助运转的逻辑,例如:
    • 表单数据变化时更新节点properties字段的属性
    • 组件容器获焦和失焦时更新快捷键禁用状态

最后我们就能获得一个如下实现的组件

<template>
  <div :style="nodeStyle" class="lf-info-card-node">
    <el-divider content-position="center">
      {{ nodeTypeZhMap[type] }}描述
    </el-divider>
    <el-form>
      <el-form-item :label="item.label" v-for="item in config" :key="item.key">
        <el-select
          size="mini"
          v-model="descData[item.key]"
          placeholder="请选择"
          @focus="onInfoCardNodeFocus"
          @blur="onInfoCardNodeBlur"
        >
          <el-option
            v-for="item in item.options"
            :key="item.label"
            :label="item.label"
            :value="item.label"
          />
        </el-select>
      </el-form-item>
    </el-form>
  </div>
</template>

<script>
import { keys, isNil } from "lodash-es";
import { nodeFormConfig, nodeTypeZhMap } from "./config";


export default {
  name: "InfoCardNode",
  inject: ["getNode", "getGraph"],
  data() {
    return {
      nodeTypeZhMap,
      keys,
      config: {},
      descData: {},
      node: null,
      type: "",
      graph: null,
    };
  },
  computed: {
    nodeStyle() {
      if (isNil(this.$data.node)) return {};
      return this.$data.node.getNodeStyle();
    },
  },
  watch: {
    descData: {
      deep: true,
      handler(newVal) {
        this.$data.node.setProperties(newVal);
      },
    },
  },
  mounted() {
    const { type } = this.getNode();
    this.$data.config = nodeFormConfig[type];
    this.$data.type = type;
    this.$data.node = this.getNode();
    this.$data.graph = this.getGraph();
  },
  methods: {
    onInfoCardNodeFocus() {
      this.$data.graph.eventCenter.emit("info-card-select-focus");
    },
    onInfoCardNodeBlur() {
      this.$data.graph.eventCenter.emit("info-card-select-blur");
    },
  },
};
</script>


<style>
.lf-info-card-node {
  width: calc(90% - 12px);
  height: calc(96% - 20px);
  background: #fff;
  /* margin: 4px; */
  padding: 10px;
  border-radius: 10px;
}
.lf-info-card-nod-title {
  margin-bottom: 10px;
}


.el-form-item__label {
  line-height: 1;
}
</style>

Model定义

Model定义时主要重写了两个方法:

  1. getDefaultAnchor方法 → 将节点锚点从默认上下左右4个锚点改为展示1个或2个锚点
  2. getConnectedSourceRules方法 → 限制节点和节点的连接关系,保证基础的逻辑连贯性,编者在这里设置的限制规则是「场景节点只能连接主体节点,主体节点只能连接行为节点」
// 这里以主题节点的实现为例
export class SubjectNodeModel extends VueNodeModel {
  getDefaultAnchor() {
    const { x, y, id, width, height } = this;
    const anchors = [ // 定义锚点为节点左右各一个
      {
        x: x + width / 2,
        y: y + height / 4,
        id: `${id}_right`,
        type: "right",
      },
      {
        x: x - width / 2,
        y: y,
        id: `${id}_left`,
        type: "left",
      },
    ];
    return anchors;
  }
  getConnectedSourceRules() { // 设置主体节点的连线规则
    const rules = super.getConnectedSourceRules();
    const behaviorNodeOnlyAsTarget = {
      message: "主体节点的下一个节点只能是行为节点",
      validate: (source, target) => {
        let isValid = true;
        if (target.type !== "behaviorNode") {
          isValid = false;
        }
        return isValid;
      },
    };
    const onlyCanAddOneTarget = {
      message: "一个主体节点只能连接一个行为节点",
      validate: (source) => {
        let isValid = true;
        if (source.outgoing.nodes.length >= 1) {
          isValid = false;
        }
        return isValid;
      },
    };
    assign(rules, [behaviorNodeOnlyAsTarget, onlyCanAddOneTarget]);
    return rules;
  }
}

注册自定义vue节点

最后将组件在画布组件里引入,再使用@logicflow/vue-node-registry包中的register方法注册节点即可在画布中展示 场景节点、主体节点和行为节点了。

const lf = new LogicFlow({...});
// 因为nodeType在config文件中声明过,所以直接引过来遍历注册节点了
nodeType.forEach((type) => {
  register(
    {
      type: type,
      component: InfoCardNode,
      model: nodeType2Model(type),
    },
    lf
  );
});

到这里我们就能实现拖拽出指定的节点啦

录屏2024-11-19 12.49.22.gif

制作自定义插件

现在我们来制作图片生成面板模块,这个模块将以插件的形式加到画布上。

这个模块核心的运转流程大概是这样:

plugin-running-flow.png

因此我们需要实现以下几个功能:

  1. 操作面板
  2. 图片生成
  3. 图片下载 首先我们把插件类的架子搭好
export class ImgGenerator {
  static pluginName = "imgGenerator";
  constructor({ lf }) {}


  render(lf, domContainer) {}


 // 其他工具函数的定义...

  destroy() {
    if (
      this.domContainer &&
      this.panelElement &&
      this.domContainer.contains(this.panelElement)
    ) {
      this.vueInstance.$destroy();
      this.domContainer.removeChild(this.panelElement);
    }
  }
}

操作面板

操作面板组件的实现和上面自定义组件类似,这里编者不再赘述。但可能有的朋友会比较困惑如何把操作面板组件挂到画布页面上,其实就跟上面自定义框架组件的写法类似,大概就是:

vue-ins-mount-to-dom.png

  render(lf, domContainer) {
    // 这是前文讲插件包时提到的render函数
    // 它接收的第二个参数是工具层DOM
    // 因此在这里我们可以直接挂上
    const panelElement = document.createElement("div"); // 创建挂载vue实例的DOM
    const panelContainer = document.createElement("div"); // 创建一个容器DOM
    panelContainer.className = "lf-generate-panel";
    panelContainer.appendChild(panelElement); // 把挂载vue实例的DOM和容器DOM依次挂到父容器上
    domContainer.appendChild(panelContainer);
    this.domContainer = domContainer;
    this.vueInstance = new Vue({ // 创建Vue实例
      render: (h) => {
        const component = h(ImgGeneratePanel, { // 初始化需要渲染的组件
          props: {
            lf,
            desc: this.desc,
          },
        });
        this.panelComponent = component;
        return component;
      },
    });
    this.vueInstance.$mount(panelElement); // 挂载vue实例
    this.panelElement = panelElement;
  }

图片生成

在用户点击生成后,工具就需要拿到流程图内容,它转换成一段文本描述,再作为参数请求AI接口,获取图片大概流程会是这样:

img-generate-flow.png

这次实践里编者选择用通义万相的AI能力,原因主要在于它的图片生成效果看起来还行、支持中文prompt,最重要的是新注册的账号前180天可以免费用API生成500张图片!

图片下载

图片下载的实现没有做过多的处理,就是简单的通过a标签下载图片

downloadImg(url) {
  if (!url) return;
  const a = document.createElement("a"); // 创建 <a> 标签
  a.href = url; // 设置图片链接
  a.download = `${url}.jpg`; // 设置下载的文件名(可修改格式)
  document.body.appendChild(a); // 将 <a> 标签添加到 DOM
  a.click(); // 触发下载
  document.body.removeChild(a); // 下载后移除 <a> 标签
}

到这里,整个工具就制作完成了。

录屏2024-11-19 14.55.22.gif

LogicFlow高可定制性的秘密

设计之初的考量

说到支持定制,我们在设计之初考虑了两种实现方式:

实现方式配置化插件化
思路功能内部实现大而全的模块,对外暴露很多配置项,让开发者通过更新配置实现自己想要的效果。功能内部只保留基础能力和插件运行模式,对外提供插件加载方法,开发者根据实际情况开发自己需要的能力附加到基础能力上。
特点1.定制成本低:开发者可以通过修改配置快速调整功能的行为。易于维护:相比维护代码来说,维护一份配置文件逻辑更简单。
2.功能受限:配置化只能改变现有功能的参数,无法新增功能。
3.配置版本管理难度增加:配置项多且更新频繁时,容易引入不兼容或错误配置,若文件定义不规范,还会增加团队合作维护成本。
1.扩展性强:核心系统与插件解耦,降低了对核心代码的修改频率;开发者可以独立开发和集成,便于新增功能,满足不断变化的需求。
2.灵活性高:可以按需加载,适配不同场景;支持功能模块化,方便功能复用和组合。
3.有一定学习成本和开发难度:插件与核心系统之间需要清晰的接口定义,开发者还需要熟悉插件开发规范,增加上手成本。
类比这种模式就像是在一个固定形状的画板上画画,最终能达到的效果是有上限的。这种模式就像是搭积木,积木使用什么形状搭成什么样子没有限制,都由搭积木的人决定。

为了能让流程图在各个系统中平滑迁移,最好是各种功能都能按需使用,最终选择了 让核心模块可扩展+支持功能插件化 的道路:

  • 核心模块支持自定义扩展;
  • 拆出插件包@logicflow/extension,核心模块支持挂载插件;

核心模块可扩展:自定义元素

在前一篇 一起实现动画边 文章中我们提到过,LogicFlow的节点和边都是继承基础类实现的,这其实就是LogicFlow核心模块可扩展的一种体现方式,用户可以根据自己的需要继承类实现自定义的节点和边。如今大部分系统都是基于框架开发的,因此 LogicFlow 还支持使用框架组件自定义节点。

自定义框架组件节点

在LogicFlow中有两种实现方式

实现自定义框架组件的方式.png

继承HtmlNode

通过重写setHtml方法,在其返回的内容里挂载框架实例。

    // 这里以实现自定义vue节点为例
    import { HtmlNode, HtmlNodeModel } from "@logicflow/core";
    import { createApp, h } from "vue"; // 引入辅助函数
    import Application from "./Application.vue"; // 引入需要渲染的vue组件


    class ApplicationNode extends HtmlNode {
      // ...
      setHtml(rootEl: any) { // 主要逻辑
        const { properties } = this.props.model;
        if (!this.isMounted) {
          // 如果还没挂载过组件,创建组件挂到 DOM 上
          const node = document.createElement("div");
          rootEl.appendChild(node);
          this.customComponent = h(Application, {
            properties,
            model: this.props.model
          });
          this.vueItem = createApp({
            render: () => this.customComponent,
          });
          this.vueItem.mount(node);
          this.isMounted = true;
          return;
        }
        // 如果挂载了组件,那就在方法触发时手动更新组件依赖
        this.customComponent.component!.props.properties =
          this.props.model.getProperties();
        this.customComponent.component!.props.model = this.props.model;
      }
    }


    export default {
      type: "applicationNode",
      model: HtmlNodeModel,
      view: ApplicationNode,
    };
使用LogicFlow新推出的功能包

@logicflow/vue-node-registry和@logicflow/react-node-registry。

小知识:这种方式本质上还是继承HtmlNode,重写setHtml方法实现,不同的是LogicFlow把定义相关的逻辑都写好了,一般情况下用户只需要把组件传入到注册函数即可

    // 这里仍然以vue组件为例
    import { VueNodeModel, register } from "@logicflow/vue-node-registry"; // 引入依赖
    import HelloWorld from "./components/HelloWorld.vue"; // 引入自定义组件


    class CustomVueNodeModel extends VueNodeModel {
     // 如果有需要自定义的内容可以继承VueNodeModel并重写
      setAttributes() {
        super.setAttributes();
        const { width, height } = this.properties;
        if (width) this.width = width;
        if (height) this.height = height;
      }
      // 其他自定义逻辑
    }
    // ...一些其他逻辑
    $_initLfIns() {
      this.lf = new LogicFlow(...);
      // 在实例创建后注册节点
      register(
        {
          type: "hello-world",
          component: HelloWorld, // 可以直接绑定组件
          // view: CustomVueView, // 跟使用 HTMLNode 类似,支持传入自定义视图
          model: CustomVueNodeModel, // 跟使用 HTMLNodeModel 类似,支持传入自定义视图
        },
        this.lf,
      );
    }

自定义属性

有些情况下,流程图需要带上定制属性传给后端执行流程图,针对这种情况,LogicFlow 提供了 properties 字段用来存业务定制属性,用户可以直接把定制字段在初始化时放到 properties 中,一般情况下 LogicFlow 不会对 properties 属性做修改,在导出数据时 properties 属性也会跟随其他元素属性一起导出供开发者使用。

插件包@logicflow/extension

定位

除了核心的流程图绘制与展示能力之外,LogicFlow 提供的能力都放到了插件包中,目前 LogicFlow 插件包中包含 菜单、工具栏等功能性组件和 BPMN 节点、分组节点这样的特色形态的节点。

使用方式

要使用插件也很简单,只需要取出对应的插件再引入到实例中即可

    import { Control, Menu, DndPanel } from '@logicflow/extension'
    import '@logicflow/extension/lib/style/index.css'


    LogicFlow.use(Control) // 引入方式1:全局引入


    const lf = new LogicFlow({
      // ...
      plugins: [DndPanel, Menu] // 引入方式2:局部引入
    });

实现方案

插件的设计

在实现上,LogicFlow目前支持两种形式的插件,支持用户根据自己的需求选取使用。

  1. 类形式插件:以声明类的形式声明一个插件,比较适合在需要持续维护插件内元素状态或者需要提供api给实例外部调用的场景使用。

一个🌰:画布上需要展示一个小地图组件,为了保障小地图的数据实时更新、显隐可以手动修改,这个小地图插件就需要以类的形式声明,后续以实例的形式挂在LogicFlow实例上以便小地图展示数据和显隐状态的更新。

    class PluginCls {
      static pluginName = 'pluginName'
      constructor({ lf, LogicFlow }) {}
      render(lf, toolOverlay) {}
      destroy() {}
      // 其他业务方法
    }


    export default PluginCls
  1. 对象形式:导出一个带有固定方法的对象,其中的方法会在插件注册时调用。比较适合在需要向实例上增加自定义节点或者静态元素的场景下使用。

另一个🌰:如果画布上需要展示一些自定义鉴权节点和水印,那就可以用这种形式声明插件,在install方法中把鉴权节点注册逻辑写好,在render方法中写水印相关的逻辑,在实例上引用后就能展示鉴权节点和水印了

export const PluginObj = {
  pluginName: 'pluginName',
  install(lf) { // 注册一些业务自定义组件
      // lf.register(xxx);
  },
  render(lf, toolOverlay) { // 渲染一些业务自定义内容
      // ...创建水印展示DOM、生成水印
      // toolOverlay.append(水印所在DOM)
  }
}

export default PluginObj

 

插件的加载过程

在实例化阶段,LogicFlow会调用installPlugins()方法把所有插件注册并挂载到实例上。

在渲染时,工具层(ToolOverlay)会从实例上拿到插件,遍历执行插件的render方法,把插件内容渲染在这个组件里。

plugin-render-logic.png

结语

其实在这个demo里还简化了很多想法,像prompt的文法如何组织,流程图内容如何更严谨地转化成描述,以及按流程图所画的内容依次根据每个节点的配置生成文本,拼接后传给下一个节点被编者简单粗暴地用一套逻辑带过了。

如果有看官感兴趣,可以再深入研究一下,应该会很有意思(如果能直接做出一个 流程图AI成图产品 那就泰酷啦!

以上就是本期“流程编排 × AI生图”的小工具的实现过程和涉及的LogicFlow原理分享,如果这篇文章对你有帮助,欢迎关注我们的账号,我们会持续输出干货文章。

如果这篇文章对你有帮助,请为我们的项目点上 Star,这对我们非常重要,感恩~

github.com/didi/LogicF…