# 5-5 [process] 主编辑区与树域单一真源对齐方案 v1 > 更新时间:2026-05-18 > > 当前状态:`process-reference`。本文作为 Page Aggregate 单一真源长期方案保留;当前执行进度与剩余项以 `5-6` 及 MVP 后阶段 checklist 为准。 > > 2026-05-18 口径补充: > - 页面聚合的默认 source 已开始向 local-first workspace 收口;页面正文、标题、设置和上传资源不应再默认把 Convex 视作主数据层。 > - 本文中涉及 Convex 的表述只应理解为 local-first 与 Convex-backed 两种 `WorkspaceSource` 的过渡兼容背景,不应再作为默认产品形态解释。 > > 2026-05-19 口径补充: > - local-first 下,本地 `.md` 文件是正文真相;Page Aggregate / EditorBlockDocument / tiptap state 都是投影或工作副本。 > - `documents/save` 只能作为 compat adapter,不能继续承担长期写侧仲裁。AI 后台写入、tiptap 保存、外部编辑器修改必须统一到本地文件版本冲突模型。 > > 关联文档: > - `/mnt/Data1T/mnote/design/05-editor-mainline/done/5-4-leptos-tiptap-mainline-correction-v1.md` > - `/mnt/Data1T/mnote/design/old/05-editor-mainline/process/5-3-tiptap-leptos-rust-migration-checklist-v1.md` > - `/mnt/Data1T/mnote/design/04-tree-domain/done/4-sidebar-pagetree-filetree-rust-web-rebuild-v1.md` > - `/mnt/Data1T/mnote/design/04-tree-domain/done/4-4-tree-projection-protocol-contract-v1.md` > - `/mnt/Data1T/mnote/design/01-tree-first-graph-kernel/process/1-tree-first-graph-kernel-v1.md` > - `/mnt/Data1T/mnote/design/05-editor-mainline/done/5-5-1-page-aggregate-contract-v1.md` ## 1. 文档目的 这份文档只回答一个当前已经暴露出来的主线问题: > **当 `leptos-tiptap` 已经成为页面内正式主编辑区后,页面树 / 文件树 / 页面标题 / 页面设置 / 页面正文,是否已经统一成 Rust 主导的单一真源架构。** 当前结论是: > **还没有。** 当前系统已经不再是: - `iframe runtime` - 纯前端假接入 - 纯 `BlockNote` 主链 但也还不是: - Rust 持有页面聚合真相 - Leptos island 只消费 Rust projection - 树域与主编辑区共享同一份 page aggregate 它现在更接近于: > **`Rust snapshot 主读链 + local-first / Convex-backed 双 source 过渡 + 前端本地 state 仍存在` 的混合态。** 因此,当前看到的这些“小问题”: - 标题单一真源问题已经开始收口,但页面聚合仍未完全统一 - 页面宽度和主编辑区宽度看起来不是一套规则 - 页面设置大部分只改了壳层,没有真正进入主编辑器 并不是孤立 UI bug,而是同一个底层断层的表现: > **树域 projection 与主编辑区 page runtime 之间,还缺一个统一的 page aggregate 真相层。** --- ## 2. 先给结论 ### 2.1 当前不能说已经实现了 Rust 单一真源 虽然当前文档页已经: - 默认使用页面内正式 `leptos-tiptap` island - 不再依赖外部 iframe bridge 作为主编辑器 - 正文保存已经能按 `workspaceId/documentId` 绑定到正确页面 但以下事实仍然成立: - 读取主链已固定为 Rust `mnote.page_aggregate.v1` snapshot,不再通过 TS builder 兜底 - 标题、正文、页面设置虽已开始收口到同一组 page aggregate command family,但 projection 回流与运行时语义还没完全闭环 - 页面树 / 文件树与页头标题的一致性已明显改善,但页头标题、页面设置与编辑器 runtime 还没有完全共享同一份聚合真相 - `pageOptions` 已部分进入 `leptos-tiptap` 运行时语义层,但还没有整体收口完成 所以当前不能把这条线描述成: > **“主编辑区、页面树、文件树已经统一为 Rust 下唯一真源”。** 更准确的表述应该是: > **树域已经在向 Rust canonical contract 靠拢,但文档页主编辑区仍处于页面聚合尚未完成的混合切流阶段。** ### 2.2 当前最该收口的不是换编辑器,而是统一页面聚合边界 当前不该重新争论: - 要不要 `Tiptap` - 要不要 `leptos-tiptap` - 要不要继续保留 Convex 作为控制面和可选同步协作 source 这些结论都已经足够明确: - `Tiptap` 继续作为浏览器输入 runtime - `leptos-tiptap` 继续作为 Leptos 内正式 editor island 接缝 - `local_folder` 作为默认页面数据真相,Convex / 服务端退居控制面和可选同步协作 source 当前真正要收口的是: > **页面这一层,到底由谁持有聚合语义,前端到底应该消费什么,编辑器到底应该回发什么。** local-first 后,答案进一步收紧为: > **正文真相由本地 Markdown 文件持有;Page Aggregate 负责把文件投影成 UI 和 editor runtime 所需结构;写侧必须围绕文件版本做仲裁,而不是继续让 `documents/save` 兼容面决定谁的 revision 有效。** ### 2.3 后续主线应固定为 Page Aggregate,而不是继续零散补洞 从长期架构看,当前主线不应再描述成: - “继续补几个 `leptos-tiptap` 细节” - “再把标题同步修一下” - “再把页面设置接一点进去” 后续主线应改写成: > **建立 Rust 主导的 `page aggregate`:让页面树 / 文件树 / 页面头部 / 页面设置 / 主编辑区 / AI 写入,都围绕同一组 page projection 与 page command family 运转。** --- ## 3. 当前问题的精确判断 ## 3.1 当前页面数据并不是一个统一聚合 当前文档页读取主链已经固定走 Rust `mnote.page_aggregate.v1` 快照: - Rust `/api/page-aggregate/:id` snapshot - 页面本地 aggregate state reducer - preferred sidebar snapshot / 页头标题补偿链 需要特别区分“Rust route 主读链”和“完整 kernel-native projection”:当前 `/api/page-aggregate/:id` 已经由 Rust route / Rust runtime adapter 对外提供稳定 `mnote.page_aggregate.v1` 契约,前端不再恢复 TS runtime builder 主链;但底层 substrate 仍主要来自 `documents:getMeta + documents:getContent` 的兼容聚合。因此当前 projection provenance 应标记为 `CompatMetaContentJoin`,不能写成已经完全由 Rust kernel 原生投影闭环的 `KernelProjection`。 这意味着当前已经不再是: - `page.tsx` 手工拉 `meta + content` 再现场拼 props 但也还不是: - 页面所有读写、标题回流、页面设置运行时语义都只围绕一份 Rust page aggregate 自动闭环 这条链本身已经说明: > **当前页面已经进入 Rust-first 聚合读取阶段,但 canonical page projection 仍未成为页面域唯一真相。** ### 3.1.1 标题链已开始收口,但尚未完成页面聚合统一 当前页头标题已经不再单纯由 `DocumentContent` 内部的长期本地 `pageTitle` 真相驱动,而是开始依赖与 sidebar / breadcrumb 共享的 preferred sidebar snapshot。 这带来两个直接后果: 1. 标题不同步这一类“前端本地状态长期漂移”问题已经明显减少 2. 但标题仍然没有和正文、页面设置一起统一到单一 page aggregate command / projection 边界中 于是: - 页头、breadcrumb、sidebar、page tree、file tree 的标题一致性已有一轮真实收口 - 但从聚合语义上看,标题仍是一条相对独立的更新链,不等于 page aggregate 已经完成 这类问题不是“防抖没配好”,而是: > **标题更新仍然是页面聚合之外的一条独立 side effect 链。** ### 3.1.2 正文和页面树仍然不是同一份 projection family 当前正文编辑器使用的是: - `leptos-tiptap` runtime - `/api/documents/save` - `EditorBlockDocument / Tiptap / blocks` 的转换边界 而页面子树 / outline / evidence 使用的是: - `pageSubtree` - 前端本地定义的 TS 结构 - “内容与标题未变化时复用旧快照”的策略 这说明当前主编辑区与树域虽然已经能同时工作,但它们仍然不是: - 同一个 Rust projection family 的不同视图 而是: - 一边是编辑器保存链 - 一边是页面子树附带快照 因此当前 `pageSubtree` 更像: - 页面阅读态和 TOC 的辅助投影 而不是: - 主编辑区与树域共享的 canonical page aggregate ### 3.1.3 页面设置已部分进入 island,但还不是完整 editor runtime 语义 当前页面设置里至少有三类配置: #### A. 页面壳层布局类 - `wideLayout` - `smallText` - `layoutDensity` #### B. 页面视图类 - `showToc` - `collapseBacklinks` - `hideChildPages` #### C. 编辑器语义类 - `showHeadingNumbers` - `showBlockRefCount` - `embedDefaultBlockId` 现在的主要问题不是“这些字段没有存下来”,而是: > **它们虽然能持久化到 documents 表,也已有一部分进入 `leptos-tiptap` island,但还没有统一进入正式 page aggregate,更没有完整地进入 editor runtime 语义层。** 结果就是: - 一部分设置已经作用于 island 布局或排版 - 一部分设置只影响阅读态 - 一部分设置被展示出来,但主编辑器内部几乎不消费 这也是为什么当前会出现: > **页面设置看起来像是真的,但很多只是 UI 层局部生效。** ### 3.1.4 所谓“Rust 路径”里仍有明显 compat 痕迹 当前 rename / move / archive 等页面操作,虽然已经开始声明: - `preferredCommandName` 但真实落地仍大量停留在: - `documents.title.update` - `documents.move` - `documents.delete` 这说明当前并不能说: - 树域命令已经完整切到 Rust tree command family 更不能说: - 页面编辑域已经和树域命令收敛到同一个 page aggregate contract 因此现状应判定为: > **Rust bridge 已经进入主链,但 page aggregate command cutover 仍未完成。** ### 3.1.5 写侧冲突不能继续藏在 `documents/save` 后面 当前读侧已优先消费 Rust `mnote.page_aggregate.v1` snapshot,但写侧仍有明显兼容痕迹: - tiptap 保存仍通过 `/api/documents/save` 兼容入口进入 `page.body.save` - AI 兼容工具写正文时也可能最终进入同一条保存链 - local-first 下 agent 还会直接修改 `.md` 文件 如果继续让这些写入都挤在 `documents/save` 兼容面后面,会再次出现: - 谁拥有最新 revision - AI 写入是否覆盖了用户未保存编辑 - tiptap autosave 是否覆盖了 agent 刚写回的文件 - Page Aggregate 读到的是旧 content、EditorBlockDocument 还是最新 `.md` 因此写侧必须改成 VSCode-like 文件版本模型: ```text current .md file -> fileVersion = hash + mtime + size -> Page Aggregate snapshot 带 baseFileVersion -> tiptap 工作副本记录 baseFileVersion 与 dirty 状态 -> AI / 外部编辑器写入触发 watcher -> clean editor 自动 reload;dirty editor 进入 conflict state ``` 这意味着 `EditorBlockDocument` 原生落库闭环不能理解成“再建一份新的正文真相”。local-first 下更合理的定位是: - `.md` 是 canonical body - `EditorBlockDocument` 是 runtime-native projection / cache - `.mnote/cache/.editor.json` 或内存缓存可用于加速和保留编辑器特有信息,但必须带 `sourceFileVersion` - 当 source file version 不匹配时,cache 失效并重新从 `.md` 投影 长期命令面应从 `/api/documents/save` compat route 收口到 `page.body.write` / LocalFS executor,并显式接收 `expectedFileVersion`。任何不带 expected version 的正文写入都只能进入 compat / import 路径,不能作为自动保存主链。 --- ## 4. 为什么这些问题会直接影响长期方向 ## 4.1 它会削弱 Rust + Leptos 的真正优势 你们要的不是: - “一个能跑的 Tiptap 页面” 而是: - Rust 掌握页面与树的真相 - Leptos 成为页面内正式 editor island - AI 最终直接接入主编辑区写作 如果标题、正文、设置、树节点标题分别走不同链路,那么 Rust + Leptos 的长期优势就会被削弱成: - 只是“比纯前端多了一层 bridge” 而不是: - “由 Rust 持有统一对象语义,前端只消费真相投影” ### 4.1.1 AI 直接写主编辑区会缺乏稳定入口 如果后续 AI 要直接进入主编辑区编写,而当前系统没有统一的 page aggregate command family,那么 AI 最终只能选下面几条坏路: - 直接操作前端 DOM - 直接操作 Tiptap JSON - 直接调用不同的标题 / 正文 / 设置接口拼写入 这三条路都不符合长期目标。 长期正确路线必须是: > **AI 先操作 Rust page aggregate 的命令与块语义,再由 editor island 把结果投影到 live editing surface。** ### 4.1.2 树域与页面域会继续出现“双真相漂移” 只要页面树 / 文件树消费的还是一套资源 meta,而主编辑区头部和页面设置是另一套前端状态,那么后续还会不断出现: - 标题单一真源已开始收口,但当前尚未和正文、页面设置统一成同一 page aggregate 命令边界 - 页面设置变更只影响一部分视图 - 阅读态与编辑态宽度规则不一致 - 新增一个页面能力时,要改三四条链路 这类问题越到后面越难收。 --- ## 5. 正确的目标重写 当前阶段的目标不应再写成: - “把 `leptos-tiptap` 再打磨得更像官方模板” 而应重写为: > **建立 Rust 主导的 `page aggregate`,让页面树 / 文件树 / 页面头部 / 页面设置 / 主编辑区 / AI 写入共享同一份页面真相。** 这句话拆开后,意味着下面五点。 ### 5.1 Page Aggregate Projection Rust 侧需要提供一个统一的页面聚合投影,至少包含: - `page_identity` - `workspace_id` - `document_id` - `node_id` - `page_head` - `title` - `icon` - `cover` - `updated_at` - `permissions` - `page_layout` - `wide_layout` - `small_text` - `layout_density` - `show_toc` - `show_structure` - `page_body` - `editor_document` - `revision` - `conflict_detection_key` - `stats` - `page_tree` - `outline` - `child_pages` - `resource_meta` 当前不要求一次把所有字段做满,但必须先把口径冻结。 ### 5.2 Page Command Family 后续页面域的操作不应继续被拆成互不统属的零散 mutation,而应在语义上统一到 page aggregate 之下。 最小命令族至少包括: - `page.head.rename` - `page.layout.update` - `page.body.save` - `page.body.insert_reference` - `page.body.set_embed_default_block` - `page.tree.refresh_projection` 这不意味着现在立刻把所有接口名都改掉,而是: > **从现在开始,所有页面能力都应先判断它属于哪个 page aggregate 子域,而不是继续新增离散 mutation。** ### 5.3 Leptos Island 的正式边界 `leptos-tiptap` island 的职责应收敛为: - 消费 `page_body` - 消费必要的 `page_layout` 编辑器相关配置 - 回发正文与块级命令 - 暴露可供 AI / 页面壳使用的稳定 editor handle 它不应再负责: - 定义页面总真相 - 自己发明页面设置语义 - 在页面壳之外另持有一套页面元信息状态 ### 5.4 Tree Domain 的接缝 页面树 / 文件树不需要知道编辑器内部细节,但必须与页面聚合共享: - 同一份 `document_id` - 同一份页面标题 - 同一份 `resource_meta` - 同一份页面可见状态与能力语义 也就是说: - 树域保持 tree-first - 页面域保持 page aggregate - 两者通过稳定 projection contract 对齐 而不是: - 树域一套标题来源 - 页面域另一套标题来源 ### 5.5 Convex 的正确定位 当前不应该把 Convex 当作默认页面主数据层继续扩写,也不应该无计划硬拆已有 Convex 路径。 正确口径是: - local folder 是早期产品默认页面数据真相 - Convex / 服务端继续作为账号、分享、同步、协作和远端副本控制面 - Rust 持有 canonical contract 与语义编排 - Leptos / Next 负责消费 projection 与呈现 island 因此,“Rust 单一真源”在当前阶段的正确含义不是: - 只有 Rust 数据库 而是: - **Rust 持有语义单一真源,local-first workspace 持有默认数据真相,Convex 只作为控制面和可选同步协作 source。** --- ## 6. 当前哪些地方需要纠偏 ## 6.1 需要纠偏的不是“页面里还有前端状态” 页面壳中存在一些本地临时状态本身没有问题。 问题在于: - 哪些状态是临时 UI 状态 - 哪些状态却在冒充对象真相 后续必须把两者分清。 ### 6.1.1 可以继续留在前端壳的状态 - inspector 开关 - comments drawer 开关 - history drawer 开关 - 当前是否在编辑态 - 当前 host observability 这些都是 UI state,不必进入 Rust 真相层。 ### 6.1.2 必须退出前端壳真相地位的状态 - 页面标题 - 页面宽度 / 页面布局正式选项 - 编辑器默认嵌入位置 - 页面 outline 的正式来源 - 页面 stats 的正式来源 这些都不应继续被前端 `useState` 长期定义为页面事实。 ## 6.2 需要纠偏的不是继续换保存底座 当前正文保存链已经基本可用,真正的问题不是“保存不到页面”,而是: - 保存成功不等于 page aggregate 已建立 因此: - 现在不该推翻 `documents.save` - 也不该另造一套临时保存格式 而应做的是: > **把标题 / 正文 / 页面设置的语义边界对齐到同一 page aggregate 之下。** ## 6.3 需要纠偏的是页面设置的产品口径 当前页面设置里已经混入了三类字段: - 真正属于页面布局的 - 真正属于阅读壳的 - 真正属于编辑器 runtime 的 后续必须先分类,再决定哪些继续保留在页面设置面板里,哪些推迟支持,哪些进入 editor island。 否则只会继续出现: - UI 有按钮 - 数据能存 - 但主编辑器不真正消费 --- ## 7. 分阶段落地 下面的阶段不是完整开发清单,而是主线纠偏顺序。 ## 7.1 Phase F: 冻结 Page Aggregate Contract 目标: - 在设计层明确页面聚合的 canonical contract - 不再让页面页头 / 正文 / 设置 / 子树各自长字段 完成标准: - 有单独的 page aggregate contract 文档 - 明确 `page_head / page_layout / page_body / page_tree` 的最小字段 - 明确哪些字段是 UI state,哪些字段是 aggregate truth ## 7.2 Phase G: 主文档页改为消费统一聚合 目标: - 文档页不再手工拼 `title + options + content + pageSubtree` - 改为消费统一页面聚合返回值 完成标准: - 页面头部使用聚合中的 `page_head` - 页面设置初始值使用聚合中的 `page_layout` - 主编辑区 bootstrap 使用聚合中的 `page_body` - 树与 TOC 使用聚合中的 `page_tree` ## 7.3 Phase H: `leptos-tiptap` 正式消费 editor-related page options 目标: - 把真正属于编辑器 runtime 的设置接入 `leptos-tiptap` 最小优先级建议: 1. `wideLayout` 2. `smallText` 3. `layoutDensity` 4. `showHeadingNumbers` 5. `embedDefaultBlockId` 完成标准: - inspector 改动后,主编辑区表现真实变化 - 不再出现“设置能存,但编辑器内部几乎没变化” ## 7.4 Phase I: 树域与页面域标题统一 目标: - rename 后页头、sidebar row、page tree、file tree 一致刷新 完成标准: - 标题变更只经过一套 page aggregate command 语义 - 所有标题消费者都来自同一份更新后的 projection ## 7.5 Phase J: AI 写入入口对齐 page aggregate > 2026-05-16 口径补充:`mnote.doc.fetch/find/plan_update` 与 `mnote.block.fetch/replace/insert_after/move_after` 已进入 Rust Hermes tool manifest 与 dispatch,最小块级读写闭环已启动。本文继续保留本节,是因为 `scope=selection`、`format=page_xml/text`、多块插入、复杂块移动矩阵和持久审阅 UI 仍由 `7-10` 继续验收。 目标: - AI 不再绕过 page aggregate 直接拼前端对象 完成标准: - AI 能通过统一页面命令把内容写入主编辑区 - 人工编辑与 AI 编辑共享同一块语义边界 --- ## 8. 当前阶段的完成判定 只有当下面这些条件同时成立时,才能把这条线描述成: > **“主编辑区与树域已经基本统一到 Rust 主导的单一真源架构”。** - 文档页消费统一 `page aggregate projection` - 标题 / 正文 / 页面设置不再是三条分裂真相链 - `leptos-tiptap` island 真正消费 editor-related `page_layout` - 树标题与页头标题来自同一份 projection - AI 写入入口开始围绕 page aggregate command family 设计 在此之前,正确口径都应保持为: > **`leptos-tiptap` 主编辑区已基本可用,但页面聚合仍未收口,当前仍处于从混合态向 Rust 单一真源过渡的过程中。** --- ## 9. 本文结论 这次暴露出的页面宽度不统一、页面设置只部分生效,以及标题链虽已收口但聚合仍未统一,并不是坏消息。 它们的价值在于: > **它们准确暴露了当前真正缺的不是“再修几个细节”,而是“把页面域提升成一个与树域对齐的 Rust page aggregate”。** 因此,后续主线不应继续散落成: - 修标题 - 修宽度 - 修页面设置 而应统一收口为: > **Page Aggregate Contract** > > **Page Aggregate Projection** > > **Page Aggregate Command Family** 这才是让 `tree-first graph kernel`、`leptos-tiptap` 主编辑区、以及后续 AI 直接写入主编辑区三者真正对齐的正确底层方向。