跳到主要内容

数据截至 (上游 commit b78a3462c9a6)

编辑侧:画布画出什么、数据库存什么、DSL 带走什么

30 秒导读: 在 Dify 里拖一个节点、连一条线,最终只会变成两件东西——一个 nodes 数组和一个 edges 数组。本章讲这份图在被执行之前的一生:浏览器里怎么被画出来、多人同时改怎么不打架、怎么变成数据库里的一行、怎么导出成一份别人能导入的 YAML。运行期的事全在别的章。

1. 这是什么(零基础也能懂)

一句话定义: Dify 的工作流编辑器是一个「JSON 图的可视化编辑器」——画布负责把 JSON 画成方块和连线,也负责把你的拖拽改回 JSON。

解决什么问题: 你要搭一条 AI 流程(用户提问 → 检索知识库 → 交给大模型 → 输出答案),不想写代码。于是你在网页上拖四个方块、连三条线。这些操作必须被存下来、被别人看到、还能打包送给同事

编辑侧就管这三件事,对应三种形态:

形态长什么样活在哪
画布态React Flow 的 nodes / edges 数组(带一堆临时 UI 字段)浏览器内存
存储态一段 JSON 文本Postgres workflows 表的 graph
可移植态一份 YAML(带版本号、依赖清单,不带密钥)用户下载的 .yml 文件

用起来什么样(直观感受): 你在画布上把一个 LLM 节点从左边挪到右边。5 秒后浏览器悄悄发一个 POST 到 /apps/{appId}/workflows/draft,body 大致长这样:

{
"graph": {
"nodes": [{ "id": "1711...", "type": "custom", "position": { "x": 380, "y": 282 },
"data": { "type": "llm", "title": "LLM", "model": { "provider": "openai" } } }],
"edges": [{ "id": "start-source-1711-target", "source": "start", "target": "1711...",
"data": { "sourceType": "start", "targetType": "llm" } }],
"viewport": { "x": 0, "y": 0, "zoom": 1 }
},
"features": { "opening_statement": "" },
"environment_variables": [],
"conversation_variables": [],
"hash": "上一次服务端返回的图指纹"
}

上面这段是示意,非源码——字段名照着真实的 payload 组装逻辑写(web/app/components/workflow-app/hooks/use-nodes-sync-draft.ts:107-123getPostParams 返回值),值是举例。重点看:图的全部内容就是 nodes + edges,别的都是配料。

一句话直觉: 把画布当成 Git 工作区、把 version="draft" 的那行记录当成未提交的改动、把「发布」当成 commit、把 DSL 导出当成 打 tarball 寄给别人。这个类比后面每一节都成立。

2. 顶层全景(它大概怎么转)

怎么读这张图:中间一竖是「画布 → 数据库 → 版本」的主干,①②③ 是画布内容的三条去路(落库、广播给同伴、打包导出)。

浏览器(React Flow 画布)
nodes[] + edges[] + viewport

┌───────────┼───────────────┐
│ │ │
①剥掉 _ 字段 ②CRDT 广播 ③导出 YAML
│ │ │
▼ ▼ ▼
POST /workflows/draft socket.io 房间 DSL 文件
│ (同一个 app 的所有人) (version/app/
▼ │ workflow/dependencies)
workflows 表 ◄──┘ 只有 leader 负责落库
version="draft" 的那一行

│ 点「发布」

workflows 表新增一行(version = 时间戳)

部件职责:

部件干什么在哪个文件
画布容器挂 React Flow,持有 nodes/edges 两个 stateweb/app/components/workflow/index.tsx:174
节点渲染分发data.type 找到对应的节点组件与配置面板web/app/components/workflow/nodes/index.tsx:9
边渲染画贝塞尔曲线,悬停时露出「在这插一个节点」web/app/components/workflow/custom-edge.tsx:15
节点选择器列出可加的 block/工具/触发器web/app/components/workflow/block-selector/index.tsx:61
画布状态仓zustand 多 slice store(面板宽度、历史、变量…)web/app/components/workflow/store/workflow/index.ts
行为注入仓让同一套画布被 workflow / rag-pipeline / snippet 复用web/app/components/workflow/hooks-store/store.ts
草稿同步组装 payload、剥字段、防冲突、发 POSTweb/app/components/workflow-app/hooks/use-nodes-sync-draft.ts:44
协作管家Loro CRDT 文档 + socket.io + leader 状态web/app/components/workflow/collaboration/core/collaboration-manager.ts:161
协作服务端鉴权入房、事件转发、leader 选举api/services/workflow_collaboration_service.py:40
存储模型workflows 表,一行 = 一个版本api/models/workflow.py:177
草稿/发布服务写草稿、发版本、把发布版还原回草稿api/services/workflow_service.py:203
DSL 服务导出/导入 YAML、抽依赖、加解密 dataset idapi/services/app_dsl_service.py:106

