拓冰建站拓冰建站
首页 / 资讯中心 / 正文

Sim 服务端协同文档转换模块:markdown 与 Yjs 双向转换的工程实践

Sim 服务端协同文档转换模块markdown 与 Yjs 双向转换的工程实践【免费下载链接】simSim is the collaborative workspace to build, deploy, and monitor AI agents and workflows. Used by 100,000 builders.项目地址: https://gitcode.com/GitHub_Trending/sim16/sim/lib/collab-doc是 Sim 工作区Sim 是构建、部署与监控 AI Agent 与工作流的协作工作台中负责服务端协同文档转换的模块。它以文件中的 markdown 为持久真相源durable source of truth在 markdown 与协作式 Yjs 文档之间做双向转换使服务端能够拥有文档生命周期冷启动播种seed、投影回 markdownprojection、以及让 Agent 在用户打字的同时写入同一份文档。读完本文你将掌握该模块三个核心转换函数的职责、其构造性一致parity by construction的设计取舍、服务端权威播种与持久化回写的完整调用链以及支撑这些能力的测试与源码证据。背景为什么要有一个服务端协同文档模块在引入本模块之前Sim 的协作式文件编辑存在两个写入方却没有共享的 CRDTCopilot 的edit_content直接把 markdown 写入文件面向持久化存储用户在一个临时的、由客户端播种的 Yjs 文档中持续输入面向编辑器实时渲染。两者无法调和——Agent 的编辑不会流入编辑器且后写者覆盖last-writer clobber会丢失对方的内容。解决方案是引入一个服务端权威的 Yjs 文档server-authoritative Yjs doc让两个写入方都写进这一份文档而把 markdown 作为它的投影projection。apps/sim/lib/collab-doc/目录正是这一方案的落地实现。模块提供的核心 APIStage A模块对外暴露三个函数全部集中在 converter.ts函数用途markdownToYDoc(md)冷启动播种文件 markdown → 全新的Y.DocyDocToMarkdown(ydoc)投影Y.Doc→ 文件的规范 markdown仅正文不含 frontmatterapplyMarkdownToYDoc(ydoc, md)Agent 写入把新内容以最小 CRDT diff 合并进一个活的Y.Doc不覆盖除此之外converter.ts还提供两个面向持久化与一致性的扩展函数yDocToFileMarkdown(ydoc)把 CRDT 正文与 config map 中携带的 frontmatter 重新拼接成文件的完整规范 markdown镜像编辑器保存路径见下文投影的字节级一致canonicalizeYDoc(ydoc)把 CRDT 收敛到其自身 markdown 投影所能描述的规范形态并报告是否发生变化用于持久化前的正确性修复。三个函数的实现要点markdownToYDoc先确保 DOM 可用见下文 jsdom 部分然后调用prosemirrorJSONToYDoc(markdownSchema(), editorNormalForm(markdown), COLLAB_DOC_FIELD)。注意它先经过editorNormalForm归一化——这一点很关键ProseMirror 挂载时会做同样的归一化因此以编辑器自身的 normal form 播种才能让客户端绑定编辑器时不改变任何内容测试binding an editor to the seed changes nothing逐一验证了以列表、标题、表格、分隔线、空行结尾的文档绑定前后 XmlFragment 长度不变。yDocToMarkdown反向调用yDocToProsemirrorJSONserializeDocToMarkdown同样走的是客户端同一套引擎。applyMarkdownToYDoc把目标 markdown 经editorNormalForm构建成 ProseMirror 节点再通过updateYFragment计算当前 XmlFragment 与目标之间的差异并以 Yjs 事务应用。updateYFragment正是浏览器端ySyncPlugin每次按键都会运行的底层原语因此这是diff 而非 replace——Yjs 会自动把 Agent 的写入与正在进行的远端编辑做 CRDT 调解。测试merges an agent write with a concurrent remote edit证明远端用户在第一个段落追加 EDITED 的同时Agent 经转换器改写了第二个段落双向交换 update 后两处修改都存活。设计决策为什么这套方案不hacky1. 构造性一致复用同一套 markdown 引擎markdown ↔ ProseMirror 这一步复用的是客户端完全相同的引擎——parseMarkdownToDoc/serializeDocToMarkdown由tiptap/markdown基于共享的扩展集驱动而不是在服务端另写一套 markdown 实现。因此服务端在构造上就不可能与编辑器渲染产生分歧。像表格、脚注、raw HTML、sim:mention 这类自定义保真结构由与浏览器端完全相同的代码覆盖往返round-trip测试则断言两者等价。这一点在 converter.test.ts 中有直接验证round-trips markdown through the Yjs doc identically to the client engine遍历了包含加粗/斜体/行内代码、嵌套列表、引用、代码块、含转义竖线的表格、链接、脚注引用、raw HTML div、任务列表等代表性样本断言yDocToMarkdown(markdownToYDoc(md))与客户端自身的规范序列化serializeMarkdownBody(md)完全相等——Yjs 这一跳是无损的。2. 与浏览器相同的 Yjs 绑定ProseMirror ↔ Yjs 使用tiptap/y-tiptap——这正是 TipTap Collaboration 扩展在浏览器端使用的绑定版本固定且共享同一份prosemirror-model/yjs实例peer deps。因此服务端产出的 Yjs 结构与客户端字节级兼容双方指向同一个default片段。这个片段名并非魔法字符串而是由 field.ts 中的COLLAB_DOC_FIELD default统一管理客户端以Collaboration.configure({ document })配置且不显式指定field于是落在 TipTap 默认的default服务端转换、播种、持久化必须命中同一片段否则客户端会同步到一份空文档。这个无依赖的常量被服务端与客户端两个 bundle 共同引用是唯一的权威来源。3. 合并而非替换applyMarkdownToYDoc使用updateYFragmentySyncPlugin每次按键都会运行的原语只应用 diffYjs 负责把 Agent 的写入与在途的远端编辑调和。测试证明 Agent 写入与并发远端编辑可以同时存活见上文 merge 测试以及 merge.test.ts 中基于捕获快照的更强场景。4. 服务端专属DOM 由 jsdom 提供markdown 引擎需要构建一个从不挂载的TipTap 编辑器而构建它需要 DOM。在服务端这个 DOM 由单个懒创建的 jsdom window支撑ensureDomForTipTap见 converter.ts。jsdom 通过require懒加载绝不静态顶层 import且该模块只被服务端代码seed 构建器 内部路由引用从而保证客户端 bundle 永远不会把 jsdom 拖进来。实现上有两个值得注意的细节源码注释中说明了踩坑历程守卫条件是检查globalThis.window与globalThis.document都存在而不是只查documentNext 服务端运行时可能暴露一个没有 window 的残缺 document仅靠document守卫加粘性标志会跳过初始化导致 TipTap 抛出 there is no window object available。每次调用都重新检查全局对象残缺 stub 就永远不会卡住流程。守卫与安装都通过globalThis显式读写且 jsdom window 是模块级单例服务端 bundler 可能给模块一个不读globalThis的window绑定这也是 TipTap/Yjs 需要放进serverExternalPackages的原因见next.config.ts裸window守卫配globalThis.window安装会永远不一致读写同一对象让守卫自洽单例则把每个进程的 jsdom window 数量封顶为一个每个 window 占用数 MB 内存。服务端权威播种Stage B从客户端选举到服务端一言堂模块随附的服务端权威播种机制通过内部端点把每个房间的文档种子交给 realtime relay 应用buildFileDocSeed → POST /api/internal/file-doc/seed → ensureServerSeed这条调用链取代了整套客户端播种子系统选举 / 截止时间 /triedSeeders/MAX_SEED_ROUNDS/SEED_REQUEST握手使其可以被整体删除。客户端在连接截止时间前的离线回退offline fallback被有意保留——它与播种无关。切换没有 feature flag是一次性全面切换all-at-once cutover。内部端点seed/route.ts 实现了POST /api/internal/file-doc/seed仅限内部使用以共享的x-api-key: INTERNAL_API_SECRET头做鉴权与 realtime relay 发送的头一致realtime 服务端也有对应的入站校验器。请求体按buildFileDocSeedContract解析返回{ update: base64, version }——update为Y.encodeStateAsUpdate的编码结果relay 用Y.applyUpdate应用即可version是文件可持久化的updatedAtepoch 毫秒relay 记录为其新播种 live doc 所同步到的版本供持久化乐观并发守卫使用。播种实现细节buildFileDocSeedseed.ts的完整流程读取工作区文件记录throwOnError: true——只有文件真正不存在已删除/从未存在才返回null瞬时读失败会抛出而不是返回null避免 relay 把一次 DB 抖动误当成空文件、把空白内容播种到真实文档之上。取内容作用域版本只随内容写入前进改名/移动不会推进作为持久化的 If-Match 令牌——元数据 bump 不会让竞态的持久化用陈旧内容覆盖实时编辑。读取文件 buffer上限MAX_SEED_BYTES 5MB超过则编辑器本就走非协作路径服务端转换纯属浪费。计算 markdown 的 sha256 哈希sourceHash作为缓存新鲜度标签。冷启动快速路径若collab-state缓存中的sourceHash与当前 markdown 一致说明缓存文档已投影为该 markdown直接原样应用Hocuspocus load-document 模式不再重新转换若prepareCachedSeed检测到缓存需要规范修复或缺失文档身份则重新编码并回写缓存。否则走转换路径splitFrontmatter分离 frontmatter它是文件元数据不属于协作正文与客户端播种剥离方式完全一致用resumeDocument把已存储的文档带上新 markdown而不是新建第二份文档——见下文一份文件一生一份文档在 config map 中写入播种标志FILE_DOC_SEED.flag与 frontmatterensureDocumentIdentity补发文档身份编码 update 并立即写入缓存不是等下一次 persist最后返回{ update, version }。一份文件一生一份文档resumeDocument 的深意两个从相同 markdown 构建的 Yjs 文档不是同一份文档它们的 item 携带不同的 client id合并会把一份内容追加到另一份后面——文件内容翻倍。任何从 markdown 重建文档的操作都会铸造新身份而仍持有旧文档的客户端一旦重连就会破坏文件。因此seed.ts 的注释明确说明一份文档只构建一次之后的每次变更都以 CRDT diff 的形式应用进它与 copilot 编辑同一路径——这就是一份文件对应一份文档贯穿其整个生命周期的保证。resumeDocument遇到无法解码的缓存时会记录告警并从 markdown 重建——缓存终究是缓存不能把文件的 markdown 拖下水但重建意味着新身份旧客户端会被拒绝而非合并见下文docIdKey。缓存一致性collab-statecollab-state.ts 负责冷启动缓存workspace_file_collab_state表hashMarkdownsha256hex新鲜度标签loadCollabDocState(fileId)读取存储的 Yjs 二进制与sourceHashsaveCollabDocState(fileId, docState, sourceHash)upsert每个文件一行紧跟 markdown 写入之后调用保证缓存二进制与其sourceHash始终与刚保存的文件一致collabDocStateSourceHash(fileId)只查标签不载入二进制用于磁盘上的字节是否仍是我们上一次的写入这一判断。关键原则注释明确缓存是新鲜sourceHash匹配当前 markdown还是陈旧markdown 已带外变动调用方都必须更新这份文档绝不能另建第二份——缓存二进制携带文档身份。文档身份docIdKeyensureDocumentIdentity会在 config map 中写入docIdKeygenerateId()且只在缺失时写入一次。身份的作用是让 join-ack 守卫可以拒绝持有旧身份的客户端一个标签页可能比房间活得久比如笔记本休眠超过了共享流的 TTL重连时若撞上一个被重建新身份的文档合并会把内容重复一遍。服务端播种立即存储的另一个原因也在此——若不立即存储每次冷打开都会从 markdown 重建并铸造新身份一个被打开但从未编辑的文件会在每次打开时得到不同文档。合并端点Agent 写入开放文档的原始操作Stage C 基础merge.ts 中的buildFileDocMergeUpdate(docState, markdown)是 Stage Ccopilot 写入打开中的文档的底层原语relay 拥有文档但不拥有转换引擎于是把当前文档状态发过来由应用计算最小 Yjs diff 后返回relay 应用该 diffYjs 再与任何并发的用户编辑合并后中继给每个已连接编辑器。实现在临时Y.Doc上Y.applyUpdate(docState)还原状态 → 记录before状态向量 →splitFrontmatter分离 frontmatter 与正文 →applyMarkdownToYDoc只合并正文 →仅当确实变化时才把 frontmatter 写入 config map避免每次合并都搅动 config map→ 返回Y.encodeStateAsUpdate(doc, before)相对调用时状态的精确变更markdown 已匹配时为空 no-op update。frontmatter 写入 config map 而非正文是有意为之编辑器在自动保存时会重新挂载config map 里的 frontmatter因此 open 状态下的编辑器不会用自己的陈旧副本覆盖新的 frontmatter 变更。对应内部端点位于 merge/route.ts。merge.test.ts 验证了四个关键行为diff 正确收敛到目标 markdownfrontmatter 被剥离出正文但存入 config map 供编辑器重新挂载markdown 已匹配时返回空 diff状态向量不变以及最关键的——copilot 基于捕获的旧快照计算 diff而远端用户在此之后继续编辑第一段应用 diff 后两处修改都存活Alpha paragraph. EDITED与expanded by the agent同时出现在合并结果中。持久化回写从客户端自动保存到服务端权威落盘persist.ts 的persistFileDoc(workspaceId, fileId, userId, docState, expectedVersion?)把 live Yjs 文档投影回可持久化 markdown 并写入文件取代编辑器的客户端自动保存成为服务端权威的持久化路径。relay 拥有 live 文档但没有 blob/DB 访问权因此它把文档状态发过来由应用落盘。结果类型PersistFileDocResult有四种status含义persisted投影已写入version为新的持久化内容版本content_updated_atepoch msmissing文件已删除无可写conflict文件在 relay 上次同步后发生带外变更写入会覆盖它RFC 7232If-Match失败不写入deferred没有可用的期望版本如 Redis 抖动导致 relay 同步版本令牌短暂缺失推迟而非无条件写入几个工程要点乐观并发守卫expectedVersion是内容版本relay 上次同步的来源写入仅在文件仍处于该内容版本时提交。版本缺失时推迟而不是无条件写——无条件写可能覆盖带外编辑且刻意不做空文件无条件写的豁免每个已存在文件都有content_updated_at且record.size在读事务之外信任它空文件没有可覆盖的内容会与并发的首次内容写入形成 TOCTOU 竞态。写前字节比较若待写字节与磁盘现有长度一致再做一次逐字节比较内容未变时跳过写入并返回当前持久化版本重同步 relay 的 If-Match 令牌而非让它卡在陈旧令牌上。原因很实在updateWorkspaceFileContent会在新的存储 key下上传、重指行并删除旧对象任何仍持有旧 key 的读者都会 404——这正是打开页面时与自身首次内容读取竞争的偶发 not-found。而仅仅打开一个文件绑定编辑器会发出一次 y-tiptap 属性归一化产生的 update就会安排一次字节级相同的保存所以这个比较是必要成本长度检查是免费拒绝只有真正可能 no-op 时才做比较读。冲突恢复不靠时钟靠内容stale If-Match 不一定说明别人写了文件relay 的令牌是房间内存 尽力而为的集群键进程在成功写入后立刻死亡会持有旧版本。recoverFromVersionConflict直接比对磁盘字节哈希与collabDocStateSourceHash哈希一致说明没有带外变更用文件当前版本重试一次不一致则冲突成立持久化内容保持权威。快照缓存与规范修复写盘前先对分离副本执行canonicalizeYDoc——这个被缓存的快照会直接播种之后的冷房间必须与将要落盘的 markdown 描述同一份文档若需修复则用修复后的二进制缓存。写盘后以刚写入的 markdown 哈希为标签缓存 Yjs 二进制尽力而为markdown 始终是权威真相源。返回内容版本而非updatedAtrelay 会把它记录为新的 If-Match 令牌必须与后续 persist 校验的字段一致内容写入会让两者相同但之后的元数据写入会让它们分叉。对应内部端点位于 persist/route.ts。规范形态与占位符⇄live CRDT 一致性canonicalizeYDoc是本模块最微妙的设计之一其动机来自一个具体 bugProseMirror 文档严格比 markdown 丰富parse ∘ serialize并非恒等——postProcessSerializedMarkdown会折叠尾部空段落、超出解析界限的空白段会被截断、必须整体解析的文档raw HTML、引用定义根本不能保留空段落。如果 CRDT 持有任何这类状态它描述的文档就是其自身 markdown 无法复现的文件从 live doc 渲染是一种样子从持久化字节渲染是另一种样子差异表现为编辑器在绘制后跳动一下再静默丢弃空白。canonicalizeYDoc的修复思路是把 CRDT 收进解析的像image内并把规范定义为往返的产物而不是手工列举 markdown 不能容纳什么——这样未来任何表示层之间的新裂缝都会自动被吸收无需第二处维护。它天然幂等规范文档投影出的 markdown 会解析回自身第二次调用是 no-op且通过applyMarkdownToYDoc应用差异所以是最小 CRDT diff 而非替换。两个必须遵守的调用纪律源码注释明确只在分离文档上调用解码的快照绝不在 live 房间上调用——这是针对持久化产物的正确性修正收敛一份正在被输入文档会移动用户的光标是否变化以文档而非 markdown 判定markdown 相等检查对此类修复是盲目的——尾部段落序列化出的尾随空行会被 post-process 折叠因此每个以列表/标题/表格/分隔线结尾的文档都被修复过却仍报告未变两个调用方都依赖该标志决定是否重新编码若改用 markdown 比较缓存里就会保留未修复的字节重新打开堆叠空段落bug 之门。converter.test.ts 用it.each覆盖了全部相关场景超出间隙界限的空段落串、尾部/头部空段落、两个列表/引用之间的空段落、raw-HTML 文档内的空段落、引用定义文档内的空段落断言canonicalizeYDoc之后 CRDT 与占位符由投影 markdown 构建的编辑器 normal form形态一致且第二次调用返回false以及规范文档投影出的正是要落盘的文件正文含 callout 转义、空列表项、尾部空行、以列表结尾等用例。测试还断言了报告它实际做出的修复以列表/标题/表格/分隔线结尾的文档修复后形态为${before},∅追加一个空段落且返回true。投影的字节级一致yDocToFileMarkdown镜像编辑器保存路径完全一致applyFrontmatter(resolveSaveFrontmatter(), postProcessSerializedMarkdown(editor.getMarkdown()))服务端版本从 config map 读取 frontmatter正文经postProcessSerializedMarkdown空列表标记、callout 反转义、尾部空白处理后拼接。这样服务端持久化与客户端保存字节级一致与客户端脏检查基线吻合往返不会产生虚假的 blob 抖动。converter.test.ts 用yDocToFileMarkdown matches the client save composition用例守卫了这一组合防止 post-process 步骤从服务端路径被误删。未来阶段文档中规划的后续 PR关联文档明确列出的后续工作在本文写作时尚未在本仓库落地为完整实现属于路线规划持久化持久性Durable persistence为 Yjs 二进制增加数据库列 防抖快照使文档在没有协作者连接时也能存活而不是冷打开时从 markdown 重新播种。Copilot 进文档 投影Copilot into the doc projection文档活跃时edit_content调用applyMarkdownToYDoc防抖的yDocToMarkdown投影持续让文件的 markdown 保持最新。值得注意的是从当前仓库源码看Stage C 的 merge 原语与缓存即文档身份等机制已经落地buildFileDocMergeUpdate、resumeDocument、docIdKey等均已实现并有测试覆盖——说明文档所述的阶段边界是演进性的实际代码比文档列出的 Stage A/B 更超前。总结/lib/collab-doc通过构造性一致的三条支柱——复用客户端 markdown 引擎、复用浏览器端 Yjs 绑定、以最小 CRDT diff 合并而非替换——让服务端可以安全地拥有协作文档的生命周期。服务端权威播种buildFileDocSeed 内部 seed 端点删除了整套客户端选举子系统合并原语buildFileDocMergeUpdate与持久化回写persistFileDoc If-Match 乐观并发则为Agent 写入、用户同时输入、服务端权威落盘这一三写者场景提供了无覆盖no-clobber的最终收敛。每一层都配有 converter.test.ts、merge.test.ts、seed.test.ts 等测试将并发编辑都存活字节级一致幂等修复这些承诺固化为可回归的断言。【免费下载链接】simSim is the collaborative workspace to build, deploy, and monitor AI agents and workflows. Used by 100,000 builders.项目地址: https://gitcode.com/GitHub_Trending/sim16/sim创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门