前端画布:ReactFlow 怎么把拖拽变成 flowData
30 秒导读: 前面四章(01–04)讲的是"一份 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 }
}
- 前端:用户拖拽 → 生成/修改
nodes、edges→ 序列化成上面这段 JSON → 存库。 - 后端(01–04 章):读这段 JSON → 拓扑排序 → 构建成 LangChain 流水线 / AgentFlow V2 解释器 → 执行。
所以画布这一层的核心职责只有两件事:可视化编辑(让人操作图)和序列化(把图变成 JSON)。执行细节一律回指 02 / 03。
用起来什么样。 打开 /agentcanvas 路由,看到一块无限画布:左上角有个"+"抽屉列出所有可用节点,把节点拖进画布,拖节点右侧的小圆点(handle)到另一个节点左侧,就连成一条边;双击节点弹出参数表单填值;Cmd/Ctrl+S 保存。这一整套就是本章要拆开的东西。
2. 两套前端并存(这是最容易懵的地方)
Flowise 仓库里同时存在两套画布代码,服务不同产品线。先分清,否则读源码会串。
| 维度 | 老画布 packages/ui | 新画布 @flowiseai/agentflow(packages/agentflow) |
|---|---|---|
| 形态 | 一个完整 React 应用(Vite + Redux) | 一个可被别的 app 引入的独立 npm 包(库) |
| 语言 | JavaScript(.jsx) | TypeScript(.tsx) |
| 服务对象 | Chatflow(经典引擎,02)+ 老版 Multi-Agent | AgentFlow V2(03) |
| 状态管理 | 全局 Redux store(store/) | 包内自带 Context + reducer(infrastructure/store) |
| 画布入口 | views/canvas/index.jsx | Agentflow.tsx |
| ReactFlow 节点类型 | customNode / stickyNote / buttonedge | agentflowNode / iteration / stickyNote / agentflowEdge |
为什么要抽出一个独立包? 老画布是"应用的一部分",只能在 Flowise 自己的 UI 里跑。新的 @flowiseai/agentflow 被设计成可嵌入的库—— 别的 React 应用可以 import { Agentflow } from '@flowiseai/agentflow',传入 apiBaseUrl、token、components 就得到一块 AgentFlow V2 画布。这解释了它为什么要有严格的分层架构(见 §3)和 provider 注入设计。
依据:老画布
packages/ui/src/views/canvas/index.jsx:58(nodeTypes = { customNode, stickyNote });新包packages/agentflow/src/Agentflow.tsx:402(export const Agentflow = forwardRef(...),带apiBaseUrl/token/components的注释示例见Agentflow.tsx:386-400)。
2.1 老画布长什么样(速览,不深入)
老画布 Canvas 是一个巨型函数组件,把所有东西塞在一起:ReactFlow 实例、Redux dispatch、API 调用、拖放回调都在 index.jsx 里。看两个代表性片段建立印象就够了——
- 拖放建节点:
onDrop(views/canvas/index.jsx:274)读dataTransfer里的节点 JSON,用initNode初始化后concat进nodes。 - 保存:
handleSaveFlow(views/canvas/index.jsx:211)调reactFlowInstance.toObject()拿到{ nodes, edges, viewport },JSON.stringify后作为flowData发给createNewChatflow/updateChatflowAPI。
老画布的模式和新包思路一致(拖放→ 改 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 积木,"哑"组件,无业务逻辑 | MainCard、NodeInputHandler、各种输入控件 | 只能 core/types、core/theme、core/primitives |
features/ | 自成一体的领域模块(画布/抽屉/编辑器/生成器) | canvas、node-palette、node-editor、generator | atoms、infrastructure、core |
core/ | 框架无关的纯逻辑与类型(无 React) | types、validation、node-catalog、node-config、utils | 无(叶子节点) |
infrastructure/ | 与外部世界通信 | api(axios 客户端)、store(Context + reducer) | 只 core |
两条硬规则,读代码时靠它们定位:
- features 之间不能互相 import。 若
canvas需要generator的逻辑,就把那段逻辑挪进core/。 - atoms 和 core 是叶子。 它们绝不 import
features/或infrastructure/,保证可单测、可复用。
依据:
packages/agentflow/ARCHITECTURE.md(真实目录说明,作事实来源);实际目录packages/agentflow/src/{atoms,features,core,infrastructure};provider 的层层嵌套见AgentflowProvider.tsx:76-86(ReactFlowProvider → 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.ts:nodeTypes = { agentflowNode, stickyNote, iteration }、edgeTypes = { agentflowEdge }。
4.1 拖放建节点(useDragAndDrop)
要解决的小问题: 用户从左上抽屉里把一个节点"拖"到画布某个位置,如何在正确坐标处新建一个节点?
思路: HTML 原生拖放把节点的 schema JSON 塞进 dataTransfer;drop 时把鼠标屏幕坐标换算成画布坐标,再初始化一个节点对象插进去。
原理演示(示意,非源码):
// 示意,非源码:拖放落点如何变成一个新节点
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:35 的 handleDrop。几个要点——
- 用
reactFlowInstance.project()而非screenToFlowPosition()(useDragAndDrop.ts:52,注释解释 了在此场景下后者视口变换会算错落点)。 - 落点先过放置约束校验
checkNodePlacementConstraints(useDragAndDrop.ts:58):比如 Start 节点唯一、迭代节点不能嵌套、Human Input 不能放进迭代里;违规就弹 snackbar 拒绝。 - 若落在迭代节点内部(
findParentIterationNode),坐标转成相对父节点、并挂上parentNode+extent: 'parent'(useDragAndDrop.ts:65-77)。 - 节点数据由
initNode(core/utils/nodeFactory.ts:135)按 schema 的inputParams铺默认值——这里的inputParams正是 01 章 讲的INodeParams。
4.2 连线(useFlowHandlers.handleConnect)
要解决的小问题: 用户从源节点的输出圆点拉一条线到目标节点的输入圆点,怎么生成一条合法的边?
关键点:先校验,再造边。 handleConnect(useFlowHandlers.ts:48)第一步就调 isValidConnectionAgentflowV2(core/validation/connectionValidation.ts:6)判断这条连接是否允许,不合法直接 return,不建边。
合法后它做几件事(useFlowHandlers.ts:57-95):
- 取源/目标节点颜色,写进边的
data.sourceColor/targetColor(供边的渲染上色)。 - 对有动态输出口的节点算边标签 :
conditionAgentflow/conditionAgentAgentflow按 handle 序号,humanInputAgentflow分proceed/reject(useFlowHandlers.ts:66-73)。 - 用 ReactFlow 的
addEdge把新边并入,type: 'agentflowEdge';若源和目标同属一个迭代父节点,加一个zIndex让边画在迭代容器之上。
对比老画布:老
onConnect(views/canvas/index.jsx:119)在连线时顺手改目标节点的inputs——把{{sourceNodeId.data.instance}}这种连接字符串写进目标输入。这是经典引擎"用连接串表达依赖"的做法(见 01)。V2 新包不这么干,连接关系只落在edges上。
4.3 拖动、改动与"脏"标记
其余画布事件都走 useFlowHandlers 里的三个函数,套路一致:接住 ReactFlow 的原生 change → 应用到状态 → 若是"有意义"的改动就 setDirty 并 onFlowChange。
handleNodesChange(useFlowHandlers.ts:109):过滤掉select/dimensions/position这类噪声改动(选中、量尺寸、拖动中途),只对真正的增删触发通知——拖动坐标交给拖动结束时统一处理。handleNodeDragStop(useFlowHandlers.ts:130):拖动松手时才把最终坐标写回并通知一次,避免拖动每一帧 都刷状态。handleAddNode(useFlowHandlers.ts:169):点击抽屉节点(不拖)时的等价路径,同样过约束校验、initNode、追加。
5. 加节点抽屉与节点参数编辑
画布之外,features 里另两个模块负责"往画布里加什么"和"节点里填什么"。
5.1 node-palette:加节点抽屉
AddNodesDrawer(node-palette/AddNodesDrawer.tsx:45)是左上角"+"点开的抽屉:按类别(category)分组、带 300ms 防抖搜索(search.ts 的 searchNodes / groupNodesByCategory)。抽屉里每个节点条目可拖(触发 §4.1 的 drop)也可点(触发 §4.3 的 handleAddNode)。
抽屉里的可用节点从哪来?useFlowNodes(canvas/hooks/useFlowNodes.ts:9)向后端 GET nodes 拉全部组件,只留 category === 'Agent Flows' 的节点(useFlowNodes.ts:25);若外部传了 components 白名单,再过滤一遍(永远保留 startAgentflow,useFlowNodes.ts:32-34)。这就是嵌入方能限制"只暴露哪些节点"的机制。
5.2 node-editor:INodeParams 驱动的参数表单
双击节点 → AgentFlowNode 容器调 openNodeEditor(data.id)(AgentFlowNode.tsx:56)→ useOpenNodeEditor(useOpenNodeEditor.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 的渲染循环。几个要点——
- 大多数参数落到
NodeInputHandler(EditNodeDialog.tsx:400,来自atoms/),由它按type出具体输入控件;少数复杂参数(条件、消息数组、结构化输出)走专用 builder。 - 用户改值走
onCustomDataChange(EditNodeDialog.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)。
写回的落点是 updateNodeData(infrastructure/store/AgentflowContext.tsx:273):它改 context 里的 nodes,同步 ReactFlow 本地状态,并调 onFlowChange 通知外层。这样表单里填的每个值都进了 flowData——也就是后端节点执行时读的 nodeData.inputs(见 01/03)。
6. 往返闭环:图 ↔ flowData
这节把前面所有交互收束成一句话:画布状态(nodes/edges/viewport)如何变成 flowData,又如何被后端消费。
6.1 出:序列化
真正的"取当前状态"就一个函数——getFlowData(AgentflowContext.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 拿到最新草稿,可自动持久化 |
| 主动保存 | onSave | Cmd/Ctrl+S 或 Save 按钮 | 先 validateFlow 校验,通过才回调(Agentflow.tsx:199-216) |
| 编程读取 | useAgentflow().getFlow() / .toJSON() | 宿主代码主动调 | 返回对象 / JSON 字符串(useAgentflow.ts:46,53) |
注意 state.nodes 在写入前都过 normalizeNodes(agentflowReducer.ts:6):对内容自适应大小的节点(agentFlow/stickyNote)剥掉存储的 width/height,让它们始终按内容撑开,不被旧尺寸卡住。这是序列化里的一个易漏细节。
6.2 入:反序列化
反向也简单:外层把存库的 flowData 解析后,作为 initialFlow 传给 <Agentflow>。AgentflowStateProvider 用它初始化 reducer 的 nodes/edges(AgentflowContext.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 铺好节点和边,你再填参数"。
前端触发。 GenerateFlowDialog(generator/GenerateFlowDialog.tsx:46)让用户输入 prompt、选一个 chat model,handleGenerate(GenerateFlowDialog.tsx:123)调 chatflowsApi.generateAgentflow({ question, selectedChatModel })。返回的 { nodes, edges } 通过 onGenerated 直接 setLocalNodes / setLocalEdges 铺到画布上(Agentflow.tsx:181-196)。注意对话框文案明确:只生成节点和边,参数要你自己填。
后端两段式。 服务端 generateAgentflowv2(server/src/services/agentflowv2-generator/index.ts:184)先把 marketplaces/agentflowsv2 下的模板样例喂进系统提示词当 few-shot(index.ts:104-108, 190-192),再把活儿交给 components 包里的 generateAgentflowv2(components/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
updateEdges(agentflowv2Generator.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 生成收 敛到同一结构。
handleConnect(useFlowHandlers.ts:48)和updateEdges(agentflowv2Generator.ts:168)对边做同样的着色/handle 归一,保证两条来路产出可互换的 flowData。 - 拖动只在松手时落一次。
handleNodeDragStop(useFlowHandlers.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.tsx | AgentflowCanvas、Agentflow |
| 包对外 API | packages/agentflow/src/index.ts | (barrel 导出) |
| Provider 层层注入 | packages/agentflow/src/AgentflowProvider.tsx | AgentflowProvider |
| 编程句柄(getFlow/toJSON/validate) | packages/agentflow/src/useAgentflow.ts | useAgentflow |
| 拖放建节点 | packages/agentflow/src/features/canvas/hooks/useDragAndDrop.ts | useDragAndDrop、handleDrop |
| 连线/改动/拖动处理 | packages/agentflow/src/features/canvas/hooks/useFlowHandlers.ts | handleConnect、handleNodesChange、handleNodeDragStop、handleAddNode |
| 加载可用节点(过滤 Agent Flows) | packages/agentflow/src/features/canvas/hooks/useFlowNodes.ts | useFlowNodes |
| ReactFlow 节点/边类型注册 | packages/agentflow/src/features/canvas/index.ts | nodeTypes、edgeTypes |
| 加节点抽屉 | packages/agentflow/src/features/node-palette/AddNodesDrawer.tsx | AddNodesDrawer |
| 打开节点编辑器 | packages/agentflow/src/features/canvas/hooks/useOpenNodeEditor.ts | useOpenNodeEditor |
| INodeParams 驱动的参数表单 | packages/agentflow/src/features/node-editor/EditNodeDialog.tsx | EditNodeDialog、onCustomDataChange |
| 状态中心(序列化真源) | packages/agentflow/src/infrastructure/store/AgentflowContext.tsx | getFlowData、updateNodeData、syncNodes |
| 节点尺寸归一 | packages/agentflow/src/infrastructure/store/agentflowReducer.ts | normalizeNodes |
| 节点工厂(id/默认值/类型解析) | packages/agentflow/src/core/utils/nodeFactory.ts | initNode、getUniqueNodeId、resolveNodeType |
| 连接/放置校验 | packages/agentflow/src/core/validation/{connectionValidation,constraintValidation,flowValidation}.ts | isValidConnectionAgentflowV2、checkNodePlacementConstraints、validateFlow |
| FlowData / FlowNode 类型 | packages/agentflow/src/core/types/flow.ts | FlowData、FlowNode、FlowEdge |
| 生成对话框(前端) | packages/agentflow/src/features/generator/GenerateFlowDialog.tsx | GenerateFlowDialog、handleGenerate |
| 生成服务(后端) | packages/server/src/services/agentflowv2-generator/index.ts | generateAgentflowv2 |
| 生成算法(components) | packages/components/src/agentflowv2Generator.ts | generateAgentflowv2、updateEdges、generateNodesData |
| 老画布(对照) | packages/ui/src/views/canvas/index.jsx | Canvas、onDrop、handleSaveFlow、onConnect |