跳到主要内容

前端画布:ReactFlow 怎么把拖拽变成 flowData

30 秒导读: 前面四章(0104)讲的是"一份 flowData JSON 被后端怎么表示、怎么执行"。这份 JSON 从哪来?答案是前端画布:用户在一张 ReactFlow(一个 React 流程图库)画布上拖节点、拉连线、填参数,这些鼠标操作被实时序列化成 { nodes, edges, viewport },存进数据库的 flowData 字段。本章讲这条链的前端一端,闭合"图即程序"的另一半。


1. 这是什么(先建直觉)

一句话定义: 前端画布是 Flowise 的"可视化编程界面"——把 LLM 应用的每个组件画成一个盒子(节点),盒子之间连线表示数据流,整张图就是程序本身。

它在整条链里的位置。 后端章节反复出现一个词:flowData。它其实是一段 JSON 字符串,形如:

{
"nodes": [ { "id": "llmAgentflow_0", "type": "agentflowNode", "position": {...}, "data": {...} } ],
"edges": [ { "id": "...", "source": "startAgentflow_0", "target": "llmAgentflow_0", ... } ],
"viewport": { "x": 0, "y": 0, "zoom": 1 }
}
  • 前端:用户拖拽 → 生成/修改 nodesedges → 序列化成上面这段 JSON → 存库。
  • 后端(01–04 章):读这段 JSON → 拓扑排序 → 构建成 LangChain 流水线 / AgentFlow V2 解释器 → 执行。

所以画布这一层的核心职责只有两件事:可视化编辑(让人操作图)和序列化(把图变成 JSON)。执行细节一律回指 02 / 03

用起来什么样。 打开 /agentcanvas 路由,看到一块无限画布:左上角有个"+"抽屉列出所有可用节点,把节点拖进画布,拖节点右侧的小圆点(handle)到另一个节点左侧,就连成一条边;双击节点弹出参数表单填值;Cmd/Ctrl+S 保存。这一整套就是本章要拆开的东西。


2. 两套前端并存(这是最容易懵的地方)

Flowise 仓库里同时存在两套画布代码,服务不同产品线。先分清,否则读源码会串。