主线走一遍(不进代码):

  1. 打开编辑器 → GET 草稿 → 后端把 graph 列的 JSON 吐回来。
  2. 前端「补水」:给每个节点算出连了哪些 handle、给 if-else 算出分支列表、给老数据打补丁。
  3. 你拖一下 → 画布 state 变 → 两件事并行:广播给同房间的人、5 秒 debounce 后 POST 草稿。
  4. 后端拿 hash 比对指纹,一致才覆盖那一行 JSON,并返回新指纹。
  5. 点发布 → 复制草稿那一行,version 换成时间戳,新增一行。
  6. 点导出 → 把草稿(或指定版本)转成 YAML,顺手把凭据抹掉、把知识库 ID 加密、把用到的插件列成依赖清单。

3. 核心机制

3.1 画布最终只产出两个数组

它要解决的小问题: 一个可视化编辑器可以设计得非常复杂,但存下来的东西越简单越好。Dify 的选择是:只存 nodesedges,外加一个记录镜头位置的 viewport

节点和边都是 React Flow 的原生类型 + 一个 data 载荷。 类型定义只有两行:

export type Node<T = {}> = ReactFlowNode<CommonNodeType<T>>
export type Edge = ReactFlowEdge<CommonEdgeType>

web/app/components/workflow/types.ts:138:145。也就是说位置、宽高、父子关系这些都交给 React Flow 管,Dify 只管 data 里塞什么。

data.type 是整张图的路由键。 画布不给每种节点写一套渲染逻辑,而是查两张表:

  • NodeComponentMap —— 画在画布上的方块(web/app/components/workflow/nodes/components.ts:80)
  • PanelComponentMap —— 右侧的配置面板(同文件 :109)

CustomNodeprops.data.type 去查表,查到就渲染(web/app/components/workflow/nodes/index.tsx:9)。整个画布只注册了 6 种 React Flow 节点类型(CUSTOM_NODECUSTOM_NOTE_NODE、迭代/循环的起点节点等),真正的几十种业务节点全靠 data.type 分发(web/app/components/workflow/index.tsx:108-118nodeTypes/edgeTypes)。

这份「JSON 里的 data.type」到了后端怎么变成可执行对象,是 03 章 的事。本章只关心它在画布和数据库之间的样子。

新节点从哪来: 节点选择器列出的每一项都是一个 NodeDefault,里面带 metaData(类型、分类、标题)和 defaultValue(这种节点的默认配置)。useAvailableNodesMetaData 把它们组装成一张表,并按聊天模式/工作流模式过滤(web/app/components/workflow-app/hooks/use-available-nodes-meta-data.ts:29)。选中后 generateNewNode 造出节点对象——它顺手处理了迭代/循环节点必须自带一个起点子节点这件事(web/app/components/workflow/utils/node.ts:16)。

新边从哪来: 你手动连线时 handleNodeConnect 造一条边,边的 id 是 ${source}-${sourceHandle}-${target}-${targetHandle},data 里冗余记下两端的节点类型 sourceType/targetType(web/app/components/workflow/hooks/use-nodes-interactions.ts:493-575)。冗余是有意的:边组件要据此决定「在这条线上能插什么节点」,不必反查节点表。

同一个函数里还能看到几条硬约束:自己连自己不行、跨父容器连不行、注释节点不能连、重复边不加。

3.2 下划线前缀 = 画布态与存储态的分界线

这是本章最值得带走的一条

它要解决的小问题: 画布需要一大堆临时状态——这个节点正在运行吗、鼠标悬停了吗、它连出去的 handle 有哪些、if-else 有几个分支。这些不能存进数据库,否则草稿会被 UI 噪声污染,指纹也会天天变。

Dify 的约定极其朴素: data 里凡是 _ 开头的字段,都是派生的、临时的、不落库的CommonNodeType 里一眼望去全是这类字段:_connectedSourceHandleIds_targetBranches_runningStatus_isCandidate_iterationIndex…(web/app/components/workflow/types.ts:77-120)。

