Agent 平台

画布这一步:深链、运行历史与续跑

一次补齐八项画布操作:URL 深链、小地图、运行历史、复制粘贴、批量框、一键整理布局、便签,以及失败运行的单节点续跑。

STRUCTURE ── 篇幅剖面8 节 · 点击跳转

画布是这套平台里最复杂的界面。节点、连线、参数、批量框、运行状态,任何一件事都要在同一个视口里表达清楚。

这一天补齐八项操作。每一项都不难,难的是它们之间不能互相打架。

一、URL 是唯一事实来源

原先画布是个单页状态机:打开就是列表,点进去切到编辑器,刷新回列表。

改成路由:

/canvas            列表
/canvas/:id        编辑器

四个行为一起对齐:

  • 深链直达编辑器。
  • 刷新不回列表。
  • 后退回列表,而不是退出画布模块。
  • 打开画布写入地址栏。

popstate 监听已有的解析函数,pushState / replaceState 写入。

这条改动看起来只是加了个路由,实际改变的是状态的归属:画布 ID 从组件内的 ref 变成了 URL 的一部分。后面几项都依赖它——运行历史要能链到具体的运行,批量框要能被分享,都要求「当前在看哪张画布」是一个可以从外部确定的量。

二、运行历史

一个新面板,两个接口:

GET /api/canvas-flow/runs?flowId=…      列表
GET /api/canvas-flow/runs/:id           快照

列表默认取 10 条。点开某一条,用它的快照推导出画布上每个节点的状态,覆盖显示。

这个面板的数据来源和实时运行状态用的是同一套 nodeStates——历史记录不是另一条平行显示,而是把画布切到那一次运行的视角。

登录态走单一来源:

// 登录态只有 authToken() 一个来源(有守卫测试盯着,别直读 localStorage)

这条注释是守卫测试抓出来的结果,不是提前的设计。

三、复制粘贴

复制粘贴里有两个决定值得记。

用应用内剪贴板,不碰系统剪贴板。

let clipboard: FlowClipboardPayload | null = null;

粘贴的内容是节点和边,格式带版本号。走系统剪贴板意味着把画布的 JSON 写进用户的剪贴板,用户去别处粘贴会看到一堆结构数据。应用内的模块级变量没有这个问题。

没选节点时不拦截。

// Cmd/Ctrl+C:复制选中的节点。
// 没选节点就不拦截——用户可能正在复制节点产物里的文字,抢了就是坏默认行为
// Cmd/Ctrl+V:粘贴。应用内剪贴板为空时同样放行系统默认行为

这两条合起来是一个原则:快捷键只在它有明确意图时生效。用户按 Cmd+C 时可能是想复制提示词里的文字,此时抢过来是损失。

粘贴到画布时重新生成 ID(节点 ID 最多重试 20 次防碰撞),偏移 24 像素,粘贴的结果进入撤销栈并成为新的选中项。

四、一键整理布局

按依赖深度分层的纯函数,不引布局引擎:

/**
 * 一键整理布局:按依赖深度分层的纯函数。
 *
 * 不引 dagre/elk——画布的图是小规模 DAG(上限 100 节点),
 * 「上游在左、下游在右、同层竖排」这一条规则就够读顺一张乱图,
 * 引一个布局引擎为它的边缘能力买单不值。
 */
const COLUMN_GAP = 380;
const ROW_GAP = 240;
const ORIGIN = { x: 40, y: 60 };

同列保持用户原有的上下相对顺序。这一条是这类功能能不能用的分界线——整理完之后用户还得能认出自己的图。

节点上限 100,单批最大 50 项,展开后的总任务上限 200。这几个数决定了上面那个判断成立:在这个规模内,一条规则够用。

五、便签不能做成节点

画布上要有地方写注释。最自然的做法是加一个「便签节点」,但它的实现方式正好相反:

/**
 * 画布便签:纯注释,不参与调度、连线与计费。
 */
// 不是 xyflow 节点——它不参与连线、调度与计费,做成节点要在注册表、校验器、执行器三处逐一开豁免,成本远高于一张自绘卡片。

做成节点意味着它要在三处地方被显式排除。而排除逻辑每加一处,将来就多一处要维护的例外。做成一张画在流坐标系里的自绘卡片,这些例外一个都不需要。

