Vue Flow实战:5步打造可拖拽的AI工作流编辑器(附完整代码)

Vue Flow实战:5步打造可拖拽的AI工作流编辑器(附完整代码)

最近在折腾一个AI应用的原型,需要把几个大语言模型和图像处理服务串起来,形成一个可配置的推理管道。画流程图的时候,用PPT或者Visio总觉得差点意思——没法实时交互,更没法把画出来的图直接变成可执行的代码。直到我发现了Vue Flow,一个基于Vue 3的图形编辑库,它让我意识到,构建一个可视化、可拖拽的AI工作流编辑器,其实并没有想象中那么复杂。

如果你也在为如何直观地编排AI模型节点、定义数据处理流程而头疼,这篇文章就是为你准备的。我们将抛开那些抽象的理论,直接动手,用五个清晰的步骤,从零开始搭建一个功能完整的编辑器。这个编辑器不仅能让你用鼠标拖拽连接不同的AI节点,还能实时生成对应的配置数据,甚至为后续的自动化执行铺平道路。整个过程,我会把踩过的坑和优化技巧都分享出来,并提供完整的、可运行的代码。

1. 环境搭建与项目初始化

在开始敲代码之前,我们需要一个干净、现代的Vue 3开发环境。这里我强烈推荐使用Vite作为构建工具,它的启动速度和热更新体验远超传统的Webpack,能让我们更专注于逻辑开发。

首先,打开你的终端,创建一个新的Vue项目。我习惯用TypeScript,它能提供更好的类型提示,减少运行时错误。

npm create vue@latest vue-ai-workflow-editor

创建过程中,你会被询问一些配置选项。我的选择如下,你可以根据喜好调整:

  • TypeScript: Yes
  • JSX: No
  • Vue Router: No (我们这个单页应用暂时不需要)
  • Pinia: Yes (状态管理很有用)
  • Vitest: No (为了简化,先不引入测试)
  • ESLint: Yes (保持代码规范)
  • Prettier: Yes (统一代码格式)

项目创建完成后,进入目录并安装Vue Flow的核心库及其样式。

cd vue-ai-workflow-editor
npm install @vue-flow/core

Vue Flow的样式是独立打包的,我们需要在入口文件(通常是main.tsmain.js)中引入。

// src/main.ts
import { createApp } from 'vue'
import App from './App.vue'
import '@vue-flow/core/dist/style.css' // 引入核心样式
import '@vue-flow/core/dist/theme-default.css' // 引入默认主题(可选)

createApp(App).mount('#app')

提示:除了默认主题,Vue Flow还提供了深色主题 (theme-dark.css)。如果你的应用支持主题切换,可以动态引入不同的样式文件。

接下来,我们清理一下默认的App.vue组件,为我们的画布腾出空间。同时,创建一个专门存放工作流相关组件和逻辑的目录结构。

src/
├── components/
│   ├── WorkflowEditor.vue  # 主编辑器组件
│   ├── nodes/              # 自定义节点组件
│   │   ├── AINode.vue
│   │   ├── DataSourceNode.vue
│   │   └── OutputNode.vue
│   └── controls/           # 画布控制组件
│       └── EditorControls.vue
├── stores/
│   └── workflow.ts         # Pinia状态管理
└── types/
    └── workflow.ts         # TypeScript类型定义

这个结构将业务逻辑、UI组件和状态管理清晰地分离开,项目规模变大时也能保持可维护性。

2. 核心数据结构设计与类型定义

在动手画图之前,我们必须想清楚要画的是什么。一个AI工作流本质上是一张有向无环图,由节点构成。节点代表处理单元(如一个AI模型),边代表数据流向。设计一个健壮的数据结构是项目成功的基石。

我们先在src/types/workflow.ts中定义核心类型。

// src/types/workflow.ts

// 节点在画布上的位置
export interface Position {
  x: number
  y: number
}