进出各一道工序:

数据库 JSON ──► initialNodes / initialEdges ──► 画布态
(干净) 补 _ 字段 + 老数据迁移 (带 _ 字段)

画布态 ──► getPostParams 里的 produce 删除 _ 键 ──► POST 出去
(又干净)

入口(补水): initialNodes 给每个节点算出 _connectedSourceHandleIds / _connectedTargetHandleIds,给 if-else 从 cases 推出 _targetBranches,给迭代/循环填 _children,还顺手做了几处历史数据迁移(旧 if-else 的 conditions+logical_operator 升级成 cases、HTTP 节点补默认重试配置、工具节点补 tool_node_version: '2')——web/app/components/workflow/utils/workflow-init.ts:190initialEdges 则补 sourceType/targetType,并过滤掉会成环的边(getCycleEdges,同文件 :314)。

出口(脱水): 同步草稿时用 immer 把 _ 开头的键全删掉:

const producedNodes = produce(nodes, (draft) => {
draft.forEach((node) => {
Object.keys(node.data).forEach((key) => {
if (key.startsWith('_'))
delete node.data[key]
})
})
})

web/app/components/workflow-app/hooks/use-nodes-sync-draft.ts:72-78,边走一模一样的逻辑(:80-88)。

同一道工序还顺手滤掉了两类「只在本地存在」的节点: StartPlaceholder(工作流还没选入口时的占位方块)和 _isTempNode 标记的临时节点,以及连着它们的边(:36-56:80)。占位节点是纯前端造的,连后端都不知道它存在(web/app/components/workflow-app/hooks/use-workflow-draft-graph-for-canvas.ts:31-55)。

踩过的坑留下的痕迹: 这条规则在协作路径上被破了一个小口子——shouldSyncDataKey 白名单允许四个 _ 字段跨端同步:_children_connectedSourceHandleIds_connectedTargetHandleIds_targetBranches(web/app/components/workflow/collaboration/core/collaboration-manager.ts:408)。原因不难猜:这四个是结构性派生字段(谁连谁、有哪些分支),对端不同步就会画错;而 selected 反倒被明确排除,因为「我选中了哪个节点」不该传染给别人。

3.3 草稿同步:5 秒防抖 + 指纹乐观锁

它要解决的小问题: 编辑器不能每拖一像素就发一次请求,也不能允许两个标签页互相覆盖。

防抖: store 里挂了一个 5000ms 的 debounce 函数,所有「改完了该存一下」都走它(web/app/components/workflow/store/workflow/workflow-draft-slice.ts:35)。需要立刻存的场景(比如页面卸载)可以传 sync=true 绕过防抖(web/app/components/workflow/hooks/use-nodes-sync-draft.ts:17-23),页面关闭时改用 postWithKeepalive 发出最后一枪(web/app/components/workflow-app/hooks/use-nodes-sync-draft.ts:130-138)。

乐观锁: 每次 POST 都带上一个 hash,后端对比这行记录当前的指纹:

if workflow and workflow.unique_hash != unique_hash:
raise WorkflowHashNotEqualError()

api/services/workflow_service.py:437。指纹本身就是图的内容哈希——json.dumps({"graph": graph_dict}, sort_keys=True) 的文本哈希(api/models/workflow.py:557unique_hash)。

冲突了怎么办: 控制器把它翻译成 DraftWorkflowNotSync 错误(api/controllers/console/app/workflow.py:676),前端认出 code === 'draft_workflow_not_sync'放弃本次写入、重新拉一遍草稿(web/app/components/workflow-app/hooks/use-nodes-sync-draft.ts:198)。也就是「后到的让路」,没有自动合并。

写入前还有两道校验: validate_features_structure 按应用模式校验 features,validate_graph_structure 做轻量图检查——目前只管两件事:start 节点和触发器节点不能共存、human-input 节点的数据得合法(api/services/workflow_service.py:1796-1827)。注意这里不做连通性、类型、变量引用的检查,那些留到运行期(见 01 章)。

请求本身还有一层串行化: 串行化落在 doSyncWorkflowDraftLocally——它被 useSerialAsyncCallback 包过,保证同一时刻只有一次草稿写入在飞(web/app/components/workflow-app/hooks/use-nodes-sync-draft.ts:213);外层 doSyncWorkflowDraft:202)在协作模式下还会把写入路由给 leader。