维度老画布 packages/ui新画布 @flowiseai/agentflowpackages/agentflow
形态一个完整 React 应用(Vite + Redux)一个可被别的 app 引入的独立 npm 包(库)
语言JavaScript(.jsxTypeScript(.tsx
服务对象Chatflow(经典引擎,02)+ 老版 Multi-AgentAgentFlow V2(03
状态管理全局 Redux store(store/包内自带 Context + reducer(infrastructure/store
画布入口views/canvas/index.jsxAgentflow.tsx
ReactFlow 节点类型customNode / stickyNote / buttonedgeagentflowNode / iteration / stickyNote / agentflowEdge

为什么要抽出一个独立包? 老画布是"应用的一部分",只能在 Flowise 自己的 UI 里跑。新的 @flowiseai/agentflow 被设计成可嵌入的库——别的 React 应用可以 import { Agentflow } from '@flowiseai/agentflow',传入 apiBaseUrltokencomponents 就得到一块 AgentFlow V2 画布。这解释了它为什么要有严格的分层架构(见 §3)和 provider 注入设计。

依据:老画布 packages/ui/src/views/canvas/index.jsx:58nodeTypes = { customNode, stickyNote });新包 packages/agentflow/src/Agentflow.tsx:402export const Agentflow = forwardRef(...),带 apiBaseUrl/token/components 的注释示例见 Agentflow.tsx:386-400)。

2.1 老画布长什么样(速览,不深入)

老画布 Canvas 是一个巨型函数组件,把所有东西塞在一起:ReactFlow 实例、Redux dispatch、API 调用、拖放回调都在 index.jsx 里。看两个代表性片段建立印象就够了——

  • 拖放建节点onDropviews/canvas/index.jsx:274)读 dataTransfer 里的节点 JSON,用 initNode 初始化后 concatnodes
  • 保存handleSaveFlowviews/canvas/index.jsx:211)调 reactFlowInstance.toObject() 拿到 { nodes, edges, viewport }JSON.stringify 后作为 flowData 发给 createNewChatflow / updateChatflow API。

老画布的模式和新包思路一致(拖放→改 state→序列化→存库),但代码是"一坨"。下面把篇幅留给结构清晰、更有教学价值的新包。


3. agentflow 包的四层架构

新包 @flowiseai/agentflow 采用领域驱动的四层架构,核心规则一句话:依赖只能向下流。先看这张图(从上到下是依赖方向,上层能用下层,下层绝不反向 import):

┌───────────────────────────────────────────────┐
│ 根文件 (Public Face) │
│ index.ts · Agentflow.tsx · AgentflowProvider │ ← 对外 API
└───────────────────────────────────────────────┘


┌───────────────────────────────────────────────┐
│ features/ 「它做什么」(Smart) │
│ canvas · node-palette · node-editor · generator│
└───────────────────────────────────────────────┘
│ │
▼ ▼
┌──────────────────┐ ┌────────────────────────┐
│ atoms/ │ │ infrastructure/ │
│ 「它长什么样」 │ │ 「外部世界」api·store │
│ (Dumb UI 积木) │ │ │
└──────────────────┘ └────────────────────────┘
│ │
└────────────┬─────────────┘

┌──────────────────┐
│ core/ 「大脑」 │
│ types·validation │ ← 纯 TS,叶子,谁都不 import
│ node-config... │
└──────────────────┘

四层各自干什么、导入规则是什么:

白话职责典型内容允许 import
atoms/纯 UI 积木,"哑"组件,无业务逻辑MainCardNodeInputHandler、各种输入控件只能 core/typescore/themecore/primitives
features/自成一体的领域模块(画布/抽屉/编辑器/生成器)canvasnode-palettenode-editorgeneratoratomsinfrastructurecore
core/框架无关的纯逻辑与类型(无 React)typesvalidationnode-catalognode-configutils无(叶子节点)
infrastructure/与外部世界通信api(axios 客户端)、store(Context + reducer)core

两条硬规则,读代码时靠它们定位:

  1. features 之间不能互相 import。canvas 需要 generator 的逻辑,就把那段逻辑挪进 core/
  2. atoms 和 core 是叶子。 它们绝不 import features/infrastructure/,保证可单测、可复用。

依据:packages/agentflow/ARCHITECTURE.md(真实目录说明,作事实来源);实际目录 packages/agentflow/src/{atoms,features,core,infrastructure};provider 的层层嵌套见 AgentflowProvider.tsx:76-86ReactFlowProvider → ThemeProvider → ApiProvider → ConfigProvider → AgentflowStateProvider)。

根文件是"公开脸面"。 包对外只暴露 index.ts 里列的东西:Agentflow 组件、useAgentflow 钩子、几个 context 钩子和类型(packages/agentflow/src/index.ts:6-73)。内部子组件通过各模块的 index.ts 网关(gatekeeper)收口,外面碰不到。


4. 画布机制:ReactFlow 怎么承接拖拽

这节讲 features/canvas——整个包最核心的一层。它基于 ReactFlow,把"节点、连线、拖放"三种交互接住,改写本地状态,并把变化通知出去。

4.0 一张图看清交互怎么流动

先给"怎么读这张图":左边是用户动作,中间是接住动作的 hook,右边是状态与副作用。命中即改 state,改完通过 onFlowChange 回调外抛。

用户动作 canvas hook 结果
───────── ───────────── ──────
从抽屉拖入 ──► useDragAndDrop.handleDrop ──► 新节点入 nodes + setDirty
拉 handle 连线──► useFlowHandlers.handleConnect ──► 新边入 edges(先校验合法性)
拖动/删除节点──► useFlowHandlers.handleNodesChange ─► applyNodeChanges → nodes
松开拖动 ──► useFlowHandlers.handleNodeDragStop ─► 写回最终坐标

▼(每次有意义的改动)
onFlowChange({ nodes, edges, viewport }) ──► 外层 app 拿到最新 flowData

