--- name: standard-graph-workflow-editor description: > 图工作流编辑器。做可拖拽的图编辑、草稿与发布,或新增一种节点类型时使用。 --- # 图工作流编辑器 本篇只管建图、存图、校验、发布。LLM 对话的执行见 `standard-llm-workflow-runtime`。 画布用 React Flow(`@xyflow/react`)。整张图存进一个 JSONB 列,草稿与发布快照分两张表。 ## 图数据模型 顶层固定为扁平 `nodes`、显式 `edges`(`source` / `target` / `source_handle`)、`start_node_id` 和元数据。直线流程也写 edges。用「第 i 个流向第 i+1 个」隐含连线时,加分支、并行或循环就要改存储格式,存量图已经是旧格式。 `start` 与 `end` 都是真实节点。`start` 唯一且无入边。`end` 无出边,每条路径都连到它。边的 `target` 只写存在的节点 id。 不用 `"__end__"` 这类保留字符串当 `target`。终止走节点类型,不另立一份前后端各写的常量。可达性就是从 `start` 能否走到 `end`。`end` 上可以挂返回值、结束原因、收尾动作。 发布前结构约束全部通过: | 约束 | 说明 | |---|---| | start 唯一且无入边 | 多入口则执行起点不确定 | | end 可达 | 每条路径都要能走到 end,否则运行时挂起 | | 边的 target 必须是节点 | 不存在指向保留字符串的边 | | 节点 id / 边 id 分别唯一 | | | 边的 source/target 必须引用存在的节点 | 悬空边 | | 不允许自环 | | | 不允许孤立节点 | 拖进来忘了连,运行时不执行 | | 分支节点每个出口恰好一条出边 | 条件/意图类节点的每个 `source_handle` 都要有归属 | | 节点数 / 边数 / id 长度上限 | 防止画布卡死 | ## 草稿与发布 草稿表存当前编辑态,编辑、保存、发布校验读它。发布快照表存发布那一刻的完整副本,执行读它。 执行只读发布快照。草稿上的 `status` 挡不住「已发布过、现在又在改」:运营改到一半时,线上仍要跑上一份快照。发布前试跑读草稿。 发布顺序固定: 1. 跑发布门校验,不通过就拒绝 2. 草稿状态置为已发布 3. 旧发布快照软删(保留 `deleted_at`,不物理删,以便回溯线上跑的是哪一版) 4. 写入新的完整快照 状态只有 `DRAFT`(未发布,或发布后又编辑过)和 `PUBLISHED`。 ## 两道校验 | | 结构完整性 | 业务 / 发布语义 | |---|---|---| | 管什么 | type 是否合法、字段类型是否吻合、边引用是否存在、id 是否唯一、handle 是否合法 | 环检测、可达性、分支是否穷尽、领域规则(如「这类节点后面不能接那类」) | | 何时拦 | 每次保存(含自动保存) | 只在发布时 | | 判据 | 这个值的类型和形状对不对 | 这张图能不能上线 | 该在保存时拦的放到发布,坏结构会进发布快照。该在发布时拦的放到保存,运营存不了中间态草稿。 结构校验在反序列化时失败:未知节点 type、缺必填、多余字段都抛错。兜底进库后要到执行才炸,而且查不出是谁写进去的。 业务校验规则编号(`V1`、`V2`……),报错带上编号。 ## 画布 `nodeTypes` 是模块级稳定引用。写在组件函数里,每次渲染都是新对象,React Flow 会把节点类型当成变了,全量重挂载。 ```ts // 模块级常量 export const workflowNodeTypes = { clip: ClipNodeCard, condition: ConditionNodeCard, ... } // 组件内定义:每次渲染都是新对象 function Editor() { const nodeTypes = { ... } } ``` 节点组件按 Handle 模式归并: - 固定 handle(一进一出)的多种节点合成一个组件,按 type 换图标和配色 - 动态 handle(分支数由配置决定,条件节点每个分支一个出口)各自独立组件 加节点有三种入口: | 方式 | 触发点 | |---|---| | 在节点后追加 | 节点上的 `+` | | 在边中间插入 | 边上的 `+`(自动重连前后) | | 放到视口中心 | 工具栏 / 类型选择器 | 属性面板按 type 分发,用 patch 合并变化字段。整节点重建再回写会盖掉并发改过的其他字段。 ```ts function patch(partial: Partial) { onChange((n) => ({ ...n, ...partial })) } ``` 类型少时,一个文件按 type 分支渲染。类型多、每类表单都复杂时,拆成独立面板。同一个项目里只用一种。 撤销用快照,不记每步操作和反操作: ```ts { past: Snapshot[], present: Snapshot, future: Snapshot[] } // Snapshot = { nodes, edges } ``` - 深克隆再入栈,避免撤销后仍指向同一个对象 - 相邻快照用 `JSON.stringify` 比内容,拖动过程中的重复快照不入栈 - 深度上限大约 50 层,否则大图吃内存 - 快捷键与浏览器默认行为区分 自动保存 debounce 大约 3 秒,变更停止后触发。`isDirty` 用当前图与服务端图做内容比较。用「触发过 onChange」会在节点拖走又拖回原位时误报。 保存带上取图时的 `updated_at`。服务端不一致就返回冲突错误码,前端提示「这张图已被他人修改」。没有这层时,两人同开一张图,后保存的覆盖先保存的,后保存的人无感知。 `graphToFlow(graph)` 负责加载,`flowToGraph(nodes, edges)` 负责保存,两个函数放在同一个模块。React Flow 的 `position`、嵌套的 `data` 与后端存储不同,转换散落到各处后,两边字段会对不上。 ## 新增节点类型 新增一种节点类型时,下面每处都改。漏一处时草稿仍能存,执行会跳过,或画布没有属性面板。 后端: 1. 类型定义:新增节点的数据类,并加入节点联合类型 2. 序列化 / 反序列化:`to_dict` / `from_dict` 两个方向都加判别分支 3. 校验分派:结构校验的类型分派链加一支。项目若有语义分组(「屏幕类」「产出内容类」驱动发布规则),新类型并入对应分组。漏并入会让新类型绕过那些规则 4. 执行分派:引擎遍历时的类型分派链加一支 前端: 5. TS 类型联合:新增 interface 并加入判别联合 6. 画布注册表:`nodeTypes` 加一行,key 与后端 type 判别值同名 7. 卡片组件和属性面板分支。属性面板若有无匹配时的兜底分支,新类型要在兜底之前判中 跨端: 8. 评估客户端契约版本号是否要 +1(见下节) 项目若还有额外注册点,并进同一张清单。 ## 反序列化 一条历史坏图会让全库统计或引用分析整体失败,报错不指向那条坏图。当前正在操作的行严格失败;遍历全部已发布图时逐行跳过,并记日志。 ```python # 当前正在操作的这一行:转成带上下文的领域异常抛出 try: definition = record.to_def() except (ValueError, KeyError, TypeError) as e: raise InvalidWorkflowPayload(f"图结构非法:{e}") from e # 遍历全部已发布图:逐行跳过,不让异常冒泡 for r in records: try: defs[r.id] = r.to_def() except (ValueError, KeyError, TypeError): logger.warning("skip_broken_graph", flow_id=r.id) # 必须记日志 continue ``` 不记日志的 `continue` 会让受影响范围分析少算:解析失败的图被跳过,界面显示「无影响」。 ## 共享常量 handle 名、节点 type 判别值若前后端各写一份,改一处不改另一处会渲染错乱或执行走偏。两边的测试各用各的常量,测不到这个偏差。 能用真实节点和正常类型机制表达的,就不另造保留字符串(终止节点见上文,不用 `"__end__"`)。去不掉的常量(如 handle 名),两侧注释互相指认定义位置。改值前先看存量 JSONB:这些字符串已经写进历史图,改值会让历史数据失效。能生成共享类型或常量(OpenAPI codegen 等)就生成。 ## 客户端契约版本 图或图的裁剪子集下发给客户端渲染时,带一个形状契约版本号,让客户端判断自己认不认识这份数据。 它不是图存储格式的版本(那由结构校验保证),也不是某条图每次编辑 +1 的草稿 / 发布版本。 新增节点 type,或对既有节点做破坏性字段变更(改变已有字段形状)时 +1。只加字段、不动既有字段时不 +1,老客户端忽略不认识的字段。 漏 +1 时,老客户端拿到不认识的 type,会崩溃或丢掉内容,没有信号表明这是一次不兼容变更。 ## 共享子图的参数 子图被多处引用时,随调用场景变化的参数(时长档位、音量、间隔)放在调用方节点上,不焊在被引用子图的内部节点上。焊死之后,另一个引用方的场景键对不上,解析会退回默认值,行为变了也不报错。 引用节点上的覆盖参数,默认值是「不覆盖」(`None`)。照抄普通节点的具体默认值,会改写全库存量引用点的行为。新增字段的默认值保持既有行为不变。 ```python # 照抄普通节点的默认值:全库存量引用点的行为被改写 pad_ms: int = 200 # None = 不覆盖,与「这个字段本来不存在」一致 pad_ms: int | None = None ```