3.4 多人同时编辑一张图:CRDT 广播 + leader 落库

它要解决的小问题: 三个人同时开着同一张画布。图要实时同步(你拖我也看见),但数据库那一行只能有一个人写——否则 3.3 的指纹锁会让三个人互相打回。

Dify 的取舍是把这两件事拆开:

  • 图的实时同步用 CRDT——人人平等,谁改都能合并;
  • 图的落库权用 leader 选举——只有一个人有资格 POST 草稿。

怎么读下面这张图:上半部分是「人人平等」的图内容广播,下面一行是「只有一条路」的落库路径。

用户A(leader) 用户B 用户C
LoroDoc LoroDoc LoroDoc
│ │ │
└── graph_event ─────┴──── graph_event ─┘
(不透明的二进制增量)

socket.io 房间 = app_id
│ 转发给房间里其他人

各端 doc.import() → 回填自己的 React Flow

落库只有一条路:B/C 发 sync_request → A → POST /workflows/draft

图内容:Loro CRDT。 前端为每个 app 建一个 LoroDoc,里面两个 map:nodesedges(web/app/components/workflow/collaboration/core/collaboration-manager.ts:565-570)。本地一改就导出二进制增量发 graph_event,收到别人的 graph_updatedoc.import():

this.doc.subscribe((event: { by?: string }) => {
if (event.by === 'local') {
const update = this.doc.export({ mode: 'update' })
emitWithAuthGuard(this.socket, 'graph_event', update, { onUnauthorized: this.onUnauthorized })
}
})

web/app/components/workflow/collaboration/core/crdt-provider.ts:32。服务端对 graph_event 不解析、不落库,纯转发——注释直说是 "simple broadcast relay"(api/controllers/console/socketio/workflow.py:109,落到 WorkflowCollaborationService.relay_graph_event,api/services/workflow_collaboration_service.py:338)。二进制增量对服务端是不透明的字节流。

回填画布时的两条讲究(collaboration-manager.ts:1322-1406):只处理 event.by === 'import'(自己产生的变更不回灌,否则死循环);回填时保留本地的 _ 私有字段和自己的选中状态,再调 React Flow 的原生 setNodes,绕过协作广播。

落库权:leader 选举。 服务端在 Redis 里用 SET NX EX 抢一个 workflow_leader:{app_id} 键,谁抢到谁是 leader(api/repositories/workflow_collaboration_repository.py:263set_leader_if_absent,TTL 3600 秒)。选举与广播的入口在这几处:

时机服务端做什么符号
加入房间校验 tenant + app 归属,写会话信息,抢/读 leader,发 statusauthorize_and_join_workflow_room
首次抢锁抢不到就读当前 leaderget_or_set_leader
leader 掉线从活跃会话里挑一个新 leaderhandle_leader_disconnect + _select_graph_leader
身份变更逐个 sid 发 status: {isLeader}broadcast_leader_change
人员变动清理僵尸会话、必要时补选、广播在线名单broadcast_online_users

全在 api/services/workflow_collaboration_service.py:39 / :160 / :182 / :197 / :228 / :269

非 leader 怎么办: 它根本不发 POST,而是发一个 sync_request;服务端定向转发给 leader 的 sid(而不是广播),leader 收到后替全房间存一次:

follower: performSync() 发现自己不是 leader
→ collaborationManager.emitSyncRequest()
→ 服务端 relay_collaboration_event 找到 leader sid,单播
→ leader 的 manager 判定 isLeader 才 emit 'syncRequest'
→ workflow-main 收到后调 doSyncWorkflowDraft() → POST

依据:web/app/components/workflow-app/hooks/use-nodes-sync-draft.ts:226-241api/services/workflow_collaboration_service.py:228-283collaboration-manager.ts:1799web/app/components/workflow-app/components/workflow-main.tsx:347

服务端还有一层「leader 已经死了但键还在」的兜底: 转发 sync_request 前先用 is_session_active 三重确认(socket 还连着、房间会话记录还在、sid 映射还在),不活就删键重选(workflow_collaboration_service.py:511)。

鉴权在连接层就做完了: socket 建连时校验 token、加载账号、要求 has_edit_permission,不满足直接拒连(api/controllers/console/socketio/workflow.py:23-60);进房间时再校验 app 属于该 tenant(_can_access_workflow,workflow_collaboration_service.py:154)。