契约里限制条数 100、单条 2000 字。

六、批量框

批量框是「框内的子图跑 N 遍」。它带来的第一件事是边界规则:

框内节点的输出不能接到框外,反过来可以。

// 批量框边界规则:框内节点的输出不能接到框外(或另一个框)。
// 框内子图每份各跑一遍,往外接意味着下游要收 N 份——运行时不支持这种收束;
// 反方向(框外 → 框内)合法:同一上游共享给每份拷贝。

拒绝时的文案要给下一步:

批量框内的节点不能连到框外:框内每份各跑一遍,产物会直接进素材库。要串联处理就把目标节点也拖进框里。

第二件事是删除节点的连带处理。节点被删掉之后,批量框里会留下幽灵成员,而运行创建时会拒绝整张图:

/**
 * 从所有批量框成员里剔除已删除的节点;成员清空的框一并删除。
 *
 * 删除节点必须同步清理:残留的幽灵成员会让 run-create 直接拒绝整张画布
 * (「批量框引用了图中不存在的节点」),用户面对的是一张再也跑不起来的图。
 */

第三件事是撤销栈。批量框要进快照:

export interface GraphSnapshot {
  readonly nodes: readonly CanvasFlowNode[];
  readonly edges: readonly CanvasFlowEdge[];
  readonly selected: readonly string[];
  /** 批量框。撤销/重做要连它一起回放,否则撤销删框后节点回来了框没了 */
  readonly batchGroups: readonly CanvasFlowBatchGroup[];
}

第四件事是状态聚合。批量框内一个节点对应多行执行状态,显示取哪个:

failed > running > pending > cancelled > succeeded > idle

原先只让 failed 优先。结果是第一份先成功、第二份还在跑的时候,节点就提前显示成「成功」。

七、续跑不重复扣费

这一项是这八项里唯一涉及钱的。

失败或被取消的运行,可以续跑。实现方式不是「重跑一遍」:

/**
 * 单节点重试:给失败/被取消的运行造一个「续跑」运行。成功节点的行原样回填
 * (产物、billingRef、时间戳都保留)——executor 的调度器见到 succeeded 行
 * 会直接当作上游已就绪,不会重新执行,也就不会重复扣费;其余节点
 * (failed / cancelled / pending)重置成全新的 pending 行,正常调度重跑。
 * 不修改原运行:重试是一条新的 CanvasFlowRun,历史记录保持完整。
 */

关键在于复用判定落在行状态上,而不是「这次运行是新是旧」。所以续跑不需要额外的豁免逻辑:被判成功的节点带着原来的 billingRef,调度器看到它就不再执行。

预估只算子集,余额检查同理。接口层有幂等入口(按用户 + 请求 ID 查重)和归属校验。不可重试的两种情形给出明确文案:

这次运行已全部成功,没有可重试的节点
运行还没结束,等它终结后再重试

界面上的按钮从「重试」改成「重试失败节点」,带一句说明:

成功节点的产物直接沿用,只有失败的节点会重新执行并计费。

八、八项之间的关系

单独看每一项,都是常规功能。放在一起时,出现了几条贯穿的取舍:

状态的归属要单一。 画布 ID 放 URL;运行状态用同一套 nodeStates;撤销栈是唯一的变更入口。三处都收成一个来源之后,「刷新之后看到什么」才有确定答案。

例外要少。 便签不做成节点,就是为了避免在注册表、校验器、执行器三处开豁免。每开一处例外,就多一处将来会忘记的地方。

别抢用户的操作。 没选节点时不拦 Cmd+C;整理布局保留同列原有顺序;批量框拒绝时给下一步而不是只报错。

涉及钱的判定落在状态上。 续跑复用靠行状态,不靠运行的新旧;批量份数由框上显式配置,预估与执行读同一个函数。

最后一条是这一天唯一和故障档案有关的部分。同一天里,批量链路翻出一处行约定矛盾和一处从未命中的推断分支,两处的成因都是「两侧各写一份」。八项补齐之后,取值入口都比之前更集中——这不是巧合,是同一件事的两个方向。

星野的头像

星野 XINGYE

全栈工程师。这里记录 82 篇复盘:24 份故障档案、OTA、架构演进与工作流。