Agentflow.tsx 里的 AgentflowCanvas 是这一切的宿主:它用 ReactFlow 的 useNodesState / useEdgesState 持有本地节点/边,把上面几个 hook 的处理函数挂到 <ReactFlow>onNodesChange / onConnect / onNodeDragStop 等 props 上(Agentflow.tsx:332-347)。节点/边的渲染类型注册在 canvas/index.tsnodeTypes = { agentflowNode, stickyNote, iteration }edgeTypes = { agentflowEdge }

4.1 拖放建节点(useDragAndDrop)

要解决的小问题: 用户从左上抽屉里把一个节点"拖"到画布某个位置,如何在正确坐标处新建一个节点?

思路: HTML 原生拖放把节点的 schema JSON 塞进 dataTransferdrop 时把鼠标屏幕坐标换算成画布坐标,再初始化一个节点对象插进去。

原理演示(示意,非源码):

// 示意,非源码:拖放落点如何变成一个新节点
function handleDrop(event) {
const nodeData = JSON.parse(event.dataTransfer.getData('application/reactflow')) // 抽屉塞进来的 schema
const position = reactFlowInstance.project({ // 屏幕坐标 → 画布坐标
x: event.clientX - bounds.left - 100,
y: event.clientY - bounds.top - 50
})
const id = getUniqueNodeId(nodeData, nodes) // 生成不重名的 id
const data = initNode(nodeData, id, true) // 按 schema 铺默认参数值
setLocalNodes(nds => [...nds, { id, type, position, data }])
}

真实实现: useDragAndDrop.ts:35handleDrop。几个要点——

  • reactFlowInstance.project() 而非 screenToFlowPosition()useDragAndDrop.ts:52,注释解释了在此场景下后者视口变换会算错落点)。
  • 落点先过放置约束校验 checkNodePlacementConstraintsuseDragAndDrop.ts:58):比如 Start 节点唯一、迭代节点不能嵌套、Human Input 不能放进迭代里;违规就弹 snackbar 拒绝。
  • 若落在迭代节点内部(findParentIterationNode),坐标转成相对父节点、并挂上 parentNode + extent: 'parent'useDragAndDrop.ts:65-77)。
  • 节点数据由 initNodecore/utils/nodeFactory.ts:135)按 schema 的 inputParams 铺默认值——这里的 inputParams 正是 01 章 讲的 INodeParams

4.2 连线(useFlowHandlers.handleConnect)

要解决的小问题: 用户从源节点的输出圆点拉一条线到目标节点的输入圆点,怎么生成一条合法的边?

关键点:先校验,再造边。 handleConnectuseFlowHandlers.ts:48)第一步就调 isValidConnectionAgentflowV2core/validation/connectionValidation.ts:6)判断这条连接是否允许,不合法直接 return,不建边。

合法后它做几件事(useFlowHandlers.ts:57-95):

  • 取源/目标节点颜色,写进边的 data.sourceColor / targetColor(供边的渲染上色)。
  • 有动态输出口的节点算边标签:conditionAgentflow / conditionAgentAgentflow 按 handle 序号,humanInputAgentflowproceed / rejectuseFlowHandlers.ts:66-73)。
  • 用 ReactFlow 的 addEdge 把新边并入,type: 'agentflowEdge';若源和目标同属一个迭代父节点,加一个 zIndex 让边画在迭代容器之上。

对比老画布:老 onConnectviews/canvas/index.jsx:119)在连线时顺手改目标节点的 inputs——把 {{sourceNodeId.data.instance}} 这种连接字符串写进目标输入。这是经典引擎"用连接串表达依赖"的做法(见 01)。V2 新包不这么干,连接关系只落在 edges 上。

4.3 拖动、改动与"脏"标记

其余画布事件都走 useFlowHandlers 里的三个函数,套路一致:接住 ReactFlow 的原生 change → 应用到状态 → 若是"有意义"的改动就 setDirty 并 onFlowChange

  • handleNodesChangeuseFlowHandlers.ts:109):过滤掉 select/dimensions/position 这类噪声改动(选中、量尺寸、拖动中途),只对真正的增删触发通知——拖动坐标交给拖动结束时统一处理。
  • handleNodeDragStopuseFlowHandlers.ts:130):拖动松手时才把最终坐标写回并通知一次,避免拖动每一帧都刷状态。
  • handleAddNodeuseFlowHandlers.ts:169):点击抽屉节点(不拖)时的等价路径,同样过约束校验、initNode、追加。