// AI节点的类型,代表不同的处理能力
export type AINodeType = 'text-generator' | 'image-classifier' | 'sentiment-analyzer' | 'data-transformer'

// 节点数据负载,根据节点类型不同而不同
export interface NodeData {
  label: string
  type: AINodeType
  config: Record<string, any> // 节点特定的配置,如模型参数、API密钥等
  status?: 'idle' | 'processing' | 'success' | 'error' // 节点执行状态,用于可视化反馈
}

// 工作流节点定义
export interface WorkflowNode {
  id: string // 唯一标识符,通常使用UUID或自增ID
  type: string // 对应Vue Flow的节点类型,用于渲染不同的自定义组件
  position: Position
  data: NodeData
  // Vue Flow内部会使用的扩展字段
  width?: number
  height?: number
  selected?: boolean
  dragging?: boolean
}

// 工作流边定义,连接两个节点
export interface WorkflowEdge {
  id: string
  source: string // 源节点ID
  target: string // 目标节点ID
  sourceHandle?: string // 源节点的输出句柄ID
  targetHandle?: string // 目标节点的输入句柄ID
  label?: string // 边上显示的标签,可描述数据类型
  animated?: boolean // 是否显示流动动画,常用于表示数据正在传输
  style?: Record<string, any> // 自定义样式
}

// 整个工作流的快照
export interface WorkflowDefinition {
  id?: string
  name: string
  description?: string
  nodes: WorkflowNode[]
  edges: WorkflowEdge[]
  createdAt?: Date
  updatedAt?: Date
}

定义了类型之后,我们创建一个Pinia store来集中管理工作流的状态。状态管理能让我们在多个组件间轻松共享和修改数据,尤其是处理复杂的拖拽、连接和更新逻辑时。

// src/stores/workflow.ts
import { defineStore } from 'pinia'
import { ref } from 'vue'
import type { WorkflowNode, WorkflowEdge, WorkflowDefinition } from '@/types/workflow'
import { v4 as uuidv4 } from 'uuid' // 需要安装uuid库: npm install uuid @types/uuid

export const useWorkflowStore = defineStore('workflow', () => {
  // 状态
  const nodes = ref<WorkflowNode[]>([])
  const edges = ref<WorkflowEdge[]>([])
  const selectedNodeIds = ref<string[]>([])
  const workflowName = ref('未命名工作流')

  // 生成唯一ID的辅助函数
  const generateId = (prefix: string = '') => `${prefix}_${uuidv4().slice(0, 8)}`

  // 动作 (Actions)
  const addNode = (node: Omit<WorkflowNode, 'id'>) => {
    const newNode: WorkflowNode = {
      ...node,
      id: node.id || generateId('node'),
    }
    nodes.value.push(newNode)
    return newNode.id
  }

  const addEdge = (edge: Omit<WorkflowEdge, 'id'>) => {
    // 简单的环路检测:防止连接到自身或创建重复边
    if (edge.source === edge.target) {
      console.warn('不能创建连接节点自身的边')
      return null
    }
    const existingEdge = edges.value.find(
      e => e.source === edge.source && e.target === edge.target
    )
    if (existingEdge) {
      console.warn('已存在相同的连接')
      return null
    }

    const newEdge: WorkflowEdge = {
      ...edge,
      id: edge.id || generateId('edge'),
    }
    edges.value.push(newEdge)
    return newEdge.id
  }

  const deleteNode = (nodeId: string) => {
    const index = nodes.value.findIndex(n => n.id === nodeId)
    if (index > -1) {
      nodes.value.splice(index, 1)
      // 同时删除所有与该节点关联的边
      edges.value = edges.value.filter(e => e.source !== nodeId && e.target !== nodeId)
    }
  }

  const deleteEdge = (edgeId: string) => {
    const index = edges.value.findIndex(e => e.id === edgeId)
    if (index > -1) {
      edges.value.splice(index, 1)
    }
  }

  const updateNodeData = (nodeId: string, data: Partial<WorkflowNode['data']>) => {
    const node = nodes.value.find(n => n.id === nodeId)
    if (node) {
      node.data = { ...node.data, ...data }
    }
  }

  const updateNodePosition = (nodeId: string, position: WorkflowNode['position']) => {
    const node = nodes.value.find(n => n.id === nodeId)
    if (node) {
      node.position = position
    }
  }

  // 获取器 (Getters)
  const getNodeById = (nodeId: string) => {
    return nodes.value.find(n => n.id === nodeId)
  }

  const getConnectedEdges = (nodeId: string) => {
    return edges.value.filter(e => e.source === nodeId || e.target === nodeId)
  }

  // 导入/导出工作流
  const exportWorkflow = (): WorkflowDefinition => {
    return {
      name: workflowName.value,
      nodes: JSON.parse(JSON.stringify(nodes.value)), // 深拷贝
      edges: JSON.parse(JSON.stringify(edges.value)),
    }
  }

  const importWorkflow = (definition: WorkflowDefinition) => {
    workflowName.value = definition.name
    nodes.value = definition.nodes
    edges.value = definition.edges
  }

  // 重置工作流
  const resetWorkflow = () => {
    nodes.value = []
    edges.value = []
    selectedNodeIds.value = []
    workflowName.value = '新工作流'
  }

  return {
    // 状态
    nodes,
    edges,
    selectedNodeIds,
    workflowName,
    // 动作
    addNode,
    addEdge,
    deleteNode,
    deleteEdge,
    updateNodeData,
    updateNodePosition,
    // 获取器
    getNodeById,
    getConnectedEdges,
    // 工具函数
    exportWorkflow,
    importWorkflow,
    resetWorkflow,
  }
})