这套取舍的代价,诚实说:

  • CRDT 保证各端画布最终一致,但数据库里的那一行是 leader 的快照——leader 恰好在合并中途落库,存下的就是中途状态。
  • 整个协作是功能开关控制的(enable_collaboration_mode),关掉时前端根本不连 socket,退化成单人编辑;没有编辑权限(canEdit 为假)时同样不连(web/app/components/workflow/collaboration/hooks/use-collaboration.ts:43)。
  • 服务端不理解图语义,所以服务端无法做冲突仲裁,也无法拒绝一个结构非法的中间态。

3.5 存储形态:一张表、一行一个版本

workflows 表就是全部。 草稿和所有历史版本都在同一张表里,靠 version 列区分(api/models/workflow.py:177 起):

类型说明
tenant_id / app_idUUID归属;与 version 一起建索引 workflow_version_idx
typeWorkflowTypeworkflow / chat / rag-pipeline / snippet
kindWorkflowKindstandard / snippet,可空,默认 standard
version字符串"draft" 或发布时间戳字符串
graphLongText整张图的 JSON:nodes + edges(+ viewport)
features(属性名 _features)LongText开场白、语音、引用等应用级开关
environment_variablesLongText环境变量,secret 类型加密存
conversation_variablesLongText会话变量(chatflow 用)
rag_pipeline_variablesLongTextRAG 流水线变量
marked_name / marked_comment字符串发版时的名字与备注

对应行号::210-238,VERSION_DRAFT = "draft":240

草稿只有一行,发布是「另存一行」。 get_draft_workflow 就是按 version == VERSION_DRAFT 查(api/services/workflow_service.py:245);publish_workflow 读出草稿,用 Workflow.new(...) 复制一份、version 换成时间戳、插入新行(:454-530)。App 表的 workflow_id 指向「当前生效的那一行」,所以发布 = 换指针(get_published_workflow,:200)。

拿 Git 类比继续对:草稿 = 工作区,发布版 = 一个个 commit,app.workflow_id = HEAD。

反向操作也有: restore_published_workflow_to_draft 把某个历史版本盖回草稿(:417)。这里有个安全细节值得看——它不让前端把明文密钥传回来,而是服务端直接搬运密文:copy_serialized_variable_storage_from 原样拷贝三个变量列的 JSON 字符串(api/models/workflow.py:739)。

三类变量,三种作用域:

变量类谁能用存成什么特殊处理
environment整张图,跟版本走以变量 name 为 key 的对象secret 类型用租户密钥加解密
conversationchatflow 的一次会话,跨轮次以变量 name 为 key 的对象
rag_pipelineRAG 流水线,跟版本走variable 为 key 的对象RAGPipelineVariable 校验

依据:api/models/workflow.py:595(environment getter)、:679(conversation)、:692(rag_pipeline)。三者都是「列里存 JSON 字符串、属性上做序列化/反序列化」的同一套写法。

密钥的三道防线(全在 api/models/workflow.py):

  1. 读时解密:environment_variables getter 逐个把 SecretVariable 解密,用 workflow.tenant_id 而不是当前请求用户,以便后台线程也能用(:565-590)。
  2. 写时保值:如果传进来的值等于 HIDDEN_VALUE 哨兵,就沿用原值、只改名字(:617-621)——这样前端永远不必持有明文。
  3. 前端掩码归一:同步草稿时,控制器先把 UI 显示的掩码串换成 HIDDEN_VALUE(normalize_environment_variable_mappings,:638;调用处 api/controllers/console/app/workflow.py:645)。

kind 是给「可复用子图」用的。 snippet(代码片段式的子工作流)也存在同一张表,kind = snippet,app_id 填的是 snippet 自己的 id(api/services/snippet_service.py:582),查询时靠 _snippet_kind_filter 隔离(:86-89)。运行期也会读这个字段来分流(api/core/app/apps/workflow/app_generator.py:76)。

顺带一提编辑器的本地状态: 面板宽度、变量检查面板高度、指针/抓手模式这类纯个人偏好不进数据库,走 localStorage,由 WorkflowLocalStorageBridge 在挂载时读进 store、在 store 变化时写回(web/app/components/workflow/persistence/local-storage-bridge.tsx:12persistence/local-storage-options.ts)。变量检查面板(variable-inspect/)也只是这类 UI 状态的消费者——它展示的草稿变量值属于运行期留痕,见 04 章