5. 加节点抽屉与节点参数编辑

画布之外,features 里另两个模块负责"往画布里加什么"和"节点里填什么"。

5.1 node-palette:加节点抽屉

AddNodesDrawernode-palette/AddNodesDrawer.tsx:45)是左上角"+"点开的抽屉:按类别(category)分组、带 300ms 防抖搜索(search.tssearchNodes / groupNodesByCategory)。抽屉里每个节点条目可(触发 §4.1 的 drop)也可(触发 §4.3 的 handleAddNode)。

抽屉里的可用节点从哪来?useFlowNodescanvas/hooks/useFlowNodes.ts:9)向后端 GET nodes 拉全部组件,只留 category === 'Agent Flows' 的节点useFlowNodes.ts:25);若外部传了 components 白名单,再过滤一遍(永远保留 startAgentflowuseFlowNodes.ts:32-34)。这就是嵌入方能限制"只暴露哪些节点"的机制。

5.2 node-editor:INodeParams 驱动的参数表单

双击节点AgentFlowNode 容器调 openNodeEditor(data.id)AgentFlowNode.tsx:56)→ useOpenNodeEditoruseOpenNodeEditor.ts:16)取出该节点的 schema,把 inputParams 交给 EditNodeDialog

这里是"表单由数据驱动"的关键。 编辑器不为每个节点写死表单,而是遍历 inputParams(即 01 章INodeParams),按每个参数的 type 渲染对应控件

// 示意,非源码:参数表单是"照着 inputParams 一项项渲染"出来的
inputParams
.filter(p => p.display !== false)
.map(p => {
if (p.type === 'array' && p.name === 'conditions') return <ConditionBuilder .../>
if (p.type === 'array' && MESSAGE_PARAM_NAMES.has(p.name)) return <MessagesInput .../>
// ……特例分派……
return <NodeInputHandler inputParam={p} .../> // 默认:按 type 渲染 string/number/options/credential…
})

真实实现: EditNodeDialog.tsx:341-415 的渲染循环。几个要点——

  • 大多数参数落到 NodeInputHandlerEditNodeDialog.tsx:400,来自 atoms/),由它按 type 出具体输入控件;少数复杂参数(条件、消息数组、结构化输出)走专用 builder。
  • 用户改值走 onCustomDataChangeEditNodeDialog.tsx:103):它调 applyVisibleFieldDefaults + evaluateFieldVisibility 重算字段的显隐(show/hide 条件,01 章 讲过),再 updateNodeData 把新 inputs 写回节点。
  • 首次打开会把参数 default 合并进 inputs,好让引用了其它字段默认值的显隐条件在初次渲染就正确(EditNodeDialog.tsx:149-176)。
  • conditionAgentflow 改条件数组时,同步用 buildDynamicOutputAnchors 重建输出口、清理孤儿边——一次 updateNodeData 带上 inputs+outputAnchors+edges,保证 onFlowChange 只触发一次(EditNodeDialog.tsx:118-133)。

写回的落点是 updateNodeDatainfrastructure/store/AgentflowContext.tsx:273):它改 context 里的 nodes,同步 ReactFlow 本地状态,并调 onFlowChange 通知外层。这样表单里填的每个值都进了 flowData——也就是后端节点执行时读的 nodeData.inputs(见 01/03)。


6. 往返闭环:图 ↔ flowData

这节把前面所有交互收束成一句话:画布状态(nodes/edges/viewport)如何变成 flowData,又如何被后端消费。

6.1 出:序列化

真正的"取当前状态"就一个函数——getFlowDataAgentflowContext.tsx:333):

// AgentflowContext.tsx:333 —— getFlowData,序列化的唯一真源
const getFlowData = () => ({
nodes: state.nodes,
edges: state.edges,
viewport: state.reactFlowInstance?.getViewport() || { x: 0, y: 0, zoom: 1 }
})

