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.ts或main.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

&spm=1001.2101.3001.5002&articleId=149585107&d=1&t=3&u=ccd304c51313404d8b8bbf41a7eba97f)
4821

被折叠的 条评论
为什么被折叠?