3.6 DSL:把一张图装箱寄走

它要解决的小问题: 我做了一条好用的流程,想让同事在他自己的 Dify 上跑起来。可我的知识库 ID、我的 API 凭据、我装的插件,他都没有。

导出的箱子长这样(结构照 AppDslService.export_dsl 组装,值为举例;api/services/app_dsl_service.py:664):

version: "0.6.0" # CURRENT_DSL_VERSION
kind: app
app: # 名字、图标、模式
name: 我的流程
mode: workflow
workflow: # to_dict() 的产物
graph: { nodes: [...], edges: [...] }
features: {...}
environment_variables: [...]
conversation_variables: [...]
rag_pipeline_variables: [...]
dependencies: # 这张图用到的插件
- type: marketplace
value: { plugin_unique_identifier: langgenius/openai:... }

version 常量在 api/constants/dsl_version.py(当前 0.6.0),workflow 段来自 Workflow.to_dict()(api/models/workflow.py:692)。

装箱时的三次「消毒」(_append_workflow_export_data,:552-604):

处理对象做了什么为什么
知识库节点的 dataset_idsAES-CBC 加密成 base64别的租户拿到也解不开,同租户导回来能还原
工具节点 / Agent 节点的 credential_idinclude_secret 时直接删掉凭据引用不出租户
触发器节点schedule 恢复默认配置、webhook 清空 URL、plugin 清空 subscription_id这些是实例私有的运行时绑定

外加一层:to_dict(include_secret=False) 会把所有 SecretVariable 的值置空(api/models/workflow.py:694-697)。

加密用的是「租户 id 派生密钥」: sha256(tenant_id) 当 key、取前 16 字节当 IV(_generate_aes_key,:779;encrypt_dataset_id,:784)。可以由 DSL_EXPORT_ENCRYPT_DATASET_ID 关掉(默认开,api/configs/feature/__init__.py:1283)。

解密走的是「先猜是不是明文」的容错路径: decrypt_dataset_id 先看它是不是合法 UUID(是就当明文直接用,兼容老 DSL),否则尝试解密,解出来还得再验一次是不是 UUID,任何一步失败都返回 None——然后导入侧把 None 从列表里滤掉(:796-820,调用处 :478-487)。别人的知识库 ID 会安静消失,而不是变成一个指向陌生数据的悬空引用。

依赖清单是逐节点「认脸」抽出来的。 _extract_dependencies_from_workflow_graph 遍历 graph["nodes"],按 data.type 匹配五类节点,各自用 Pydantic 实体解析后取出 provider(:645-717):

节点类型抽什么
toolprovider_id → 工具插件
llmmodel.provider → 模型插件
question-classifier / parameter-extractor各自的 model.provider
knowledge-retrievalrerank 模型或 embedding 模型的 provider(按 retrieval_mode 分叉)

每个节点单独 try/except,解析失败只记日志不中断——宁可少列一个依赖,不能因为一个坏节点导不出整张图(:718-719)。

开箱:版本兼容的四态判定。 check_version_compatibility 只有 20 行,却定义了导入的全部结局(api/services/dsl_version.py:6):

比较结果状态用户看到什么
导入版本 > 当前版本PENDING「这份 DSL 比你的系统新」,要二次确认
主版本号偏小PENDING同上,跨大版本要确认
次版本号偏小COMPLETED_WITH_WARNINGS导入成功但给警告
其余COMPLETED直接成功

PENDING 不是失败——它把整份 YAML 连同参数存进 Redis(key app_import_info:{import_id},TTL 10 分钟),等用户点确认后由 confirm_import 用同一份 pending data 重跑一次创建流程(api/services/app_dsl_service.py:245-268:296)。前端对应的两个分支就是 handleCompletedImporthandlePendingImport(web/app/components/workflow/update-dsl-modal.tsx:86:114)。

导入的其余关口(import_app,api/services/app_dsl_service.py:111):

  • 从 URL 导入时特判 GitHub blob 链接、限制 10MB(DSL_MAX_SIZE)、空内容直接失败(:121-148);
  • YAML 必须是 mapping,缺 version0.1.0,kind 强制为 app(:176-179);
  • 覆盖已有 app 时,只允许 workflow / advanced-chat 两种模式(:205-213);
  • 老版本(≤0.1.5)没有 dependencies 段时,现场从图里抽一遍(:246-255);
  • 图落地走的仍是 sync_draft_workflow,所以 3.3 那套校验和指纹逻辑一视同仁(:488-497);
  • 导入完清掉旧的草稿变量(delete_app_workflow_variables,:272)。

