微昕·砚文档

画布 operations 同步协议

同账号多端编辑

本功能处理同一账号在多个浏览器或设备编辑画布,不增加跨账号共享权限,也不是逐字符 CRDT。浏览器持久保存已确认基线和未确认操作,以基线、云端最新内容、本地草稿三方合并;不同节点或字段直接合并。同一节点的标题、提示词、文本或 Markdown 内容按共同基线上的增删合并,同一位置的不同输入按已提交文字在前、本地新增在后的顺序保留;相同修改只应用一次,删除只作用于基线原有文字,不删除另一端新输入。富文本通过编辑器文档切片重放,保留段落、插入文字的格式与独立格式变化。无关云端更新和新增对象继续合入,不用整张云端快照替换本地编辑,不创建替代画布,也不复活已删除对象。

GET /api/canvas-projects/:id/sync-status 返回 revision、服务端时间、有效节点租约与 Agent 占用摘要,不返回画布正文或 Agent 历史。客户端订阅 GET /api/canvas-projects/:id/realtime 的 WebSocket 通知;画布或租约事务成功提交后发出 invalidate,客户端再核对状态,只有 revision 改变才读取正文。连接正常且无活动 Agent 时每 30 秒补偿校验;断线或活动 Agent 期间前台每 2 秒、后台至少每 15 秒检查。离线暂停、失败退避至最长 30 秒;单请求超时 8 秒且不重叠,401/403/404 停止轮询。租约维护使用独立的 2 秒计时器,不依赖轮询频率。

多窗口光标与拖拽预览

同一账号拥有的同一画布按窗口建立独立实时会话。单窗口隐藏设备入口和全部光标标签,不采集或广播 pointer/拖拽预览;多人在线时在画布顶部正中间显示紧凑入口,避让右侧生成任务面板,展开列表也居中。点击查看“设备1、设备2”等名称,仅在本机对应行附加“此窗口”,支持 Esc 或点外部关闭。编号由服务端按画布统一分配,每个会话存续期间固定,在所有窗口显示一致;离开的设备释放编号,不给其他存续会话重新编号。只绘制其他窗口的光标,本机鼠标不附加标签;拖拽状态并入远端光标的小标签,节点只显示轻量轮廓。名称代表窗口会话,不识别物理设备,也不增加跨账号分享编辑权限。切换画布、账号或关闭窗口后释放订阅,重连视为新会话并重新分配空闲编号。

服务端首帧 welcome 返回 selfId 和 peers。客户端发送 { type: "presence", cursor: { x, y } | null, drag: [{ id, x, y, width, height }] | null },坐标为画布世界坐标。服务端广播 peer,断开时广播 leave,15 秒发送 heartbeat;客户端按自身平移和缩放投影光标及拖拽轮廓。拖拽预览最多 100 个节点,发送约每 50 毫秒合并一次;失焦或隐藏页面清理光标与预览,移出画布清理光标,结束拖拽清理轮廓。最终节点位置仍由既有 operations、CAS 和 journal 保存,不持久化光标或临时预览。

高频坐标不进入 React 状态:设备列表只在成员变化时更新,光标与轮廓在独立图形子树中按动画帧合并更新并复用 DOM,屏幕外图形不绘制。容器边界仅在启用多人采集、布局尺寸或页面滚动变化时测量,不在 pointer 和 viewport 帧读取布局;画布自身的合成滚动通知不触发重测。本地发送不回显到订阅者,相同 Presence 不重复发送。

握手使用现有登录 Cookie 并校验画布归属和 Origin,不在 URL 放凭据;长连接定期复核会话和归属。输入有消息大小、坐标及频率限制。客户端自动退避重连,重连后核对状态;连接正常时仍做低频校验,通知丢失不会作为已保存依据。

每个 Presence 含服务端 number(1–64),客户端不参与编号。单实例在房间内分配空闲编号;Redis 模式用原子脚本分配跨实例共享临时编号,租期 60 秒、每 15 秒续期,临时集合无续期 120 秒后清理;失去编号租期会关闭会话并重连。该编号只用于识别在线窗口,不是编辑权限或持久设备身份,不改变数据库。

未配置 Redis 时,实时会话在单个后端进程内分发。配置 REDIS_URL 时,通过按用户和画布隔离的 Pub/Sub 频道跨实例分发;Presence 不入库,异常进程退出后通过心跳和过期清理。Pub/Sub 不承担持久消息重放,最终状态由 HTTP 读取校准。生产 Nginx 与开发 Vite 代理均需支持 WebSocket Upgrade;自定义反向代理也需配置,旧代理不支持时保留 HTTP 轮询降级。