这个Store囊括了我们对工作流数据的所有核心操作。有了它,我们的组件就可以专注于渲染和交互,而将数据管理的复杂性隔离在外。

3. 自定义AI节点组件的实现

Vue Flow默认的矩形节点太单调了,无法表达AI模型的丰富信息。我们需要创建一系列自定义节点组件,让不同类型的节点(文本生成、图像分类等)拥有独特的视觉外观和交互逻辑。

首先,创建一个基础的AI节点组件AINode.vue,它将成为其他特定类型节点的模板。

<!-- src/components/nodes/AINode.vue -->
<template>
  <div
    :class="['ai-node', `ai-node--${type}`, { 'ai-node--selected': selected, [`status-${nodeData.status}`]: nodeData.status }]"
    @click.stop="onNodeClick"
    @dblclick.stop="onNodeDoubleClick"
  >
    <div class="ai-node__header">
      <div class="ai-node__icon">
        <!-- 这里可以根据type放置不同的图标 -->
        <span v-if="type === 'text-generator'">📝</span>
        <span v-else-if="type === 'image-classifier'">🖼️</span>
        <span v-else-if="type === 'sentiment-analyzer'">😊</span>
        <span v-else>⚙️</span>
      </div>
      <div class="ai-node__title">{
  
  { nodeData.label }}</div>
      <div v-if="nodeData.status" class="ai-node__status-indicator" :title="nodeData.status"></div>
    </div>

    <div class="ai-node__body">
      <!-- 节点配置的简要信息 -->
      <div v-if="configSummary" class="ai-node__config-summary">
        {
  
  { configSummary }}
      </div>
      <slot name="body"></slot>
    </div>

    <!-- Vue Flow 连接句柄 -->
    <Handle
      v-if="!isOutputNode"
      type="target"
      :position="Position.Left"
      :is-valid-connection="isValidConnection"
      class="handle handle--target"
    />
    <Handle
      v-if="!isDataSourceNode"
      type="source"
      :position="Position.Right"
      :is-valid-connection="isValidConnection"
      class="handle handle--source"
    />
  </div>
</template>

<script setup lang="ts">
import { Handle, Position, type NodeProps } from '@vue-flow/core'
import { computed } from 'vue'
import type { AINodeType } from '@/types/work
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值