它返回的 { nodes, edges, viewport } 就是 FlowData 类型(core/types/flow.ts:39)。对外有三个出口,都建在它之上:

出口符号何时触发给谁
实时变更onFlowChange每次有意义的画布改动外层 app 拿到最新草稿,可自动持久化
主动保存onSaveCmd/Ctrl+S 或 Save 按钮validateFlow 校验,通过才回调(Agentflow.tsx:199-216
编程读取useAgentflow().getFlow() / .toJSON()宿主代码主动调返回对象 / JSON 字符串(useAgentflow.ts:46,53

注意 state.nodes 在写入前都过 normalizeNodesagentflowReducer.ts:6):对内容自适应大小的节点(agentFlow/stickyNote剥掉存储的 width/height,让它们始终按内容撑开,不被旧尺寸卡住。这是序列化里的一个易漏细节。

6.2 入:反序列化

反向也简单:外层把存库的 flowData 解析后,作为 initialFlow 传给 <Agentflow>AgentflowStateProvider 用它初始化 reducer 的 nodes/edgesAgentflowContext.tsx:113-118),AgentflowCanvas 用它初始化 ReactFlow 的本地状态(Agentflow.tsx:89-92)。若是全新空画布,则自动放一个 Start 节点(Agentflow.tsx:119-136)。

6.3 谁消费这份 flowData

闭环合上:前端吐出的这段 JSON,正是 01–04 章的输入。

[本章] 画布交互 ──► getFlowData ──► flowData JSON ──► 存库(chatflow.flowData)


[01] 被解析成 nodes/edges 数据模型
[02] 经典引擎:拓扑排序 → 构建 LangChain 流水线
[03] AgentFlow V2 引擎:队列驱动的有状态解释器执行
[04] 请求生命周期 / SSE 流式 / 队列扩展

前端只管"把图画对、存对";一旦 flowData 落库,执行是后端的事——细节回指对应章节,本章不重复。


7. generator:自然语言生成整张流

画布右上角的 ✨ 按钮是用自然语言生成一整张 AgentFlow。它把"从零拖节点"降级成"描述一句话,AI 铺好节点和边,你再填参数"。

前端触发。 GenerateFlowDialoggenerator/GenerateFlowDialog.tsx:46)让用户输入 prompt、选一个 chat model,handleGenerateGenerateFlowDialog.tsx:123)调 chatflowsApi.generateAgentflow({ question, selectedChatModel })。返回的 { nodes, edges } 通过 onGenerated 直接 setLocalNodes / setLocalEdges 铺到画布上(Agentflow.tsx:181-196)。注意对话框文案明确:只生成节点和边,参数要你自己填

后端两段式。 服务端 generateAgentflowv2server/src/services/agentflowv2-generator/index.ts:184)先把 marketplaces/agentflowsv2 下的模板样例喂进系统提示词当 few-shot(index.ts:104-108, 190-192),再把活儿交给 components 包里的 generateAgentflowv2components/src/agentflowv2Generator.ts:151)。后者是真正的生成流水线:

// components/src/agentflowv2Generator.ts:151 —— 生成四步
const result = await generateNodesEdges(config, question, options) // LLM 出节点+边草稿
const { nodes, edges } = generateNodesData(result, config) // 铺成完整 node.data
const updatedNodes = await generateSelectedTools(nodes, config, ...) // 给 agent 节点再选工具
const updatedEdges = updateEdges(edges, nodes) // 清洗边:去孤儿、上色、修 handle

updateEdgesagentflowv2Generator.ts:168)做的清洗和 §4.2 手动连线时 handleConnect 做的事一模一样——去掉指向 hideInput/hideOutput 的边、写 sourceColor/targetColor、把条件节点的 true/false handle 归一成 0/1。也就是说:不管边是"手拉"还是"AI 生成",最终都收敛成同一种 agentflowEdge 结构,这正是序列化能统一的前提。生成算法本身不是本章重点,深挖见 components 包。


8. 巧妙之处与边界