前端还有一道模式白名单: 导入前先用 js-yaml 解一遍,advanced-chat 模式禁止 end 和三种触发器节点,workflow 模式禁止 answer 节点(web/app/components/workflow/update-dsl-modal.helpers.ts:46-67)。导入成功后前端重新 GET 一次草稿再刷画布,而不是拿本地 YAML 直接渲染(update-dsl-modal.tsx:85-105)——保证画布看到的永远是服务端认可的那份图。

4. 巧妙之处(可借鉴的技术)

① 用命名约定代替两套类型。 画布态和存储态共用一个 data 对象,靠 _ 前缀区分「这个字段该不该存」。代价是一行 key.startsWith('_'),收益是不必维护 DTO ↔ ViewModel 的映射(use-nodes-sync-draft.ts:75types.ts:78)。反面教训也在同一处:一旦有例外,就得开白名单(collaboration-manager.ts:408)。

② 图的指纹就是乐观锁。 不加版本号列、不加 updated_at 比对,直接对 graph 的规范化 JSON 做哈希(models/workflow.py:557)。好处是「内容没变的重复提交」天然幂等。

③ 实时协作与持久化解耦。 CRDT 管「看到的一致」,leader 管「存下来的唯一」。服务端对图内容零解析,因此加/换 CRDT 库不影响后端(crdt-provider.ts:32workflow_collaboration_service.py:338)。

④ 密钥永不出服务端。 前端只见掩码,回传掩码或 HIDDEN_VALUE 就等于「别动」;版本还原直接在服务端搬密文(models/workflow.py:647:709)。这条模式可以直接抄到任何「表单里有密钥字段」的场景。

⑤ 解密失败当作「不属于我」而不是报错。 decrypt_dataset_id 的多级容错(是 UUID → 明文;能解密且是 UUID → 还原;否则 None 被过滤)让跨租户导入优雅降级,而不是抛栈(app_dsl_service.py:1026)。

⑥ 依赖抽取按节点隔离失败。 一个节点的实体校验炸了只落一条日志,导出照常(app_dsl_service.py:948)。导出功能的可用性比依赖清单的完整性优先级高。

⑦ 历史数据迁移放在「补水」那一步。 老图的字段升级不写数据库迁移脚本,而是在 initialNodes 里就地修正,下次同步草稿时自然写回新格式(workflow-init.ts:236:285:293)。惰性迁移,不停机。

5. 边界与局限(诚实版)

  • 草稿冲突不合并,后到者重拉。 指纹不匹配就整体拒绝,没有字段级 merge(workflow_service.py:291)。
  • 协作是可选功能,且服务端不懂图。 enable_collaboration_mode 关掉即退化为单人;开着时服务端也只转发字节,无法仲裁语义冲突。
  • 落库快照 = leader 的画布。 数据库那一行未必是任何时刻「所有人都看到过」的状态。
  • 编辑期校验很浅。 validate_graph_structure 只查 start/trigger 共存与 human-input 数据(workflow_service.py:1533),连通性、类型匹配、变量引用要到运行期才炸。
  • 导出的 DSL 默认不带凭据,也从不带知识库内容。 导入方必须自己装插件、配凭据、建知识库;dependencies 只是一张「你还缺这些」的清单(check_dependencies / get_leaked_dependencies,app_dsl_service.py:411:975)。唯一带密钥的路径是同租户内复制应用,那条路上服务端自己调 export_dsl(include_secret=True) 再立刻导回,YAML 不落地(api/controllers/console/app/app.py:979)。
  • 跨租户导入会静默丢掉 dataset_ids 这是有意的安全取舍,但对用户来说是「知识库节点空了」而没有显式提示(:478-487)。
  • PENDING 的导入只活 10 分钟。 Redis key 过期后确认按钮就失效,得重新上传(IMPORT_INFO_REDIS_EXPIRY,api/services/app_dsl_service.py:72)。
  • 协作会话状态在 Redis 里带 TTL(3600s)。 网络分区久了会出现「僵尸 leader」,靠 is_session_active 三重检查兜底(repositories/workflow_collaboration_repository.py:12workflow_collaboration_service.py:511)。

6. 和同组其它章的关系