实际文字输入通过 POST /api/canvas-projects/:id/edit-lease 获取节点租约;展开、查看、单纯聚焦不获取锁。租约只串行化云端写入,不把其他端的输入框设为只读;争抢失败仍可继续输入,自动保存每 1200 毫秒重新尝试,不为每次按键重复申请。每批保存确认后立即释放租约,即使本机又有新输入也让另一端获得写入机会;后续修改重新取得凭据。服务端租约 30 秒,未确认的活跃编辑续期最短间隔 5 秒,停止输入 10 秒后停止续期。内容保存独立防抖 1200 毫秒,连续编辑最长等待 5 秒。浏览器崩溃由服务端到期处理,不依赖浏览器释放通知。

operations 可携带 editProofs: [{nodeId, sessionId, token}],服务端校验租约与写入在同一事务完成。凭据不进入 mutation 摘要,刷新后允许更新凭据重放相同操作;已提交的 receipt 仍幂等返回。服务端始终是最终裁决方,客户端看到可编辑不构成写入授权。

同画布 Agent admission 在任务创建与积分预留前串行裁决。排队、执行、等待审批和终态清理占用画布;另一轮请求返回 409 canvas_agent_busy,同时返回观察到的占用摘要供浏览器立即锁定,不自动重试。运行该轮的窗口仍可按既有接口插话,断开浏览器不释放服务端任务。

生成进度不触发画布自动保存;任务 ID、绑定与终态内容仍持久保存。观察器断开后重查原任务,文本 SSE 使用事件序号恢复,不创建替代任务。最后一个订阅者离开时取消本地观察,不取消服务端任务。

当前租约覆盖节点标题、主要提示词输入和文本编辑;分镜逐行对白等字段尚未全部覆盖。真实输入法、双浏览器断网恢复及 PostgreSQL 部署仍需单独验收,不能将单元测试结果当作生产容量承诺。

自动恢复与本地编辑

素材尚未入库、素材 ID 不存在或资源配对不一致返回 400 canvas_media_assets_pending。客户端读取服务端实际素材记录,修复引用配对,先同步素材再继续提交原画布。明确的字段或对象冲突会重新读取服务端基线,把本地实际编辑重放到最新画布;文字合并两端修改,其余同字段标量采用当前编辑值,云端无关更新和新增对象继续合入。云端已删除的对象不被迟到编辑复活,新任务绑定与更新的任务结果不能被旧任务观察值覆盖。

网络超时、5xx 和响应丢失不表示事务回滚,继续保留原 mutationId/body 重放,不把未确认写入显示为已同步。本机租约申请失败或过期时,保存链路自动重新申请,不要求再输入一个字符或手动重连;刷新后的 journal 取得当前会话的编辑凭据,mutation 身份不变。其他会话有效租约及服务端 Agent 占用仍由服务端裁决。

存在未确认 journal 时,轮询和重新打开优先保留当前草稿,由原请求先解析 receipt,再按提交快照合入后续输入;不能提前把可能包含本机未确认文字的远端快照再次合入。中文组词期间缓冲输入,组词结束后再与最新画布合并;云端更新保留输入位置,富文本外部更新不写入本机撤销历史。

有界等待与故障隔离

  • 同范围保存使用 single-flight;调用方最长等待 15 秒,超时只返回控制权,不释放仍在执行的事务、不换 mutationId。同步 API 请求超时 15 秒,媒体上传及分片请求超时 60 秒;连续编辑每轮最多追赶两次后让出队列。
  • 素材按条同步。一条素材失败只影响引用该素材的画布,不阻断无关画布;素材版本冲突或写入被明确拒绝时备份原记录并采用云端素材。
  • 只有 operations 返回已识别的 canvas_field_conflict、canvas_object_missing、canvas_edit_lease_conflict 或 canvas_media_assets_pending 明确拒绝,才允许重建基线并生成新请求。凭据申请失败不能被当作原 operations 已回滚;canvas_concurrent_write、5xx 和无响应继续重放原请求。401/403 不绕过权限,未识别的拒绝如实失败并保留当前编辑与 journal。
  • 冲突恢复直接保留当前编辑,先持久化包含最新基线、合并修改和合并前草稿的新 journal,再更新编辑器与缓存。中途刷新时,用缓存的基线哈希识别尚未应用的投影,先接回合并结果并保留后续输入,再重放原请求;若再次冲突,以该 journal 的基线继续合并,避免使用落后的内存基线重复插字。服务端确认与本地落盘成功后才清理 journal;新 journal 落盘失败时保留原请求,不能用云端内容替换本地内容。保存期间的新编辑进入后续批次,撤销历史保留。
  • 同步静默运行,不弹出同步报错、恢复选择卡片、手动重连、采用云端、导出草稿或另存画布入口;状态图标只区分待同步和已确认。同步始终写回原画布 ID。云端已删除的画布不能伪报保存成功,也不自动创建替代画布。
  • 有缓存(包括空白画布)时先打开本地编辑器;云端加载失败不再覆盖成错误遮罩。云端待同步不拦截离开页面,真正未落盘的本地输入仍保留离开提醒。
  • Agent 提交和审批前等待当前画布同步;未确认保存时终止本次提交并保留输入,不使用旧云端快照代替当前编辑。
  • 对话记录读取和提交身份落盘也有时间上限,提供原地重新读取记录;记录未确认仅禁止发送,不冻结消息输入。不能恢复幂等身份时不发送可能重复计费的新请求。
  • 账号切换重置队列尾,旧操作保留 epoch 校验,不能等待旧账号的卡住操作才能进入新账号。网络恢复时自动重连;恢复期间新输入会进入下一轮,不被旧恢复结果覆盖。