几处值得借鉴的设计:

  • 一个序列化真源。 所有出口(onFlowChange/onSave/getFlow/toJSON)都建在 getFlowData 一个函数上(AgentflowContext.tsx:333),不存在"多处各拼一份 JSON"的漂移风险。
  • 手连与 AI 生成收敛到同一结构。 handleConnectuseFlowHandlers.ts:48)和 updateEdgesagentflowv2Generator.ts:168)对边做同样的着色/handle 归一,保证两条来路产出可互换的 flowData。
  • 拖动只在松手时落一次。 handleNodeDragStopuseFlowHandlers.ts:130)而非每帧写状态,明显省重渲染。
  • 表单 100% 数据驱动。 加一种新节点参数类型,只需在 schema 里加 INodeParams 并让 NodeInputHandler 认得它,编辑器无需改(EditNodeDialog.tsx:341)。

边界与局限(诚实):

  • 两套画布并存是历史包袱:Chatflow/老 Multi-Agent 走 packages/ui,AgentFlow V2 走 packages/agentflow,两边拖放/连线/保存逻辑各写一遍,行为相近但不共享代码。
  • generator 只出骨架:节点参数(API key、prompt 等)一律留空要人工补,对话框文案已说明(GenerateFlowDialog.tsx:202-205)。
  • 约束校验在前端:Start 唯一、迭代不嵌套等规则由 checkNodePlacementConstraints 在拖放/加节点时挡(useDragAndDrop.ts:58),是 UX 护栏;真正的结构合法性以后端执行时为准。

9. 代码地图(导航索引)

主题文件路径符号
新画布组件(宿主 + ReactFlow)packages/agentflow/src/Agentflow.tsxAgentflowCanvasAgentflow
包对外 APIpackages/agentflow/src/index.ts(barrel 导出)
Provider 层层注入packages/agentflow/src/AgentflowProvider.tsxAgentflowProvider
编程句柄(getFlow/toJSON/validate)packages/agentflow/src/useAgentflow.tsuseAgentflow
拖放建节点packages/agentflow/src/features/canvas/hooks/useDragAndDrop.tsuseDragAndDrophandleDrop
连线/改动/拖动处理packages/agentflow/src/features/canvas/hooks/useFlowHandlers.tshandleConnecthandleNodesChangehandleNodeDragStophandleAddNode
加载可用节点(过滤 Agent Flows)packages/agentflow/src/features/canvas/hooks/useFlowNodes.tsuseFlowNodes
ReactFlow 节点/边类型注册packages/agentflow/src/features/canvas/index.tsnodeTypesedgeTypes
加节点抽屉packages/agentflow/src/features/node-palette/AddNodesDrawer.tsxAddNodesDrawer
打开节点编辑器packages/agentflow/src/features/canvas/hooks/useOpenNodeEditor.tsuseOpenNodeEditor
INodeParams 驱动的参数表单packages/agentflow/src/features/node-editor/EditNodeDialog.tsxEditNodeDialogonCustomDataChange
状态中心(序列化真源)packages/agentflow/src/infrastructure/store/AgentflowContext.tsxgetFlowDataupdateNodeDatasyncNodes
节点尺寸归一packages/agentflow/src/infrastructure/store/agentflowReducer.tsnormalizeNodes
节点工厂(id/默认值/类型解析)packages/agentflow/src/core/utils/nodeFactory.tsinitNodegetUniqueNodeIdresolveNodeType
连接/放置校验packages/agentflow/src/core/validation/{connectionValidation,constraintValidation,flowValidation}.tsisValidConnectionAgentflowV2checkNodePlacementConstraintsvalidateFlow
FlowData / FlowNode 类型packages/agentflow/src/core/types/flow.tsFlowDataFlowNodeFlowEdge
生成对话框(前端)packages/agentflow/src/features/generator/GenerateFlowDialog.tsxGenerateFlowDialoghandleGenerate
生成服务(后端)packages/server/src/services/agentflowv2-generator/index.tsgenerateAgentflowv2
生成算法(components)packages/components/src/agentflowv2Generator.tsgenerateAgentflowv2updateEdgesgenerateNodesData
老画布(对照)packages/ui/src/views/canvas/index.jsxCanvasonDrophandleSaveFlowonConnect