你想知道去哪章
这份图被点「运行」之后发生什么01 请求生命周期
data.type 怎么变成可执行节点对象03 graphon 边界与 DifyNodeFactory
执行过程怎么留痕、单节点怎么调试04 执行期横切
human-input 节点停下来等人的那一半05 停下来等人
触发器节点的 webhook_url、插件运行时06 触发器与插件运行时

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

主题文件路径符号名
画布容器与 React Flow 装配web/app/components/workflow/index.tsxWorkflownodeTypesedgeTypes
节点/边的类型定义web/app/components/workflow/types.tsCommonNodeTypeCommonEdgeTypeNodeEdge
按 type 分发节点与面板web/app/components/workflow/nodes/components.tsNodeComponentMapPanelComponentMap
边渲染与「线上插节点」web/app/components/workflow/custom-edge.tsxCustomEdge
可加节点的元数据表web/app/components/workflow-app/hooks/use-available-nodes-meta-data.tsuseAvailableNodesMetaData
造新节点(含迭代/循环起点)web/app/components/workflow/utils/node.tsgenerateNewNodegetIterationStartNode
加载时补 _ 字段与老数据迁移web/app/components/workflow/utils/workflow-init.tsinitialNodesinitialEdgespreprocessNodesAndEdges
落库前剥 _ 字段、组 payloadweb/app/components/workflow-app/hooks/use-nodes-sync-draft.tsgetPostParamsperformSyncsyncWorkflowDraftWhenPageClose
5 秒防抖与草稿指纹 stateweb/app/components/workflow/store/workflow/workflow-draft-slice.tscreateWorkflowDraftSlicesyncWorkflowDraftHash
画布 store 的多 slice 组装web/app/components/workflow/store/workflow/index.tscreateWorkflowStoreShape
行为注入(多形态复用同一画布)web/app/components/workflow/hooks-store/store.tscreateHooksStoreCommonHooksFnMap
协作总管(CRDT + leader 状态)web/app/components/workflow/collaboration/core/collaboration-manager.tsCollaborationManagershouldSyncDataKeysetupSubscriptionsemitSyncRequest
CRDT 增量收发web/app/components/workflow/collaboration/core/crdt-provider.tsCRDTProvider
广播前抹掉本地选中态web/app/components/workflow/hooks/use-collaborative-workflow.tssanitizeNodeForBroadcastuseCollaborativeWorkflow
socket 事件入口api/controllers/console/socketio/workflow.pysocket_connecthandle_user_connecthandle_graph_event
房间鉴权、转发、leader 选举api/services/workflow_collaboration_service.pyWorkflowCollaborationServiceauthorize_and_join_workflow_roomrelay_graph_eventget_or_set_leaderbroadcast_leader_changebroadcast_online_users
协作状态的 Redis 键与 TTLapi/repositories/workflow_collaboration_repository.pyWORKFLOW_LEADER_PREFIXset_leader_if_absentSESSION_STATE_TTL_SECONDS
存储模型与变量作用域api/models/workflow.pyWorkflowVERSION_DRAFTWorkflowKindgraph_dictunique_hashenvironment_variablesto_dictcopy_serialized_variable_storage_from
草稿写入、发布、还原api/services/workflow_service.pyWorkflowServiceget_draft_workflowsync_draft_workflowpublish_workflowrestore_published_workflow_to_draftvalidate_graph_structure
草稿同步 HTTP 端点api/controllers/console/app/workflow.pyDraftWorkflowApiSyncDraftWorkflowPayload(错误类 DraftWorkflowNotSyncapi/controllers/console/app/error.py:94)
DSL 导入导出与消毒api/services/app_dsl_service.pyAppDslServiceimport_appconfirm_importexport_dsl_append_workflow_export_data_extract_dependencies_from_workflow_graphencrypt_dataset_iddecrypt_dataset_id
DSL 版本兼容判定api/services/dsl_version.py / api/constants/dsl_version.pycheck_version_compatibilityCURRENT_APP_DSL_VERSION
导入弹窗与前端模式白名单web/app/components/workflow/update-dsl-modal.tsx / update-dsl-modal.helpers.tsUpdateDSLModalvalidateDSLContentgetInvalidNodeTypes
编辑器本地偏好持久化web/app/components/workflow/persistence/local-storage-bridge.tsxWorkflowLocalStorageBridge
可复用子图(snippet)的存储api/services/snippet_service.py_snippet_kind_filterWorkflowKind.SNIPPET