请求合同

画布写入使用 PATCH /api/canvas-projects/:id/operations。请求体为:

{
  "mutationId": "客户端生成的稳定请求 ID",
  "baseRevision": 12,
  "operations": [
    {
      "type": "patch_node",
      "id": "node-1",
      "patch": { "metadata.prompt": "新的提示词" },
      "before": { "metadata.prompt": "旧的提示词" }
    }
  ]
}

mutationId 是重试身份,不是版本号。请求超时、响应丢失、断网、刷新或后台自动重试时,必须使用相同的 mutationId、baseRevision 和原始 operations。修改原始 payload 后复用 ID 会返回 canvas_mutation_id_reused。

baseRevision 是生成请求时的已确认版本,用于记录和诊断。真正的并发校验在操作本身:字段补丁使用 before,对象更新或删除使用 expected,顶层字段使用 beforeValue。不同字段可以合并;同一字段或同一对象的基线不匹配时返回冲突。可选顶层字段不存在时,客户端必须在 JSON 中将 value 或 beforeValue 编码为 null,不能让 undefined 被序列化为缺失属性。服务端仅兼容早期客户端对这些可选字段省略属性的请求,并按 null 参与同样的基线校验。

同任务观察结果的收敛

同一任务可能由多个终端观察并保存。客户端只在原节点和新节点绑定同一非空任务 ID、且写入非空任务时间时,给补丁补充 before["metadata.taskId"];换绑、首次绑定和解绑保留真实旧 ID 的基线,不能用新 ID 覆盖。

服务端在应用补丁前一次性确认任务身份、存在非空的 metadata.taskUpdatedAt 写入,且没有 taskId 或整个 metadata 替换。满足条件时,任务时间按事件时间取较新值;其他写字段只有在原基线匹配,或当前值已经精确等于本次目标值时才接受。before-only 断言仍严格检查;当前值既不等于原值也不等于目标值仍使整笔事务冲突。清空时间、换绑任务和整块 metadata 替换不走时间合并,继续执行原字段 CAS。

操作类型

  • create_canvas:只在画布不存在时创建,第一项必须是该操作。
  • create_node:显式新增节点,已有 ID 不会被覆盖。
  • patch_node:对已有节点按字段路径更新,节点缺失或字段基线不匹配都会失败。
  • remove_node:按完整对象基线删除节点,并删除关联连接。
  • create_connection:显式新增连接,两个端点必须存在。
  • update_connection:按连接对象基线更新,连接缺失或端点缺失都会失败。
  • remove_connection:按连接对象基线删除。
  • set_field:更新白名单中的画布字段,并校验 beforeValue;字段不存在时用 JSON null 表示删除或缺失基线。

只有 create 操作可以创建实体。删除后的迟到编辑、媒体回写或 Agent 更新得到 canvas_object_missing,不会隐式重建节点、连接或画布。画布删除后的迟到请求同样不会重建画布;只有新的 create_canvas 才能显式重新创建。

事务与 receipt

服务端事务按以下顺序处理请求:锁定画布写入、创建或读取 receipt、核对请求摘要、读取当前画布、应用 operations、校验媒体引用、递增 revision、写入画布和完成 receipt。画布内容、revision、receipt 的结果 JSON 在同一事务提交,任一步失败都会回滚。

canvas_mutation_receipts 使用 (user_id, canvas_id, mutation_id) 唯一约束。相同 ID 和相同摘要返回之前保存的确定性结果,不再次应用操作;相同 ID 和不同摘要返回冲突。receipt 不替代当前画布,也不保存整批版本历史。

结构化容量校验仍在同一事务内使用事务 repository 读取实时用量,但只统计当前账号的素材/画布数量和 JSON 字节数。此路径使用 UserStructuredStorageUsage,不扫描任务正文、结果或 API 请求/响应日志;任务与日志配额继续使用原有完整统计。容量上限、创建数量限制、存储锁、CAS 和 receipt 原子提交不变,也不引入可能过期的配额缓存。

浏览器 journal 与刷新

浏览器在发送请求前把以下内容写入账号隔离的本地 journal:

  • 已确认的画布基线;
  • 原始 operations 请求及 mutationId;
  • 生成请求时的本地投影;
  • 创建时间。

服务端确认、完整画布回读和本地落盘成功后才删除对应未确认请求。响应丢失时保留 journal,刷新后使用原始请求重放;请求发出后产生的新编辑不会改写原请求,而是在确认后从新基线生成下一批 operations。服务端完整读取或 Agent 刷新只能更新已确认基线,不能覆盖仍未确认的本地请求之后的修改。

早期未带任务身份的时间戳请求,先按原 ID、原 payload 重放。只有收到 HTTP 409 且 reason=canvas_field_conflict、确认服务端整笔回滚后,才允许从原 journal 的基线和提交快照证明同一任务,为原请求补缺失的任务 ID 前置条件。所有业务 patch 和原 before 值保持不变,用新的 mutationId 持久化后重试一次。缺少可信快照、任务换绑、时间重置或已带身份条件时不自动修改请求;重试结果未知仍保留新的原请求。不能因超时而重写 payload,也不能清空 journal 假装保存成功。

前台保存按画布 ID 排队:共用串行写入边界,但不共用其他画布的成功/失败结果。Agent 提交与审批只要求当前画布保存成功;仅素材关联的路径不保存任何画布。自动后台同步仍可扫描所有画布,某张画布冲突保留它自己的 journal 和进度错误,不应阻塞其他画布的前台提交。

viewport 是本地视图状态,不进入 operations。媒体文件先完成资源登记,再提交引用画布的 operations,避免产生只有节点没有资源记录的远端状态。

Agent 和媒体回写

普通编辑、Agent、媒体生成终态、预演台、创作提交、LibTV/TapNow 导入都必须经过同一 operations 服务。Agent 事件里的 canvasPatch 只是客户端通知:小变更携带有界对象增量;删除、重排、顶层字段变化或事件过大时使用 requiresRefresh: true,客户端重新读取后继续处理未确认 journal。

媒体终态只更新仍存在且任务绑定未变化的节点。目标节点已删除、任务绑定已改变或资源不可用时,任务记录保留,画布不创建幽灵节点。

浏览器本地三方合并按 JSON 的字段存在语义处理:对象属性为 undefined 与属性缺失等价,null 仍是显式值。任务状态先到编辑器、自动保存尚未跟上时,生成结果补写资源、素材或完成时间不会仅因 JSON 省略空字段被判为并发冲突;真实的同字段修改和已删除对象仍报告冲突。

图片工具的素材绑定

Issue #788 的局部重绘、标注与共用图片编辑通过既有 consumer 采用任务 Resource/Asset,不再重复上传原样输出。主节点和子节点先准备匹配资源的素材并等待素材持久化,再提交画布;原始结果缺物化绑定时拒绝交付。普通批量根在存储锁内从最新主图派生,复用主图的资源和素材。

图层归一化、提取、修补、合成及情绪编辑恢复中的局部融合会产生新文件,保留必要上传并登记自身素材。资源不同就不能继承原任务 Asset;派生素材登记的并发去重包含当前资源身份。素材准备位于画布存储锁之外,避免登记触发同步时锁重入;完整生命周期由最外层 consumer 管理,覆盖加工、素材落盘和画布提交。

任务中心恢复沿用同一交付合同;图层重放只补现存节点绑定,不复活已删除图层。保存或消费确认失败保留原成功任务,使用 CanvasGenerationDurableAckError 区分内容校验失败,不回退到未绑定节点,也不隐式付费重生成。等待期间继续核对取消、任务身份和图层版本,保留用户改名、移动及手动尺寸。

本地持久化不等于云端 receipt 确认,资源、素材和画布不是跨服务事务。跨刷新且派生文件尚未登记或引用时,仍可能重新加工上传。本次不迁移情绪工具首次执行的完整交付流程,不处理模型透明背景参数或历史资源清理;运行验收边界见待测试。

On this page