feat: align local-first workspace direction

Document the VSCode-like local-first product shape, demote Convex to a control-plane role, and retire stale architecture drafts.

Add local workspace migration/export references plus smoke coverage for no-Convex managed workspace startup, local markdown title/body/options persistence, asset upload behavior, and Convex fixture export.

Verification: git diff --cached --check; node scripts/check-local-first-convex-guard.js --staged; node scripts/task444-convex-workspace-export-local-fixture-smoke.js; node scripts/task166-local-first-managed-workspace-no-convex-smoke.js; node scripts/task167-local-markdown-title-body-options-no-convex-smoke.js
This commit is contained in:
lix-2026
2026-05-19 08:11:58 +08:00
parent 68d321e297
commit cdff672aa5
67 changed files with 5242 additions and 932 deletions
+8 -7
View File
@@ -3,7 +3,8 @@
## 当前主线
- 当前长期方向以 `tree-first graph kernel` 为准,不以 `BlockNote-first``Mindmap-first` 为准。
- `Convex` 继续保留为本地自托管的存储、实时、文件协作底座,不因为推进 Rust 主线而先拆掉
- 当前产品形态固定为:`VSCode 简化版工作区 + tiptap 的 Markdown 前端编辑器 + Hermes/Reasonix agent + simplemindmap/office 插件 + Wolai 主题 Web 壳 + 鉴权控制面`
- 本地文件夹是早期产品默认数据真相;本地 `.md` 是页面正文真相。`Convex` / 服务端降级为 auth、membership、share grants、sync state、AI policy、cloud source、compat 和 sync replica 控制面,不再作为新增能力的默认正文、附件或 AI 会话全文主存储。
- `Rust kernel` 持有树、子树、边、projection、query、command 的语义主导权;新增树规则不要继续散落到前端、Next route 或临时 compat 层。
- `mnote-web` 是当前 Rust Web 承载层,负责 transport、projection 分发、兼容切流;`compat``fixture` 只用于过渡和测试,不应继续承载长期业务语义。`3000` 是唯一前端公开入口;`3104` 已退役为仅显式 debug/internal 使用的边界。`/tree``/document-debug` 等 debug 壳默认关闭,仅在显式 debug/runtime 验证时启用。
- 前端主路径应消费稳定 projection,不应在 UI 层重新拼出第二份对象真相。
@@ -11,7 +12,7 @@
- `3000` 当前主壳已接入 Rust Web WebSocket push 主链(`/api/realtime/ws`+ SSE fallback`/api/tree/events`)的 snapshot / delta / resync consumer,并已有 browser smoke 验证(2026-05-17 WS 迁移 `57ec8322`);后续收口重点是统一 live cache 与减少补偿链,而不是把它描述成“还没接 live stream”。
- 文档页默认主编辑器已切到页面内 `leptos-tiptap` island`BlockNote` 已退出文档页默认主路径,只保留为历史参考实现 / 对照材料。
- Page Aggregate 当前已输出 `blockDocument / blockProjectionVersion / projectionSource``projectionSource=documents.content`),标题/正文/页面设置写入后 smoke 已通过(`task110``task-page-aggregate-body-sync-smoke``task-page-aggregate-options-sync-smoke`);页面/块 AI 最小工具链已通过 Rust Hermes tools 读取和写入块投影;但这仍是从 `documents.content` / local markdown content 投影出来的过渡态,不是 EditorBlockDocument 原生落库完成态。客户端 `PageAggregateClientState` reducer 仍在,页面域单一真源未完全闭环。
- 当前最优先的架构收口不是继续扩编辑器 UI,而是 `Page Aggregate` 单一真源收口(标题/正文/页面设置 smoke 已通过)`tree command cutover` 收尾、`tree realtime event stream` live cache 统一(WS push 主链 2026-05-17 上线,SSE 降级为 fallback三条主线;AI 侧简单正文编辑当前主路径已切到 `mnote.doc.markdown_edit`(模型生成 search/replace 或 full_content,经统一 mnote tool executor 执行;在线文档与本地 `.md` 共用合同),`mnote.doc.fetch` 负责读取上下文,`mnote.doc.apply_block_ops` / `mnote.block.*` 作为结构辅助;`review session`/流式 apply 属于 Phase C(设计冻结,当前不实施),不扩新 AI 功能,不把粗粒度 `mnote.page.save` 当成精确块编辑主入口。
- 当前最优先的架构收口不是继续扩编辑器 UI,而是 local-first 工作区产品化、`Page Aggregate` 单一真源收口、`tree command cutover` 收尾、`tree realtime event stream` live cache 统一(WS push 主链 2026-05-17 上线,SSE 降级为 fallback。AI 侧普通 Markdown 编辑主路径已收口为“当前文件引用 + selection + allowed roots + agent 原生 patch/diff + watcher 同步”;`mnote.doc.fetch``mnote.doc.markdown_edit``mnote.doc.apply_block_ops``mnote.block.*` 作为 cloud / remote agent / compat fallback 或复杂结构辅助;`review session`/流式 apply 属于 Phase C(设计冻结,当前不实施),不扩新 AI 功能,不把粗粒度 `mnote.page.save` 当成精确块编辑主入口。
## 组件定位
@@ -19,7 +20,7 @@
- `BlockNote` 是历史参考实现 / 对照材料,不再是运行时系统组件、默认回退编辑器或系统事实源。
- `Mindmap``tree-first graph` 的一种视图和编辑挂件,不是对象真相层。
- `OnlyOffice` 是独立页面型编辑器,不直接嵌入 `BlockNote` 画布;正文中通常通过附件块跳转进入。
- `page-ai/block-edit-workflow` route 是当前简单编辑的 fast-path 入口;当前实现通过模型生成 markdown 级 search/replace / full_content 后调用 `mnote.doc.markdown_edit`,并复用统一 Hermes mnote tool executor`mnote.block.*` 保留为结构性辅助。复杂任务仍走 Hermes agent。
- `page-ai/block-edit-workflow` route 只保留为兼容门面或非 local source 的快捷路径,不作为 local-first 普通 Markdown 编辑默认主路径;local-first 下优先给 Hermes / Reasonix 授权文件引用并让 agent 使用自身文件编辑能力。`mnote.doc.markdown_edit` 是 cloud / remote agent / compat fallback`mnote.block.*` 保留为结构性辅助。
- 树域三层模型:`Resource Tree`kernel 对象组织真源)→ `File Tree`(主组织投影,`{title}.md` 为页面正文行)→ `Page Tree`(导航投影,不持有结构真相)。涉及资源归属优先走 Rust kernel 的 `KernelObjectIdentity` / `KernelProjectionResourceKind`
## 目录优先级
@@ -50,19 +51,19 @@
- `mnote-web``compat route` 可以承接过渡流量,但不要把新的长期业务逻辑继续堆进 compat。
- 涉及文档页标题、页面设置、正文保存、页头与树一致性时,优先判断是否应收口到 `Page Aggregate`,不要继续在页面壳或 island 外侧拼第二份页面真相。
- 涉及页面新建、重命名、移动、归档、恢复、嵌入时,优先沿 `tree.*` 正式命名推进;`documents.*` 只视为兼容层,不应继续扩写为长期命令面。
- 涉及 AI 读取、定位或精确编辑页面块时,优先沿 `mnote.doc.*` / `mnote.block.*` Hermes tools 与 Rust `EditorCommand` 推进`mnote.page.save` 只作为页面级兜底写入工具。
- 涉及 local-first AI 普通 Markdown 编辑时,优先沿“授权文件引用 + `AiAccessScope` + allowed roots + agent 原生 patch/diff + 文件版本冲突模型 + watcher 同步”推进;涉及 cloud / remote agent / 复杂结构辅助时,才沿 `mnote.doc.*` / `mnote.block.*` Hermes tools 与 Rust `EditorCommand` 推进`mnote.page.save` 只作为页面级兜底写入工具。
- 涉及资源归属(哪个页面拥有哪个 mindmap/附件)、object identity 和资源生命周期时,优先沿 Resource Tree → File Tree projection → `tree.resource.*` 命令面推进;`tree.*` 是正式命令面,`documents.*` 只视为兼容层。
- 页面 AI 编辑当前主路径为 `mnote.doc.markdown_edit`search/replace 或 full_contentmarkdown 文本级),在线文档和本地 `.md` 文件共用。`POST /api/page-ai/block-edit-workflow` 是简单编辑 fast-path,当前通过模型生成 markdown 编辑意图后调用统一 mnote tool executor`mnote.block.*` 保留为结构性辅助(拖拽排序等)。两层操作模型已获 CLI Main(Lark Doc)参考实现验证。流式 apply + suggest/review(参考 BlockNote AI)作为 Phase C 设计冻结,当前不实施。复杂任务进入 `/api/hermes/client/runs`。模型不直接产出 unified diff 或 blockId 操作;最终定位、校验和写入由 Rust runtime 负责。
- 页面 AI 编辑当前 local-first 主路径为:页面定位到真实 `.md` 文件,MNote 计算 `AiAccessScope` / allowed roots / selectionHermes 或 Reasonix 在白名单目录内用自身 patch/diff/文件编辑能力写入,MNote 通过 watcher / refresh 同步 tiptap。`mnote.doc.markdown_edit` 只作为 cloud / remote agent / compat fallback`mnote.block.*` 保留为结构性辅助(拖拽排序等)。两层操作模型已获 CLI Main(Lark Doc)参考实现验证。流式 apply + suggest/review(参考 BlockNote AI)作为 Phase C 设计冻结,当前不实施。
- 需要架构判断时,优先参考:
- `/mnt/Data1T/mnote/ARCHITECTURE.md`
- `/mnt/Data1T/mnote/design/01-05-current-priority-overview.md`
- `/mnt/Data1T/mnote/design/01-tree-first-graph-kernel/process/1-tree-first-graph-kernel-v1.md`
- `/mnt/Data1T/mnote/design/02-convex-rust-long-term-architecture/process/2-tree-first-graph-convex-rust-long-term-architecture-v1.md`
- `/mnt/Data1T/mnote/design/02-convex-rust-long-term-architecture/done/2-2-local-first-workspace-convex-control-plane-v1.md`
- `/mnt/Data1T/mnote/design/05-editor-mainline/process/5-5-page-aggregate-single-truth-alignment-v1.md`
- `/mnt/Data1T/mnote/design/05-editor-mainline/process/5-6-page-aggregate-alignment-checklist-v1.md`
- `/mnt/Data1T/mnote/design/07-ai/process/7-10-page-block-ai-tooling-execution-checklist-v1.md`
- `/mnt/Data1T/mnote/design/04-tree-domain/done/4-6-tree-command-protocol-cutover-stage2-v1.md`
- `/mnt/Data1T/mnote/design/03-rust-web/process/3-3-rust-web-tree-realtime-event-stream-v1.md
- `/mnt/Data1T/mnote/design/03-rust-web/process/3-3-rust-web-tree-realtime-event-stream-v1.md`
- `design/03-rust-web/done/3-14-rust-web-tree-realtime-ws-push-v1.md`
- `/mnt/Data1T/mnote/design/10-review/process/08-kernel-architecture-next-priority-review-and-checklist.md`
- `/mnt/Data1T/mnote/design/10-review/done/09-page-ai-fast-block-edit-runtime-review.md`
+37 -30
View File
@@ -1,8 +1,10 @@
# MNOTE 当前架构梳理
> 更新时间:2026-05-16
> 更新时间:2026-05-19
>
> **当前阶段:初步 MVP 已达成。** 3000 下文档页完整读写链路(标题、正文、页面设置、Page Aggregate)、Sidebar/File Tree/Page Tree 三层树模型、tree command`tree.*` preferred)、tree realtime SSEsnapshot/delta/resync)、页面 AI 快速编辑(fast-path < 1s,当前 `local_rule` planner 为过渡实现,长期方向为 markdown 级编辑,见 7-14)均已通过真实浏览器 smoke 验证。剩余工作集中在单一真源收口、live cache 统一、AI 编辑路径从块级收敛到 markdown 文本层,而非继续证明架构可行性
> 2026-05-19 口径更新:产品形态已切换为 local-first workspace;本地文件夹是默认数据真相,Convex / 服务端降级为账号、分享、同步、协作和 AI 隔离控制面。相关设计已完成并迁入 `design/02-convex-rust-long-term-architecture/done/2-2-local-first-workspace-convex-control-plane-v1.md`。本地 Markdown 图片与附件上传已新增 `/api/local-folder/assets/upload`,页面内上传默认写入 `{mdBase}.assets/` 并保存相对 Markdown 路径,不再走 Convex media asset
>
> **当前阶段:local-first MVP 骨架已达成。** 3000 下文档页、Sidebar/File Tree/Page Tree 三层树模型、tree command`tree.*` preferred)、tree realtime WS 主链 / SSE fallback、本地 Markdown 上传、AI 会话本地化与 VSCode-like agent 运行口径均已建立。剩余工作集中在管理员目录授权、VSCode-like 冲突合并 UI、agent diff 审计、本地索引 / 分享 / 同步闭环,而不是继续扩张专用 page-ai fast-path 或 Convex 主存储链。
本文只描述当前仓库中真实成立的主线结构,以及当前最优先的架构收口点。
@@ -17,21 +19,21 @@
当前主线固定为:
1. `tree-first graph kernel` 是长期对象真相层
2. `Convex` 继续保留为当前自托管存储 / 实时 / 文件协作底座
2. 本地文件夹是默认数据真相,Convex / 服务端退居账号、分享、同步、协作和 AI 隔离控制面
3. `mnote-web` 是当前 Rust Web 主执行面,负责 3000 gateway、server-first shell、query / command / projection / transport 与 realtime streamNext App Router 已降为 legacy compat / island bundle source,不再是当前主入口
4. 文档页默认主编辑器已切到页面内 `leptos-tiptap` island
5. `BlockNote` 已退出文档页默认主路径,仅作为历史参考实现 / 对照材料保留
6. 页面 AI 工具链已建立最小闭环:`POST /api/page-ai/block-edit-workflow` 提供快速编辑 route(当前 `local_rule` planner 为过渡态,长期方向为 `mnote.doc.markdown_edit` 文本级搜索替换,见 7-14);Hermes tools`mnote.doc.*` / `mnote.block.*`)承担复杂任务编排
6. 页面 AI 当前最合理的长期形态是:MNote 只负责页面定位、白名单目录权限、agent runtime 管理、文件变更同步;Hermes / Reasonix 直接在授权工作区内编辑本地文件。`page_ai_workflow``mnote.doc.*` / `mnote.block.*` 保留为兼容 / cloud / 复杂结构辅助层,不再作为 local-first 普通正文编辑默认主路径
一句话收口:
> **Rust 持有语义主导权,Convex 保留底座,前端逐步从重壳转向消费稳定 projection 与少量交互 island。**
> **Rust 持有语义主导权,本地文件夹是默认数据真相,Convex 保留控制面与可选同步协作能力,前端逐步从重壳转向消费稳定 projection 与少量交互 island。**
补充口径:
> **主 Web 执行面当前以 `mnote-web` 为 3000 ownerNext App Router 只保留为 legacy compat、交互 island bundle source 与显式 debug/迁移辅助边界。**
>
> **页面 AI 块编辑当前以 fast-path `local_rule` planner`/api/page-ai/block-edit-workflow`)为简单操作首选;Hermes agent`mnote.doc.*` / `mnote.block.*` tools)保留为复杂任务编排器。**
> **页面 AI 的 local-first 主路径应尽量贴近 VSCode:当前页面解析成真实 `.md` 文件,Hermes / Reasonix 在授权目录白名单内直接读写,tiptap 只消费后台文件变化后的最新投影。**
## 2. 当前主线目录
@@ -122,7 +124,7 @@
## 5. 当前编辑器与页面聚合关系
当前文档页已经开始消费统一的前端侧 `PageAggregateProjection`,但仍不能说“页面域已完全统一成 Rust 单一真源”。
当前文档页已经开始消费统一的 `PageAggregateProjection`,但仍不能说“页面域已完全统一成 Rust 单一真源”。
当前真实状态是:
@@ -132,7 +134,7 @@
- 正文默认由 `leptos-tiptap` island 编辑与保存
- 页面设置已有一部分进入 island 运行时语义
- `page_tree` / `pageSubtree` 已进入统一聚合入口
- 页面/块 AI tools 已开始通过 Page Aggregate block projection 读取、定位、dry-run、替换、插入和受限移动块,并经 `page.body.save -> documents:updateContent` 持久化
- 页面/块 AI tools 通过 Page Aggregate block projection 读取、定位、dry-run、替换、插入和受限移动块local-first 普通正文编辑不再要求走块工具,在线 / cloud / compat 场景才回到受控 mnote tool 写入。
2026-05-16 更新:以下四项已通过真实 3000 browser smoke 验证(证据见 `tmp/page-aggregate-*` 目录):
@@ -144,8 +146,8 @@
仍未完成的关键点是:
1. Rust 侧已提供最小 `Page Aggregate` snapshot 和 block projection v1,但当前 block projection 仍主要从 `documents.content` / local markdown content 投影(`projectionSource=documents.content`),不是 EditorBlockDocument 原生落库完成态
2. 标题 / 正文 / 页面设置虽已收口到 `page.*` family 并通过 smoke,但客户端仍保留 `PageAggregateClientState` reducer(混合 server snapshot / draft title / local content),页面域单一真源仍未完全闭环
3. AI 块工具最小闭环已启动且 fast-path < 1s,但 `scope=selection``format=page_xml/text`、多块插入、复杂块移动矩阵和持久审阅 UI 仍未完成
2. 标题 / 正文 / 页面设置虽已收口到 `page.*` family 和 local-first 文件版本模型,但客户端仍保留 `PageAggregateClientState` reducer(混合 server snapshot / draft title / local content),页面域单一真源仍未完全闭环
3. AI 块工具继续作为复杂结构辅助;local-first 普通 Markdown 编辑主路径已经转为“授权文件引用 + agent 原生 patch/diff + watcher 同步”
4. Sidebar 仍通过 preferred snapshotinitial / query / tree_stream)做 freshness 选择,tree realtime live cache 未统一
因此当前正确表述应是:
@@ -261,8 +263,10 @@ OnlyOffice 仍然是:
- ✅ 标题单一真源 smoke 通过(`task110`):页头/Breadcrumb/Sidebar/Page Tree/File Tree 一致
- ✅ 正文写入后 `body.revision` / `blockDocument` 同步 smoke 通过
- ✅ 页面设置写入后 `pageOptions` 同步与刷新持久化 smoke 通过
- ✅ local-first `page.body.write` / `/api/page-body/write``expectedFileVersion` 已成为本地正文写入主入口,`/api/documents/save` 降级为 compat adapter
- ✅ 本地 Markdown 图片 / 附件上传写入 sibling assets,并由真实浏览器 smoke 覆盖保存后刷新恢复
- ✅ Convex 不可用时返回 degraded error,不返回伪 fixture
- ⬜ block projection 从 EditorBlockDocument 原生落库(当前仍从 `documents.content` 投影
- cloud / compat block projection 继续减少 `documents.content` 后备;local-first 正文真相已转为 `.md` 文件投影
- ⬜ 客户端 `PageAggregateClientState` reducer 退役,页面域完全以 Rust snapshot 为单一运行时真相
对应设计稿:
@@ -297,37 +301,39 @@ OnlyOffice 仍然是:
目标:
-AI 读取、定位、dry-run 和精确块写入统一走 Rust Hermes tools、Page Aggregate block projection 与 `EditorCommand`
-`mnote.page.save` 固定为页面级兜底写入工具,不再代表长期精确块编辑主入口
- 补齐 `scope=selection``format=page_xml/text`、多块插入、复杂块移动矩阵和持久审阅 UI
-local-first 页面 AI 默认走“页面定位 -> 授权文件引用 -> agent 原生 patch/diff -> 前台同步”
-`mnote.page.save` 固定为页面级兜底写入工具,不再代表普通正文编辑默认入口
- `mnote.doc.*` / `mnote.block.*` 收口为兼容 / cloud / 复杂结构辅助工具
- 补齐 `scope=selection`、白名单目录权限、changed_files/diff 审计、前台同步刷新矩阵
当前进度(2026-05-16,按 7-14 v2 更新):
当前进度(2026-05-19,按 `2-2` 完成态更新):
- ✅ 页面 AI 快速编辑 route 已上线:`POST /api/page-ai/block-edit-workflow`
-简单编辑本地 planner`local_rule`)已验证:788ms(过渡实现,将被 `markdown_edit` 替代)
- ✅ Hermes tools`mnote.doc.*` / `mnote.block.*`)已可读取和写入块投影
-参考实现已确认两层模型可行性:CLI Main(Lark Doc`str_replace` 对应 `markdown_edit``block_*` 对应 `apply_block_ops`
- **Phase A(当前唯一活跃实施)**`mnote.doc.markdown_edit` + `mnote.doc.fetch` 增强(search/replace + `format: "markdown"` + local source + Hermes 注册)
- **Phase B(下一阶段)**:退役 `direct_block_edit_operations``page_ai_workflow.rs` 改走 `markdown_edit`system prompt 产 search/replace 对
-`scope=selection``format=page_xml/text` 稳定输出
-tool manifest annotations`readonly` / `destructive` / `requiresApproval` 等)
- ✅ 页面 AI 与 ACP runtime 已能承载本地优先方向
-`page_ai_workflow` 已在 local source 下退出主路径
- ✅ Hermes tools`mnote.doc.*` / `mnote.block.*`)已可作为兼容 / cloud / 复杂结构辅助层读取和写入
-本地优先设计已经收口为 VSCode-like 运行模型:页面定位 + 白名单目录 + agent 直改文件 + tiptap 同步显示
- ✅ 页面 AI 默认输入已收口到 `currentFile + selection + allowedRoots / aiAccessScope`
- ✅ Hermes / Reasonix runtime 已显式带白名单目录运行
-MNote 回收 changed_files / diff 审计
-`scope=selection``format=page_xml/text` 继续作为结构化辅助输出,不作为普通 Markdown 编辑必需路径
- ⬜ tool manifest annotations 继续补齐 destructive / requiresApproval 等高级语义
-**Phase C(设计冻结,不实施)**`StreamApplyController` + `ReviewSession` + `GhostTextOverlay`(流式 apply + suggest/review
- ⬜ stale revision / idempotency 重放保护(已有 revision 乐观锁覆盖,补端到端 smoke)
关键原则(2026-05-16 补充,按 7-14 v2 更新):
关键原则(2026-05-18 口径更新):
- **两层操作模型**(参考 CLI Main Lark Doc):文本级 `mnote.doc.markdown_edit`str_replace)为主路径(覆盖 80%+ 场景),块级 `mnote.doc.apply_block_ops` 为结构性辅助(< 20%
- AI 编辑主路径应从块级降维到 markdown 文本层:新增 `mnote.doc.markdown_edit`search/replace 或 full_content)作为主要 AI 写入工具
- **local-first 主路径**:当前页面解析成授权 `.md` 文件,Hermes / Reasonix 在白名单目录内直接读写,MNote 负责权限、审计和前台同步
- **兼容两层模型**(参考 CLI Main Lark Doc):`mnote.doc.markdown_edit` 作为文本级兼容 / fallback`mnote.doc.apply_block_ops` 作为块级结构性辅助;local-first 正常编辑优先走“授权文件引用 + agent 原生 patch/diff + 文件版本冲突模型”
- `mnote.block.*` 降级为结构性辅助(拖拽排序、精确块删除等),不删除
- 当前 `local_rule` planner`direct_block_edit_operations`)是过渡实现,应退役并替换为模型产出 search/replace 对
- 在线 Convex 文档和本地 `.md` 文件共用同一条 markdown AI 写入路径(`resolve_source` → Convex | LocalFS
- 当前 `local_rule` planner`direct_block_edit_operations`)是过渡实现,应继续退役
- cloud / remote agent 无法直接访问本地文件时,才回到 `resolve_source` → Convex | LocalFS 的受控代理写入
- Markdown 既是 AI 编辑格式也是人类可读格式,不需要 XML 中间层
- 流式 apply + suggest/review(参考 BlockNote AI 的 `StreamToolExecutor` + `suggestChanges`)仅作为 Phase C 设计冻结,当前不实施
对应设计稿:
- `/mnt/Data1T/mnote/design/07-ai/process/7-10-page-block-ai-tooling-execution-checklist-v1.md`
- `/mnt/Data1T/mnote/design/10-review/process/09-page-ai-fast-block-edit-runtime-review.md`
- `/mnt/Data1T/mnote/design/10-review/done/09-page-ai-fast-block-edit-runtime-review.md`
- `/mnt/Data1T/mnote/design/07-ai/process/7-14-online-local-ai-markdown-editing-convergence-v1.md`
### 8.5 定向 Bug Hunt 与质量基线
@@ -364,7 +370,8 @@ OnlyOffice 仍然是:
- “当前最优先是先做树域 UI 重构”
- “Rust Web 还只是实验,没有接真实页面链路”
- “页面 AI 编辑必须经过 Hermes agent,没有快路径”
- “页面 AI 编辑必须经过 MNote 专用工具才能改普通 Markdown”
当前真正的卡点已经从“能不能跑”变成:
> **单一真源如何收口(Page Aggregate ClientState → Rust snapshot),projection / command / realtime 三条链如何从兼容态进入正式主链,以及 AI 块编辑如何从“能写”变成产品级可靠(selection、review、conflict、idempotency。**
> **管理员目录授权、文件版本冲突合并、agent diff 审计、本地索引 / 分享 / 同步如何产品化;Page Aggregate ClientStateprojection / command / realtime 兼容链如何继续瘦身。**
+78 -129
View File
@@ -1,164 +1,113 @@
# 当前完整架构
> 更新时间:2026-05-17
> 更新时间:2026-05-19
>
> 范围:`/mnt/Data1T/mnote` 当前可见实现的完整架构、冲突口径、过渡态和缺失功能
> 范围:`/mnt/Data1T/mnote` 当前可见实现、主线口径、历史退役边界和后续功能缺口
## 1. 结论
## 1. 当前产品形态
当前系统已经形成一条清晰的主线
当前项目口径固定为
`Convex / 本地文件` -> `Rust kernel / bridge-runtime` -> `mnote-web` -> `前端壳与编辑器` -> `AI runtime`
> **MNote = VSCode 简化版工作区 + tiptap 的 Markdown 前端编辑器 + Hermes / Reasonix agent + simplemindmap / office 插件 + Wolai 主题 Web 壳 + 鉴权控制面。**
但它还不是单一真源闭环。现在同时存在三类并行真相
这意味着
1. `tree` / `page` / `resource` 的 Rust 语义真相。
2. 文档页与 Sidebar 的前端本地派生真相
3. AI 写入链路中的 markdown / block 双合同真相
- 本地 workspace folder 是早期产品默认数据真相。
- 本地 `.md` 文件是页面正文真相;Page Aggregate、tiptap state、AI context 都是投影或工作副本
- Rust kernel / projection / command 持有树、页面、资源和权限语义
- tiptap 是 Markdown 的前端显示与交互层,不是 agent 的主工作面。
- Hermes / Reasonix 默认应像 VSCode 中的 agent 一样,在授权目录白名单内用自身 patch / diff / 文件编辑能力修改文件。
- Convex / 服务端不再是默认正文、附件、AI 会话全文主存储,而是 auth、membership、share grants、sync state、AI policy、cloud source、compat 和 sync replica 控制面。
因此,项目当前更像“主线已经立住,但收口尚未完成”的状态,而不是“架构已统一完成”的状态。
上位设计已完成并迁入:
## 2. 分层架构
- [2-2 local-first workspace 与 Convex 控制面降级方案](/mnt/Data1T/mnote/design/02-convex-rust-long-term-architecture/done/2-2-local-first-workspace-convex-control-plane-v1.md)
### 2.1 事实存储
## 2. 当前分
- `Convex` 仍是在线协作、文档、媒体、树数据的实际后端存储底座。
- 本地 `.md` 文件是 `mnote.doc.fetch` / `mnote.doc.markdown_edit` 的另一条合法输入输出面。
- 本地文件路径与在线文档路径在工具层已经分叉,不能再假设只有一种存储后端。
### 2.1 Workspace / Storage
### 2.2 Kernel / Projection 层
- `local_folder` 是默认 source`/mnt/Data1T/Mnote_data/users/<actor>/workspaces/my-space/` 是受管“我的空间”默认根。
- 管理员通过控制面授权用户可读写目录;普通用户不能自助获得全盘读写。
- 上传到本地 Markdown 页面时,图片和附件默认写入 sibling assets,例如 `README.assets/image.png`,正文保存相对 Markdown 链接。
- Convex source 仍可作为 cloud / compat / sync replica,但新增功能不能默认把 `documents.*``mediaAssets.*``aiSessions.*` 当主存储。
- `rust/crates/core-protocol/src/kernel.rs` 定义 `KernelProjectionKind``KernelProjectionResourceKind``KernelObjectIdentity` 等协议语义。
- `rust/crates/bridge-runtime/src/lib.rs` 负责从 Convex 侧数据归一化出 kernel nodes / edges / projections,并生成 command plan。
- 语义主导权已经明显从前端迁到 Rust,但前端仍保留若干本地派生投影。
### 2.2 Rust Kernel / Projection
### 2.3 Tree / Command 层
- `core-protocol` 定义树、资源、页面、AI access scope、page body write 等协议语义。
- `bridge-runtime``mnote-web` 负责把 LocalFS / Convex / future sync source 归一成稳定 projection 与 command。
- 前端只消费 `file_tree``page_tree``page_aggregate`、tree command result 和少量 editor runtime payload。
- 正式命令面应落在 `tree.*``tree.resource.*`
- `mnote-web` 同时暴露 `tree` 命令路由与兼容/过渡路由。
- FileTree 资源行已与页面命令对象隔离:资源 owner 只作为上下文,`data-document-id` / 页面命令目标只保留给真正页面行。
### 2.3 Web Shell / Editor
### 2.4 Transport / Realtime 层
- `mnote-web` 是当前 3000 ownerNext / React 前端已退入 `recycle/`,只作为历史参考或 island bundle source。
- 文档页默认编辑 host 是页面内 `leptos-tiptap` island。
- local source 正文保存主入口已收口到带文件版本的 `page.body.write` / `/api/page-body/write``/api/documents/save` 只作为 compat adapter。
- 文件 watcher 发现 agent / 外部编辑器写入后,clean editor 自动刷新,dirty editor 进入冲突态,不静默覆盖。
- Rust Web 已同时注册 `/api/tree/events`SSE)与 `/api/realtime/ws`WS)两条实时链路([routes/mod.rs:170-172](rust/crates/mnote-web/src/routes/mod.rs:170))。
- Rust SSR 主壳([layout.rs](rust/crates/mnote-web/src/ssr/pages/layout.rs:7126))已内置 WS 消费者(`startWithWebSocket`),并在 WS 断开后自动切 SSE fallback`ws.onclose → startWithSseFallback`)。
- 当前默认 `bootstrap.transport` 已切到 `convex-command-log-ws`,主壳先走 WSSSE 保留为 fallback。
- `recycle/wolai-frontend`(已退役的 Next.js legacy 侧)仅使用 SSE,无 WS 消费者。
- WS 与 SSE 的 snapshot / delta 载荷合同已在 Rust 主壳消费层统一;后续重点是继续减少兼容 fallback 与 live cache 补偿链。
### 2.4 AI Runtime
### 2.5 Page Aggregate 层
- local-first 普通 Markdown 编辑主路径是:
- 文档页入口已经优先消费 Rust `page-aggregate` 快照。
- AI context 的 page subtree 已只读 Rust Page Aggregate 的稳定 projection,不再由前端本地构造第二份 page tree 真相。
- 当前 page aggregate 仍是从 `documents.content` / local markdown content 投影出来的过渡闭环,不是 EditorBlockDocument 原生单一真源闭环。
```text
当前页面定位到真实 .md 文件
-> MNote 计算登录用户 allowed roots / current file / selection
-> Hermes / Reasonix 在白名单目录内运行
-> agent 使用自身 patch / diff / 文件编辑能力写文件
-> MNote watcher / refresh 同步 Page Aggregate 与 tiptap
```
### 2.6 Editor Runtime 层
- `mnote.doc.fetch``mnote.doc.markdown_edit``mnote.doc.apply_block_ops``mnote.block.*``mnote.page.*` 保留为 cloud / remote agent / compat / 复杂结构辅助工具。
- `/api/page-ai/block-edit-workflow` 不再作为 local-first 普通正文编辑主路径。
- AI session 全文在 local source 下默认写入 `ai-sessions/private/*.jsonl``ai-sessions/shared/<share-id>/*.jsonl`Convex 只保存必要 metadata / audit / sync replica。
- 前端壳:Rust mnote-web 独享 3000 入口,通过 SSR 输出 workspace shell、文档壳、Sidebar、tree 等完整 HTML[gateway.rs](rust/crates/mnote-web/src/routes/gateway.rs:156)、[web_shell.rs](rust/crates/mnote-web/src/routes/web_shell.rs:63))。Next.js 前端代码已随 wolai-frontend 整体移入 `recycle/`,不再作为运行时 daemon 维护。
- `leptos-tiptap` island 已是文档页默认编辑 host,以 WASM 形式由 Rust SSR 加载。
- 保存正文仍会经过 `documents/save` 兼容面。
- 这意味着编辑器体验已经切主,但写回语义还没有完全切到唯一主命令面。
## 3. 已完成收口
### 2.7 AI Runtime 层
- Local-first workspace 上位设计和 checklist 已完成,迁入 `design/02-convex-rust-long-term-architecture/done/2-2-*`
- `cargo fmt --check --all``local_folder``hermes_tools`、local/shared AI session、local-first Convex guard、Convex export fixture、local asset upload smoke、external-change conflict smoke 均已作为 `2-2` 验收证据记录。
- 本地上传链路已避免 Convex media asset,落盘相对 Markdown 链接。
- 本地 AI 会话已区分 private / shared / cloud,并在 UI 上显示来源。
- Convex guard 已阻止 active 路径重新引入未标注的 Convex documents / media / aiSessions 默认主存储口径。
- `mnote.doc.fetch``mnote.doc.markdown_edit``mnote.doc.apply_block_ops``page_ai_workflow`、Hermes / ACP / Reasonix 构成当前 AI 主链。
- 简单正文编辑主路径已统一为模型生成 search/replace / full_content → `mnote.doc.markdown_edit` → 统一 mnote tool executor`mnote.doc.apply_block_ops` 保留为结构性块操作辅助。
- 工具权限、dryRun、幂等、revision / conflictDetectionKey、manifest schema、tool guidance、ACP payload / response 等 P0 合同漂移已收口;Phase C 的 review session / 流式 apply 仍冻结。
## 4. 仍在推进的功能缺口
## 3. 当前成立的事实与过渡态
1. **管理员目录授权 UI / API**
- 需要把 `access-policy.json` 和当前 auth actor、admin grant、read/write/share 权限做成可管理界面。
- 管理员可授权任意 canonical 目录;普通用户只能访问自己的受管 my-space 或被授权目录。
### 3.1 已成立事实
2. **VSCode-like 冲突处理 UI**
- 当前已有 clean/dirty watcher 行为和冲突态 smoke。
- 还需要做可用的“接受磁盘版本 / 保留当前版本 / 打开 diff 合并”交互。
- Rust 协议层已经成为语义主线,不再主要依赖前端拼装。
- Tree 主链已经从旧兼容入口退向 Rust Web
- 文档页主编辑器已经切到 `leptos-tiptap` island
- AI 页面编辑已经不再是纯前端本地逻辑。
- **前端壳已切换到 Rust**:默认 `desktop:hot` 仅启动 Rust mnote-web 作为 3000 网关 owner。`wolai-frontend`Next.js 前端)已移至 `recycle/`,不再作为运行时 daemon 或 legacy fallback 维护。
3. **agent 写入审计**
- local-first agent 应回收 changed files、diff summary、tool/run id、actor、workspace root、permission level
- audit 默认本地落盘,同步开启时再上报控制面
### 3.2 过渡态
4. **本地全文搜索、引用和索引**
- 本地 `.md` workspace 需要独立索引:全文搜索、反链、页面引用、资源引用、标签。
- 不能依赖 Convex search 才能搜索本地工作区。
- Page Aggregate 仍从 `documents.content` 侧 join 构造,而不是原生 EditorBlockDocument 真源。
- 页面正文写回仍经过兼容保存面,尚未完全切到唯一主命令面
- realtime / Page Aggregate / AI 写入的 P0 合同已基本收口,但兼容路由和历史 adapter 仍偏多
5. **分享与同步闭环**
- 需要把 share grants、shared workspace cache、shared AI session、只读/可写权限和冲突处理连成产品级闭环
- 控制面不可用时不得扩大本地缓存权限
## 4. 架构冲突矩阵
6. **插件资源模型**
- simplemindmap / office 应作为 Resource Tree 对象打开和保存。
- Markdown 中只保留链接或嵌入引用,不把复杂对象强塞进普通正文块。
### 4.1 Tree realtime
7. **旧 Convex 数据迁移产品化**
- 当前已有 fixture/offline 导出脚本。
- 后续需要真实 Convex workspace 导出入口、迁移进度、冲突报告、回滚/备份策略。
- 事实:Rust Web 同时注册 WS 与 SSE。
- 事实:Rust SSR 主壳默认以 WS 为主链,SSE 是断线 fallback。
- 当前状态:原“WS 文档口径 / 前端 SSE 实现”冲突已修复。
- 剩余风险:live cache 与兼容 fallback 仍需要继续瘦身,避免未来再次出现多链路补偿。
## 5. 已退役或降级口径
### 4.2 FileTree 资源行
以下说法不再作为当前主线:
- 事实:资源投影行带 `documentId`
- 事实:资源行的 owner document 与页面命令目标已分离,资源行不再暴露页面命令目标 ID。
- 当前状态:原“资源行被当成页面命令对象处理”冲突已修复。
- 剩余风险:资源对象的完整 `tree.resource.*` 命令面仍应继续补齐。
- “Convex 是默认正文 / 附件 / AI 会话全文主存储。”
- `mnote.doc.markdown_edit` 是 local-first 普通 Markdown 编辑唯一主路径。”
- “页面 AI 必须走 `/api/page-ai/block-edit-workflow``mnote.block.*` 才能改正文。”
- `/api/documents/save` 是长期正文保存主入口。”
- “BlockNote 是当前默认编辑器或系统事实源。”
- “Next App Router / wolai-frontend 是 3000 主运行时。”
### 4.3 Page Aggregate
- 事实:Rust snapshot 已是入口事实。
- 事实:AI context 的 page subtree 已只读 Rust Page Aggregate projection,外部 AI 写入后会同步本地 aggregate script。
- 当前状态:原“AI context / 文档页 / Rust Aggregate 多源抢真相”的 P0 问题已修复。
- 剩余风险:Page Aggregate 仍从 `documents.content` 投影,不是 EditorBlockDocument 原生落库真相。
### 4.4 AI 写入
- 事实:`markdown_edit` 已是简单正文编辑主路径。
- 事实:Hermes guidance、manifest、page_ai_workflow、tool executor、Reasonix ACP payload 已同步到同一合同。
- 当前状态:原“markdown / block / tool executor 三套合同漂移”的 P0 问题已修复。
- 剩余风险:复杂结构编辑仍应明确落到 `apply_block_ops` / `mnote.block.*`Phase C review / streaming apply 尚未实施。
## 5. 缺失功能
- Page Aggregate 仍缺 EditorBlockDocument 原生落库真相。
- 正文保存仍经过 `documents/save` 兼容面,唯一主命令面尚未完全闭环。
- `tree.resource.*` 仍需要补完整资源对象生命周期命令。
- 兼容路由、历史 adapter 与 fallback 仍偏多,需要继续减小长期维护面。
## 6. 推荐收口顺序
1. 先推进 Page Aggregate 原生 EditorBlockDocument 落库,减少 `documents.content` 投影过渡层。
2. 再收正文保存主命令面,把 `documents/save` 兼容写入逐步迁到正式页面 / 编辑器命令。
3. 再补齐 `tree.resource.*` 资源对象生命周期命令,避免资源操作长期停留在禁用或兼容态。
4. 最后清理兼容路由、历史 adapter 与 fallback,使 Rust kernel / Rust Web / SSR 主壳的合同成为唯一运行口径。
## 7. 相关审查与缺陷
- [设计审查:当前 mnote 项目 AI / Page Aggregate 定向 Review](./design/10-review/done/10-current-mnote-ai-runtime-review-v1.md)
- [设计审查:当前完整架构 Review](./design/10-review/done/11-current-full-architecture-review-v1.md)
## 8. 本次落档缺陷索引
### 8.1 Rust Web / Realtime / ACP
- [3-16 tree realtime WS 主链口径与前端 SSE 实现不一致](./bugs/03-rust-web/done/3-16-tree-realtime-ws-sse-doc-contract-drift-v1.md)
- [3-17 SSE push 模式跳过 polling safety net](./bugs/03-rust-web/done/3-17-sse-push-skips-polling-fallback-v1.md)
- [3-18 WS 与 SSE delta 载荷合同分裂](./bugs/03-rust-web/done/3-18-ws-sse-delta-contract-split-v1.md)
- [3-19 Reasonix ACP wrapper 调 mnote tool 缺少身份与幂等字段](./bugs/03-rust-web/done/3-19-acp-reasonix-tool-call-missing-identity-fields-v1.md)
- [3-20 ACP incoming request 只记录日志不响应](./bugs/03-rust-web/done/3-20-acp-request-permission-no-response-v1.md)
- [3-21 ACP run payload 被第一次 stream_events 消费后移除](./bugs/03-rust-web/done/3-21-acp-run-payload-consumed-and-removed-v1.md)
### 8.2 Tree Domain
- [4-46 FileTree 资源行被当成页面命令对象处理](./bugs/04-tree-domain/done/4-46-filetree-resource-row-document-command-leak-v1.md)
### 8.3 Editor Mainline
- [5-15 PageAggregateClientState 仍在前端生成第二份 page tree 真相](./bugs/05-editor-mainline/done/5-15-page-aggregate-client-state-second-truth-v1.md)
- [5-16 Sidebar preferred snapshot 中 query 可覆盖 live stream](./bugs/05-editor-mainline/done/5-16-sidebar-preferred-snapshot-query-overrides-live-stream-v1.md)
- [5-17 文档页标题优先 liveSidebarTitle 而非 Page Aggregate head](./bugs/05-editor-mainline/done/5-17-document-title-source-drift-live-sidebar-over-head-v1.md)
- [5-18 AI 写正文后本地 Page Aggregate content 可能不刷新](./bugs/05-editor-mainline/done/5-18-ai-write-body-does-not-sync-page-aggregate-content-v1.md)
### 8.4 AI
- [7-18 AI markdown_edit 阶段状态合同漂移](./bugs/07-ai/done/7-18-ai-markdown-edit-phase-state-contract-drift-v1.md)
- [7-19 Hermes 工具指导仍优先 apply_block_ops 而非 markdown_edit](./bugs/07-ai/done/7-19-hermes-tool-guidance-markdown-edit-contract-drift-v1.md)
- [7-20 page_ai_workflow 绕过 Hermes tool executor / audit / toggle](./bugs/07-ai/done/7-20-page-ai-workflow-bypasses-hermes-tool-executor-v1.md)
- [7-21 mnote.doc.markdown_edit 本地文件写入绕过 dryRun / idempotency](./bugs/07-ai/done/7-21-markdown-edit-local-write-contract-bypass-v1.md)
- [7-22 mnote.doc.apply_block_ops 批量写入缺少 revision / conflictDetectionKey 校验](./bugs/07-ai/done/7-22-apply-block-ops-missing-write-preconditions-v1.md)
- [7-23 mnote.doc.markdown_edit manifest schema 缺少 required 与写入字段](./bugs/07-ai/done/7-23-markdown-edit-manifest-schema-contract-drift-v1.md)
- [7-24 在线 markdown_edit 写回不以最终 Markdown 为真源](./bugs/07-ai/done/7-24-markdown-edit-online-write-does-not-use-final-markdown-v1.md)
- [7-25 ACP Reasonix 页面 AI block_edit_workflow 在 markdown 命中后生成空 block_ops](./bugs/07-ai/done/7-25-reasonix-block-edit-workflow-empty-block-ops-after-markdown-match-v1.md)
历史文件如果需要保留这些说法,必须明确标注为 `[recycle]`、legacy、review snapshot 或 compat / cloud source 背景。
+55
View File
@@ -0,0 +1,55 @@
# MNOTE — Reasonix working knowledge
## Stack
- **Rust** — workspace of 11 crates, edition 2021, resolver 2, wasm32 target (`rust/rust-toolchain.toml`)
- **Axum 0.8** — HTTP/WebSocket server in `mnote-web` crate
- **Leptos 0.8.14** — SSR island rendering in `mnote-web`
- **Convex 1.x** — self-hosted document/real-time/storage backend (`infra/convex/docker-compose.yml`)
- **Electron** — desktop packaging (`desktop-electron/`)
- **Playwright** — browser smoke tests in `scripts/task-*.js`
## Layout
| Path | Contents |
|------|----------|
| `rust/crates/mnote-web/` | Rust web gateway (axum + Leptos SSR, `:3000`) |
| `rust/crates/core-protocol/` | Kernel types, projection protocol, tree/graph terms |
| `rust/crates/bridge-runtime/` | Kernel query/command, Convex transport args generation |
| `rust/crates/mnote-cli/` | CLI tool (`sidebar dataset`, `page get/create/title/save/move`, `block insert/patch`) |
| `rust/crates/storage-convex-bridge/` | Convex storage bridge for Rust |
| `convex/` | Convex schema (`schema.ts`) + functions (`aiSessions.ts`) |
| `infra/convex/` | Docker Compose for self-hosted Convex backend |
| `infra/onlyoffice/` | Docker Compose for OnlyOffice |
| `scripts/` | Node.js dev/build scripts + Playwright smoke tests |
| `design/` | Architecture design documents organized by domain |
| `bugs/` | Bug tracking mirroring design domain structure |
| `recycle/` | Retired code (old frontend, deprecated design drafts) |
## Commands
- **`npm run desktop:hot`** — hot-reload dev start (Rust gateway + optional FastAPI backend + optional Celery)
- **`npm run dev:hot`** — same but with `cargo-watch` for Rust auto-recompile
- **`npm run desktop`** — production start
- **`npm run desktop:local`** — local dev start (no build step)
- **`npm run desktop:electron`** — Electron dev runner
- **`npm run dist:win`** — build Windows installer (Next assets + electron-builder)
- **`cargo test -p <crate>`** — test a specific Rust crate
- **`cargo run -p mnote-web --bin mnote-web`** — run the web server binary directly
## Conventions
- **Smoke test pattern**: standalone Playwright scripts under `scripts/task-*.js` using a shared harness (`ensureAuthenticated`, `createTempDocument`, `cleanupDocuments`). All use `"use strict"` and `require("playwright")` (`scripts/task110-page-title-single-truth-smoke.js:1-4`).
- **Rust workspace**: edition 2021, resolver "2", MIT license. All crates inherit workspace version (`rust/Cargo.toml:2-8`).
- **Convex schema**: `defineSchema` + `defineTable` via `convex/server` package; typed fields with `v.*` validators (`convex/schema.ts:1-3`).
- **Bug tracking**: bugs organized by design domain directory (e.g. `bugs/04-tree-domain/process/`), moved to `done/` when fixed (`bugs/README.md`).
- **Design docs**: organized by domain in `design/`, with `process/` (in-progress) and `done/` (completed) subdirs (`design/README.md`).
## Watch out for
- **write_file path rule**: paths are sandbox-relative; leading `/` is stripped. Use `path: "design/..."` not `/mnt/Data1T/mnote/design/...`.
- **`.aionrs/`, `.claw/`, `.codex/`, `.gemini/`** — session data from other AI tools, not project code. Don't read or edit.
- **`recycle/`** — retired code, not current implementation. Don't use as evidence for current behavior.
- **Convex must be running** (`infra/convex/`) for full-stack dev — smoke tests depend on a live Convex backend.
- **Rust wasm32 target** required (`rust/rust-toolchain.toml`), needed for `tree-shell-runtime-wasm` crate.
- **`rust/target/`** is gitignored and large — `cargo build` runs from scratch if cache is missing.
@@ -0,0 +1,53 @@
# 3-16 [done] 本地 Markdown 上传误走云端 media asset 修复 v1
> 创建时间:2026-05-18
>
> 状态:`[done]`
>
> 所属主线:`03-rust-web` / `local_folder` / `local markdown`
## 问题
本地文件夹模式下,`.md` 页面内上传图片或附件沿用了云端 `/api/media/upload` 链路,会生成 Convex media asset,并在正文中写入云端 URL 或 OnlyOffice assetId 链接。
这会破坏本地 Markdown 的可迁移性:离开 MNote 后文件引用不可读,也无法符合 local folder “文件系统即资源真源”的边界。
## 修复
- 新增 `POST /api/local-folder/assets/upload`
- 本地上传复制到当前 Markdown 同目录下的 `{mdBase}.assets/`
- 返回给编辑器的 `sourcePath` / `file_url` 使用相对当前 Markdown 目录的路径。
- 前端在 `sourceKind=local_folder` 时分流到本地上传入口,云端页面继续走 `/api/media/upload`
- 本地附件插入普通相对路径链接,不再生成 OnlyOffice assetId URL。
- 本地图片保存时写回标准 Markdown 图片语法:`![alt](relative/path)`
## 验证
```bash
cargo fmt --manifest-path rust/Cargo.toml --all --check
cargo test --manifest-path rust/Cargo.toml -p mnote-web local_markdown_ -- --nocapture
cargo test --manifest-path rust/Cargo.toml -p mnote-web local_folder -- --nocapture
```
临时 HTTP smoke
```bash
MNOTE_WEB_BIND=127.0.0.1:13001 MNOTE_WEB_PUBLIC_BIND=127.0.0.1:13001 cargo run --manifest-path rust/Cargo.toml -p mnote-web --bin mnote-web
curl -sS -X POST \
-F "file=@/etc/hosts;filename=photo.png;type=image/png" \
-F "rootUri=file:///tmp/mnote-local-smoke-..." \
-F "documentId=local-md:README.md" \
-F "kind=image" \
http://127.0.0.1:13001/api/local-folder/assets/upload
```
结果:
- `local_markdown_`19 passed
- `local_folder`36 passed
- `cargo fmt --check`:通过
- HTTP smoke:返回 `sourcePath=README.assets/photo-1.png`,落盘文件存在且 size=188
## 剩余
- 尚未跑真实浏览器 smoke:本地 `.md` 页面上传图片/附件、刷新后恢复、文件树可见。
@@ -0,0 +1,47 @@
# 5-19 [done] Page Aggregate 块投影未优先消费 EditorBlockDocument
## 级别
P1 / 05-editor-mainline 主线迁移缺陷。
## 现象
`page.body.save` 已在 Rust runtime plan 与 artifact 中保留 `editorDocument`,但 `page.aggregate.get` 构建 `body.blockDocument` 时仍固定从 legacy `documents.content` 投影。
`editorDocument` 与旧 `content` 不一致时,AI 块读取、块定位与 Page Aggregate 回读会继续看到旧内容,导致页面域单一真源迁移停留在 legacy content 过渡层。
## 根因
`rust/crates/bridge-runtime/src/lib.rs``build_page_aggregate_projection_result` 只读取 `content_result.content`,随后调用 `project_legacy_content_to_block_document`,并把 `projectionSource` 固定为 `documents.content`
## 修复
- `page.aggregate.get` 的内容结果识别现在包含 `editorDocument``blockDocument`
- Page Aggregate `body.blockDocument` 来源顺序调整为:
- `editorDocument`:优先解析 `EditorBlockDocument`,由 Rust runtime 投影为 Page Aggregate 块文档。
- `blockDocument`:已有块投影直接作为次级来源。
- `documents.content`:仅在缺少原生块文档来源时回退。
- `body.projectionSource` 改为真实来源:`editorDocument` / `blockDocument` / `documents.content`
- 新增回归测试 `page_aggregate_get_prefers_editor_document_over_legacy_content`,覆盖 `editorDocument` 与旧 `content` 冲突时的优先级。
- 新增非法原生块文档防回退保护:
- `editorDocument` 存在但不可解析时返回 validation error,不再静默回退到旧 `content`
- `blockDocument` 存在但不是对象时返回 validation error,不再静默回退到旧 `content`
- Page Aggregate `body.content` 也同步按原生来源重建:
- `editorDocument` 来源下由 `EditorBlockDocument` 生成 legacy content。
- `blockDocument` 来源下由块投影生成 legacy content。
- 避免同一份 Page Aggregate 中 `body.blockDocument` 正确但 `body.content` 仍指向旧正文,导致编辑器/session 下游继续显示或保存旧真相。
## 验证
- RED`cargo test --manifest-path rust/Cargo.toml -p bridge-runtime page_aggregate_get_prefers_editor_document_over_legacy_content -- --nocapture`
- 旧实现失败,返回 `legacy_1` 而不是 `editor_1`
- GREEN`cargo test --manifest-path rust/Cargo.toml -p bridge-runtime page_aggregate -- --nocapture`
- 6 passed。
- Rust Web 回归:`cargo test --manifest-path rust/Cargo.toml -p mnote-web page_aggregate -- --nocapture`
- 7 passed。
- 格式:`cargo fmt --manifest-path rust/Cargo.toml --all -- --check`
- passed。
## 剩余边界
这次修的是 Page Aggregate 读侧优先级,不是 Convex 原生 `EditorBlockDocument` 落库 schema 的最终切换。`documents:updateContent` 兼容写入仍保留 legacy content substrate;后续主线应继续推进持久层原生块文档字段与命令写入闭环。
@@ -0,0 +1,39 @@
# 5-20 [done] page.body.save 发往 Convex 前剥离 EditorBlockDocument
## 级别
P1 / 05-editor-mainline 主线迁移缺陷。
## 现象
`page.body.save` 的 Rust runtime plan 已经生成 `editorDocument` / `tiptapDocument`,但 `mnote-web` transport 在真正调用 `documents:updateContent` 前会删除这两个字段。
这会导致原生块文档只存在于 plan / artifact 里,持久层仍只能保存 legacy `content`。刷新后 Page Aggregate 即使读侧已支持 `editorDocument -> blockDocument -> documents.content`,也会因为 substrate 没有原生字段而回到 `documents.content`
## 根因
`rust/crates/mnote-web/src/transport/convex.rs``documents.save | page.body.save` 视为 legacy validator,只保留 `id/content/expectedRevision/conflictDetectionKey`,显式删除 `editorDocument``tiptapDocument`
同时,当前可见的 Convex `documents:updateContent` 源码仍只声明 `content`,没有持久化原生编辑器快照字段。
## 修复
- `mnote-web` transport 不再剥离 `editorDocument` / `tiptapDocument`,只剥离 Rust artifact 事件字段。
- 本地可见 Convex substrate `recycle/wolai-frontend/convex/documents.ts` 已同步接受并写入:
- `editorDocument -> documents.editor_document`
- `tiptapDocument -> documents.tiptap_document`
- `getContent` / `getContentForIngest` 已同步返回 `editorDocument` / `tiptapDocument`,让 Page Aggregate 读侧能够优先消费原生块文档。
- `recycle/wolai-frontend/convex/schema.ts` 已补 `editor_document` / `tiptap_document` optional 字段。
说明:`recycle/` 当前被仓库 `.gitignore` 忽略,但这是本仓库可见的唯一 `documents:updateContent` Convex 源码位置;tracked Rust transport 已按该 validator 口径更新。
## 验证
- RED`cargo test --manifest-path rust/Cargo.toml -p mnote-web convex_command_args_keeps_editor_runtime_fields_for_page_body_save -- --nocapture`
- 旧实现失败,实际 args 中 `editorDocument` / `tiptapDocument` 被删除。
- GREEN`cargo test --manifest-path rust/Cargo.toml -p mnote-web convex_command_args_keeps_editor_runtime_fields_for_page_body_save -- --nocapture`
- 1 passed。
## 剩余边界
这次修复让 `page.body.save` 不再在 Rust Web transport 边界丢弃原生块文档,但完整 EditorBlockDocument 原生落库仍需要后续真实 Convex 部署 schema 与迁移流程确认。历史页面已有 legacy `content`,不会自动反推补齐 `editor_document`
@@ -0,0 +1,31 @@
# 5-21 [done] page.body.save 对非法 editorDocument 不能静默回退
## 级别
P1 / 05-editor-mainline 主线迁移缺陷。
## 现象
`page.body.save` 在保存时会同时携带 `editorDocument``tiptapDocument` 和 legacy `content`
`editorDocument` 字段存在但结构非法时,原实现会静默回退到 `tiptapDocument``content`,把原生块快照损坏隐藏掉。
## 根因
`normalize_save_editor_document(...)` 之前只在 `serde_json::from_value::<EditorBlockDocument>` 成功时才使用 `editorDocument`,失败后会继续尝试 `tiptapDocument`,最后再回退 `legacy content`
## 修复
- `editorDocument` 一旦存在就必须可解析。
- `editorDocument` 无法反序列化时,直接返回 validation error,不再回退到 `tiptapDocument``content`
- 现有“合法 editorDocument 优先于其他来源”语义保持不变。
## 验证
- RED`cargo test --manifest-path rust/Cargo.toml -p bridge-runtime documents_save_command_plan_rejects_unparseable_editor_document_instead_of_falling_back -- --nocapture`
- 旧实现失败,证明原先会静默回退。
- GREEN`cargo test --manifest-path rust/Cargo.toml -p bridge-runtime documents_save_command_plan -- --nocapture`
- 12 passed。
## 剩余边界
这次修复只收紧非法 `editorDocument` 的失败语义,不改变合法 `editorDocument` / `tiptapDocument` 的既有优先级顺序。
@@ -0,0 +1,62 @@
# 5-22 [done][bug] documents.content.get 在 Page Aggregate 前剥离原生块文档字段 v1
> 发现时间:2026-05-18
>
> 状态:`[done]`
>
> 关联主线:`05-editor-mainline`
## 1. 问题定义
`page.aggregate.get` 已支持按 `editorDocument -> blockDocument -> documents.content` 的顺序构建正文真相,但 `/api/page-aggregate/:id` 主链会先通过 `documents.content.get` 读取内容。
旧的 `DocumentContentResult` 只保留:
- `content`
- `revision`
- `conflictDetectionKey`
- `title`
- `pageSubtree`
上游返回的 `editorDocument / blockDocument / tiptapDocument` 被提前剥离,导致 Page Aggregate route 真实运行时仍回落到 `documents.content`
## 2. 根因
`bridge-runtime``build_document_content_result` 没有把原生块文档字段带入 `DocumentContentResult`
这使 5-19 的 Page Aggregate 优先级修复只在合成输入测试里成立,真实 `mnote-web` route 入口仍拿不到原生字段。
## 3. 修复
- `core-protocol::DocumentContentResult` 新增可选字段:
- `editorDocument`
- `tiptapDocument`
- `blockDocument`
- `blockProjectionVersion`
- `build_document_content_result` 保留这些上游字段。
- `mnote-web` Page Aggregate route 回归测试加入含 `editorDocument``documents:getContent` fixture,并断言:
- `body.projectionSource=editorDocument`
- `body.blockDocument.rootBlockIds[0]=editor_1`
- `body.blockDocument.blocks[0].text` 来自 `editorDocument`
## 4. 验证
RED
```bash
cargo test --manifest-path rust/Cargo.toml -p mnote-web page_aggregate_endpoint_returns_snapshot_contract -- --nocapture
```
旧实现失败,`projectionSource` 仍为 `documents.content`
GREEN
```bash
cargo test --manifest-path rust/Cargo.toml -p mnote-web page_aggregate_endpoint_returns_snapshot_contract -- --nocapture
```
结果:`1 passed`
## 5. 剩余边界
这修复的是 Rust route 读取链字段保留;底层 Convex schema/mutation 是否已完整原生落库仍按后续持久层迁移继续跟进。
@@ -0,0 +1,59 @@
# 5-23 [done][bug] editorDocument 缺少 blocks 或 root 引用损坏仍被当作合法真源 v1
> 发现时间:2026-05-18
>
> 状态:`[done]`
>
> 关联主线:`05-editor-mainline`
## 1. 问题定义
`EditorBlockDocument` 的 serde 结构对 `blocks/rootBlockIds` 使用默认值。旧实现只检查 JSON 是否能反序列化,导致这类输入被接受:
```json
{"documentId":"doc_1"}
```
或者:
```json
{"documentId":"doc_1","rootBlockIds":["missing"],"blocks":[]}
```
它们会优先于 legacy content 被当作原生块文档真相,可能把页面保存为空,或让 `body.content``body.blockDocument` 分裂。
## 2. 根因
`normalize_save_editor_document``page_aggregate_block_document_projection` 只做 serde 形状解析,没有做原始字段存在性和 root 引用完整性校验。
## 3. 修复
- 新增 `validate_editor_document_structure`
- 显式 `editorDocument` 必须带 `blocks` 数组。
- `rootBlockIds` 中的非空 id 必须能在 `blocks` 中找到。
- 保存链和 Page Aggregate 读链共用同一校验。
## 4. 验证
RED
```bash
cargo test --manifest-path rust/Cargo.toml -p bridge-runtime rejects_editor_document -- --nocapture
```
旧实现中以下两个新增测试失败:
- `documents_save_command_plan_rejects_editor_document_missing_blocks`
- `page_aggregate_get_rejects_editor_document_root_ids_missing_blocks`
GREEN
```bash
cargo test --manifest-path rust/Cargo.toml -p bridge-runtime rejects_editor_document -- --nocapture
```
结果:`2 passed`
## 5. 剩余边界
当前校验保证 `editorDocument` 不会因 serde default 被误判为合法真源;更深的 schema 版本、块类型特定必填字段仍应在 EditorBlockDocument 原生落库迁移中继续收紧。
@@ -0,0 +1,60 @@
# 5-24 [done][bug] page.body.save 上传图片与附件持久化字段丢失 v1
> 发现时间:2026-05-18
>
> 状态:`[done]`
>
> 关联主线:`05-editor-mainline`
## 1. 问题定义
主编辑区文本保存修复后,继续验证上传图片与附件发现两个持久化缺陷:
- 附件能插入到编辑器,并且 `/api/documents/save` 返回 200,但 `/api/documents/content` 回读时链接 mark 被降级成纯文本。
- 图片能上传并触发保存,但 `/api/documents/content` 回读时图片块只剩 `type: "image"``props.src / alt / title / tiptapImage` 为空。
这会导致刷新或跨浏览器打开后,附件不能作为 OnlyOffice 附件链接恢复,图片也缺少可渲染的资源地址。
## 2. 根因
保存链优先使用 `editorDocument` 生成 canonical legacy content 后再写入当前 Convex legacy substrate。
两个字段在这一步丢失:
- `legacy_content_from_editor_document` 只用 `legacy_text_from_editor_block()` 生成纯文本,未保留 `ContentNode.attrs.styles.link`
- `EditorBlockDocument` 反序列化时 `BlockProps.extra` 不是 flatten 字段,前端传来的 image `props.src / alt / title / tiptapImage` 会被 serde 忽略;`hydrate_editor_document_props_from_raw` 旧实现只回填 mindmap props,没有回填 image props。
## 3. 修复
- `legacy_content_from_editor_document` 对带结构化样式的 inline 节点输出 legacy inline content 数组,保留 `styles.link` 等样式;普通纯文本段落仍保持旧的字符串 content。
- `hydrate_editor_document_props_from_raw` 增加 image 分支,从原始 `editorDocument.blocks[].props` 回填 `src / alt / title / tiptapImage`
- `legacy_props_from_editor_block` 对 image 块输出回填后的直接 props 与 `tiptapImage`
## 4. 验证
单测:
```bash
cargo fmt --manifest-path rust/Cargo.toml --all --check
cargo test --manifest-path rust/Cargo.toml -p bridge-runtime legacy_content_from_editor_document_preserves_inline_link_styles -- --nocapture
cargo test --manifest-path rust/Cargo.toml -p bridge-runtime documents_save_hydrates_image_props_from_raw_editor_document -- --nocapture
cargo test --manifest-path rust/Cargo.toml -p bridge-runtime documents_save_command_plan_prefers_valid_editor_document_over_other_sources -- --nocapture
cargo test --manifest-path rust/Cargo.toml -p mnote-web documents_save_route_executes_page_body_save_command -- --nocapture
```
浏览器验证:
- 以测试账号登录 `http://127.0.0.1:13000`
- 新建临时页面。
- 通过 slash 上传 `.docx` 附件和 `.png` 图片。
- 断言两次 `/api/documents/save` 均返回 200。
- 断言 `/api/documents/content` 回读包含:
- image `props.src`
- 附件 inline `styles.link`
- 刷新页面后,编辑器 DOM 中仍能找到附件链接与图片节点。
结果:通过;临时测试页面已 purge。
## 5. 剩余边界
当前仍是 legacy content substrate 下的过渡修复。完整 EditorBlockDocument 原生落库完成后,图片/附件 props 应由原生 schema 和迁移校验继续收紧。
@@ -0,0 +1,64 @@
# 7-31 [done][bug] 页面 AI block_edit_workflow 丢弃读取类回答摘要 v1
> 发现时间:2026-05-18
>
> 状态:`[done]`
>
> 关联主线:`07-ai`
## 1. 用户可见症状
在页面 AI 使用 `ACP · Reasonix` 输入:
```text
检查你是否能读取到本页第一段,同时请修改第二段为:测试123
```
`block_edit_workflow` 即使完成写入,前端也只显示固定文案:
```text
已通过页面 markdown 编辑快路径完成写入。
```
用户要求中的“是否能读取到第一段”没有被回答。
## 2. 根因
`page_ai_workflow.rs` 的模型提示已经要求模型输出:
```json
{"operations": [...], "summary": "..."}
```
但路由只提取 `operations`,丢弃了 `summary`,并在成功响应中固定返回“已通过页面 markdown 编辑快路径完成写入。”。
这会让读写混合请求退化为纯写入反馈。
## 3. 修复
- 新增 `MarkdownEditPlan`,同时解析模型输出中的 `operations``summary`
- `block_edit_workflow` 成功响应优先返回模型 `summary`,缺失时才使用固定 fallback。
- 系统提示明确要求:如果用户要求读取某段,`summary` 必须包含从 `page_text` 读取到的原文。
- 新增回归测试 `block_edit_workflow_surfaces_model_summary_for_read_and_edit_request`,覆盖“读取第一段 + 修改第二段”的真实快路径响应。
## 4. 验证
RED
```bash
cargo test --manifest-path rust/Cargo.toml -p mnote-web block_edit_workflow_surfaces_model_summary_for_read_and_edit_request -- --nocapture
```
旧实现失败,`payload.message` 不包含 `已读取第一段:第一段`
GREEN
```bash
cargo test --manifest-path rust/Cargo.toml -p mnote-web page_ai_workflow -- --nocapture
```
结果:`5 passed`
## 5. 剩余边界
本修复只保证 fast workflow 成功响应不丢模型摘要;复杂多步推理、review session、流式 apply 仍属于 Phase C 冻结范围。
@@ -0,0 +1,50 @@
# 7-32 [done][bug] markdown_edit 直接 page.body.save 时丢失 revision / conflictDetectionKey v1
> 发现时间:2026-05-18
>
> 状态:`[done]`
>
> 关联主线:`07-ai`
## 1. 问题定义
7-27 后,在线 `mnote.doc.markdown_edit` 不再通过空 `block_ops` 写回,而是以最终 Markdown 生成 `content` 后直接执行 `page.body.save`
旧实现构造的写入 payload 只包含:
- `documentId`
- `workspaceId`
- `content`
- `mode`
没有携带当前 Page Aggregate 的 `revision``conflictDetectionKey`
## 2. 影响
AI markdown 主路径可能基于旧快照覆盖用户并发编辑,绕过 7-22 已补到 `apply_block_ops` 的写前置条件。
## 3. 修复
- 在线 `markdown_edit` 写回前读取当前 Page Aggregate。
- 构造 `page.body.save` payload 时带入:
- `/body/revision`
- `/body/conflictDetectionKey`
- 新增回归测试检查 command artifact payload 中真实携带这两个字段。
## 4. 验证
RED
```bash
cargo test --manifest-path rust/Cargo.toml -p mnote-web hermes_tools_markdown_edit_online_page_body_save_carries_revision_conflict_key -- --nocapture
```
旧实现失败,artifact payload 中 `revision``Null`
GREEN
```bash
cargo test --manifest-path rust/Cargo.toml -p mnote-web hermes_tools_markdown_edit_online_page_body_save_carries_revision_conflict_key -- --nocapture
```
结果:`1 passed`
@@ -0,0 +1,41 @@
# 7-33 [done][bug] markdown_edit 全部 search 失败仍返回成功 v1
> 发现时间:2026-05-18
>
> 状态:`[done]`
>
> 关联主线:`07-ai`
## 1. 问题定义
`mnote.doc.markdown_edit` 逐条执行 search/replace 时,如果所有 search 都失败,旧实现仍会继续进入写回阶段,并返回:
```json
{"ok": true, "operationsApplied": 0}
```
这会让页面 AI fast workflow 把“没有改动”误报为编辑成功。
## 2. 修复
-`applied == 0` 时立即返回 `mnote_markdown_edit_no_operations_applied`
- 不再执行本地文件写入或在线 `page.body.save`
- 新增回归测试 `hermes_tools_markdown_edit_rejects_no_applied_operations`
## 3. 验证
RED
```bash
cargo test --manifest-path rust/Cargo.toml -p mnote-web hermes_tools_markdown_edit_rejects_no_applied_operations -- --nocapture
```
旧实现返回 `200`
GREEN
```bash
cargo test --manifest-path rust/Cargo.toml -p mnote-web hermes_tools_markdown_edit_rejects_no_applied_operations -- --nocapture
```
结果:`1 passed`
@@ -0,0 +1,45 @@
# 7-34 [done][bug] 页面 AI ACP Runtime 默认值回跳 Hermes v1
> 发现时间:2026-05-18
>
> 状态:`[done]`
>
> 关联主线:`07-ai`
## 1. 用户可见症状
页面 AI 面板中选择 `ACP · Reasonix` 后,会自动回跳到 `ACP · Hermes`
用户期望:
- 默认 runtime 为 `ACP · Reasonix`
- 下拉仍可手动切换到 `ACP · Hermes`
## 2. 根因
页面 AI 的历史 runtime 语义仍把空 `acpRuntime` 当成旧 `Hermes HTTP` 默认值。当前主线已经退役 HTTP Hermes,但前端 session/localStorage 恢复、后端空 runtime 推导、以及 `/profiles` 尚未返回时的下拉渲染仍可能产生空 runtime。
空 runtime 进入旧逻辑后会被解释成 Hermes 路径,导致用户刚选择 Reasonix 后又被历史 session 或空 select value 覆盖。
## 3. 修复
- 前端 `pageAiAcpRuntime` 初始值改为 `reasonix`
- localStorage 版本提升到 `3`,避免旧缓存把 active runtime 覆盖回 Hermes。
- session 创建、持久化、停止 run、发送 run 默认都写入 `reasonix`
- 前端内置 `Reasonix / Hermes` 两个 ACP runtime fallback`/profiles` 慢或失败时也不会渲染空 select。
- 后端空 `acpRuntime` 默认归入 ACP 默认 runtime,默认值为 `reasonix`;旧 HTTP proxy 只保留在显式兼容开关后。
## 4. 验证
新增回归约束:
```bash
cargo test --manifest-path rust/Cargo.toml -p mnote-web page_ai_acp_runtime_defaults_to_reasonix_and_keeps_hermes_switch -- --nocapture
```
覆盖:
- `pageAiAcpRuntime` 默认是 `reasonix`
- 下拉 options 固定包含 `reasonix` / `hermes`
- 不再包含旧的 `默认 (Hermes HTTP)` 空选项
- change handler 空值 fallback 到 `reasonix`
@@ -59,6 +59,11 @@
- `bugs/05-editor-mainline/done/5-16-sidebar-preferred-snapshot-query-overrides-live-stream-v1.md`
- `bugs/05-editor-mainline/done/5-17-document-title-source-drift-live-sidebar-over-head-v1.md`
- `bugs/05-editor-mainline/done/5-18-ai-write-body-does-not-sync-page-aggregate-content-v1.md`
- `bugs/05-editor-mainline/done/5-19-page-aggregate-prefers-editor-document-v1.md`
- `bugs/05-editor-mainline/done/5-20-page-body-save-strips-editor-document-before-convex-v1.md`
- `bugs/05-editor-mainline/done/5-21-page-body-save-rejects-invalid-editor-document-v1.md`
- `bugs/05-editor-mainline/done/5-22-documents-content-strips-editor-document-before-page-aggregate-v1.md`
- `bugs/05-editor-mainline/done/5-23-editor-document-semantic-validation-v1.md`
- `bugs/05-editor-mainline/done/5-9-c12-topbar-sidebar-toggle-noop-v1.md`
收口结果:
@@ -66,6 +71,8 @@
- Page Aggregate 标题、正文、页面设置、page tree、AI fetch 回读已经形成同一组可验证真相。
- AI 外部写入后,文档 shell 会同步 Page Aggregate JSON script / session snapshot,避免下一次 AI context 继续读旧正文。
- Sidebar preferred snapshot 与 live stream 的优先级漂移已从当前 P0 blocker 中移除。
- Page Aggregate 读侧已从 route 主链保留 `editorDocument / blockDocument`,不再在 `documents.content.get` 结果中提前剥离原生块文档字段。
- `editorDocument` 显式输入已补语义校验:必须带 `blocks`,且 `rootBlockIds` 必须引用真实块。
关键证据:
@@ -92,6 +99,9 @@
- `bugs/07-ai/done/7-28-markdown-edit-selection-scope-not-enforced-v1.md`
- `bugs/07-ai/done/7-29-markdown-edit-flattens-inline-content-v1.md`
- `bugs/07-ai/done/7-30-block-tools-selection-scope-not-enforced-v1.md`
- `bugs/07-ai/done/7-31-page-ai-workflow-drops-read-summary-v1.md`
- `bugs/07-ai/done/7-32-markdown-edit-page-body-save-missing-preconditions-v1.md`
- `bugs/07-ai/done/7-33-markdown-edit-no-applied-operations-success-v1.md`
收口结果:
@@ -105,6 +115,9 @@
- `mnote.doc.markdown_edit` / `page_ai_workflow` 已补 selection scope 服务端兜底,`allowedTargetBlockIds` 外的写入会被拒绝。
- `mnote.doc.markdown_edit` 写回普通块时不再无条件扁平化 inline content;未变更块保持原结构,单 inline text 节点的已变更块保留 marks / attrs。
- 单个 `mnote.block.*` 写工具已补 selection scope 服务端兜底,越界块写入会返回 `mnote_block_target_out_of_scope`
- 页面 AI `block_edit_workflow` 现在会把模型 `summary` 返回给前端;“读取第一段 + 修改第二段”这类读写混合请求不再被固定写入文案吞掉读取结果。
- 在线 `mnote.doc.markdown_edit` 直接 `page.body.save` 写回时已携带当前 `revision/conflictDetectionKey`
- `mnote.doc.markdown_edit` 全部 search 失败时返回 `mnote_markdown_edit_no_operations_applied`,不再误报成功或触发无意义保存。
关键证据:
+60 -17
View File
@@ -1,6 +1,13 @@
# 01-05 当前主线与优先级总览
> 更新时间:2026-05-09
> 更新时间:2026-05-19
>
> 2026-05-18 口径补充:
> - 产品形态已调整为 `Local-first workspace`:本地文件夹是默认数据真相,Rust kernel 是唯一语义真相,Convex / 服务端降级为账号、分享、同步、协作和 AI 隔离控制面。
> - 产品定义可进一步收口为:`VSCode 简化版工作区内核 + tiptap markdown 编辑器 + Hermes / Reasonix agent + simplemindmap / office 插件 + Wolai 风格 web 壳 + 鉴权控制面`。
> - 上位设计已完成并迁入:`/mnt/Data1T/mnote/design/02-convex-rust-long-term-architecture/done/2-2-local-first-workspace-convex-control-plane-v1.md`。
> - 本地 Markdown 图片与附件上传已新增本地 route 并避免 Convex media asset,见 `bugs/03-rust-web/done/3-16-local-markdown-upload-uses-convex-media-asset-v1.md`;真实浏览器上传 / 刷新 / 文件树可见 smoke 已由 `scripts/task443-local-markdown-asset-upload-smoke.js` 覆盖。
> - 旧的“Convex 作为默认自托管存储 / 实时 / 文件底座”口径只作为当前代码过渡态理解,不再作为新增能力默认方向。
>
> 2026-05-16 口径补充:
> - `5-4` 的默认页面内 `leptos-tiptap` island 主链切流已完成并迁入 `done/`;官方模板视觉和菜单细节继续由 `5-2 / 5-7 / 5-9` 承接。
@@ -16,22 +23,39 @@
下面三份仍然是当前架构判断的上位依据:
- `/mnt/Data1T/mnote/design/01-tree-first-graph-kernel/process/1-tree-first-graph-kernel-v1.md`
- `/mnt/Data1T/mnote/design/02-convex-rust-long-term-architecture/process/2-tree-first-graph-convex-rust-long-term-architecture-v1.md`
- `/mnt/Data1T/mnote/design/02-convex-rust-long-term-architecture/done/2-2-local-first-workspace-convex-control-plane-v1.md`
- `/mnt/Data1T/mnote/design/03-rust-web/process/3-rust-web-long-term-architecture-v1.md`
它们分别固定了三件事:
补充:旧稿 `/mnt/Data1T/mnote/design/old/02-convex-rust-long-term-architecture/process/2-tree-first-graph-convex-rust-long-term-architecture-v1.md` 已降级为 `[recycle][legacy]` 历史过渡判断,只解释“不要无计划硬拆 Convex”,不再作为新增能力默认存储口径。
它们分别固定了四件事:
1. 长期事实源是 `tree-first graph kernel`
2. `Convex` 保留为当前自托管存储 / 实时底座,不先拆
3. `mnote-web` 是 Rust Web 承载层,长期继续承担 transport、projection 分发与切流
2. 本地文件夹是早期产品默认数据真相,Convex / 服务端降级为控制面与可选同步协作层
3. 旧 Convex 主存储链只作为当前代码过渡态,不再作为新增能力默认方向
4. `mnote-web` 是 Rust Web 承载层,长期继续承担 transport、projection 分发与切流
## 2. 当前第一优先级
当前最优先的不是继续扩 UI,也不是继续大规模重写执行面,而是把页面域和树域的真相边界收口。
当前最优先的不是继续扩 UI,也不是继续大规模重写执行面,而是把页面域和树域的真相边界收口到本地优先工作区
### 2.0 Local-First Workspace
当前第一优先级固定为:
- `/mnt/Data1T/mnote/design/02-convex-rust-long-term-architecture/done/2-2-local-first-workspace-convex-control-plane-v1.md`
原因:
- 前期产品是 10 人以内、个人笔记默认独立、可分享、AI 隔离的知识库,不是默认云协作平台
- 本地 markdown 文件更适合作为 AI 编辑、上传下载、备份、git 管理和调试的默认数据形态
- Convex 配置、schema、functions、实时订阅和 storage 不应继续阻塞本地个人工作区主路径
- 迁移重点是让 LocalFS 成为默认 `WorkspaceSource`,Convex 保留为账号、分享、同步和协作控制面
- 本地 Markdown 页面内上传已完成后端、HTTP 和真实浏览器 smoke 基线;下一步重点转为管理员目录授权、冲突合并 UI、agent diff 审计、本地索引和分享 / 同步闭环
### 2.1 Page Aggregate
当前第优先级固定为:
当前第优先级固定为:
- `/mnt/Data1T/mnote/design/05-editor-mainline/process/5-5-page-aggregate-single-truth-alignment-v1.md`
- `/mnt/Data1T/mnote/design/05-editor-mainline/process/5-6-page-aggregate-alignment-checklist-v1.md`
@@ -47,7 +71,7 @@
### 2.2 Tree Command Cutover
当前第优先级固定为:
当前第优先级固定为:
- `/mnt/Data1T/mnote/design/04-tree-domain/done/4-6-tree-command-protocol-cutover-stage2-v1.md`
@@ -59,7 +83,7 @@
### 2.3 Tree Realtime 主链
当前第优先级固定为:
当前第优先级固定为:
- `/mnt/Data1T/mnote/design/03-rust-web/process/3-3-rust-web-tree-realtime-event-stream-v1.md`
@@ -103,18 +127,37 @@
- 文档中的事实基线已经落后于当前默认主编辑器、page aggregate 入口与 island 主链
- 继续保留在活跃 `process` 容易让后续工作按旧阶段推进
## 5. 当前执行顺序
## 5. VSCode-like 产品缺口
`2-2` 已完成 local-first 骨架与迁移 checklist,后续新增能力应优先补齐这些产品闭环:
1. 管理员目录授权 UI / API:管理用户可读写目录、共享目录、只读/可写权限和 AI 目录权限。
2. VSCode-like 冲突处理 UI:接受磁盘版本、保留当前编辑器版本、打开 diff / 合并。
3. agent 写入审计:回收 changed files、diff summary、actor、workspace root、permission level、run id。
4. 本地索引:全文搜索、反链、页面引用、资源引用、标签,不依赖 Convex search 才能搜索本地 workspace。
5. 分享与同步闭环:share grants、shared workspace cache、shared AI session、离线权限收敛和同步冲突报告。
6. 插件资源模型:simplemindmap / office 作为 Resource Tree 对象打开和保存,Markdown 只保留链接或嵌入引用。
7. 旧 Convex 数据迁移产品化:真实导出入口、迁移进度、备份、冲突报告和回滚策略。
## 6. 当前执行顺序
当前推荐顺序固定为:
1. `Page Aggregate`
2. `Tree Command Cutover Stage 2`
3. `Tree Realtime Event Stream`
4. 树域产品交互合同补齐
5. 编辑器官方模板行为对齐
1. 管理员目录授权 UI / API
2. VSCode-like 冲突处理 UI
3. agent changed files / diff 审计
4. 本地搜索、反链和资源引用索引
5. 分享与同步闭环
6. 插件资源模型产品化
7. 旧 Convex 数据迁移产品化
8. Page Aggregate / tree command / realtime 兼容链继续瘦身
## 6. 一句话收口
持续推进 checklist
- `/mnt/Data1T/mnote/design/01-tree-first-graph-kernel/process/1-3-current-priority-execution-checklist-v1.md`
## 7. 一句话收口
当前 `design/01-05` 的真实主线,不是“继续证明 Rust Web 值不值得做”,也不是“继续证明 `leptos-tiptap` 能不能用”,而是:
> **先把页面域树域收口到 Rust 主导的单一真源,再推进 tree realtime 和树域产品执行面。**
> **先把页面域树域、上传下载和 AI 编辑收口到本地优先工作区,由 Rust kernel 持有唯一语义,再把 Convex 降级为账号、分享、同步和协作控制面。**
@@ -0,0 +1,170 @@
# 1-2 [process] Tree-First Graph 产品愿景进展评估 v1
> 更新时间:2026-05-19
>
> **目的**:对照原始产品愿景(Wolai 编辑体验 + VSCode 文件树 + simple-mindmap + OnlyOffice + AI 操作一切),评估当前代码在 1.0 MVP 后的实际进展和剩余的缺口。
>
> 关联:
> - `design/01-05-current-priority-overview.md`
> - `design/10-review/done/11-current-full-architecture-review-v1.md`
> - `CURRENT_ARCHITECTURE.md`
> - `design/11-wolai/help-center-reference.md` — Wolai 帮助中心功能对照基准
## 1. 一句话结论
核心链路已经全部立住,但 AI 对思维导图和 Office 文件的操作是明确的功能缺口,其余模块处于"已可用、收口中"状态。
---
## 2. 各模块状态
### 2.1 Wolai 编辑体验 + UI — ✅ 收口中
#### 架构层
| 子能力 | 状态 | 关键证据 |
|--------|------|----------|
| 默认编辑器已切到 leptos-tiptap island | DONE | `design/05-editor-mainline/done/5-4-leptos-tiptap-mainline-correction-v1.md` |
| Page Aggregate 读链已切到 Rust projection | DONE | `design/05-editor-mainline/done/5-5-1-page-aggregate-contract-v1.md` |
| 文档页入口优先消费 Rust page-aggregate 快照 | DONE | `CURRENT_ARCHITECTURE.md` §2.5 |
| 前端壳已切换到 Rust SSR`:3000`| DONE | `CURRENT_ARCHITECTURE.md` §3.1 |
| Page Aggregate 写链(标题/正文/设置)smoke 通过 | DONE | `scripts/task110-*`, `task-page-aggregate-*-smoke.js` |
| 页面设置运行时语义 / 页头回流 / AI 设置面未完全闭环 | PROCESS | `design/05-editor-mainline/process/5-5-page-aggregate-single-truth-alignment-v1.md` |
| Wolai UI 行为持续对齐 | PROCESS | `design/05-editor-mainline/process/5-9-wolai-aline-continuous-checklist-v1.md` |
| `documents.content` 仍是兼容后备而非唯一真源 | PARTIAL | `CURRENT_ARCHITECTURE.md` §3.2 |
#### 基础块类型覆盖(对照 Wolai 帮助中心)
| 块类型 | Wolai | mnote | 备注 |
|--------|-------|-------|------|
| 文本 | ✅ | ✅ | |
| 标题 H1/H2/H3 | ✅ | ✅ | |
| 无序列表 | ✅ | ✅ | |
| 有序列表 | ✅ | ✅ | |
| 待办列表(checkbox| ✅ | ✅ | |
| **折叠列表(toggle** | ✅ | ❌ | 缺口 |
| 页面块(subpage| ✅ | Partial | Tiptap 嵌入页 |
| 引述文字(blockquote| ✅ | ✅ | |
| **着重文字(callout** | ✅ | ❌ | 缺口 |
| 代码片段 | ✅ | ✅ | |
| 数学公式 | ✅ | ✅ | LaTeX |
| 分割线 | ✅ | ✅ | |
#### 进阶块类型覆盖(仅基础级引用)
| 块类型 | Wolai | mnote | 备注 |
|--------|-------|-------|------|
| 简单表格 | ✅ | ✅ | Tiptap table |
| **数据表格(database** | ✅ | ❌ | 缺口,类似 Notion DB |
| 统计图表 | ✅ | ❌ | |
| Mermaid 绘图 | ✅ | ❌ | |
| 思维导图 | ✅ | ✅ | Phase 6 leptos-mindmap |
| 页面目录(TOC| ✅ | ❌ | |
| 网址书签 | ✅ | ✅ | |
| 嵌入第三方应用 | ✅ | Partial | OnlyOffice 已接,其他待定 |
| 文件附件 | ✅ | ✅ | OnlyOffice adapter |
| 按钮 / 进度条 / 仪表数字 | ✅ | ❌ | 基础缺口 |
#### 基础操作覆盖
| 操作 | Wolai | mnote | 备注 |
|------|-------|-------|------|
| 基本编辑(输入/换行/拖拽/缩进)| ✅ | ✅ | |
| `/` 快捷命令菜单 | ✅ | ✅ | Tiptap slash command |
| **模板创建页面** | ✅ | ❌ | 缺口 |
| 页面选项/设置 | ✅ | ✅ | Page Aggregate 写链已通 |
| **页面关系图** | ✅ | ❌ | 缺口 |
| 全局搜索(Ctrl+P| ✅ | ✅ | Search modal smoke 通过 |
| **导入/导出** | ✅ | ❌ | 缺口(Local/Cloud 桥接仅 P3 |
| 垃圾桶 | ✅ | ✅ | `tree.resource.*` 实现 |
| **回收站恢复位置+reveal** | ✅ | ✅ | 已实现(`design/04-tree-domain/process/4-28` |
| 个人设置(主题/语言)| ✅ | Partial | 暗色模式有,设置 UI 不全 |
| 图标/表情选择器 | ✅ | Partial | |
| **快捷方式/星标栏** | ✅ | ❌ | 缺口 |
完整对照表见 `design/11-wolai/help-center-reference.md`
### 2.2 VSCode 文件树 — ✅ 大部完成
| 子能力 | 状态 | 关键证据 |
|--------|------|----------|
| VSCode Explorer 对标设计 | DONE | `design/04-tree-domain/done/4-20-vscode-explorer-file-tree-alignment-v1.md` |
| 三层模型(Resource → File → Page Tree)固定 | DONE | `design/04-tree-domain/done/4-24-resource-tree-filetree-pagetree-source-contract-checklist-v1.md` |
| `tree.*` 命令切流(Stage 2| DONE | `design/04-tree-domain/done/4-6-tree-command-protocol-cutover-stage2-v1.md` |
| 文件树资源行与页面命令分离 | DONE | `bugs/04-tree-domain/done/4-46-filetree-resource-row-document-command-leak-v1.md` |
| 垃圾箱恢复位置记录 + reveal/focus | DONE | `design/04-tree-domain/process/4-28-trash-restore-location-reveal-focus-v1.md` §2 |
| 资源生命周期命令 `tree.resource.*`mindmap/table rename 未完成)| PARTIAL | `design/04-tree-domain/process/4-27-resource-lifecycle-command-cutover-v1.md` |
| 双浏览器 no-refresh(页面 + 文件 + 垃圾箱实时同步)| PROCESS | `design/04-tree-domain/process/4-34-filetree-trash-dual-browser-no-refresh-v1.md` |
| Local/Cloud 桥接 | P3 CANDIDATE | `design/04-tree-domain/process/4-23-local-cloud-explicit-bridge-p3-candidate-v1.md` |
### 2.3 Simple-mindmap — ✅ 编辑器完成,AI 未覆盖
| 子能力 | 状态 | 关键证据 |
|--------|------|----------|
| Phase 6 口径修正(Rust kernel + simple-mind-map adapter| DONE | `design/06-mindmap/process/6-mindmap-kernel-phase6-projection-editor-v1.md` §2 |
| Leptos UI shell | DONE | git `c64487c6` |
| 即时显示修复 | DONE | git `3be102a4` |
| Filetree 切换稳定 | DONE | git `b300562a` |
| KMind 级详细验收 | DONE | `design/06-mindmap/done/6-mindmap-phase6-kmind-parity-detail-checklist-v1.md` |
| **AI 读写思维导图(增删改节点)** | **GAP** | Hermes manifest 无 `mnote.mindmap.*` 工具 |
### 2.4 OnlyOffice — ✅ 打开/链路完成,AI 未覆盖
| 子能力 | 状态 | 关键证据 |
|--------|------|----------|
| adapter-onlyoffice crate | DONE | `rust/crates/adapter-onlyoffice/` |
| Rust 路由 | DONE | `rust/crates/mnote-web/src/routes/onlyoffice.rs` |
| Docker 部署 | DONE | `infra/onlyoffice/docker-compose.yml` |
| 附件打开链路修复 | DONE | git `7bd0c9e9` |
| Kernel 工具(session/签名/proxy/callback/forcesave| DONE | `core-protocol/src/tool.rs` `ONLYOFFICE_TOOL_*` |
| **AI 读写 Office 文件内容(.docx/.xlsx/.pptx** | **GAP** | 无 `mnote.office.*` Hermes 工具 |
### 2.5 AI 操作文档 — ✅ 主线已完成
| 子能力 | 状态 | 关键证据 |
|--------|------|----------|
| 在线文档 markdown 级读写 | DONE | `mnote.doc.markdown_edit` + `mnote.doc.fetch`smoke 验证通过 |
| 本地 `.md` 文件读/写 | DONE/PARTIAL | `mnote.doc.fetch` 已支持本地文件,写侧仍在过渡(`design/03-rust-web/process/3-13`|
| 结构性块操作(移动/插入/删除)| DONE | `mnote.block.*` 工具群,Hermes 完整 smoke |
| 工具权限 / dryRun / 幂等 / revision 约束 | DONE | `bugs/07-ai/done/7-22` ~ `7-30` |
| ACP runtime + UI | DONE | `design/07-ai/done/7-25-acp-session-runtime-enhancement-plan-v1.md` |
| Phase C(流式 apply / review session| FROZEN | `design/07-ai/process/7-14` — 当前不实施 |
---
## 3. 缺口汇总
| 缺口 | 分类 | 说明 |
|------|------|------|
| AI 操作思维导图 | 功能缺失 | Hermes 13 个工具没有 mindmap 操作。AI 无法在导图中增删改节点 |
| AI 操作 Office 文件 | 功能缺失 | 无 `mnote.office.*` 工具。OnlyOffice 可手动编辑,但 AI 无法读写 `.docx`/`.xlsx`/`.pptx` 内容 |
用户确认 "日记" 即当前主编辑区(Tiptap / `.md` 文件),不构成独立缺口。
---
## 4. 当前架构收口主线(已完成进度 ~70%)
```
第一优先:Page Aggregate 单一真源 ████████████░░░░░░ 70%
└─ EditorBlockDocument 原生落库 ██████░░░░░░░░░░░░ 40%
└─ 页面设置运行时闭环 ████████░░░░░░░░░░ 50%
└─ AI 写入链走统一 markdown_edit 口径 ██████████████░░░░ 80%
第二优先:Tree Command Cutover ████████████░░░░░░ 70%
└─ tree.resource.* 命令收口 ██████████░░░░░░░░ 60%
└─ 资源 rename 补全 ████░░░░░░░░░░░░░░ 30%
第三优先:Tree Realtime Event Stream ██████████████░░░░ 80%
└─ WS 主链已上线 (57ec8322) ████████████████░░ 90%
└─ 双浏览器 no-refresh 统一 live cache ██████░░░░░░░░░░░░ 45%
└─ SSE fallback / 补偿链减少 ██████████░░░░░░░░ 60%
```
---
## 5. 后续方向建议
1. **继续推进三条架构收口主线**Page Aggregate → Tree Command → Tree Realtime),这是当前最优先的工程投入点。
2. **确认两个功能缺口是否需要本轮填补**:AI 操作思维导图和 Office 文件。如果需要在 MVP 后立即补齐,需要为 `mnote.mindmap.*``mnote.office.*` 工具做设计;如果可推迟,则当前架构收口完成后再说。
3. Local/Cloud 桥接(P3 candidate)和 Phase C AI(流式 apply)属于明确的"下一轮"范围,不在此轮冲刺内。
@@ -0,0 +1,86 @@
# 2-3 [process] Local Workspace Access Control 产品化 checklist v1
> 创建时间:2026-05-19
>
> 当前状态:`PROCESS`
>
> 上位依据:
> - `/mnt/Data1T/mnote/design/02-convex-rust-long-term-architecture/done/2-2-local-first-workspace-convex-control-plane-v1.md`
> - `/mnt/Data1T/mnote/ARCHITECTURE.md`
> - `/mnt/Data1T/mnote/CURRENT_ARCHITECTURE.md`
>
> 目标:把已存在的 admin / owner / grant 本地目录权限模型产品化,形成管理员可管理、agent 可复用、全入口不绕过的控制面。
---
## 1. 当前已具备底座
- [x] 管理员身份支持 `MNOTE_ADMIN_USER_IDS`
- [x] 目录授权文件默认位置为 `/mnt/Data1T/Mnote_data/control-plane/access-policy.json`
- [x] 本地目录访问已有 admin / owner / grant 三层模型。
- [x] 普通用户不能访问未授权目录。
- [x] read grant 只能读,write grant 可写。
- [x] 读路径使用 `ensure_local_workspace_read_access`,写路径使用 `ensure_local_workspace_access`
---
## 2. 本轮 P0:最小管理员控制面 API
- [x] A1. 新增 `GET /api/admin/access-policy`
- 目标:管理员查看当前策略、策略文件路径、有效管理员来源和 grant 列表。
- 验证:单测覆盖非管理员 403、管理员可读。
- 实现:`rust/crates/mnote-web/src/routes/local_folder_source.rs` + `rust/crates/mnote-web/src/routes/mod.rs`
- [x] A2. 新增 `POST /api/admin/access-policy/validate-root`
- 目标:管理员输入 `rootUri``rootPath` 后返回 canonical path / rootUri,并拒绝不存在或非目录路径。
- 验证:单测覆盖合法目录、非法路径。
- [x] A3. 新增 `POST /api/admin/access-policy/grants`
- 目标:管理员添加用户目录授权,写入 `access-policy.json`
- 字段:`userId``rootUri/rootPath``permission=read|write``recursive``capabilities=[ai|share]`
- 验证:单测覆盖创建 grant、重复 grant 拒绝、非管理员拒绝。
- [x] A4. 新增 `DELETE /api/admin/access-policy/grants/{grantId}`
- 目标:管理员删除授权。
- 验证:单测覆盖删除后普通用户失去对应访问权。
---
## 3. P1:权限覆盖审计
- [x] B1. 审计 local folder open / default workspace / file open / asset upload。
- 结论:default workspace 走 owner manifestasset upload 走 write accessfile open 已从 write access 收口为 read access,并补 `local_file_open_allows_read_grant` / root escape 单测。
- [x] B2. 审计 page body write / documents compat save / title / options。
- 结论:`/api/page-body/write``documents/save`、title、options 在 local source 下统一走 `ensure_local_workspace_access` 后写本地 markdown / metadata。
- [x] B3. 审计 tree command / trash / restore / purge。
- 结论:tree snapshot 读路径走 read accesscreate / rename / copy / trash / restore / purge 写路径走 write access;已有 tree command local folder 单测覆盖。
- [x] B4. 审计 Hermes / Reasonix run allowed roots 与 AI tools。
- 结论:Hermes/Reasonix local ACP session 创建和 run 写入本地 private jsonl 前走 write access`mnote.doc.fetch` 直接本地文件读取改走 `ensure_local_path_read_access``mnote.page.get` 传递 `sourceKind/rootUri` 并校验 `aiAccessScope.allowedResourceIds`
- [x] B5. 审计 shared AI session read/write 不扩大 workspace 权限。
- 结论:AI tool 层补 `aiAccessScope.allowedResourceIds` 校验;共享只读场景下 markdown edit 仍禁止写入,验证见 `hermes_tools_markdown_edit_shared_read_is_forbidden`
---
## 4. P2:后续产品化
- [ ] C1. 管理员 UI:列表、添加、删除、validate root。
- [ ] C2. agent changed files / diff 审计落盘。
- [ ] C3. 权限策略变更 audit 记录。
- [ ] C4. share / ai capability 接入真实执行路径。
---
## 5. 本轮验证包
- [x] `cargo fmt --check --all`
- [x] `cargo test -p mnote-web local_access_policy -- --nocapture`
- [x] `cargo test -p mnote-web local_workspace_access -- --nocapture`
- [x] `cargo test -p mnote-web local_folder -- --nocapture`
- [x] `npm run check:local-first-convex-guard`
- [x] `git diff --check -- <changed-files>`
补充验证:
- [x] `cargo test -p mnote-web local_path_read_access -- --nocapture`
- [x] `cargo test -p mnote-web local_file_open -- --nocapture`
- [x] `cargo test -p mnote-web hermes_tools_doc_fetch -- --nocapture`
- [x] `cargo test -p mnote-web hermes_tools_page_get -- --nocapture`
- [x] `cargo test -p mnote-web hermes_client_local_acp -- --nocapture`
- [x] `cargo test -p mnote-web hermes_tools_markdown_edit_shared_read_is_forbidden -- --nocapture`
@@ -0,0 +1,209 @@
# 3-15 [process] 本地 Markdown 图片与附件上传相对路径设计 v1
> 创建时间:2026-05-18
>
> 状态:`[process]`(代码、单测和 HTTP smoke 已完成;真实浏览器 smoke 待补)
>
> 所属主线:`03-rust-web` / `local_folder` / `local markdown`
>
> 参考:`design/05-editor-mainline/reference-code/vscode`
>
> 实现与缺陷闭环:`/mnt/Data1T/mnote/bugs/03-rust-web/done/3-16-local-markdown-upload-uses-convex-media-asset-v1.md`
## 1. 背景
云端页面的图片与附件上传当前走 `/api/media/upload`,生成 Convex media asset,并在正文中写入 image block 或 OnlyOffice 附件链接。
本地文件夹模式不同:页面真源是本地 `.md` 文件,资源真源是同一 local root 下的真实文件。若本地 `.md` 页面内的“上传图片/附件”继续走云端 media asset,会导致 Markdown 文件不可迁移、离开 MNote 后链接失效,也会破坏 local folder 的文件系统可理解性。
当前本地 Markdown 读写已具备基础能力:
- `local_markdown_parser.rs` 能把非 `.md` 相对链接解析为 `media` 块。
- `save_local_markdown_page` 能把 `media` 块写回 `[name](sourcePath)`
- 本地文件树已能显示 local root 下的普通资源文件,并支持资源生命周期。
缺口是:编辑器上传文件时,还没有把外部文件复制到本地 Markdown 资源目录,并把页面正文写成标准相对 Markdown 链接。
## 2. VS Code 参考结论
参考文件:
- `extensions/markdown-language-features/src/languageFeatures/copyFiles/newFilePathGenerator.ts`
- `extensions/markdown-language-features/src/languageFeatures/copyFiles/copyFiles.ts`
- `extensions/markdown-language-features/src/languageFeatures/copyFiles/shared.ts`
VS Code 的关键做法:
- 复制文件时先计算目标路径,默认目标在当前 Markdown 文档旁边。
- 支持 `markdown.copyFiles.destination` 配置,目标路径可包含 `${documentBaseName}``${fileName}` 等变量。
- 冲突策略默认递增命名,如 `image.png``image-1.png`
- 插入 Markdown 时使用相对当前文档目录的路径。
- 图片使用 `![alt](relative/path)`,普通文件使用 `[text](relative/path)`
- 路径包含空格或括号不匹配时用 `<...>` 包裹。
MNote 不需要先完整复刻 VS Code 的配置系统,但应采用同一底层原则:本地 Markdown 写标准相对路径,资源文件留在 local root 内。
## 3. 目标
本阶段目标是给本地 `.md` 页面提供可验证的最小上传链路:
1.`sourceKind=local_folder` 的文档编辑器中上传图片或附件时,不调用云端 `/api/media/upload`
2. 外部文件复制到当前 `.md` 文件旁的 `{documentBaseName}.assets/` 目录。
3. 页面正文写入标准 Markdown 相对路径:
- 图片:`![file.png](README.assets/file.png)`
- 附件:`[file.docx](README.assets/file.docx)`
4. 保存后 `.md` 文件落盘包含相对路径;刷新页面能恢复图片/附件块。
5. local file tree 能通过现有 watch / projection 看到新增资源文件。
非目标:
- 不做 VS Code 风格可配置 `copyFiles.destination`
- 不自动删除未引用资源文件。
- 不把本地附件接入 Convex media asset。
- 不在本阶段实现 OnlyOffice 对本地 Office 文件的完整编辑写回;可先作为本地文件链接/附件行打开或下载。
## 4. 目录与命名规则
默认目标目录:
```text
<md-dir>/<md-base>.assets/
```
示例:
```text
docs/README.md
docs/README.assets/image.png
docs/README.assets/spec.docx
```
命名规则:
- 原始文件名做 UTF-8 保留,但必须去掉路径分隔符和空名。
- 若目标已存在且内容未判断相同,使用递增后缀:
- `image.png`
- `image-1.png`
- `image-2.png`
- 生成的相对路径使用 `/`,不使用平台分隔符。
- 返回给编辑器的 `sourcePath` 必须是相对当前 `.md` 文件所在目录的路径。
## 5. 安全边界
所有本地上传必须在 Rust Web 层校验:
- `rootUri` 必须解析为已存在 local root。
- `documentId` 必须解析到 local root 下的 `.md` 文件。
- 目标目录和目标文件必须在 local root 内。
- 禁止绝对目标路径、`..` 逃逸、空文件名、目录文件名。
- 不覆盖已有文件,除非后续有显式配置;本阶段只递增命名。
## 6. 数据流
### 6.1 图片上传
```text
编辑器 slash 图片 / 拖入图片
→ local markdown asset upload route
→ 复制文件到 README.assets/
→ 返回 local asset descriptor
→ 编辑器插入 image nodesrc=相对路径
→ /api/documents/save?sourceKind=local_folder
→ save_local_markdown_page 写 ![alt](relative/path)
```
### 6.2 附件上传
```text
编辑器 slash 附件 / 拖入附件
→ local markdown asset upload route
→ 复制文件到 README.assets/
→ 返回 local asset descriptor
→ 编辑器插入普通 link mark 或 media block
→ /api/documents/save?sourceKind=local_folder
→ save_local_markdown_page 写 [name](relative/path)
```
## 7. 接口设计
新增本地上传入口:
```http
POST /api/local-folder/assets/upload
multipart/form-data
```
字段:
- `file`: 上传文件。
- `rootUri`: local folder root URI。
- `documentId`: 当前 local markdown document id。
- `kind`: `image | attachment`,可由 MIME 推断兜底。
响应:
```json
{
"ok": true,
"asset": {
"id": "local:asset:docs/README.assets/image.png",
"asset_type": "image",
"file_name": "image.png",
"mime_type": "image/png",
"file_size": 1234,
"file_url": "README.assets/image.png",
"sourcePath": "README.assets/image.png",
"document_id": "local-md:docs/README.md",
"sourceKind": "local_folder"
}
}
```
说明:
- `file_url` 在本地模式中不是 HTTP URL,而是给 editor 插入的 Markdown 相对路径。
- 若前端需要实际预览,可用后续已有 local asset serving route 或补同源读取 route;正文落盘仍保持相对路径。
## 8. 前端分流
`uploadFileToMediaAsset` 在上传前判断:
-`currentSourceKind() === "local_folder"`:调用 `/api/local-folder/assets/upload`
- 否则:继续调用 `/api/media/upload`
`insertUploadedAssetIntoEditor` 对 local asset 的处理:
- 图片:`setImage({ src: sourcePath, alt: file_name, title: file_name })`
- 附件:插入 link`href=sourcePath`,不生成 `/onlyoffice?...assetId=...`
这能让现有 `inlineTextNodes -> styles.link -> save_local_markdown_page` 路径写出普通 Markdown 链接。
## 9. Checklist
- [x] 设计稿落入 `design/03-rust-web/process/`
- [x] Rust route 新增 local markdown asset upload handler。
- [x] handler 能解析 `rootUri + documentId` 到本地 `.md` 文件路径。
- [x] handler 能创建 `{mdBase}.assets/` 并递增命名避免覆盖。
- [x] handler 拒绝 `..` 逃逸、空文件名、非 local folder 请求。
- [x] 前端上传分流:local folder 走本地 route,云端继续走 `/api/media/upload`
- [x] 前端 local image 插入相对路径 image node。
- [x] 前端 local attachment 插入相对路径 link,不生成 OnlyOffice assetId URL。
- [x] `save_local_markdown_page` 写回 `![alt](path)``[name](path)`
- [x] 单测覆盖本地上传目标路径和冲突递增。
- [x] 单测覆盖 local markdown 图片/附件 roundtrip。
- [x] HTTP smoke 覆盖 `/api/local-folder/assets/upload` 真实 multipart 上传与落盘。
- [ ] 浏览器 smoke 覆盖本地 `.md` 页面上传图片/附件、刷新后恢复、文件树可见。
## 10. 验收命令
```bash
cargo fmt --manifest-path rust/Cargo.toml --all --check
cargo test --manifest-path rust/Cargo.toml -p mnote-web local_markdown -- --nocapture
cargo test --manifest-path rust/Cargo.toml -p mnote-web local_folder -- --nocapture
```
浏览器 smoke 后续新增:
```bash
MNOTE_UI_BASE_URL=http://127.0.0.1:3000 node scripts/task-local-markdown-asset-upload-smoke.js
```
@@ -1,18 +1,26 @@
# 3-3 [process] Rust Web Tree Realtime Event Stream 方案 v1
> 更新时间:2026-05-17WS push 迁移后口径更新)
> 更新时间:2026-05-18local-first 口径更新)
> 关联新设计稿:`design/03-rust-web/done/3-14-rust-web-tree-realtime-ws-push-v1.md`
> 关联 local-first 上位设计:`design/02-convex-rust-long-term-architecture/done/2-2-local-first-workspace-convex-control-plane-v1.md`
>
> 2026-05-18 口径更新:
> - 本文的 Convex realtime substrate 只适用于 `convex_workspace`、同步副本和后续协作场景。
> - 当前早期产品默认 source 是 `local_folder`;本地树变化应优先通过 LocalFS watcher / rescan / Rust projection event 进入同一条前端 projection consumer。
> - `/api/realtime/ws` 与 `/api/tree/events` 的长期职责是统一 transport;本地工作区的数据真相仍是 LocalFS / WorkspaceSource。
>
> 关联文档:
> - `/mnt/Data1T/mnote/design/02-convex-rust-long-term-architecture/process/2-tree-first-graph-convex-rust-long-term-architecture-v1.md`
> - `/mnt/Data1T/mnote/design/02-convex-rust-long-term-architecture/done/2-2-local-first-workspace-convex-control-plane-v1.md`
> - `/mnt/Data1T/mnote/design/old/02-convex-rust-long-term-architecture/process/2-tree-first-graph-convex-rust-long-term-architecture-v1.md`(历史过渡背景)
> - `/mnt/Data1T/mnote/design/01-tree-first-graph-kernel/process/1-tree-first-graph-kernel-v1.md`
> - `/mnt/Data1T/mnote/design/03-rust-web/process/3-rust-web-long-term-architecture-v1.md`
## 1. 目标
这份文档用于固定 Stage C-1 的正式实时链路口径:
这份文档用于固定 Stage C-1 的正式实时链路口径。2026-05-18 后,它应按 `WorkspaceSource` 区分底层事件来源
- 保留 Convex 作为 realtime substrate
- `local_folder`LocalFS watcher / rescan / command result 是默认事件来源
- `convex_workspace`Convex 作为 realtime substrate
- Rust 成为 tree-first graph 的 semantic owner
- Rust Web 负责正式页面 transport 与实时事件流
- 前端只消费 projection 与 delta,不再消费实验壳真相
@@ -26,18 +34,16 @@
## 2. 职责划分
### 2.1 Convex substrate
### 2.1 Workspace event substrate
Convex 继续承担
不同 `WorkspaceSource` 使用不同 substrate
- 持久化
- mutation / query 底座
- 实时订阅底座
- 文件 / 对象存储协作
- `local_folder`:本地文件系统、watcher、rescan、命令执行结果。
- `convex_workspace`Convex 持久化、mutation / query、实时订阅、对象存储协作。
Convex 在这里是:
Convex 在这里是可选云端 / 协作 source 的 substrate,而不是所有工作区的默认 substrate
> storage / realtime substrate
> source-specific storage / realtime substrate
而不是页面树语义 owner。
@@ -100,13 +106,15 @@ Rust Web 负责:
正式链路建议固定为四层:
### 4.1 Convex 持久化/订阅底座
### 4.1 Source-specific 持久化/订阅底座
这里负责:
- 命令落账
- 持久化页面树状态
- 输出 mutation 后可订阅的数据变化
- 持久化页面树状态或本地文件状态
- 输出 command / watcher / mutation 后可订阅的数据变化
对于 local-first 默认路径,这层是 LocalFS watcher / rescan / command result;对于云端、同步和协作路径,这层才是 Convex。
### 4.2 Rust kernel 语义 owner
@@ -1,6 +1,14 @@
# 5-5 [process] 主编辑区与树域单一真源对齐方案 v1
> 更新时间:2026-05-09
> 更新时间:2026-05-18
>
> 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`
@@ -34,7 +42,7 @@
它现在更接近于:
> **`Rust snapshot 主读链 + Convex-backed + 前端本地 state 仍存在` 的混合态。**
> **`Rust snapshot 主读链 + local-first / Convex-backed 双 source 过渡 + 前端本地 state 仍存在` 的混合态。**
因此,当前看到的这些“小问题”:
@@ -79,18 +87,22 @@
- 要不要 `Tiptap`
- 要不要 `leptos-tiptap`
- 要不要继续保留 Convex
- 要不要继续保留 Convex 作为控制面和可选同步协作 source
这些结论都已经足够明确:
- `Tiptap` 继续作为浏览器输入 runtime
- `leptos-tiptap` 继续作为 Leptos 内正式 editor island 接缝
- `Convex` 继续作为当前存储 / 实时 / 协作底座
- `local_folder` 作为默认页面数据真相,Convex / 服务端退居控制面和可选同步协作 source
当前真正要收口的是:
> **页面这一层,到底由谁持有聚合语义,前端到底应该消费什么,编辑器到底应该回发什么。**
local-first 后,答案进一步收紧为:
> **正文真相由本地 Markdown 文件持有;Page Aggregate 负责把文件投影成 UI 和 editor runtime 所需结构;写侧必须围绕文件版本做仲裁,而不是继续让 `documents/save` 兼容面决定谁的 revision 有效。**
### 2.3 后续主线应固定为 Page Aggregate,而不是继续零散补洞
从长期架构看,当前主线不应再描述成:
@@ -238,6 +250,41 @@
> **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 自动 reloaddirty editor 进入 conflict state
```
这意味着 `EditorBlockDocument` 原生落库闭环不能理解成“再建一份新的正文真相”。local-first 下更合理的定位是:
- `.md` 是 canonical body
- `EditorBlockDocument` 是 runtime-native projection / cache
- `.mnote/cache/<page-id>.editor.json` 或内存缓存可用于加速和保留编辑器特有信息,但必须带 `sourceFileVersion`
- 当 source file version 不匹配时,cache 失效并重新从 `.md` 投影
长期命令面应从 `/api/documents/save` compat route 收口到 `page.body.write` / LocalFS executor,并显式接收 `expectedFileVersion`。任何不带 expected version 的正文写入都只能进入 compat / import 路径,不能作为自动保存主链。
---
## 4. 为什么这些问题会直接影响长期方向
@@ -387,11 +434,12 @@ Rust 侧需要提供一个统一的页面聚合投影,至少包含:
### 5.5 Convex 的正确定位
当前不应该先拆 Convex。
当前不应该把 Convex 当作默认页面主数据层继续扩写,也不应该无计划硬拆已有 Convex 路径
正确口径是:
- Convex 继续作为存储 / 实时 / 协作底座
- local folder 是早期产品默认页面数据真相
- Convex / 服务端继续作为账号、分享、同步、协作和远端副本控制面
- Rust 持有 canonical contract 与语义编排
- Leptos / Next 负责消费 projection 与呈现 island
@@ -401,7 +449,7 @@ Rust 侧需要提供一个统一的页面聚合投影,至少包含:
而是:
- **Rust 持有语义单一真源,Convex 持有当前持久化底座。**
- **Rust 持有语义单一真源,local-first workspace 持有默认数据真相,Convex 只作为控制面和可选同步协作 source。**
---
@@ -1,6 +1,15 @@
# 5-6 [process] Page Aggregate 单一真源对齐执行清单 v1
> 更新时间:2026-05-09
> 更新时间:2026-05-18
>
> 2026-05-18 口径补充:
> - 本清单继续有效,但 Page Aggregate 的默认落点调整为 local-first workspace;本地 `.md`、`.mnote/page-options.json` / frontmatter 和 `{mdBase}.assets/` 是早期产品默认闭环。
> - Convex-backed Page Aggregate 只作为兼容 / cloud source 路径,不再作为新增能力默认主存储目标。
>
> 2026-05-19 口径补充:
> - local-first 下 `.md` 文件是正文真相,`EditorBlockDocument` 是 runtime-native projection / cache,不是第二份正文真相。
> - 写侧必须退出 `/api/documents/save` 长期主路径,收口到带 `expectedFileVersion` 的 `page.body.write` / LocalFS executor。
> - AI 直改文件、tiptap autosave、外部编辑器修改必须共用 VSCode-like 文件版本冲突模型。
>
> 关联文档:
> - `/mnt/Data1T/mnote/design/05-editor-mainline/process/5-5-page-aggregate-single-truth-alignment-v1.md`
@@ -34,13 +43,16 @@
- [x] 正文保存已经能按 `workspaceId/documentId` 正确落到对应页面。
- [x] 当前主编辑区已具备可继续推进的基础交互能力。
- [x] 当前问题已经不再是“能不能接入主编辑器”,而是“接入后如何收口为单一真源”。
- [x] local-first 正文真相已明确为 `.md` 文件;tiptap / EditorBlockDocument / Page Aggregate 均不再被描述为独立正文真相。
### 2.2 还没有成立的事实
- [ ] 页面树 / 文件树 / 页面头部 / 页面设置 / 主编辑区还没有消费同一份 page aggregate projection。
- [x] 标题 / 正文 / 页面设置已经开始统一到同一组 page aggregate command family。
- [ ] 写侧还没有完全退出 `/api/documents/save` 兼容面;`page.body.write` / LocalFS executor 与文件版本冲突模型仍待落地。
- [ ] tiptap dirty 状态与 AI / 外部文件变更之间还没有完整 VSCode-like conflict UI。
- [ ] `pageOptions` 还没有整体收口到 `leptos-tiptap` island 的正式运行时语义层。
- [ ] AI 写入口还没有完整对齐 page aggregate command family
- [ ] AI 写入口还没有完整对齐“授权文件引用 + 白名单目录 + 后台文件写入 + 前台同步”模型
补充:本轮已新增前端统一 `page-command-client`,并把 `DocumentContent` 的标题 / 页面设置写入、AI 正文写回、以及 `BlockNote` / `leptos-tiptap` 各 host 的正文保存统一到 `page.head.updateTitle / page.layout.updateOptions / page.body.save`。同时,Next route 侧已新增统一 `page-write-command-adapter``/api/documents/title``/api/documents/options``/api/documents/save` 三条页面写链路已开始共用同一层执行器。这里勾选的是“命令执行面开始收口”,不等于页面域真相链已经完全统一。
@@ -58,6 +70,8 @@
- [ ] 不先扩一批和 page aggregate 无关的编辑器花活。
- [ ] 不先争论替换 `Tiptap`、替换 `leptos-tiptap`、移除 Convex。
补充:当前也不打算把 `documents/save` 继续扩成正文真相主入口。local-first 下它最多是 compat adapter;长期写侧必须按本地文件版本号仲裁,像 VSCode 一样处理 dirty / clean / external change 冲突。
---
## 4. Phase F:冻结 Page Aggregate Contract
@@ -87,6 +101,12 @@
补充:2026-05-16 在 `http://127.0.0.1:3000` 的 Rust `mnote-web` 主入口实跑 Page Aggregate smoke。`task110-page-title-single-truth-smoke.js` 验证标题修改后页头、Breadcrumb、Sidebar、Page Tree、File Tree 与刷新后标题一致,证据为 `tmp/page-aggregate-single-truth-smoke/20260516-182244/task110.stdout.json`。文档打开 smoke 验证文档 HTML 包含 `data-page-aggregate-snapshot="mnote.page_aggregate.v1"``data-page-tree-source="page_aggregate.tree.pageSubtree"`,同一临时页 `/api/page-aggregate/:id` 回读 `schema=mnote.page_aggregate.v1`,证据为 `tmp/page-aggregate-single-truth-smoke/20260516-182244/page-open-snapshot.stdout.json``page-open-snapshot.png`。字段完整性 smoke 验证 `identity/head/body/tree/stats` 全部存在,证据为 `tmp/page-aggregate-single-truth-smoke/20260516-182244/page-aggregate-fields.stdout.json`。当前 `projectionSource=documents.content`,仍符合“Rust-first 读取链、非 kernel-native 落库完成态”的过渡口径。
补充:2026-05-18 继续推进 Page Aggregate 读侧块文档优先级。`build_page_aggregate_projection_result` 现在按 `editorDocument -> blockDocument -> documents.content` 构建 `body.blockDocument`,并用 `projectionSource` 暴露真实来源;同时 `body.content` 也会按原生来源重建,避免编辑器/session 下游继续从旧 legacy content 初始化。新增 `page_aggregate_get_prefers_editor_document_over_legacy_content``page_aggregate_get_projects_body_content_from_block_document_source` 与非法原生块文档防回退测试。验证命令:`cargo test --manifest-path rust/Cargo.toml -p bridge-runtime page_aggregate -- --nocapture``cargo test --manifest-path rust/Cargo.toml -p mnote-web page_aggregate -- --nocapture`。这仍不等于 EditorBlockDocument 原生落库完成,`documents:updateContent` 兼容写入与底层 schema 收口继续留在后续阶段。
补充:2026-05-18 同步推进 `page.body.save` 写侧原生快照保留。`mnote-web` transport 不再在调用 `documents:updateContent` 前剥离 `editorDocument / tiptapDocument`,只剥离 Rust artifact 事件字段。验证命令:`cargo test --manifest-path rust/Cargo.toml -p mnote-web convex_command_args_keeps_editor_runtime_fields_for_page_body_save -- --nocapture`。真实部署时仍需确认当前 Convex substrate 的 schema/mutation 已接受并返回这两个字段。
补充:2026-05-18 继续收紧 `page.body.save` 原生快照失败语义。`editorDocument` 一旦出现在保存 payload 中就必须可解析;不可解析时返回 validation error,不再静默回退到 `tiptapDocument` 或 legacy `content`。验证命令:`cargo test --manifest-path rust/Cargo.toml -p bridge-runtime documents_save_command_plan -- --nocapture`
补充:2026-05-16 继续新增 `scripts/task-page-aggregate-body-sync-smoke.js`,验证真实页面正文编辑后 `/api/documents/save``/api/page-aggregate/:id` 回读闭环。证据为 `tmp/page-aggregate-body-sync-smoke/mp87mgz7.json``mp87mgz7.png``body.revision``0``1``body.conflictDetectionKey``tree_1778927703753_1:0``tree_1778927703753_1:1``body.blockDocument.blocks[0]` 回读到新段落文本和 `revisionRef`。配套命令 `cargo test --manifest-path rust/Cargo.toml -p mnote-web documents_save_route_executes_page_body_save_command``cargo test --manifest-path rust/Cargo.toml -p bridge-runtime page_aggregate_get_projects_legacy_content_to_block_document` 已通过。
### 4.3 退出标准
@@ -270,11 +290,12 @@
### 8.3 退出标准
- [x] AI 写入口已经可以被明确描述为“操作 page aggregate command family”,而不是“绕过系统写编辑器”。
- [x] AI 写入口已经可以被明确描述为“必须经过授权 scope 与页面写入仲裁”,而不是“绕过系统写编辑器”。
- [ ] local-first 下,普通正文 AI 写入必须进一步落到“授权文件 patch 或带 `expectedFileVersion``page.body.write`”,不能继续以 `/api/documents/save` 兼容面作为长期验收终点。
注:历史 `/api/ai-agent/run``Hermes tool.completed` 后,会优先尝试把 `slash_run / doc_insert_blocks / doc_replace_range` 恢复成 `mnote-web bridge-runtime` 的结构化 `tool_result`,不再只把 Hermes 事件当作薄日志。2026-05-14 起这只保留为过渡证据;新的主线已由 Hermes 发起 mnote plugin tool call,再由 Rust runtime / kernel 返回 tool result。其后:
- `doc_insert_blocks / doc_replace_range` 继续`page.body.save` 语义落到 `/api/documents/save`,再正式回显主编辑区 island
- `doc_insert_blocks / doc_replace_range` 这类历史 AI 写入曾`page.body.save` 语义落到 `/api/documents/save`local-first 新口径下,该链路只作为 compat 证据,后续普通正文写入应改为授权文件 patch 或带 `expectedFileVersion``page.body.write`
- `slash_run(rename current page)` 会把结构化结果回接到当前页 `DocumentContent` 的同一条标题提交链,并继续广播 `emitDocumentsChanged(documentId)`,因此页头标题与树标题不再靠 AI 面板内部本地状态各自漂移。
- 当前 AI 面板已经能消费结构化 `update_page_options` 结果,并把 `pageOptionsPatch` 回接到当前页 `DocumentContent` 的同一条 `patch_page_options + page.layout.updateOptions` 提交链;同时只允许 `runtimeSupport === "wired"` 的字段进入正式写回,避免 planned / ui_only 页面设置混入主链。对应最小回归测试为 `DocumentAiAgentPanel.runtime.test.tsx``document-content.test.ts`
- 历史 `mnote-cli host` 能在命中页面设置 patch 时直接执行 `page.layout.updateOptions`,并向前端回放结构化 `tool_call/tool_result` 事件;这意味着“服务端完全没有页面设置结构化写回结果”的状态已经结束。
@@ -9,7 +9,8 @@
> - `/mnt/Data1T/mnote/ARCHITECTURE.md`
> - `/mnt/Data1T/mnote/design/01-05-current-priority-overview.md`
> - `/mnt/Data1T/mnote/design/01-tree-first-graph-kernel/process/1-tree-first-graph-kernel-v1.md`
> - `/mnt/Data1T/mnote/design/02-convex-rust-long-term-architecture/process/2-tree-first-graph-convex-rust-long-term-architecture-v1.md`
> - `/mnt/Data1T/mnote/design/02-convex-rust-long-term-architecture/done/2-2-local-first-workspace-convex-control-plane-v1.md`
> - `/mnt/Data1T/mnote/design/old/02-convex-rust-long-term-architecture/process/2-tree-first-graph-convex-rust-long-term-architecture-v1.md`(历史过渡背景)
> - `/mnt/Data1T/mnote/design/03-rust-web/process/3-rust-web-long-term-architecture-v1.md`
> - `/mnt/Data1T/mnote/design/03-rust-web/process/3-3-rust-web-tree-realtime-event-stream-v1.md`
> - `/mnt/Data1T/mnote/design/04-tree-domain/done/4-2-sidebar-pagetree-filetree-product-interaction-contract-v1.md`
@@ -1,6 +1,10 @@
# 5-9 [process] Wolai-aline 连续执行 checklist v1
> 更新时间:2026-04-30
> 更新时间:2026-05-18
>
> 2026-05-18 口径补充:
> - 本 checklist 的 Wolai 体验对标仍有效,但数据真相口径跟随 local-first workspace:本地 `.md` 是默认正文真相,Convex-backed 路径只作为兼容 / cloud source。
> - 历史条目中“写回 Convex-backed 持久化底座”的表述只代表当时 smoke 的在线路径,不再作为新增编辑能力默认目标。
>
> 本清单拆自 `5-7-wolai-page-tree-main-editor-experience-restoration-v1.md`。它不是新的测试方案;执行口径统一服从 `/home/lix/.codex/skills/wolai-aline``/mnt/Data1T/mnote/design/08-wolai-aline-test-flow/process/wolai-aline-test-flow-v1.md`
@@ -1,243 +0,0 @@
# 7-5 [done] Hermes client proxy 合同 v1
> 更新时间:2026-05-14
>
> 上位依据:`design/07-ai/done/7-3-page-ai-hermes-panel-and-mnote-plugin-v1.md``design/07-ai/done/7-4-page-ai-hermes-panel-execution-checklist-v1.md`
>
> Hermes Web UI 参考:`packages/client/src/api/hermes/chat.ts``packages/client/src/api/hermes/sessions.ts``packages/server/src/routes/hermes/proxy-handler.ts``packages/server/src/services/hermes/chat-run-socket.ts`
## 1. 边界
- [x] 浏览器只访问 mnote-web 同源 `/api/hermes/client/*`
- [x] Hermes API key / gateway token 只存在 mnote-web 服务端环境变量。
- [x] mnote-web proxy 只做 auth、同源安全、页面上下文注入、trace 注入和错误码标准化。
- [x] mnote-web proxy 不保存 Hermes session、message、tool event、usage、model 真相。
- [x] `pageContext` 是 run 输入上下文,不是 Hermes session 的长期事实源。
- [x] 旧 `/api/ai-agent/run` 在并存期只作为 legacy endpoint,不是新 Hermes 面板主代理。
## 2. 路由
### `GET /api/hermes/client/sessions`
请求:
```http
GET /api/hermes/client/sessions?workspaceId=ws_1&documentId=doc_1&limit=20
```
响应:
```json
{
"ok": true,
"traceId": "trace_1",
"sessions": [
{
"sessionId": "mnote_doc_1_20260514",
"title": "当前页问答",
"preview": "请总结当前页面",
"messageCount": 2,
"toolCallCount": 1,
"updatedAt": 1778712000,
"model": "hermes-agent"
}
]
}
```
### `POST /api/hermes/client/sessions`
请求:
```json
{
"workspaceId": "ws_1",
"documentId": "doc_1",
"traceId": "trace_1",
"title": "当前页问答"
}
```
响应:
```json
{
"ok": true,
"sessionId": "mnote_doc_1_trace_1",
"traceId": "trace_1",
"persistence": "hermes_on_first_run"
}
```
说明:Hermes Web UI 参考实现没有独立 session create HTTP route,客户端生成 session id,首次 run 时由 Hermes 持久化。mnote 保留本 route 是为了同源客户端合同稳定,但不得在 mnote 保存聊天历史。
### `GET /api/hermes/client/sessions/{session_id}`
请求:
```http
GET /api/hermes/client/sessions/mnote_doc_1_trace_1?workspaceId=ws_1&documentId=doc_1
```
响应:
```json
{
"ok": true,
"sessionId": "mnote_doc_1_trace_1",
"traceId": "trace_1",
"messages": [
{
"messageId": "42",
"role": "assistant",
"content": "当前页标题是...",
"toolCallId": null,
"toolName": null,
"timestamp": 1778712000,
"reasoning": null
}
],
"usage": {
"inputTokens": 120,
"outputTokens": 30,
"totalTokens": 150
}
}
```
### `POST /api/hermes/client/runs`
请求:
```json
{
"workspaceId": "ws_1",
"documentId": "doc_1",
"sessionId": "mnote_doc_1_trace_1",
"message": "概括当前页面标题和第一段",
"model": "hermes-agent",
"pageContext": {
"title": "项目计划",
"outline": [],
"pageOptions": { "wideLayout": true }
},
"selectedBlockId": null,
"selectedText": null,
"traceId": "trace_1"
}
```
响应:
```json
{
"ok": true,
"sessionId": "mnote_doc_1_trace_1",
"runId": "run_123",
"messageId": null,
"events": [],
"traceId": "trace_1"
}
```
### `GET /api/hermes/client/events/{run_id}`
请求:
```http
GET /api/hermes/client/events/run_123?sessionId=mnote_doc_1_trace_1
```
响应:`text/event-stream`
```text
data: {"event":"message.delta","run_id":"run_123","session_id":"mnote_doc_1_trace_1","delta":"当前页"}
data: {"event":"run.completed","run_id":"run_123","session_id":"mnote_doc_1_trace_1","output":"当前页...","usage":{"input_tokens":120,"output_tokens":30,"total_tokens":150}}
```
### `POST /api/hermes/client/runs/{run_id}/abort`
请求:
```json
{
"workspaceId": "ws_1",
"documentId": "doc_1",
"sessionId": "mnote_doc_1_trace_1",
"traceId": "trace_1"
}
```
响应:
```json
{
"ok": true,
"runId": "run_123",
"sessionId": "mnote_doc_1_trace_1",
"traceId": "trace_1",
"events": [
{ "event": "abort.started", "run_id": "run_123" }
]
}
```
### `GET /api/hermes/client/models`
响应:
```json
{
"ok": true,
"traceId": "trace_1",
"defaultModel": "hermes-agent",
"models": [
{ "id": "hermes-agent", "label": "Hermes Agent", "provider": "hermes" }
]
}
```
### `GET /api/hermes/client/tools`
响应:
```json
{
"ok": true,
"traceId": "trace_1",
"tools": [
{ "name": "mnote.page.get", "scope": "page.read", "schemaVersion": "mnote.hermes_tool.v1" }
]
}
```
## 3. 错误响应
统一错误体:
```json
{
"ok": false,
"code": "hermes_client_unconfigured",
"message": "Hermes client proxy 未配置 upstream",
"traceId": "trace_1",
"requestId": "req_1"
}
```
稳定错误码:
- [x] `hermes_client_unauthorized`:未登录或缺少有效 mnote 会话。
- [x] `hermes_client_unconfigured`:未配置 `MNOTE_WEB_HERMES_UPSTREAM_URL`
- [x] `hermes_client_bad_request`:请求 JSON 或必要字段错误。
- [x] `hermes_client_upstream_unauthorized`Hermes upstream 拒绝服务端 token。
- [x] `hermes_client_upstream_rate_limited`Hermes upstream 429。
- [x] `hermes_client_upstream_unavailable`Hermes upstream 连接失败或 5xx。
## 4. 并存期规则
- [x] 新页面 AI 面板只允许请求 `/api/hermes/client/*`
- [x] `/api/ai-agent/run` 保留为 legacy guard,不再承载 Hermes 页面 AI 主链。
- [x] `provider=hermes` 不再通过旧 `/api/ai-agent/run` 表达。
- [x] 所有新 smoke 应断言页面 AI 主链没有 `/api/ai-agent/run` 请求。
@@ -1,404 +0,0 @@
# 7-8 [process] 页面 AI Hermes Runtime BFF 下一阶段设计 v1
> 更新时间:2026-05-15
>
> 当前状态:`PROCESS`。本文承接 `7-3``7-4``7-5``7-6``7-7` 已完成的 Hermes 页面内客户端主线,写入对 `hermes-web-ui-0.5.18` Chat Runtime 架构的吸收结论。
>
> 上位依据:
> - `/mnt/Data1T/mnote/ARCHITECTURE.md`
> - `/mnt/Data1T/mnote/design/01-05-current-priority-overview.md`
> - `/mnt/Data1T/mnote/design/03-rust-web/process/3-rust-web-long-term-architecture-v1.md`
> - `/mnt/Data1T/mnote/design/05-editor-mainline/process/5-5-page-aggregate-single-truth-alignment-v1.md`
> - `/mnt/Data1T/mnote/design/05-editor-mainline/process/5-6-page-aggregate-alignment-checklist-v1.md`
> - `/mnt/Data1T/mnote/design/05-editor-mainline/reference-code/hermes-web-ui-0.5.18`
> - `/mnt/Data1T/mnote/design/07-ai/done/7-3-page-ai-hermes-panel-and-mnote-plugin-v1.md`
> - `/mnt/Data1T/mnote/design/07-ai/done/7-4-page-ai-hermes-panel-execution-checklist-v1.md`
> - `/mnt/Data1T/mnote/design/07-ai/done/7-5-hermes-client-proxy-contract-v1.md`
> - `/mnt/Data1T/mnote/design/07-ai/done/7-6-mnote-hermes-plugin-tool-contract-v1.md`
> - `/mnt/Data1T/mnote/design/07-ai/done/7-7-page-ai-mini-hermes-control-surface-v1.md`
---
## 1. 结论
`hermes-web-ui-0.5.18` 对 mnote 有用,但不能原样照搬。
可吸收的是:
- Web 端应采用 `Browser -> BFF -> Hermes gateway`,不是浏览器直接 ACP。
- BFF 应承担 runtime 管理:active run registry、resume、queue、abort、event normalization、gateway manager、错误规整。
- 前端只展示 message、tool event、状态和输入,不持有 AI 编排真相。
不可吸收的是:
- 不把 mnote 的页面 AI 聊天真相改为 Convex、SQLite 或 mnote 自建 chat DB。
- 不把 Hermes 降级成 `/v1/responses store=false` 的纯模型执行器。
- 不让 mnote-web 根据自有数据库长期组装完整 `conversation_history`
当前冻结方向:
> **mnote 学 `hermes-web-ui` 的 BFF/runtime 架构思想,但会话真相继续归 Hermes;mnote 只保存业务事实、audit、artifact、edge、page/body/title/options 结果。**
---
## 2. 当前实现定位
当前页面 AI 接入是:
```text
Leptos 页面 AI 面板
-> mnote-web /api/hermes/client/*
-> Hermes gateway /v1/runs
-> Hermes gateway /v1/runs/{run_id}/events
```
同时:
- `profiles / skills / memory` 等设置能力由 `mnote-web` 本地 BFF 通过 Hermes CLI 或 `~/.hermes` 文件读写补齐。
- `mnote.*` 工具由 mnote Rust runtime 承接,Hermes 只通过 skill/plugin/tool 调用。
- 页面 AI 不再走旧 `/api/ai-agent/run`
这已经满足最小闭环,但仍只是“最小 runtime 接入”,缺少 `hermes-web-ui` 已经具备的完整 Chat Runtime 能力。
---
## 3. ACP、gateway、responses 的取舍
### 3.1 ACP
ACP 适合:
- VSCode 插件
- Tauri / Electron 桌面端
- 本地 IDE host
- 需要由宿主启动 `hermes acp` 并通过 stdio JSON-RPC 深度交互的场景
ACP 不适合作为 mnote 页面 AI 第一主链:
- 浏览器不能直接 `spawn hermes acp`
- 若 Web 使用 ACP,仍需 mnote-web 作为 ACP bridge 管理 stdio 子进程。
- 这会引入长期进程、session、backpressure、并发、恢复和崩溃清理复杂度。
- 当前 Hermes gateway 已提供 Web 更自然的 `/v1/*` 与 stream 能力。
结论:
> **ACP 后续只作为桌面端或高级 host bridge 候选,不作为页面 AI Web 主链。**
### 3.2 `/v1/runs`
`/v1/runs` 适合当前 mnote 主线:
- Hermes 持有 run / session / event 语义。
- mnote 页面 AI 只是 Hermes client。
- mnote 不需要长期组装 `conversation_history`
- 与“AI 会话归 Hermesmnote 能力归 Rust kernel”一致。
风险:
- mnote-web 需要补 runtime 状态管理,否则刷新、断线、队列、abort 体验弱。
- Hermes gateway 的非 `/v1/*` 管理面不足,需要 BFF 补 profiles/skills/memory/gateway manager。
结论:
> **短期继续以 `/v1/runs` 为默认页面 AI 主链,并在 mnote-web 上补 Runtime BFF。**
### 3.3 `/v1/responses`
`hermes-web-ui` 的产品化 Chat Runtime 使用 `/v1/responses stream=true`,典型形态是:
```text
Browser Vue UI
-> Socket.IO /chat-run
-> hermes-web-ui server
-> /v1/responses stream=true
-> server 从本地 DB 组装 conversation_history
-> server 把 response.* 事件转成 run/message/tool 事件
-> server 写入 SQLite session/messages
```
它适合“自建 Chat Runtime”:
- 后端自有 session/messages 数据库。
- 后端自行压缩上下文。
- 后端自行映射 tool event。
- `store=false` 时 Hermes 不再是会话真相。
这与 mnote 当前页面 AI 方向有冲突。除非后续明确改变设计,把“AI 会话真相”从 Hermes 迁到 mnote,否则不能把 `/v1/responses store=false + mnote conversation_history` 写成默认主链。
允许使用 `/v1/responses` 的条件:
- 只作为特定 runtime 实验或 fallback。
- 必须证明 Hermes session 仍能作为用户可见会话真相,或者另开设计稿明确改变会话真相。
- 不得把 mnote page aggregate / Convex 页面数据变成聊天消息数据库。
结论:
> **`/v1/responses` 先作为参考实现和可评估候选,不作为当前页面 AI 默认主链。**
---
## 4. 目标架构
下一阶段目标不是改成 ACP,也不是直接改成 `/v1/responses`,而是在当前 gateway 主线上补一个 mnote-web Runtime BFF
```text
Leptos 页面 AI 面板
-> mnote-web Hermes Runtime BFF
- active run registry
- session/run resume
- queue
- abort
- event normalization
- gateway/profile/skill/memory BFF
- trace/audit 串联
-> Hermes gateway /v1/runs
-> Hermes session storage
-> mnote skill/plugin/tool
-> Rust runtime / kernel
```
真相边界:
- HermesAI session、message、run、tool event、usage、model、profile。
- mnote:页面、树、正文、artifact、edge、projection、audit、权限、idempotency。
- mnote-web Runtime BFF:运行时连接和事件编排,不保存长期聊天真相。
---
## 5. Hermes Web UI 参考索引
参考根目录:
```text
/mnt/Data1T/mnote/design/05-editor-mainline/reference-code/hermes-web-ui-0.5.18
```
只参考下表,不整站搬运:
| 参考主题 | 文件 | 搜索点 | mnote 中吸收什么 | mnote 中不采用什么 |
| --- | --- | --- | --- | --- |
| Socket runtime 主链 | `packages/server/src/services/hermes/chat-run-socket.ts` | `ChatRunSocket``onConnection``resumeSession``handleRun``dequeueNextQueuedRun``handleAbort` | active run registry、resume、queue、abort、前端刷新不丢运行态 | 不照搬 Socket.IOmnote 可用 SSE / WebSocket / HTTP 状态轮询 |
| `/v1/responses` 事件映射 | `packages/server/src/services/hermes/chat-run-socket.ts` | `/v1/responses``applyResponseStreamEvent``response.output_text.delta``response.output_item.done``response.completed` | 学习如何把上游事件标准化为 `message.delta``tool.started``tool.completed``run.completed` | 不把 `/v1/responses store=false` 写成默认主链 |
| 后端 session store | `packages/server/src/db/hermes/session-store.ts` | `HermesSessionRow``HermesMessageRow``createSession``addMessage``getSessionDetail` | 理解 Hermes Web UI 如何建完整 chat DB,以及字段形态 | 不在 mnote 建第二套 chat DB;不把 Convex 当 AI 会话真相 |
| 前端 chat store | `packages/client/src/stores/hermes/chat.ts` | `resumeServerWorkingRun``queueLengths``abortState``case 'message.delta'``case 'tool.started'``case 'run.completed'` | 前端状态机、队列提示、abort UI、tool event 归并 | 不复用 Vue/Pinia;不把前端 state 当真相 |
| 前端 API | `packages/client/src/api/hermes/chat.ts` | `startRunViaSocket``resumeSession``registerSessionHandlers``RunEvent` | 客户端事件合同与 runtime event 类型 | 不让浏览器绕过 mnote-web 直连 Hermes |
| VSCode ACP tool UI | `design/05-editor-mainline/reference-code/hermes-vscode-main/src/sessionManager.ts``src/protocol.ts``src/chatPanel.ts``src/webview/main.ts` | `tool_call``tool_call_update``parseToolCall``parseToolCallUpdate``case 'toolCall'``data-tool-id` | 学习按 `toolCallId` 追加工具行、再用后续 update 原地更新状态的 UI 模型 | 不采用 ACP 作为 Web 主链;只参考 tool event UI 状态机 |
| gateway 管理 | `packages/server/src/services/hermes/gateway-manager.ts``packages/server/src/services/gateway-bootstrap.ts` | `getUpstream``start``stop``health` | profile 对应 gateway 的发现、启动、健康检查、错误提示 | 不让页面 AI 抽屉变成完整 gateway 管理台 |
| proxy handler | `packages/server/src/routes/hermes/proxy-handler.ts` | `resolveUpstream``SSE_EVENTS_PATH``streamSSE``/v1/runs` | 路由改写、SSE 拦截、upstream 错误规整 | 不把全量 proxy catch-all 暴露给浏览器 |
| skills 来源 | `packages/server/src/controllers/hermes/skills.ts` | `.bundled_manifest``.hub/lock.json``.usage.json``source``modified` | skill 来源与生成/安装/本地分类 | 不复制完整 skill 管理台 |
快速定位命令:
```bash
cd /mnt/Data1T/mnote/design/05-editor-mainline/reference-code/hermes-web-ui-0.5.18
rg -n "ChatRunSocket|resumeSession|handleRun|dequeueNextQueuedRun|handleAbort|/v1/responses|applyResponseStreamEvent|response\\.output_text\\.delta|response\\.output_item\\.done|response\\.completed|HermesSessionRow|startRunViaSocket|gateway-manager" packages/server/src packages/client/src
```
---
## 6. RuntimeEvent 合同
mnote-web BFF 对前端暴露的事件应稳定为 `mnote.hermes_runtime_event.v1`
最小事件集:
```json
{ "event": "run.started", "sessionId": "mnote_doc_1_trace_1", "runId": "run_1", "traceId": "trace_1" }
```
```json
{ "event": "message.delta", "sessionId": "mnote_doc_1_trace_1", "runId": "run_1", "delta": "当前页", "traceId": "trace_1" }
```
```json
{ "event": "tool.started", "sessionId": "mnote_doc_1_trace_1", "runId": "run_1", "toolCallId": "call_1", "toolName": "mnote.page.get", "traceId": "trace_1" }
```
```json
{ "event": "tool.completed", "sessionId": "mnote_doc_1_trace_1", "runId": "run_1", "toolCallId": "call_1", "toolName": "mnote.page.get", "summary": "读取当前页面", "auditId": "audit_1", "traceId": "trace_1" }
```
```json
{ "event": "run.completed", "sessionId": "mnote_doc_1_trace_1", "runId": "run_1", "output": "总结文本", "usage": { "inputTokens": 0, "outputTokens": 0 }, "traceId": "trace_1" }
```
```json
{ "event": "run.failed", "sessionId": "mnote_doc_1_trace_1", "runId": "run_1", "code": "hermes_client_upstream_error", "message": "Hermes upstream 返回错误", "traceId": "trace_1" }
```
```json
{ "event": "abort.started", "sessionId": "mnote_doc_1_trace_1", "runId": "run_1", "traceId": "trace_1" }
```
```json
{ "event": "abort.completed", "sessionId": "mnote_doc_1_trace_1", "runId": "run_1", "synced": true, "traceId": "trace_1" }
```
如果上游是 `/v1/runs/{run_id}/events`BFF 只做字段规整;如果后续试验 `/v1/responses`BFF 负责把 `response.*` 映射为上述 RuntimeEvent。前端不直接依赖上游原始事件格式。
---
## 7. 下一阶段 checklist
### A. Runtime 状态注册表
- [x] 在 `mnote-web` 建立内存级 `sessionId -> active run state` registry。
- [x] 记录 `sessionId``runId``profile``documentId``traceId``status``startedAt``lastEventAt`
- [x] registry 只保存运行态和最近事件摘要,不保存完整聊天历史。
- [x] 服务重启后允许 registry 丢失,但页面必须能从 Hermes session detail 恢复已完成消息。
验收标准:
- [x] 页面刷新时,若 run 仍在 registry 中,前端能看到 `running/tool_calling` 状态。
- [x] 关闭页面再打开,不会把 mnote local storage 当作聊天真相。
### B. Resume
- [x] 增加 `POST /api/hermes/client/sessions/{session_id}/resume` 或等价同源接口。
- [x] 返回 Hermes session detail 中的 messages,并附带 registry 中当前运行态。
- [x] 若 session 不在 registry 中,只返回 Hermes 已持久化状态。
验收标准:
- [x] run 过程中刷新页面,UI 能恢复 run 状态或明确显示“正在同步 Hermes 状态”。
- [x] run 完成后刷新页面,消息来自 Hermes session detail / export,不来自 mnote 自建聊天表。
验证证据:
- `cargo test -p mnote-web hermes_client_ -- --nocapture`:通过 9 个 Hermes client 单测,覆盖 registry 不保存 `messages`、tool event 摘要和 `/resume` 返回 runtime。
- `MNOTE_UI_BASE_URL=http://127.0.0.1:13000 node scripts/task-hermes-page-ai-smoke.js`:通过。13000 是本轮临时启动的新 `mnote-web`,用于避免打断当前 3000;验证后已关闭。
- 当前 3000 运行的是本轮修改前的旧 `mnote-web` 二进制,`POST /api/hermes/client/sessions/{id}/resume` 返回 404;需要重启 3000 后再用同一 smoke 复验主入口。
### C. Queue
- [x] 同一 session 已有 active run 时,后续输入进入后端 queue。
- [x] queue item 只保存待发送 input、profile、context snapshot 摘要和 trace,不保存长期聊天真相。
- [x] 当前 run 结束后自动触发下一条。
- [x] 前端显示 queue length 和可取消项。
验收标准:
- [x] 连续发送两条消息时,第二条显示 queued,而不是并发打到同一 Hermes session。
- [x] 取消 queued item 后,不触发 Hermes run。
验证证据:
- `cargo test -p mnote-web hermes_client_ -- --nocapture`:通过 12 个 Hermes client 单测,新增覆盖 active run 时第二条入队、queue item 不返回完整 `messages/pageContext`、后端取消 queued item、终态后用 mock Hermes upstream 自动启动下一条 queued run。
- `MNOTE_UI_BASE_URL=http://127.0.0.1:13000 node scripts/task-hermes-page-ai-queue-smoke.js`:通过。验证连续发送两条消息时第二条走 `queued` 响应,UI 显示队列长度,并能从 Runtime 面板取消 queued item。
- `MNOTE_UI_BASE_URL=http://127.0.0.1:13000 node scripts/task-hermes-page-ai-smoke.js`:通过。确认 resume / tool / 完成态基础链路未被 queue 改动破坏。
- 13000 是本轮临时启动的新 `mnote-web`,验证后已关闭;当前 3000 仍需重启后复验。
### D. Abort
- [x] abort 由 BFF 统一执行,调用 Hermes `/v1/runs/{run_id}/stop` 或中断当前 upstream request。
- [x] abort 前后发出 `abort.started``abort.completed`
- [x] abort 后清理 registry 状态,并处理队列后续项。
验收标准:
- [x] 点击停止后,UI 不再继续追加该 run 的 assistant delta。
- [x] 若 abort 后有队列项,后端按顺序继续处理或明确要求用户确认。
验证证据:
- `cargo test -p mnote-web hermes_client_ -- --nocapture`:通过 13 个 Hermes client 单测,新增覆盖 abort 响应返回 `abort.started/abort.completed`,并在 abort 后继续处理 queued run。
- 临时 `mnote-web``MNOTE_WEB_HERMES_UPSTREAM_URL=http://127.0.0.1:3999` 启动后,执行 `MNOTE_UI_BASE_URL=http://127.0.0.1:13000 node scripts/task-hermes-page-ai-runtime-controls-smoke.js`:通过。验证停止 run 后 UI 状态进入 `aborted`,并且后续 `message.delta` 不再追加到聊天区。
- `MNOTE_UI_BASE_URL=http://127.0.0.1:13000 node scripts/task-hermes-page-ai-queue-smoke.js``MNOTE_UI_BASE_URL=http://127.0.0.1:13000 node scripts/task-hermes-page-ai-smoke.js`:通过。确认 queue / resume 基础链路未被 abort 改动破坏。
- 13000 是本轮临时启动的新 `mnote-web`,验证后已关闭;当前 3000 仍需重启后复验。
### E. Event normalization
- [x] mnote-web 将 Hermes 上游事件规整为 `mnote.hermes_runtime_event.v1`
- [x] 前端只消费标准事件,不直接分支处理多套 Hermes 原始事件。
- [x] tool event 展示独立于 assistant 文本。
- [x] 当前 UI 已能在聊天区输出 `工具` 消息并显示工具名,例如 `mnote.page.get`;下一阶段必须升级为按 `toolCallId` 合并的 tool event card。
- [x] `tool.started` 创建或更新同一个工具卡,显示工具名、运行中状态、参数摘要。
- [x] `tool.completed` 原地更新同一个工具卡,显示完成状态、结果摘要、audit/trace id。
- [x] `tool.failed` 原地更新同一个工具卡,显示失败状态、错误码和可复制详情。
- [x] Runtime 页的最近 tool call 摘要继续保留,但不能替代聊天区内联工具卡。
验收标准:
- [x] `message.delta``tool.started``tool.completed``run.completed``run.failed` 均有 smoke 覆盖。
- [x] `tool.started -> tool.completed` 不产生两条互不关联的纯文本工具消息;同一 `toolCallId` 在 UI 中表现为同一张工具卡状态变化。
- [x] 工具卡至少展示 `toolName``toolCallId`、状态、参数摘要、结果摘要、`traceId/auditId`
- [x] tool result 不混入正文编辑器,不写入 page body,除非 Hermes 明确调用 `mnote.page.save` 等写工具。
验证证据:
- `MNOTE_UI_BASE_URL=http://127.0.0.1:13000 node scripts/task-hermes-page-ai-smoke.js`:通过。smoke 发送同一 `toolCallId=call_smoke_page_get``tool.started -> tool.completed`,断言聊天区只有 1 张 completed 工具卡;同时发送 `toolCallId=call_smoke_page_save``tool.started -> tool.failed`,断言 failed 工具卡显示 `permission_denied`
- `MNOTE_UI_BASE_URL=http://127.0.0.1:13000 node scripts/task-hermes-page-ai-runtime-controls-smoke.js``MNOTE_UI_BASE_URL=http://127.0.0.1:13000 node scripts/task-hermes-page-ai-queue-smoke.js`:通过。确认工具卡改动未破坏 abort / queue。
- `cargo test -p mnote-web hermes_client_ -- --nocapture`:通过 14 个 Hermes client 单测,新增覆盖 `assistant_message``tool.failed` 等上游事件被规整为 `mnote.hermes_runtime_event.v1`
- mnote-web `/client/events/{run_id}` 输出 `event: <canonical>``schemaVersion: "mnote.hermes_runtime_event.v1"`;前端消费路径只处理 `message.delta``run.completed``run.failed``run.aborted``tool.*` 标准事件名。
### F. Gateway manager
- [x] BFF 能检查当前 Hermes gateway health。
- [x] BFF 能识别 profile 对应 gateway / model 状态。
- [x] 缺 model/default 或 API key 时,错误提示指向 Hermes 设置,而不是只显示 502。
验收标准:
- [x] `hermes_client_upstream_error` 场景能显示具体修复建议,例如“当前 profile 缺 model.default”。
- [x] profile/skill/settings API 不再错误代理到 Hermes gateway 非 `/v1/*` 路径。
验证证据:
- `mnote-web` 新增 `GET /api/hermes/client/gateway/health?profile=<name>`,只做只读探测,不启动/停止 gateway;返回 `gateway.configured/upstream/status/httpStatus``profile.modelDefault/provider/apiKeyConfigured``suggestions`
- `profile_detail_payload()` 与 fallback profiles 会从 Hermes profile `config.yaml` 读取 `model.default``model.provider`、provider `api_key/key_env` 摘要,但不返回 API key 原文。
- `upstream_error()``hermes_client_upstream_error` 增加 Hermes 设置修复建议;当上游错误正文包含 `model.default``API key``profile/provider` 等线索时,错误 message 会指向 Hermes 设置。
- Runtime 面板增加 gateway/profile 状态区;页面请求失败时前端显示后端 `message`,不再只显示错误 code。
- `cargo test -p mnote-web hermes_client_ -- --nocapture`:通过 17 个 Hermes client 单测,新增覆盖 gateway 未配置、profile 缺 model/API key、`hermes_client_upstream_error` 错误建议。
- 临时 `mnote-web``MNOTE_WEB_HERMES_UPSTREAM_URL=http://127.0.0.1:3999` 启动到 `127.0.0.1:13000` 后,`task-hermes-page-ai-smoke.js``task-hermes-page-ai-runtime-controls-smoke.js``task-hermes-page-ai-queue-smoke.js` 均通过;验证后已关闭 13000。
- `MNOTE_UI_BASE_URL=http://127.0.0.1:13000 node scripts/task-hermes-page-ai-agent-skill-smoke.js`:通过。确认新增 gateway health 请求不影响 agent/profile 选择、SOUL.md 保存、skill toggle 与 run profile 透传。
### G. `/v1/responses` 评估,不默认切换
- [x] 增加只读 spike 文档或测试,验证当前 Hermes gateway 的 `/v1/responses stream=true` 事件格式。
- [x] 明确 `/v1/responses` 是否能在不迁移会话真相的前提下复用 Hermes session。
- [x] 若不能保持 Hermes session 真相,则不得进入默认页面 AI 主链。
验收标准:
- [x] 有明确结论:继续 `/v1/runs`,或另开设计稿说明为什么改变会话真相。
- [x] 任何 `/v1/responses` 实验都不得把 mnote Convex / page aggregate 变成聊天消息真相。
只读 spike 结论:
- 参考 `hermes-web-ui-0.5.18/packages/server/src/services/hermes/chat-run-socket.ts`,其 `/v1/responses` 主链在发送前从后端 DB 组装 `conversation_history`,随后设置 `body.stream = true``body.store = false`,再请求 `${upstream}/v1/responses`
- 同一文件的 `applyResponseStreamEvent()` 表明 responses 事件格式主要是 `response.created``response.output_text.delta``response.output_item.added``response.output_item.done``response.completed``response.failed`,由上层 BFF 映射为 `run.started``message.delta``tool.started``tool.completed``run.completed``run.failed`
- `response.output_item.done``function_call` 会被映射为 `tool.started``function_call_output` 会被映射为 `tool.completed`。这对 mnote 的 tool card 状态机有参考价值,但不改变主链。
- 本机只读探测 `http://127.0.0.1:3999/health``/v1/models``OPTIONS /v1/responses` 时,当前没有 gateway 监听,无法取得 live `/v1/responses stream=true` 事件样本;因此本轮不把 live event capture 写成验收依据。
- 基于参考实现,`/v1/responses store=false + conversation_history` 的顺滑体验依赖上层 BFF/SQLite 自建 chat session 真相。除非后续确认 Hermes gateway 支持把 `/v1/responses` 直接绑定到 Hermes session storage,否则 mnote 不应把它切成默认主链。
当前决策:
> **继续 `/v1/runs` 作为页面 AI 默认主链;`/v1/responses` 只作为事件标准化与 tool card 的参考实现。若未来要切换,必须另开设计稿证明 Hermes session 仍是 AI 会话真相,或明确批准迁移会话真相。**
---
## 8. 非目标
- 不在本阶段引入 ACP 主链。
- 不在本阶段把页面 AI 主链切到 `/v1/responses store=false`
- 不在 mnote 建 Hermes Web UI 那样的 SQLite chat DB。
- 不把 Convex 作为 AI 会话真相。
- 不把页面 AI 抽屉扩成完整 Hermes 管理台。
- 不让 mnote plugin 直接写 Convex;写入仍回到 Rust runtime / kernel。
---
## 9. 最终验收口径
本稿完成时,应能同时成立:
- [x] 页面 AI 仍是 Hermes 页面内客户端。
- [x] Hermes 仍是 AI session/message/tool event/usage/model/profile 真相。
- [x] mnote-web 有 Runtime BFF 能力,支持 run resume、queue、abort 和事件标准化。
- [x] 页面刷新或断线不导致 active run UI 状态丢失;完成后的消息从 Hermes 恢复。
- [x] mnote 不保存完整聊天历史,只保存 mnote 业务写入结果和 audit。
- [x] `/v1/responses` 是否进入主链已有单独评估结论,且没有绕过 Hermes session 真相。
一句话:
> **补齐 Web Chat Runtime 的工程能力,但不改变“AI 会话归 Hermesmnote 事实归 Rust kernel”的长期边界。**
@@ -29,15 +29,16 @@
- `PageAIApplyController` 后续应收口为 review session / tool executor / readback controller,不能成为第二套 agent 编排中心。
- 当前 `usedHermesRun=false` 的快路径只能理解为 deterministic shortcut,不代表 mnote 新建长期 agent runtime。
## 1.0.1 当前主路径修正(2026-05-18
## 1.0.1 当前主路径修正(2026-05-19
`7-18``7-25` 修复后,本文中的页面 AI fast workflow 口径进一步收口
`2-2 local-first` 完成后,本文继续保留为页面块 / 结构性工具 checklist,但普通 Markdown 正文编辑主路径已经转为 VSCode-like 文件编辑
- 普通正文编辑主路径是 `mnote.doc.markdown_edit`,模型生成 markdown `search/replace``full_content`,再由统一 mnote tool executor 写入
- `/api/page-ai/block-edit-workflow` 仍是简单编辑 fast-path 入口,但不再把 `local_rule -> mnote.doc.apply_block_ops` 作为当前主路径。
- local-first 普通正文编辑默认给 Hermes / Reasonix 当前文件引用、selection 和 allowed roots,由 agent 使用自身 patch / diff / 文件编辑能力写本地 `.md`
- `mnote.doc.markdown_edit` 是 cloud / remote agent / compat fallback,不再是 local-first 普通 Markdown 编辑唯一主路径。
- `/api/page-ai/block-edit-workflow` 只保留为兼容门面或非 local source 的快捷路径,不作为 local-first 默认主路径。
- `mnote.doc.apply_block_ops` / `mnote.block.*` 保留为结构性块操作辅助,例如块移动、资源块、复杂子块或必须按 blockId 精确处理的场景。
- Phase C 的 review session / streaming apply 仍冻结;本文只继续跟踪基础工具合同、上下文、冲突校验、幂等和审阅面边界。
- 当前 page AI runtime 口径以 `design/07-ai/process/7-12-page-ai-hermes-tool-routing-and-review-surface-v1.md``design/10-review/done/10-current-mnote-ai-runtime-review-v1.md``bugs/07-ai/done/7-18``7-25` 为准。
- 当前 page AI runtime 口径以 `design/02-convex-rust-long-term-architecture/done/2-2-local-first-workspace-convex-control-plane-v1.md``design/07-ai/process/7-14-online-local-ai-markdown-editing-convergence-v1.md``design/07-ai/process/7-15-page-ai-acp-agent-runtime-unified-layer-v1.md` 为准。
## 1.1 当前执行状态(2026-05-16
@@ -262,14 +263,14 @@ cargo test -p mnote-web block_fetch
- [x] 支持 `command=block_replace`
- [x] 支持 `command=block_insert_after`
- [x] 支持 `command=block_move_after` dry-run。
- [x] `command=str_replace` 不再作为本 checklist 当前目标;普通正文 search/replace 已收口到 `mnote.doc.markdown_edit`,多重匹配/无法安全映射阻断由 markdown_edit 合同覆盖。
- [x] `command=str_replace` 不再作为本 checklist 当前目标;cloud / remote / compat 的普通正文 search/replace 已收口到 `mnote.doc.markdown_edit`,多重匹配/无法安全映射阻断由 markdown_edit 合同覆盖。local-first 普通 Markdown 编辑默认走授权文件 + agent 原生 patch/diff。
- [x] 缺少 `revision/conflictDetectionKey` 时只允许 dry-run。
- [x] 返回 `planId``diff``warnings``risk``blocked`
2026-05-18 口径修正:
2026-05-19 口径修正:
- `7-18``7-25` 收口后,普通正文替换不再继续扩 `mnote.doc.plan_update command=str_replace`当前实现路径是 `page_ai_workflow.rs -> mnote.doc.markdown_edit`
- `mnote.doc.markdown_edit` 已覆盖精确匹配、归一化匹配、多操作同块合并、无法安全映射时失败;相关测试由 `cargo test --manifest-path rust/Cargo.toml -p mnote-web hermes_tools -- --nocapture` 覆盖。
- `7-18``7-25` 收口后,普通正文替换不再继续扩 `mnote.doc.plan_update command=str_replace`compat 实现路径是 `page_ai_workflow.rs -> mnote.doc.markdown_edit`
- `mnote.doc.markdown_edit` 已覆盖精确匹配、归一化匹配、多操作同块合并、无法安全映射时失败;相关测试由 `cargo test --manifest-path rust/Cargo.toml -p mnote-web hermes_tools -- --nocapture` 覆盖。它不再代表 local-first 普通 Markdown 编辑默认入口。
验证命令:
@@ -1,6 +1,6 @@
# 7-12 [process] 页面 AI Hermes 工具路由与编辑审阅面设计 v1
> 更新时间:2026-05-16
> 更新时间:2026-05-19
>
> 当前状态:`PROCESS`
>
@@ -21,7 +21,8 @@
本稿仍作为 Hermes 工具路由与审阅面设计保留在 `process/`,但以下口径已经更新:
- `/api/page-ai/block-edit-workflow` 当前不再以 `local_rule -> apply_block_ops` 作为主路径;简单正文编辑主路径已切到模型生成 markdown `search/replace``full_content`,再调用 `mnote.doc.markdown_edit`
- `/api/page-ai/block-edit-workflow` 当前不再以 `local_rule -> apply_block_ops` 作为主路径;local-first 普通 Markdown 编辑默认给 Hermes / Reasonix 授权文件引用,由 agent 使用自身 patch / diff / 文件编辑能力写本地 `.md`
- `mnote.doc.markdown_edit` 保留为 cloud / remote agent / compat fallback;模型生成 markdown `search/replace``full_content` 后调用该工具,只适用于 agent 不能直接访问授权文件或需要受控代理写入的场景。
- `page_ai_workflow` 已复用统一 mnote tool executor,不再绕过 Hermes tool toggle / audit / write contract。
- `mnote.doc.apply_block_ops` / `mnote.block.*` 保留为结构性块操作辅助,不再作为普通正文 search/replace 的优先入口。
- 本稿中的 `PageAIReviewSession` 只定义 Phase C 的安全合同和状态机边界;当前 Phase C 仍冻结,不实施流式 apply 或新的审阅 UI。
@@ -4,13 +4,18 @@
>
> 更新时间:2026-05-16v3:深度参考 CLI Main skill 系统,补全成熟度采纳清单)
>
> 2026-05-18 口径补充:
> - local-first workspace 已成为早期产品默认形态;本地 `.md` 是默认 AI 编辑目标,在线 Convex 文档降级为可选 cloud / sync / share source。
> - 本文早期把 `mnote.doc.markdown_edit` 描述为统一主路径;最新口径改为:local-first 普通 Markdown 编辑优先给 agent 授权文件引用,由 agent 使用自身成熟的 diff / apply_patch / 文件编辑能力完成;`mnote.doc.markdown_edit` 保留为 cloud / remote agent / compat fallback。
> - 页面内图片 / 附件上传已在 local source 下写入 `{mdBase}.assets/` 并保存相对 Markdown 路径,AI 后续处理附件引用时也应保留相对路径,不改写为 Convex media asset。
>
> 当前状态:`PROCESS`
>
> 本稿目的:
> 1. 纠正 7-9 / 7-10 / 7-12 / 7-13 中隐含的「块级编辑是 AI 唯一写入路径」假设
> 2. 基于 CLI Main 参考实现,确立 mnote 的「文本级搜索替换为主 + 块级结构性操作为辅」两层模型
> 2. 基于 CLI Main 参考实现,确立 mnote 的「agent 原生文件 patch/diff 为 local-first 默认路径,MNote 文本级兼容工具 + 块级结构性操作为 fallback / 辅助」两层模型
> 3. 规划 BlockNote AI 流式/review 能力的远期方向(当前不实施)
> 4. 统一在线 Convex 文档和本地 `.md` 文件的 AI 写入路
> 4. 统一 cloud / remote / compat 文档和本地 `.md` 文件的 AI 读取、权限、冲突与回读口
>
> 关联文档:
> - `/mnt/Data1T/mnote/design/07-ai/done/7-9-page-block-ai-tooling-roadmap-v1.md`
@@ -37,7 +42,7 @@
## 1. 结论
**在线 Convex 文档和本地 `.md` 文件本质上是同一个东西:一段 markdown 文本,Tiptap 只是块级 UI 表现层。** 当前 07-ai 设计把 AI 编辑契约钉死在 `EditorBlockDocument` 的块级操作上,导致三个连锁问题:
**在线 Convex 文档和本地 `.md` 文件本质上是同一个东西:一段 markdown 文本,Tiptap 只是块级 UI 表现层。** 进一步切到 local-first 后,本地 `.md` 已经是普通文件,因此不需要再为常规正文编辑发明一套 MNote 专用工具。当前 07-ai 设计把 AI 编辑契约钉死在 `EditorBlockDocument` 的块级操作上,导致三个连锁问题:
1. **AI 被迫在块级操作**:对「改写这段话」「补充一段总结」「调整语气」「把所有 TODO 改成 DONE」等自然请求,AI 必须产出 `{ op: "replace", blockId: "block_1", ... }` 格式的块操作,而不能直接产出修改后的文本或搜索替换对。这增加了 AI 的认知负担和出错概率。
@@ -55,15 +60,15 @@
| **BlockNote AI** | 纯块级 `add/update/delete`(依赖 blockId),流式 apply + suggest/review | ✅ **远期参考**:流式/review 能力,Phase C 规划 |
| **Tiptap AI Autocomplete** | 纯文本补全,单句接龙,无工具 | ⏳ 独立功能:内联 AI 补全(非本文讨论范围) |
**CLI Main 最关键的设计决策**:它不把 AI 钉死在某一层。`str_replace` 用于文本级修改(不需要 blockId),`block_*` 用于精确块结构调整。格式上,**XML 用于精确编辑场景,Markdown 用于整篇导入/导出**。mnote 的不同之处在于——在线文档和本地 `.md` 文件的共同分母是 **markdown 而非 XML**,因此我们的主格式是 markdown
**CLI Main 最关键的设计决策**:它不把 AI 钉死在某一层。`str_replace` 用于文本级修改(不需要 blockId),`block_*` 用于精确块结构调整。格式上,**XML 用于精确编辑场景,Markdown 用于整篇导入/导出**。mnote 的不同之处在于local-first 之后,默认对象就是本地 `.md` 文件,因此可以直接复用 Codex / Hermes / Reasonix 自身成熟的 diff、apply_patch、文件编辑能力;MNote 的职责收口为权限沙箱、文件引用解析、审计和刷新
### 正确方向
> mnote 的 AI 编辑路线:**CLI Main 的两层操作模型 + BlockNote AI 的流式/review 能力 + mnote 自己的 markdown 优先策略。**
> mnote 的 AI 编辑路线:**本地授权文件 + agent 原生 patch/diff 为主;MNote 兼容工具为 cloud/remote/结构化辅助;BlockNote AI 的流式/review 只作为远期交互参考。**
具体:
- **当前实施**Phase A/B):`mnote.doc.markdown_edit` 作为 AI 编辑主路径,文本级搜索替换为第一操作原语。`mnote.block.*` 保留为结构辅助。
- **当前实施**Phase A/B):local-first 页面 AI 只传当前文件引用、可选 selection 和用户指令;MNote 校验 `AiAccessScope` 后让 agent 在受限目录中使用原生 patch/diff 编辑 `.md``mnote.doc.markdown_edit` 作为兼容 / 远端代理 fallback`mnote.block.*` 作为复杂结构辅助。
- **远期规划**Phase C):BlockNote AI 的流式增量 apply + suggest/review 层。当前先设计,不实施。
---
@@ -377,36 +382,37 @@ mnote 当前:Hermes 只知道 workspace/document 模型,不知道本地文
AI 最自然的编辑方式是对文本进行操作。块是 UI 概念,不是 AI 概念。在线文档的持久化格式和本地文件的持久化格式都可以投影为 markdown。
### 4.2 两层操作模型
### 4.2 两层操作模型(兼容层)
| 层 | 工具 | 寻址方式 | 适用场景 | 占比 |
|----|------|---------|---------|------|
| **文级(主)** | `mnote.doc.markdown_edit` | search/replace 文本对 | "把这段改简洁"、"把所有 TODO 改 DONE"、"补充一段总结" | 80%+ |
| **文级(主)** | agent 原生 `diff/apply_patch/文件编辑` | 授权文件引用 / selection | local-first 普通 Markdown 改写 | 80%+ |
| **文本级(兼容)** | `mnote.doc.markdown_edit` | search/replace 文本对 | cloud / remote agent / 兼容旧页面 AI | 次要 |
| **块级(辅助)** | `mnote.doc.apply_block_ops` | blockId / matchText | "把第三块拖到第一块后面"、"精确删除引用块" | <20% |
### 4.3 在线和本地共用同一条写入路径
### 4.3 在线和本地的主写入路径
```
mnote.doc.markdown_edit
页面 AI / ACP
→ resolve_source(documentId) → Convex | LocalFS
读取当前 markdown
→ 应用 operations(搜索替换)
→ 写入目标(Convex 或文件系统)
返回 delta
local-first: 传授权文件引用给 agent runtime
→ agent 原生 patch/diff 写入文件
→ MNote 做权限 / 审计 / refresh
cloud / remote fallback: mnote.doc.markdown_edit
```
差异仅存在于 `resolve_source``write_target` 两个 adapter,中间的 markdown 操作逻辑完全共享。这和 CLI Main 的 `docs +update` 可以操作任何 `doc-token` 的设计一致。
差异主要在执行层:local-first 直接让 agent 修改授权文件;cloud 或受限 remote runtime 无法直接访问本地文件时,再走 `mnote.doc.markdown_edit` 代理。这和 CLI Main 的 `docs +update` 可以操作任何 `doc-token` 的设计一致,只是 mnote 进一步把“编辑算法”让渡给 agent runtime
### 4.4 Diff 是内部实现细节
AI **不**产出 unified diff(行号/上下文极易出错),也不调用独立的 diff/patch 工具。AI 产出两种形式之一:
对兼容 `mnote.doc.markdown_edit` 来说,AI **不**产出 unified diff(行号/上下文极易出错),而是产出两种形式之一:
| 形式 | 适用场景 | AI 负担 |
|------|---------|---------|
| `operations: [{ search, replace }]` | 局部修改 | 低:只需找原文片段 |
| `full_content: "..."` | 小文档全文改写 | 低:直接写完整 markdown |
服务端内部做 diff(用于 delta 推送和冲突检测),但 AI 无感知
而在 local-first 主路径中,agent runtime 自己可以安全使用成熟的 diff / apply_patch / 直接文件编辑能力;MNote 只要求这些写入被限制在授权路径内,并把 changed files / diff 摘要收回审计
---
@@ -446,7 +452,7 @@ AI **不**产出 unified diff(行号/上下文极易出错),也不调用
}
```
### 5.2 `mnote.doc.markdown_edit`(新增,主路径
### 5.2 `mnote.doc.markdown_edit`(新增,兼容 / fallback
```json
{
@@ -580,11 +586,13 @@ GhostTextOverlay(新增,编辑器)
## 8. 实施阶段(当前)
### Phase A`mnote.doc.fetch` 增强 + `mnote.doc.markdown_edit` 核心实现
### Phase A文件引用主路径 + `mnote.doc.*` 兼容层
- [x] `mnote.doc.fetch` 增加 `format: "markdown"`(在线文档 Page Aggregate → PageMarkdown
- [x] `mnote.doc.fetch` 增加本地文件 source 路由(自动检测 Convex vs 文件系统路径)
- [x] 实现 `resolve_source(documentId)` — 本地文件路径 `local_fs` vs 其余走 Convex
- [ ] 页面 AI / ACP 普通正文编辑默认只传当前文件引用、可选 selection 和用户指令,不再默认构造完整 page context
- [ ] 本地 agent runtime 在 `allowed_roots / allowed_file_paths` 内执行 patch/diff,并把 changed files / diff 摘要回传 MNote
- [x] 实现 `search_replace(text, operations)` — 四级匹配策略(精确→宽松→段落 fuzzy→失败)
- [x] 实现 Convex 写入 adapter(复用 `doc_apply_block_ops` 链路)
- [x] 实现本地文件写入 adapter`mnote.doc.markdown_edit` 检测到本地文件路径时直接 `fs::write` 写回,不经过 Convex
@@ -599,7 +607,7 @@ GhostTextOverlay(新增,编辑器)
### Phase B`page_ai_workflow.rs` 收口
- [x] 退役 `direct_block_edit_operations`(正则抠「」的快路径,代码保留但路由跳过)
- [x] `/api/page-ai/block-edit-workflow` 底层切换到 `mnote.doc.markdown_edit`
- [x] `/api/page-ai/block-edit-workflow` 不再被当作 local-first 主路径
- [x] 模型 system prompt 重构:从产块操作 JSON 改为产 search/replace 文本对
- [x] 补全 operation schema`extract_markdown_operations_from_model_text` 处理新旧格式
- [x] 浏览器 smoke`markdown_edit` 搜索替换通过,自然语言编辑路径可用
@@ -620,7 +628,7 @@ GhostTextOverlay(新增,编辑器)
| CLI Main 概念 | mnote 对应 |
|--------------|-----------|
| `str_replace`(文本级) | `mnote.doc.markdown_edit`主路径 |
| `str_replace`(文本级) | `mnote.doc.markdown_edit`兼容 / fallback |
| `block_replace/insert_after/delete/move_after`(块级) | `mnote.doc.apply_block_ops`(辅助路径) |
| XML 用于精确编辑 | mnote 不用 XML(没有 XML 存储层),direct path 退役后全部走 markdown |
| Markdown 用于导入/导出/对话引用 | mnote 全部 AI 交互走 markdown |
@@ -640,7 +648,7 @@ GhostTextOverlay(新增,编辑器)
| 设计稿 | 关系 | 修正状态 |
|--------|------|---------|
| 7-9 路线图 | 块级编辑降级为辅助,markdown_edit 为主路径 | ✅ 已修正 |
| 7-9 路线图 | 块级编辑降级为辅助,local-first 普通编辑改为授权文件 + agent patch/diffmarkdown_edit 退到兼容层 | ✅ 已修正 |
| 7-10 checklist | 新增 Phase 9 markdown_edit | ✅ 已修正 |
| 7-12 工具路由 | PageAICommandRouter 主输出改为 markdown_edit | ✅ 已修正 |
| 7-13 EditorRuntimeActor | 补充 markdown_edit 的 delta 适配 | ✅ 已修正 |
@@ -651,10 +659,10 @@ GhostTextOverlay(新增,编辑器)
## 10. 禁止项
- 不删除 `mnote.block.*` 工具(保留为辅助路径)。
- 不让 AI 产出 unified diff(行号/上下文极易出错)
- 不强迫 local-first agent 产出 MNote 自定义 diffHermes / Reasonix 可使用自身成熟 patch / diff / apply_patch 能力,MNote 负责白名单权限、文件版本冲突和审计
- 不要求本地文件有稳定的 `blockId`(本地文件没有 block identity)。
- 不在 markdown_edit 内部引入新的 AI 模型调用(diff 是确定性算法)。
- 不改变 Convex `documents:updateContent` 的持久化链路(markdown_edit 复用现有保存路径
- 不 Convex `documents:updateContent` 重新提升为 local-first 正文主存储;`markdown_edit` 在 cloud / compat 场景可复用受控保存路径。
- 不把 `mnote.page.save` 重新描述为精确编辑主入口(它仍是兜底工具)。
- **不照搬 BlockNote AI 的纯 blockId 寻址模式**(与 mnote 的 markdown 优先策略冲突)。
@@ -663,10 +671,10 @@ GhostTextOverlay(新增,编辑器)
## 11. 成功标准
- [x] `mnote.doc.fetch(documentId, format: "markdown")` 对在线文档返回正确 markdown
- [ ] `mnote.doc.fetch(documentId, format: "markdown")` 对本地 `.md` 文件返回正确 markdown读取已实现,待浏览器 smoke
- [ ] `mnote.doc.fetch(documentId, format: "markdown")` 对本地 `.md` 文件返回正确 markdown兼容路径
- [x] `mnote.doc.markdown_edit` 的简单搜索替换(1 条 operation)浏览器 smoke 通过
- [ ] `mnote.doc.markdown_edit` 的复杂改写(3+ 条 operations)成功率 > 80%
- [ ] 本地 `.md` 文件通过页面 AI 面板可被读取和写入(读取已实现,写入待本地文件 adapter)
- [ ] 本地 `.md` 文件通过页面 AI 面板可被读取和写入,且默认通过 agent 原生 patch/diff 完成
- [x] 在线文档的 markdown_edit 不增加 Convex RTT(和当前块操作持平)
- [x] `direct_block_edit_operations` 已退役(路由跳过,代码保留)
- [x] `page_ai_workflow.rs` 的 system prompt 已补全 search/replace schema
@@ -682,7 +690,7 @@ GhostTextOverlay(新增,编辑器)
| # | CLI Main 模式 | mnote 实施项 | 状态 | 参考文件 |
|---|-------------|-------------|------|---------|
| 1 | 两层操作模型 | `markdown_edit`+ `apply_block_ops`(辅助)已在 7-10 Phase 9 登记 | 📋 设计完成,待实施 | `lark-doc-update.md` |
| 1 | 两层操作模型 | local-first 默认 agent 原生 patch/diff`markdown_edit`compat fallback+ `apply_block_ops`结构辅助)已在 7-10 Phase 9 登记 | 📋 设计完成,已按 2-2 改口径 | `lark-doc-update.md` |
| 2 | Scope 四级控制 | `mnote.doc.fetch` 增加 `scope: full/section/outline/keyword` | 🔜 Phase A | `lark-doc-fetch.md` |
| 3 | Detail 三级控制 | `mnote.doc.fetch` 增加 `detail: simple/with_ids/full` | 🔜 Phase A | `lark-doc-fetch.md` |
| 4 | 片段包装 | fetch 返回中标记 `<!-- fragment -->` / `<!-- excerpt -->` 告知 AI 部分视图 | 🔜 Phase A | `lark-doc-fetch.md`fragment/excerpt 模式) |
@@ -726,7 +734,7 @@ Phase A(当前立即)
├── #3 Detail 三级控制 ← mnote.doc.fetch 增强
├── #4 片段包装 ← fetch 返回值增强
├── #8 Markdown 优先 ← 已确认
└── mnote.doc.markdown_edit 核心实现
└── 授权文件引用 + agent 原生 patch/diff 主路径;mnote.doc.markdown_edit 作为 compat fallback
Phase BPhase A 完成后)
├── #5 Code-Act Loop ← Hermes plugin SKILL.md
@@ -11,9 +11,10 @@
> 4. 复用现有参考代码,最小化重复实现工作
>
> 关联文档:
> - `/mnt/Data1T/mnote/design/07-ai/done/7-5-hermes-client-proxy-contract-v1.md`
> - `/mnt/Data1T/mnote/recycle/design/07-ai/retired-http-hermes/7-5-hermes-client-proxy-contract-v1.md`(已退役历史背景)
> - `/mnt/Data1T/mnote/design/07-ai/process/7-10-page-block-ai-tooling-execution-checklist-v1.md`
> - `/mnt/Data1T/mnote/design/07-ai/process/7-14-online-local-ai-markdown-editing-convergence-v1.md`
> - `/mnt/Data1T/mnote/design/07-ai/process/7-17-acp-session-convex-sharing-contract-v1.md`
> - `/mnt/Data1T/mnote/design/05-editor-mainline/reference-code/hermes-vscode-main/`
> - `/mnt/Data1T/mnote/design/05-editor-mainline/reference-code/DeepSeek-Reasonix-main/`
@@ -35,10 +36,10 @@ mnote-web (Rust Axum)
│ get /api/hermes/runs/{id}/events
├─ hermes_tools.rs (mnote.doc.* / mnote.block.*)
│ └─ Rust 工具实现,通过 HTTP/Convex 读写文档
│ └─ Rust 兼容工具实现,通过 HTTP/Convex 或本地代理读写文档
└─ page_ai_workflow.rs (fast-path 块编辑)
└─ local_rule planner, 不经过 Hermes
└─ page_ai_workflow.rs (兼容门面)
└─ local-first 下不再是主路径
```
**问题:**
@@ -67,15 +68,21 @@ mnote-web (Rust Axum)
│ │ └─ session/update ≫ SSE 转发
│ └─ 代理层:向下游工具通知
├─ hermes_tools.rs (不变)
├─ hermes_tools.rs (兼容层)
│ └─ mnote.doc.* / mnote.block.* / mnote.page.*
└─ page_ai_workflow.rs (不变)
└─ fast-path 块编辑
└─ page_ai_workflow.rs (兼容保留)
└─ local-first 普通正文编辑默认不经过它
```
ACP 是整个架构的支点——它是一个**开放协议**,不是某个产品的私有接口。
> 2026-05-18 local-first 口径补充:
>
> - 目标不是把 Hermes / Reasonix 再包进一层重型 MNote 工具系统,而是让它们尽量像在 VSCode 中那样直接面对授权后的本地工作区。
> - MNote 主要负责:页面定位、白名单目录授权、ACP 会话管理、审计、文件变化同步到 tiptap / File Tree / Page Aggregate。
> - local-first 普通 Markdown 编辑默认不要求 runtime 调 `mnote.doc.markdown_edit`;兼容工具只为 cloud / remote / 复杂结构场景保留。
---
## 2. ACP 协议标准
@@ -216,6 +223,25 @@ profile "reasonix" → runtime "reasonix" (spawn node reasonix-acp-wrapper.mjs)
前端获取可用运行时列表:`GET /api/hermes/client/profiles`(现有接口,扩展字段)
### 4.1.1 白名单目录即 runtime 权限边界
对 local-first 而言,真正重要的不是再做一套“文档工具能力矩阵”,而是把 workspace 白名单准确传给 runtime
```text
登录用户
-> 解析 access-policy.json / owner / admin / grant
-> 得到 allowedRoots = ["/mnt/Data1T/Mnote_data/users/.../my-space", ...]
-> 启动 Hermes / Reasonix 时把 allowedRoots / cwd / read-write scope 传入 runtime
-> runtime 直接在这些目录里工作
```
这与 VSCode / Codex 的工作模型一致:
- runtime 看到的是一个受限 workspace,而不是一堆抽象页面 API。
- 页面 AI 只额外提供“当前文件是谁”以及可选选区信息。
- 对 `.md` 的普通编辑不强制走 `mnote.doc.markdown_edit`
- 一旦文件写回磁盘,MNote 再负责把变化同步回前端显示。
### 4.2 会话生命周期 (ACP Session Manager)
```
@@ -244,9 +270,9 @@ profile "reasonix" → runtime "reasonix" (spawn node reasonix-acp-wrapper.mjs)
### 4.3 工具桥接
当前 `hermes_tools.rs` 中注册的 mnote 工具(`mnote.doc.fetch``mnote.doc.markdown_edit``mnote.block.*``mnote.page.*`)对 ACP 来说只是一组 HTTP 端点
当前 `hermes_tools.rs` 中注册的 mnote 工具(`mnote.doc.fetch``mnote.doc.markdown_edit``mnote.block.*``mnote.page.*`)对 ACP 来说只是一组 HTTP 兼容端点,而不是 local-first 普通 Markdown 编辑的唯一主路径
对于 Reasonix 作为 runtime 的场景,需要一个 Reasonix-side 的工具注册包装脚本,将 mnote 工具注册到 `ToolRegistry`
对于 Reasonix 作为 runtime 的场景,需要一个 Reasonix-side 的工具注册包装脚本,将 mnote 兼容工具注册到 `ToolRegistry`;但 local-first 默认应优先让 runtime 直接拿到授权文件引用,在受限 cwd 中使用自身成熟的 patch/diff/文件编辑能力。
```typescript
// reasonix-acp-wrapper.mjs — ACP 包装层
@@ -287,7 +313,27 @@ agent → tool call → (通过 TCP/localhost HTTP) → mnote-web Rust hermes_to
→ Convex / 文档系统
```
**不需要在 Rust 侧重新注册工具到 Reasonix。** mnote-web 的工具 HTTP 端点 (`/api/hermes/tools/mnote/call`) 不变,只通过 ACP 换掉了 agent runtime。
**不需要在 Rust 侧重新注册工具到 Reasonix。** mnote-web 的工具 HTTP 端点 (`/api/hermes/tools/mnote/call`) 仍可保留,只通过 ACP 换掉了 agent runtime;但这些端点主要承担 cloud / remote agent / compat fallback,而不是把所有本地文件编辑都重新包成 mnote 工具
### 4.3.1 local-first 默认工作流
local-first 页面 AI 的默认工作流应是:
```text
当前页面 URL / documentId
-> MNote 解析出真实 markdown 文件路径
-> MNote 校验该路径是否落在 runtime allowedRoots 白名单内
-> 把 currentFile / selection / allowedRoots 传给 runtime
-> runtime 直接读写该文件
-> watcher / refresh 触发前端 page aggregate 与 tiptap 更新
```
只有在以下情况,runtime 才需要走 mnote 兼容工具:
- runtime 本身无法直接访问本地文件
- 当前 source 是 cloud / sync replica
- 当前对象不是普通 markdown,而是 mindmap / table / 资源块 / 分享受限对象
- 需要显式审计某种结构化操作
### 4.4 前端 SSE 扩展
@@ -570,11 +616,11 @@ AiAgentPanel 增加下拉框 + 切换逻辑:
### 7.2 对 7-14 (markdown 编辑收敛) 的影响
7-14 确立的「两层操作模型」(`mnote.doc.markdown_edit` 主 + `mnote.block.*` 辅)不受影响——工具在 Rust 侧 `hermes_tools.rs` 实现不变。ACP 只是换掉 driver(从 Hermes 换成 Reasonix),不改 driver 调用的工具
7-14 的最新口径是:local-first 普通 Markdown 编辑优先走“授权文件引用 + agent 原生 patch/diff”,`mnote.doc.markdown_edit` / `mnote.block.*` 退到兼容与辅助层。ACP 只是换掉 driver(从 Hermes 换成 Reasonix),不改变这个权限与执行边界
### 7.3 对 `page_ai_workflow.rs` 的影响
不影响。`block_edit_workflow` 作为独立 fast-path 与 ACP 无关
最新口径下,`block_edit_workflow` 只保留为兼容门面;local-first 普通正文编辑不应再依赖它。ACP 主要服务 agent runtime 选择、权限隔离、事件桥接和审计
---
@@ -638,7 +684,7 @@ AiAgentPanel 增加下拉框 + 切换逻辑:
| 改动 | 文件 | 说明 |
|------|------|------|
| ACP 下拉选择器 | `layout.rs` | Agent 标签页新增 `<select data-page-ai-acp-runtime>`3 个选项:默认 (Hermes HTTP)、ACP · HermesACP · Reasonix |
| ACP 下拉选择器 | `layout.rs` | Agent 标签页新增 `<select data-page-ai-acp-runtime>`2026-05-18 起只保留 ACP · Hermes / ACP · Reasonix,移除“默认 (Hermes HTTP)”选项 |
| 状态存储 | `layout.rs` | `pageAiAcpRuntime` + `pageAiAcpRuntimes``/api/hermes/client/profiles` 加载 |
| 运行时切换 | `layout.rs` | `acpRuntime` 只表示运行时/传输层;Hermes ACP 继续保留当前 Hermes profileReasonix ACP 使用 `profile=reasonix` |
| UI 自适应 | `layout.rs` | ACP Hermes 模式下继续显示 Hermes profile 下拉;ACP Reasonix 模式下隐藏 Hermes profile 下拉;Agent 面板显示 ACP 配置 |
@@ -909,7 +955,14 @@ node /home/lix/.codex/skills/page-ai-browser-verify/scripts/verify_mnote_page_ai
### [ ] Step 16:退役旧的 Hermes HTTP proxy 代码
> 待 ACP 路径稳定后执行(至少 1 周灰度观察期)。
> 2026-05-18 已开始执行第一阶段退役:
>
> - 页面 AI 前端默认 `acpRuntime=reasonix`,不再以空 runtime 表示“默认 Hermes HTTP”;用户仍可在下拉中切换到 `ACP · Hermes`
> - mnote-web 服务端默认把 `/api/hermes/client/runs` 的空 `acpRuntime` 归入 ACP 默认 runtime(默认 `reasonix`),避免继续落到 `configured_upstream_for_profile()` 的 HTTP proxy 分支。
> - `GET /api/hermes/client/gateway/health` 在 ACP 默认路径下返回 ACP transport 状态,不再探测 `8642/8644` HTTP gateway。
> - 旧 Hermes HTTP proxy 合同与 `/v1/runs` 默认主链设计稿已移入 `recycle/design/07-ai/retired-http-hermes/`,避免干扰后续 ACP 主线判断。
>
> 剩余工作:删除或进一步隔离 `hermes_client.rs` 内的 HTTP proxy 兼容分支;当前阶段只保留显式兼容开关,避免一次性删除影响 profile/memory/tools 管理能力。
### [ ] Step 17:基准测试 — Reasonix 缓存收益量化
@@ -17,7 +17,8 @@
本文只承接这些剩余 smoke,不新增 AI 功能面,不改变当前主路径:
- 简单正文编辑主路径仍是 `mnote.doc.markdown_edit`
- local-first 普通 Markdown 编辑主路径仍是“授权文件引用 + agent 原生 patch/diff + watcher 同步”;本文只验证 mnote tools 的结构性辅助和 compat fallback
- `mnote.doc.markdown_edit` 只作为 cloud / remote agent / compat fallback 的 smoke 对象。
- `mnote.block.*` 仍只作为结构性辅助。
- `mnote.page.save` 仍只作为页面级粗粒度兜底。
- Phase C review / streaming apply 仍冻结,当前只验证基础合同和安全边界。
@@ -0,0 +1,477 @@
# 7-17 [process] ACP Session 与控制面账号作用域 / 分享合同 v1
> 创建时间:2026-05-18
>
> 当前状态:`PROCESS / FUTURE`
>
> 本稿目的:
> 1. 明确 ACP session 必须绑定当前账号权限与控制面作用域,不能以内存态绕过权限。
> 2. 冻结后续“复制会话 / 只读分享 / 多人共同会话”的产品语义,避免当前实现误扩。
> 3. 区分 MNote 产品层 AI session 与 ACP Hermes / ACP Reasonix 执行层 session。
> 4. 为后续项目基本完成后扩展共享能力预留 schema、API 和验证边界。
>
> 关联文档:
> - `/mnt/Data1T/mnote/design/07-ai/process/7-15-page-ai-acp-agent-runtime-unified-layer-v1.md`
> - `/mnt/Data1T/mnote/design/07-ai/done/7-25-acp-session-runtime-enhancement-plan-v1.md`
> - `/mnt/Data1T/mnote/design/07-ai/process/7-14-online-local-ai-markdown-editing-convergence-v1.md`
> - `/mnt/Data1T/mnote/recycle/design/07-ai/retired-http-hermes/`
---
## 0. 当前结论
ACP 默认主链已经替代旧 Hermes HTTP 主链,但这不意味着 ACP runtime session 可以成为产品层会话真相。
当前必须成立的规则:
- **控制面持有产品层 AI session 的账号作用域真相;在当前实现里它可以暂时落在 Convex,但长期不应把“Convex”写死成唯一真相。**
- **ACP runtime session 只是执行层会话**,可以被控制面 session 绑定或索引,但不能直接作为跨用户共享对象。
- **ACP session 记录必须带 `userId / workspaceId / documentId / sessionId / runId / actorId / acpRuntime / profile`**
- **写入控制面 session store 失败时,不允许静默降级为可继续写入的内存 session**。这会破坏账号隔离、审计和后续分享语义。
- 当前只实现单用户页面 AI session 基础路径;复制、分享、多人共同会话全部是后续功能,不在当前阶段实施。
---
## 1. 术语边界
### 1.1 MNote AI Session
MNote AI Session 是产品层会话对象,长期应该由控制面持有;当前实现可以暂存在 Convex,但目标不是把消息全文和权限真相永久绑死在 Convex。
它回答:
- 谁能看到这段 AI 会话?
- 这段会话属于哪个 workspace / document
- 它是否可被复制、分享、共同编辑?
- 每条 run 是哪个账号触发的?
- 哪些消息、工具调用、结果可以被其他用户看到?
MNote AI Session 的 id 可以稳定暴露给前端,例如 `mnote_{documentId}_{traceId}`,但它不是 ACP runtime 原生 session id。
### 1.2 ACP Runtime Session
ACP Runtime Session 是执行层对象,由 Hermes ACP 或 Reasonix ACP 子进程持有。
它回答:
- 某个 agent runtime 当前 prompt 属于哪个协议 session
- 运行时如何接收 `session/prompt`
- `session/update` 事件如何返回?
- 运行时内部是否有本地上下文、缓存、memory、tool state
ACP Runtime Session 不应作为产品层共享真相。它可以失效、重建、按用户隔离、按 runtime 隔离。
### 1.3 Run / Event / Tool Audit
Run 是一次用户触发的执行。
Event 是 runtime 或 mnote tool 在 run 中产生的结构化事件。
Tool Audit 是 mnote 侧工具执行与写入结果的审计记录,必须以触发账号为准,而不是 session owner 或 runtime owner。
---
## 2. 当前实现边界
当前代码可接受的最小边界:
- `POST /api/hermes/client/sessions` 在 ACP 默认路径下创建 Convex 侧 runtime session 索引。
- `POST /api/hermes/client/runs` 创建 run 并持久化到 Convex runtime store。
- `GET /api/hermes/client/events/{runId}` 将 ACP event 转成页面 SSE,并追加 runtime event。
- `GET /api/hermes/client/gateway/health` 在 ACP 默认路径下返回 `transport=acp`,不再探测旧 Hermes HTTP gateway。
当前不应声称已完成:
- 跨账号共享会话。
- 多人共同编辑同一个 AI session。
- 复制会话后的上下文重建。
- 完整消息历史作为产品层真相。
- Hermes ACP 与 Reasonix ACP 的统一长期 resume 语义。
- 分享链接、权限继承、脱敏导出。
---
## 3. 数据模型草案
### 3.1 `ai_sessions`
后续需要从当前 `acp_runtime_runs` 中抽出产品层 session 表。默认建议本地全文 + 控制面 metadata 的双层模型:
- 会话全文默认落本地 `ai-sessions/private/*.jsonl``ai-sessions/shared/*/*.jsonl`
- 控制面只保存 metadata、分享关系、审计索引、同步状态
- 只有显式开启同步或分享时,才把必要副本推到远端
建议字段:
| 字段 | 说明 |
|---|---|
| `id` | 控制面记录 id |
| `session_id` | MNote 产品层 session id |
| `owner_user_id` | 会话创建者 |
| `workspace_id` | 所属 workspace,可为空但必须显式 |
| `document_id` | 所属页面 / 文档 |
| `title` | 会话标题 |
| `visibility` | `private` / `workspace_read` / `shared_link` / `collaborative` |
| `source` | `page_ai` / `local_file_ai` / 其他 |
| `created_at` / `updated_at` / `deleted_at` | 生命周期 |
权限规则:
- 默认 `private`
- 任何 query/mutation 必须先按当前控制面 auth 解析 user,再判断 `owner_user_id`、membership 或 share grant。
- 不允许只凭客户端传入的 `userId` 授权。
### 3.2 `ai_session_members`
多人共同会话需要独立成员表,不应把共享用户塞进 session JSON。
| 字段 | 说明 |
|---|---|
| `session_id` | 产品层 session |
| `user_id` | 成员 |
| `role` | `owner` / `editor` / `commenter` / `viewer` |
| `added_by` | 添加者 |
| `created_at` | 加入时间 |
| `revoked_at` | 撤销时间 |
角色规则:
- `viewer` 只能读可共享消息和可共享 artifact。
- `editor` 可以继续发起 run,但每次 run 的 `actor_id` 必须是本人。
- `owner` 可以管理成员和删除会话。
### 3.3 `ai_runtime_bindings`
产品层 session 与运行时 session 的绑定必须独立建模。
| 字段 | 说明 |
|---|---|
| `binding_id` | 绑定 id |
| `session_id` | MNote 产品层 session |
| `run_id` | 当前 run,可选 |
| `user_id` | 运行时所属用户 |
| `runtime` | `hermes` / `reasonix` |
| `profile` | Hermes profile 或 Reasonix preset |
| `runtime_session_id` | ACP 对端 session id |
| `status` | `active` / `closed` / `expired` / `failed` |
| `created_at` / `expires_at` | 生命周期 |
关键规则:
- 同一个 MNote session 可以有多个 runtime binding。
- 不同用户默认不能复用同一个 runtime binding。
- 切换 Hermes / Reasonix runtime 时必须创建新 binding。
- binding 是执行层缓存,不是会话权限真相。
### 3.4 `ai_session_messages`
后续如需完整历史,应单独建消息表,而不是只依赖 runtime events。
| 字段 | 说明 |
|---|---|
| `message_id` | 消息 id |
| `session_id` | 产品层 session |
| `run_id` | 关联 run |
| `actor_user_id` | 触发者,可为空仅限系统消息 |
| `role` | `user` / `assistant` / `tool` / `system` |
| `content` | 可展示文本 |
| `visibility` | `private` / `members` / `owner_only` |
| `redaction` | 脱敏状态 |
| `created_at` | 创建时间 |
当前阶段可以先不落完整 messages,但必须保证已有 run/event store 能按 user 过滤。
### 3.5 `ai_session_events`
Runtime event 和 tool event 应保留原始结构,但查询时必须做权限过滤。
| 字段 | 说明 |
|---|---|
| `event_id` | 事件 id |
| `session_id` | 产品层 session |
| `run_id` | run |
| `actor_user_id` | 触发者 |
| `runtime` | `hermes` / `reasonix` |
| `event_type` | `message.delta` / `tool.started` / `tool.completed` 等 |
| `payload` | 原始或规范化 payload |
| `visibility` | 默认 `owner_only`,明确脱敏后才可扩大 |
| `created_at` | 创建时间 |
---
## 4. 权限模型
### 4.1 单用户私有会话
当前阶段只要求这个模式稳定。
规则:
- 创建 session 时使用当前 Convex Auth 解析出的真实 user id。
- `userId` 只可作为服务端派生字段,不可由浏览器任意指定。
- run / event / tool audit 都必须与同一 actor 绑定。
- 当前请求无法解析用户时,应返回 `401` 或稳定错误,而不是创建匿名共享 session。
### 4.2 复制会话
复制不是共享同一个 runtime session。
复制语义:
- 生成新的 `session_id`
- 新 session 的 `owner_user_id` 是复制者。
- 可以复制已脱敏、可共享的消息、摘要、artifact 引用。
- 不复制底层 `runtime_session_id`
- 不复制另一个用户的 Hermes profile memory、Reasonix cache handle、tool permission state。
- 复制后第一次继续对话时,为复制者创建新的 runtime binding。
适用场景:
- 用户把某段 AI 分析作为模板继续改。
- 从只读分享页复制到自己的 workspace。
### 4.3 只读分享
只读分享只读产品层 session 的可共享投影。
必须隐藏:
- 本地路径、profile 配置路径、API key 状态。
- tool args 中含有的敏感字段。
- 未授权页面内容、选区内容、私有 workspace 信息。
- actor 的内部 user id,除非产品明确展示成员身份。
分享链接不能恢复 ACP runtime session。
### 4.4 多人共同会话
多人共同会话是后续能力,不能直接复用当前 runtime session。
推荐语义:
```text
共享 MNote AI Session
-> 每个用户按自己的权限发起 run
-> 每次 run 记录 actor_user_id
-> runtime binding 默认按 actor_user_id 隔离
-> 前端展示同一个产品层 transcript
```
允许的实现策略:
- **每用户 runtime binding**:最安全,默认方案。每个成员继续对话时由自己的 runtime 处理。
- **共享 transcript 重建上下文**:runtime 不共享,只把已授权 transcript 作为 prompt context 注入。
- **共享 runtime binding**:默认禁止。只有当 runtime 明确是服务端多租户安全实例,并且 tool permission 已按 actor 隔离时才可考虑。
---
## 5. ACP Hermes 与 ACP Reasonix 差异
### 5.1 ACP Hermes
特点:
- Hermes profile、memory、skills、tools 往往绑定本机用户配置。
- `mnoteai` profile 可能包含特定用户偏好、工具开关、记忆文件。
- Hermes ACP 的底层 session 适合“当前用户私有 agent runtime”。
规则:
- 默认按用户隔离 runtime binding。
- 共享会话不能让其他用户复用 owner 的 Hermes profile memory。
- 复制会话时只复制可展示 transcript,不复制 Hermes profile 状态。
- 如果后续允许团队 Hermes profile,必须单独建 workspace-level profile 权限模型。
### 5.2 ACP Reasonix
特点:
- Reasonix 强调 cache-first / preset / project cache。
- 缓存可能跨 prompt 复用,隐私边界比普通 stateless model 更敏感。
- Reasonix 的 session 与 cache handle 不应默认跨账号共享。
规则:
- 默认按 `userId + workspaceId + documentId + preset` 隔离缓存可见性。
- 分享 transcript 不等于分享 Reasonix cache。
- 多人共同会话如果要共享 Reasonix 缓存,必须先设计 cache grant。
- 只读分享不得暴露 cache hit/miss 细节,除非确认无隐私风险。
### 5.3 统一抽象
MNote 不应把 Hermes ACP / Reasonix ACP 的内部 session 当成统一真相。
统一层只定义:
- 产品层 session。
- run 与 actor。
- runtime binding。
- event/message 投影。
- 权限与分享合同。
---
## 6. API 草案
当前阶段不实现以下 API,只冻结形状。
### 6.1 Session
```http
GET /api/hermes/client/sessions
POST /api/hermes/client/sessions
GET /api/hermes/client/sessions/{sessionId}
DELETE /api/hermes/client/sessions/{sessionId}
POST /api/hermes/client/sessions/{sessionId}/rename
POST /api/hermes/client/sessions/{sessionId}/auto-title
```
要求:
- 所有接口服务端解析当前用户。
- 查询默认只返回当前用户有权限访问的 session。
- 删除默认软删除,不删除 runtime audit。
### 6.2 复制
```http
POST /api/hermes/client/sessions/{sessionId}/copy
```
请求:
```json
{
"targetWorkspaceId": "ws_1",
"targetDocumentId": "doc_2",
"copyMode": "summary_and_visible_messages"
}
```
响应:
```json
{
"ok": true,
"sessionId": "mnote_doc_2_copy_1",
"sourceSessionId": "mnote_doc_1_original",
"runtimeBindingCopied": false
}
```
### 6.3 分享
```http
POST /api/hermes/client/sessions/{sessionId}/shares
GET /api/hermes/client/sessions/{sessionId}/shares
DELETE /api/hermes/client/sessions/{sessionId}/shares/{shareId}
```
分享 grant 必须包含:
- `scope`: `user` / `workspace` / `link`
- `role`: `viewer` / `editor`
- `expiresAt`
- `redactionPolicy`
### 6.4 多人会话成员
```http
POST /api/hermes/client/sessions/{sessionId}/members
GET /api/hermes/client/sessions/{sessionId}/members
DELETE /api/hermes/client/sessions/{sessionId}/members/{userId}
```
---
## 7. 不变量
这些规则后续实现必须写成测试。
1. 账号 A 创建的 private session,账号 B 不能读取列表、详情、事件、消息。
2. 账号 B 复制账号 A 分享给他的 session 后,得到新的 `sessionId` 和新的 owner。
3. 复制后的 session 不包含源 session 的 `runtime_session_id`
4. 多人共同会话中,账号 B 发起 run 时 `actor_user_id=B`,不能写成 owner A。
5. 只读 viewer 不能发起 run。
6. Hermes profile memory 不随 session 分享。
7. Reasonix cache handle 不随 session 分享。
8. tool args 默认 `owner_only`,只有经过脱敏的摘要可进入 shared transcript。
9. Convex store 写入失败时,run 创建必须失败或返回明确可恢复错误,不能静默创建匿名内存会话。
10. 本地文件 AI session 分享必须重新核验目标用户是否能访问对应 local root;默认不支持跨用户分享本地文件内容。
---
## 8. 实施阶段
### Phase A:当前阶段,只做约束固化
状态:当前主线。
- ACP 默认主链可用。
- session/run/event 必须账号作用域写入 Convex。
- 旧 Hermes HTTP 主链进入 `recycle`
- 不实现复制、分享、多人会话。
- 文档与测试明确禁止内存降级绕过权限。
### Phase B:项目基本完成后,补产品层 session 表
目标:
- 增加 `ai_sessions` / `ai_session_members` / `ai_runtime_bindings`
- 将现有 `acp_runtime_runs` 从“运行态索引”升级为 session 下的 run 记录。
- UI 从 localStorage 历史逐步迁到 Convex session 列表。
### Phase C:复制与只读分享
目标:
- 实现 session copy。
- 实现只读分享投影。
- 建立脱敏策略。
- 不实现多人共同编辑。
### Phase D:多人共同会话
目标:
- 成员管理。
- 多 actor transcript。
- 每 actor runtime binding。
- 共享上下文重建策略。
### Phase Eruntime-specific 高级策略
目标:
- Hermes team profile 权限模型。
- Reasonix cache grant / cache visibility。
- workspace-level AI runtime policy。
---
## 9. 当前代码注意事项
当前代码中 `acp_runtime_runs` / `acp_runtime_events` 仍是运行态索引,不是完整产品层 session 模型。
因此后续修改时:
- 不要把 `ACP_RUN_PAYLOADS``ACP_ACTIVE_RUNS` 视为权限真相。
- 不要因为 Convex 写入失败就退回“可继续写”的内存 session。
- 不要把 `profile=mnoteai` 当成 user identity。
- 不要让 `acpRuntime=hermes` 自动表示可访问 Hermes profile memory;仍需当前账号授权。
- 不要把 Reasonix cache 命中结果写入共享 transcript,除非经过脱敏和权限确认。
---
## 10. 验收清单
后续实现本稿时,至少需要以下验证:
- 两账号隔离 smokeA 创建 sessionB 列表不可见。
- 分享 smokeA 授权 B viewerB 可读脱敏 transcript,不可 run。
- editor smokeA 授权 B editorB 可 runevent actor 为 B。
- copy smokeB 复制 A 的分享 session,产生新 session,新 owner 为 B。
- runtime binding smoke:复制后没有复用源 runtime session id。
- Hermes ACP smoke:共享后不暴露 owner profile memory。
- Reasonix ACP smoke:共享后不暴露 cache handle。
- 权限失败 smokeConvex auth 缺失或 user 不匹配时,API 返回 401/403,不创建内存会话。
@@ -2,6 +2,11 @@
> 更新:2026-05-18v2:整合 CLI Main 参考实现分析,确认方向,补充见解)
>
> 2026-05-19 local-first 口径补充:
> - 本文仍适用于 `convex_workspace` / 在线文档的 `mnote.doc.markdown_edit` 修复,但当前默认产品形态已切到 local-first workspace。
> - 本地 `.md` 路径的默认 AI 编辑主路径是“授权文件引用 + agent 原生 patch/diff + watcher 同步”;`mnote.doc.markdown_edit` 只作为本地受控代理 fallback、cloud / remote agent 或 compat 路径。
> - 后续新增 AI 编辑能力默认先保证本地 `.md``{mdBase}.assets/` 相对路径不被改写为 Convex media asset;在线 Convex 文档路径只作为可选 cloud / sync / share source。
>
> 当前状态:`PROCESS`
>
> 关联缺陷:`bugs/07-ai/done/7-24-markdown-edit-online-write-does-not-use-final-markdown-v1.md`
@@ -201,21 +201,22 @@
## 6. 不建议照搬的部分
## 6.1 不照搬 `.sy + 工作空间文件` 作为系统真相层
## 6.1 不照搬 `.sy + 工作空间文件` 作为系统对象真相层
思源的数据组织很适合它自己的本地优先单机模型,但 `mnote` 当前已经明确:
- `tree-first graph kernel` 是长期对象真相层
- `Convex` 继续保留为当前存储 / 实时 / 文件协作底座
- `tree-first graph kernel` 是长期对象语义真相层
- local-first workspace 与本地 `.md` 是早期产品默认数据面和正文真相
- `Convex` / 服务端降级为鉴权、分享、同步、协作、cloud source、compat 和 sync replica 控制面
- `mnote-web` 与 Rust kernel 持有主执行语义
因此后续即使增加本地文件夹能力,也不能把:
因此后续不能照搬思源 `.sy` 对象格式,也不能把:
- 工作空间目录结构
- 任意工作空间目录结构
- 导出文件形状
- 调试缓存
误提升为新的系统真相层。
误提升为新的系统对象真相层。正确边界是:Markdown 文件持有正文真相,Rust kernel 持有树 / 资源 / 权限 / projection 语义,控制面只负责授权和同步边界。
## 6.2 不把 SQL 暴露成长期核心产品契约
@@ -0,0 +1,145 @@
# Wolai 基础功能 ↔ mnote 实现完整对照矩阵
> 生成时间:2026-05-19
> 说明:逐项对照 Wolai 帮助中心基础功能和当前 mnote 代码,每项标注 ✅/❌/🔧 并附证据路径
## 一、基础操作
| # | Wolai 功能 | 状态 | mnote 证据 |
|---|-----------|------|-----------|
| 1 | **基本编辑**(输入/换行/方向键) | ✅ | leptos-tiptap island ProseMirror 编辑器 |
| 2 | **块概念**Block Editor 模式) | ✅ | `EditorBlockType` 枚举定义 15 种块(`core-protocol/src/editor/model.rs:7-22`|
| 3 | **拖动块**:: 手柄拖拽) | ✅ | `HANDLE_SHELL_SELECTOR` / `DragEvent``lib.rs:45, 136`|
| 4 | **选中块**Esc/方向键/Shift多选) | ✅ | `TiptapSelectionState` 选择状态(`lib.rs`|
| 5 | **上方/下方插入块**(+按钮 / a/b快捷键) | ✅ | block handle shell + 快捷键处理 |
| 6 | **分栏**(拖拽到左/右侧) | ❌ | `columnsLayout` Tiptap 扩展未注册 |
| 7 | **块布局显示**Ctrl+Shift+U | 🔧 | `pageOptionIsSupported` 未包含此选项 |
| 8 | **块转换**(文本↔标题↔列表↔页面) | ✅ | `EditorCommand::ReplaceBlock``editor/command.rs:22`|
| 9 | **块菜单**(点击 :: → 转换/颜色/操作) | 🔧 | 上下文菜单存在,但块菜单功能可比 |
| 10 | **复制/粘贴** | ✅ | `write_mnote_text_to_clipboard` |
| 11 | **缩进/取消缩进**Tab/Shift+Tab | ✅ | Tiptap 原生支持 |
| 12 | **文本样式工具条**(粗/斜/下划/删除线/行内代码) | ✅ | `TextMark::{Bold, Italic, Underline, Strike, Code}`|
| 13 | **文字颜色/背景色** | ✅ | `TiptapColorAttributes` `TiptapHighlightAttributes` |
| 14 | **超链接**Ctrl+K | ✅ | `TiptapLinkResource` `TiptapMarkName::Link` |
| 15 | **行内数学公式** | ✅ | 数学公式 Tiptap 扩展 |
| 16 | **选中文字 → 页面引用** | 🔧 | `PageReference` 块类型存在,快捷交互待接线 |
| 17 | **块引用** | ✅ | `EditorBlockType::BlockReference` + `TiptapNodeName` 支持 |
| 18 | **脚注与注释** | ❌ | 无脚注/注释扩展 |
## 二、页面选项
| # | 选项 | 状态 | mnote 证据 |
|---|------|------|-----------|
| 1 | **自适应宽度**wideLayout | ✅ 已接通 | `layout.rs:531`: `maxWidth = wideLayout ? '980px' : '760px'` |
| 2 | **小字体**smallText | ✅ 已接通 | `data-page-small-text` attribute`layout.rs:529`|
| 3 | **标题目录**showToc | 🔧 UI可见待接线 | `layout.rs:438` 说"当前 Rust 壳还没有正式目录面板" |
| 4 | **标题自动编号**showHeadingNumbers | ✅ 已接通 | `layout.rs:439` hint: "标题前显示顺序编号" |
| 5 | **编辑保护**protectEditing | 🔧 UI可见待接线 | `layout.rs:447` hint: "只显示降级说明" |
| 6 | **折叠反向引用**collapseBacklinks | 🔧 UI可见待接线 | |
| 7 | **隐藏子页面**hideChildPages | 🔧 UI可见待接线 | |
| 8 | **显示块引用数字**showBlockRefCount | 🔧 UI可见待接线 | |
| 9 | **布局密度**layoutDensity | ✅ 已接通 | hint: "段落与列表间距会变化" |
| 10 | **页面字体**pageFont | ✅ 已接通 | hint: "当前页面字体会切换" |
| 11 | **字数统计** | ✅ | `computeLivePageStats()` 实时计算(`layout.rs:485-507`|
| 12 | **待办统计** | ✅ | `todoTotal` / `todoDone` 统计(`layout.rs:503-506`|
| 13 | **暗色模式** | ✅ | Good Night 模式 / 跟随系统(`layout.rs`|
| 14 | **删除页面**(进垃圾桶) | ✅ | `tree.resource.archive` |
| 15 | **移动到...** | ✅ | `tree` 命令 `routes/tree.rs` |
| 16 | **页面引用链接复制** | ✅ | 上下文菜单"复制页面引用链接"(`layout.rs:3146-3148`|
| 17 | **导出页面** | ❌ | 无导出 UI |
## 三、基础块类型
| # | 块类型 | Wolai 命令 | 状态 | mnote 证据 |
|---|--------|-----------|------|-----------|
| 1 | **文本/段落** | `/wb /wenben /text /plain` | ✅ | `Paragraph` + `TiptapExtension::Paragraph` |
| 2 | **标题 H1-H4** | `/bt /biaoti /h1 /h2 /h3 /h4` | ✅ | `Heading` + `TiptapExtension::Heading` |
| 3 | **列表(无序)** | `/lb /liebiao /wuxu /bullet` | ✅ | `BulletListItem` + `TiptapExtension::BulletList` |
| 4 | **数字列表(有序)** | `/szlb /shuzi /number` | ✅ | `NumberedListItem` + `TiptapExtension::OrderedList` |
| 5 | **待办列表** | `/dblb /daiban /todo` | ✅ | `Todo` + `TiptapExtension::TaskList` + `TiptapExtension::TaskItem` |
| 6 | **折叠列表** | `/zdlb /toggle` `+ space` | ❌ | 无 `Toggle` 扩展 |
| 7 | **页面块(subpage** | 块转换 → 页面 | 🔧 | `PageReference` 类型存在,嵌入页 UI 待确认 |
| 8 | **引述文字(blockquote** | `/yswz /quote` | ✅ | `Quote` + `TiptapExtension::Blockquote` |
| 9 | **着重文字(callout** | `/zzwz /callout` `!! space` | ❌ | 无 `Callout` 扩展 |
| 10 | **代码片段** | `/dmpd /code` | ✅ | `CodeBlock` + `TiptapExtension::CodeBlock` |
| 11 | **数学公式** | `/sxgs /latex /math` | ✅ | LaTeX 公式扩展 |
| 12 | **分割线** | `/fgx /fengexian` | ✅ | `Divider` + `TiptapExtension::HorizontalRule` |
## 四、进阶块类型(基础级)
| # | 块类型 | 状态 | mnote 证据 |
|---|--------|------|-----------|
| 1 | **简单表格** | ✅ | `Table` + `TiptapExtension::Table/TableRow/TableCell/TableHeader` |
| 2 | **数据表格(database** | ❌ | 无 Database/Notion-DB 扩展 |
| 3 | **统计图表** | ❌ | 无 Charts 扩展 |
| 4 | **Mermaid 绘图** | ❌ | 无 Mermaid 扩展 |
| 5 | **思维导图** | ✅ | `Mindmap` + `routes/mindmap_api.rs` + `ssr/pages/mindmap.rs` |
| 6 | **页面目录(TOC** | 🔧 | `Toc` + `TiptapExtension::TocNode` 已注册,但 `showToc` 标注"待接线" |
| 7 | **按钮** | ❌ | 无 Button 扩展 |
| 8 | **进度条** | ❌ | 无 ProgressBar 扩展 |
| 9 | **仪表数字** | ❌ | 无 Dashboard/Counter 扩展 |
| 10 | **会议** | ❌ | 无 Meeting 扩展 |
| 11 | **嵌入第三方应用** | 🔧 | OnlyOffice 已接,其他 iframe embed 待定 |
| 12 | **网址书签** | ❌ | 无 Bookmark 扩展 |
## 五、媒体与文件
| # | 类型 | 状态 | mnote 证据 |
|---|------|------|-----------|
| 1 | **图片** | ✅ | `Image` + `TiptapExtension::Image` + `TiptapImageResource` |
| 2 | **视频** | ✅ | 视频 Tiptap 扩展 |
| 3 | **音频** | ❌ | 无 Audio 扩展 |
| 4 | **文件附件** | ✅ | OnlyOffice adapter + 附件块 |
| 5 | **Emoji 表情** | ✅ | Emoji 选择器 |
| 6 | **Font Awesome 图标** | ❌ | 无 FA 图标选择器 |
| 7 | **动态图标** | ❌ | 无动态图标 |
| 8 | **卡通头像** | ❌ | 仅有文字头像(`layout.rs:7873`: `"L"`|
## 六、编辑器 UI / 壳
| # | 功能 | 状态 | mnote 证据 |
|---|------|------|-----------|
| 1 | **侧栏**(品牌/搜索/页面树) | ✅ | `layout.rs:7801`: "左侧栏 + 内容区的双栏布局" |
| 2 | **浮动 AI 按钮** | ✅ | `wolai-floating-ai` button`layout.rs:7927`|
| 3 | **浮动帮助按钮** | ✅ | `wolai-floating-help` button`layout.rs:7926`|
| 4 | **搜索模态**Ctrl+P | ✅ | 搜索壳 + smoke 验证 |
| 5 | **垃圾桶入口** | ✅ | 侧栏下方入口 |
| 6 | **模板中心** | ❌ | 无模板功能 |
| 7 | **右侧边栏(双栏编辑)** | 🔧 | 上下文菜单有"在右侧边栏打开",面板 UI 待确认 |
| 8 | **今日速记/日记** | ❌ | 无每日速记功能 |
| 9 | **页面关系图** | ❌ | 无关系图可视化 |
| 10 | **导航历史** | 🔧 | 页面上下文菜单含"块历史",但非历史列表 |
## 七、键盘快捷键
| # | 快捷键 | 功能 | 状态 |
|---|--------|------|------|
| 1 | `Ctrl+P` | 搜索 | ✅ |
| 2 | `Ctrl+Shift+L` | 标题目录 | 🔧(选项UI可见但功能待接线)|
| 3 | `Ctrl+Shift+U` | 显示块结构 | ❌ |
| 4 | `Ctrl+Shift+C` | 编辑保护 | 🔧 |
| 5 | `Ctrl+Z` | 撤回 | ✅ |
| 6 | `Ctrl+Shift+V` | 匹配样式粘贴 | ✅ |
| 7 | `Tab` / `Shift+Tab` | 缩进/取消缩进 | ✅ |
| 8 | `Esc` | 选中块 | ✅ |
| 9 | `a` / `b` | 上方/下方插入块 | ✅ |
| 10 | `Enter`(思维导图)| 插入同级节点 | ✅ |
| 11 | `Alt+O` | 右侧边栏打开 | 🔧 |
| 12 | `Alt+Q` | 打开/关闭右侧边栏 | ❌ |
| 13 | `Ctrl+Alt+G` | Good Night 暗色模式 | ✅ |
| 14 | `Ctrl+` | 今日速记 | ❌ |
## 八、汇总
| 类别 | 总数 | ✅ | 🔧 | ❌ | 覆盖率 |
|------|------|----|-----|-----|--------|
| 基础操作 | 18 | 11 | 3 | 4 | 61% |
| 页面选项 | 17 | 10 | 5 | 2 | 59% |
| 基础块类型 | 12 | 9 | 1 | 2 | 75% |
| 进阶块类型 | 12 | 2 | 2 | 8 | 17% |
| 媒体与文件 | 8 | 4 | 0 | 4 | 50% |
| 编辑器 UI/壳 | 10 | 6 | 2 | 2 | 60% |
| 键盘快捷键 | 14 | 8 | 3 | 3 | 57% |
| **总计** | **91** | **50** | **16** | **25** | **55%** |
> 注:🔧 = 存在代码/UI 但功能未完全接通(如标题目录 TOC 扩展已注册但 UI 面板未挂接)
+131
View File
@@ -0,0 +1,131 @@
# Wolai 帮助中心 — 基础功能参考
> 来源:https://www.wolai.com/wolai/xhqeop8UHpVTMUSVmgz8nq
> 抓取时间:2026-05-19
> 用途:作为 mnote 产品功能对标的参考目录,仅记录基础功能/架构相关条目,跳过高级功能、团队协作、商业信息
## 基础操作
| 功能 | 说明 | mnote 现状 |
|------|------|------------|
| 基本编辑能力 | 文本输入、换行、拖拽块排序、缩进/取消缩进、Copy/Paste | ✅ leptos-tiptap island |
| 快捷命令菜单 | `/` 唤出块类型选择菜单 | ✅ Tiptap slash command |
| 使用模板创建页面 | 新建页面时选择模板 | ❌ 未实现 |
| 页面选项 | 页面设置:图标、封面、字体、宽度、评论、历史、分享 | Partial - Page Aggregate 设置链已通但未完全对齐 |
| 搜索工作空间 | 全局搜索,Ctrl+P/Cmd+P | ✅ Search modal 已有 smoke |
| 导入与导出 | 导入/导出数据 | ❌ 未实现(Local/Cloud 桥接仅 P3 candidate |
| 页面关系图 | 页面间引用关系可视化 | ❌ 未实现 |
| 个人设置 | 账号、主题(亮/暗)、语言、通知偏好 | Partial - 暗色模式存在 |
| 空间设置 | Workspace 级设置:名称、成员、权限 | ❌ 未实现 |
| 锁定屏幕 | 密码保护屏幕 | ❌ 未实现 |
| 垃圾桶 | 已删除页面/资源的恢复和永久删除 | ✅ 已实现(`tree.resource.*`),双浏览器 no-refresh 进行中 |
## 基础块类型
| 块类型 | 说明 | mnote 现状 |
|--------|------|------------|
| 文本 | 普通正文段落 | ✅ |
| 标题 | H1/H2/H3 | ✅ |
| 列表 | 无序列表(bullet list | ✅ |
| 数字列表 | 有序列表(numbered list | ✅ |
| 待办列表 | 复选框列表(todo/checkbox | ✅ |
| 折叠列表 | 可折叠/展开的列表项(toggle / details | ❌ 未实现 |
| 页面 | 嵌入子页面块(page block / subpage | Partial - Tiptap page block? |
| 引述文字 | Blockquote 引用块 | ✅ |
| 着重文字 | Callout / 高亮块 | ❌ 未实现 |
| 代码片段 | 代码块(code block | ✅ |
| 数学公式 | LaTeX 数学公式 | ✅ |
| 分割线 | Horizontal rule | ✅ |
## 进阶块类型(基础级引用)
| 块类型 | 说明 | mnote 现状 |
|--------|------|------------|
| 数据表格 | 数据库/表格视图(类似 Notion database | ❌ 未实现(Tiptap table 有,database 无) |
| 统计图表 | 图表可视化 | ❌ |
| 简单表格 | 普通二维表格 | ✅ Tiptap table |
| 页面目录 | Table of Contents 自动生成 | ❌ 未实现 |
| 按钮 | 可交互按钮 | ❌ |
| 进度条 | 进度显示 | ❌ |
| 仪表数字 | 数字计数器 | ❌ |
| Mermaid 绘图 | Mermaid 图表 | ❌ 未实现 |
| 思维导图 | Mindmap | ✅ 已实现(Phase 6 leptos-mindmap |
| 会议 | 会议记录块 | ❌ |
| 嵌入第三方应用 | Embed iframe | Partial - OnlyOffice, 其他待定 |
| 网址书签 | URL 书签预览 | ✅ |
## 媒体与文件
| 功能 | 说明 | mnote 现状 |
|------|------|------------|
| 图片 | 插入图片 | ✅ |
| 视频 | 插入视频 | ✅ |
| 音频 | 插入音频 | ❌ |
| 文件附件 | 上传文件作为附件 | ✅ OnlyOffice adapter |
| Emoji | 文本中插入 Emoji | ✅ |
| Font Awesome 图标 | 文本中插入图标 | ❌ 未实现 |
| 动态图标 | wolai 动态图标 | ❌ |
| 卡通头像 | 生成用户头像 | ❌ |
## 信息架构/页面结构(从目录推断)
```
工作空间 (Workspace)
├── 个人空间 (Personal)
│ ├── 侧栏 (Sidebar)
│ │ ├── 搜索入口
│ │ ├── 快捷方式 / 星标 (Starred / Quick Access)
│ │ ├── 我的页面 (My Pages) — 页面树
│ │ ├── 垃圾桶 (Trash)
│ │ └── 设置入口
│ └── 页面编辑器 (Page Editor)
│ ├── 标题 (Title / Icon / Cover)
│ ├── 正文 (Body — blocks)
│ ├── 页面设置 (Page Options)
│ ├── 评论 / 历史 / 分享
│ └── 右侧边栏
│ ├── 页面目录 (TOC)
│ ├── 引用关系
│ └── 属性
└── 团队空间 (Team Workspace)
├── 成员管理
├── 权限
└── 子空间
```
## 已抓取的详细页面
以下页面已从 Wolai 帮助中心抓取详细内容,保存在 `design/11-wolai/pages/` 下:
| 页面 | 文件 | 内容 |
|------|------|------|
| 基本编辑能力 | `basic-editing-skills.md` | 块概念、块操作(拖动/选中/插入)、分栏、块转换、文本样式、快捷键 |
| 快捷命令菜单 | `quick-command-menu.md` | 完整 `/` 命令列表(基础块/进阶块/行内元素/媒体/引用/快速输入/分栏)|
| 页面选项 | `page-options.md` | 自适应宽度、小字体、标题目录、编辑保护、自定义页面、全局选项 |
| 搜索工作空间 | (参考引用) | 搜索行为、模糊搜索、搜索选项、图片文字识别 |
| 折叠列表 | (参考引用) | `+ space` 创建、折叠标题 |
| 着重文字(Callout) | (参考引用) | 图标更换、子块、跑马灯模式、`!! space` / `!!! space` 创建 |
| 导入与导出 | `import-export.md` | 8 种导入格式、6 种导出格式、空间导出 |
| 页面引用 | `page-reference.md` | `[[` / `#` 创建、双向链接、预引用、别名 |
| 页面关系图 | `page-graph.md` | 图例说明、布局方式、操作(鼠标/触控板)、3D 视图 |
| 思维导图 | `mindmap.md` | 创建/转换、节点快捷键、7 种结构、6 种主题、导出 |
| 右侧边栏 | `right-sidebar.md` | 双栏编辑、跨栏拖拽、快捷键 |
| 今日速记 | `daily-note.md` | 日记行为、默认位置/模板、快捷键 |
| 垃圾桶 | `trash.md` | 恢复/彻底删除、操作确认 |
## 完整对照矩阵
详细逐项对照见 `comprehensive-feature-matrix.md`91 项,8 大类)。
**核心数字**:覆盖率 **55%**50✅ / 16🔧 / 25❌)
| 类别 | ✅ | 🔧 | ❌ | 覆盖率 |
|------|----|-----|-----|--------|
| 基础操作 | 11 | 3 | 4 | 61% |
| 页面选项 | 10 | 5 | 2 | 59% |
| 基础块类型 | 9 | 1 | 2 | 75% |
| 进阶块类型 | 2 | 2 | 8 | 17% |
| 媒体与文件 | 4 | 0 | 4 | 50% |
| 编辑器 UI/壳 | 6 | 2 | 2 | 60% |
| 键盘快捷键 | 8 | 3 | 3 | 57% |
| **总计** | **50** | **16** | **25** | **55%** |
@@ -0,0 +1,92 @@
# 基本编辑能力
> 来源:https://www.wolai.com/wolai/qN1Bh9YjLAXs8bxCJoAJ6C
> 用途:mnote 编辑器基础交互行为参考
>
> 实现状态:**大部分已实现** ✅ 11/18 🔧 3/18 ❌ 4/18
## 实现对照
## 核心概念
wolai 的编辑能力基于**块编辑器(Block Editor)**模式而非传统的富文本编辑器。
一段文本、一个列表项、一个图片、一个文件等,都被称为"块"。不同的块承担不同的角色,组合在一起组成一篇文章或一个主页。
## 功能清单
### 块操作
| 操作 | 说明 |
|------|------|
| **拖动块** | 所有块可通过左侧 `::` 图标拖动,拖动时出现辅助线指示位置。拖到另一个块的左/右侧可形成分栏结构 |
| **选中块** | 光标输入状态按 `Esc` 选中当前块;上下方向键切换选中块;`Shift+上下` 多选块 |
| **块选中后 Enter** | 回到光标编辑状态 |
| **块选中后 Ctrl+A** | 第一次选中当前块全部文本,第二次选中所有块 |
| **在上方/下方插入块** | 鼠标悬停 `::` 上方/下方点击 `+`;或 Esc 选中块后按 `a`(上方)/ `b`(下方)|
| **块布局显示** | `Ctrl+Shift+U` 显示/隐藏所有块的虚线框布局 |
### 分栏
将一个块拖到另一个块的左边或右边,拖拽辅助线从横线变竖线时放开,即可实现分栏。分栏的块可继续拖动调整位置关系。
### 块转换
所有文本类块可互相转换:文本↔标题、待办列表↔数字列表、引述文字↔代码片段。文本类块转换为页面时,缩进结构变为页面内容。
三种转换方式:
1. **块菜单**:点击 `::` → 转换为 → 选择类型
2. **快捷键**:平台特定快捷键
3. **快捷命令菜单**:按 `/` → 选择"转换为xxx",或输入 `/zh + 块名拼音首字母`(如 `/zhlb` 转换为列表)
### 复制/粘贴
- `Ctrl+C / Ctrl+V` 标准复制粘贴
- `Ctrl+Shift+V` 粘贴并匹配样式
### 缩进/取消缩进
- `Tab` 缩进(成为上一个块的子元素)
- `Shift+Tab` 取消缩进
### 文本样式
选中文字后出现文本样式工具条,支持:粗体、斜体、下划线、删除线、行内代码、行内数学公式、文字颜色/背景颜色。
### 超链接
- `Ctrl+K` 创建超链接
- 选中文字后直接粘贴 URL 可快速创建超链接
### 在页面中添加更多块
- 键入 `/` 唤起快捷命令菜单选择块类型
- 点击空行前面的 `+` 图标选择创建块类型
---
## mnote 实现状态
### ✅ 已实现
| 功能 | 证据 |
|------|------|
| 块概念(Block Editor 模式) | `EditorBlockType` 枚举定义 14 种块类型(`core-protocol/src/editor/model.rs:7`|
| 文本输入/换行/方向键 | leptos-tiptap island`rust/spikes/leptos-tiptap-spike/src/lib.rs`|
| 拖动块(`::` 手柄) | `HANDLE_SHELL_SELECTOR` 常量,拖拽事件处理(`lib.rs:45-47`|
| 选中块(Esc / 方向键 / Shift 多选) | `TiptapSelectionState` 选择状态管理 |
| 上方/下方插入块(a / b 快捷键) | 块手柄菜单中的插入操作 |
| 块转换(文本↔标题↔列表等) | `EditorCommand` 中的 `ReplaceBlock` 命令(`editor/command.rs:22-26`|
| 复制/粘贴(含匹配样式) | `write_mnote_text_to_clipboard` 剪贴板操作 |
| 缩进/取消缩进(Tab/Shift+Tab | Tiptap 原生 `indent`/`outdent` |
| 文本样式工具条(粗体/斜体/下划线/删除线/行内代码) | `TextMark::{Bold, Italic, Underline, Strike, Code}``editor/model.rs:33-39`|
| 超链接(Ctrl+K | `TiptapLinkResource` + `TiptapMarkName::Link` |
| 行内数学公式 | `TiptapExtension` 中包含数学公式扩展 |
| 选中文字颜色/背景色 | `TiptapColorAttributes` / `TiptapHighlightAttributes` |
### ❌ / 🔧 未实现/待完善
| 功能 | 状态 | 说明 |
|------|------|------|
| 分栏 | ❌ | `columnsLayout` Tiptap 扩展未注册 |
| 块布局显示(Ctrl+Shift+U| ❌ | 无对应功能 |
| 选中文字→页面引用 | 🔧 | `PageReference` 类型存在但快捷创建交互未实现 |
| 脚注与注释 | ❌ | 无脚注/注释 Tiptap 扩展 |
+32
View File
@@ -0,0 +1,32 @@
# 今日速记(日记)
> 来源:https://www.wolai.com/wolai/... (今日速记)
> 用途:mnote "日记/速记" 功能行为参考
>
> 实现状态:**未实现** ❌ 无今日速记/日记专用功能
## 核心概念
以今天日期命名的页面,快速记录当日内容。
## 触发方式
| 方式 | 说明 |
|------|------|
| 侧栏闪电图标 | 点击左侧边栏上方的 `⚡` 图标 |
| 快捷键 | `Ctrl + \`` 或 `Ctrl + ,` |
| 域名 | `wolai.today`(浏览器已登录时) |
## 行为
- 自动创建以今天日期命名的页面(如 `2026-05-19`
- 日期格式取决于工作空间设置
- 如果当日页面已放入垃圾桶,进入时仍显示该页面(带删除提示)
- 如果已彻底删除,则新建页面
## 设置
| 选项 | 说明 |
|------|------|
| 默认位置 | 在"空间偏好"中设定今日速记的父页面 |
| 默认模板 | 在"空间偏好"中设定创建时的模板页面 |
+34
View File
@@ -0,0 +1,34 @@
# 导入与导出
> 来源:https://www.wolai.com/wolai/... (导入与导出)
>
> 实现状态:**未实现** ❌ 无用户端导入/导出功能
## 支持的导入格式
| 格式 | 扩展名 | 支持压缩包 |
|------|--------|-----------|
| 文本文件 | .txt | zip |
| Markdown | .md | zip/rar |
| HTML | .html | - |
| Word | .docx | - |
| Confluence 导出 | .html | zip |
| 印象笔记导出 | .enex | - |
| CSV | .csv | - |
| OPML | .opml | - |
## 导入方式
- 左侧边栏顶部 `···` → 导入文件
- 编辑器右上角 `···` → 导入页面...
## 导出格式
| 格式 | 范围 |
|------|------|
| HTML | 页面+子页面(ZIP|
| Markdown | 页面+子页面(ZIP|
| PDF | 仅当前页面 |
| Word | 仅当前页面 |
| CSV | 数据表格 |
| 整个空间 | Markdownbeta,限创建者,7天/次)|
+91
View File
@@ -0,0 +1,91 @@
# 思维导图
> 来源:https://www.wolai.com/wolai/... (思维导图)
> 用途:mnote 思维导图功能行为参考
>
> 实现状态:**核心已实现** ✅ Wolai 的 7 种结构中已覆盖部分,6 种主题、节点操作、导出已实现
## 创建
| 方式 | 命令 |
|------|------|
| 快捷命令 | `/swdt /mindmap /naotu /brainmap` |
| 块转换 | 块菜单 → 转换为 → 思维导图, 或 `/zhwswdt` |
| 恢复原类型 | 块菜单/导图菜单 → 恢复为XX |
## 画布操作
| 操作 | 快捷键 |
|------|--------|
| 放大 | `Ctrl/Cmd + +` |
| 缩小 | `Ctrl/Cmd + -` |
| 重置缩放 | `Ctrl/Cmd + 0` |
| 定位到中心 | 点击 `⌂` 图标 |
| 全屏查看 | 点击右上角 `⤢` |
| 进入编辑模式 | 点击右上角 `✎` |
| 退出编辑模式 | `Esc` |
| 拖拽模式 | `Space`(非选中节点时)或点击左上角图标 |
## 节点操作
| 操作 | 快捷键 |
|------|--------|
| 插入同级主题 | `Enter` |
| 插入子主题 | `Tab` |
| 插入父主题 | `Shift + Tab` |
| 删除节点 | `Del` |
| 编辑节点文字 | `F2` 或双击 |
| 上移/前移 | `Ctrl/Cmd + Up` |
| 下移/后移 | `Ctrl/Cmd + Down` |
| 复制 | `Ctrl/Cmd + C` |
| 粘贴 | `Ctrl/Cmd + V` |
| 折叠/展开子节点 | `/` |
| 唤出菜单 | `Space`(选中节点时)|
| 右键菜单 | 鼠标右键 |
## 外观
| 功能 | 说明 |
|------|------|
| 主题颜色 | 6 种 |
| 结构 | 7 种:思维导图、逻辑结构图-左、逻辑结构图、组织结构图、鱼骨图、目录组织图、天盘图 |
| 紧凑布局 | 开启后最大程度节约页面空间 |
## 选择与展开
| 操作 | 说明 |
|------|------|
| 全选 | `Ctrl/Cmd + A` |
| 反选 | 选中节点后反选其余节点 |
| 选中同级 | 选中所有同级节点 |
| 选中子树 | 选中所有子节点 |
| 展开层级 | 支持展开到 1-6 层 |
## 导出
| 格式 | 方式 |
|------|------|
| PNG | 页面右上角 `···` → 保存图片 |
---
## mnote 实现状态
### ✅ 已实现
| 功能 | 证据 |
|------|------|
| 思维导图块类型 | `EditorBlockType::Mindmap``editor/model.rs:9`|
| 创建(/mindmap 命令)| `routes/mindmap_api.rs` 含完整 API |
| Leptos UI shell | `ssr/pages/mindmap.rs` |
| 快捷键节点操作(Enter/Tab/Del/F2| `routes/mindmap_shell.rs` |
| 全屏编辑 | `MINDMAP_SHELL_ZOOM_EVENT` |
| 导出图片 | `mindmap_shell.rs` 导出逻辑 |
| AI 可以读取思维导图 | `mnote.doc.fetch` 通过 projection 返回导图数据 |
### ❌ 未实现
| 功能 | 说明 |
|------|------|
| 7 种结构全部(逻辑结构图/鱼骨图/天盘图等)| 部分可能缺失 |
| AI **改写** 思维导图节点 | 无 `mnote.mindmap.*` Hermes 工具 |
| 紧凑布局 | 未确认 |
| 展开到 1-6 层 | 未确认 |
+46
View File
@@ -0,0 +1,46 @@
# 页面关系图
> 来源:https://www.wolai.com/wolai/... (页面关系图)
>
> 实现状态:**未实现** ❌
## 入口
- 侧边栏左上方
- 快捷键:`Ctrl/Cmd + Shift + P`
## 图例
| 元素 | 含义 |
|------|------|
| 灰色圆点 | 页面 |
| 实心内圈大小 | 子页面数量 |
| 半透明外圈大小 | 引用关系数量 |
| 直线 + 动画方向 | 层级关系(父→子)|
| 虚线曲线 + 动画方向 | 引用关系(A→B)|
| 蓝色节点 | 选中状态 |
## 布局方式
树形向下、树形向右、向心辐射、向外辐射、随机
## 关系筛选
层级关系、引用关系、全部
## 操作
| 鼠标 | 触控板 | 作用 |
|------|--------|------|
| 滚动 | 双指上滑/下滑 | 缩放 |
| 左键拖动 | 单指拖动 | 平移 |
| 左键单击 | 轻点 | 选中节点+显示一层关系 |
| Shift+左键 | Shift+轻点 | 多选 |
| Cmd/Ctrl+左键 | Cmd/Ctrl+轻点 | 显示全部关系 |
| 双击 | 双击 | 转到页面 |
| 悬浮 | 悬浮 | 高亮关联节点 |
| 空格 | 空格 | 最佳视图 |
## 3D 关系图(实验性)
支持 3D 视图旋转操作。
+88
View File
@@ -0,0 +1,88 @@
# 页面选项
> 来源:https://www.wolai.com/wolai/... (页面选项)
>
> 实现状态:**大部分已接通** — 15 个页面选项中 10 个已接通,5 个 UI 可见待接线
## 页面级设置
| 选项 | 说明 | 默认 |
|------|------|------|
| **自适应宽度** | 页面宽度调整为最大可用宽度 | 关闭(固定 760px) |
| **小字体** | 正文字体从 16px → 14px,标题等比缩小 | 关闭 |
| **标题目录** | 页面右侧显示 H1-H4 + 子页面层级目录,`Ctrl+Shift+L` 切换 | 关闭 |
| **标题自动编号** | 标题前显示层级编号 | 关闭 |
| **编辑保护** | 锁定页面不可编辑,上锁图标,`Ctrl+Shift+C` 切换 | 关闭 |
### 标题目录
- 层级:主标题(H1) → 大标题(H2) → 中标题(H3) → 小标题(H4) → 子页面
- 宽度自适应:宽窗口=较宽目录,窄窗口=较窄目录,极窄=圆点竖线目录
- 可通过 `···` 菜单折叠/展开层级
### 自定义页面
| 选项 | 值 |
|------|-----|
| 字体 | 默认 / 宋体 / 楷体 |
| 布局 | 紧凑 / 默认 / 宽容 |
| 显示块引用数字 | 被引用的块右侧显示引用计数 |
| 折叠引用页面列表 | 页面下方反向引用列表默认折叠 |
| 隐藏子页面 | 子页面块不再显示 |
| 页面"嵌入到"默认位置 | 指定嵌入目标块的默认位置 |
### 全局选项(仅对自己生效)
| 选项 | 说明 |
|------|------|
| 显示块结构 | 所有块显示灰色虚线框,`Ctrl+Shift+U` 切换 |
| 拼写检查 | 英文拼写错误显示红色虚线(默认关闭) |
| Good Night 模式 | 暗色主题,`Ctrl+Alt+G` 切换 |
| 跟随系统 | 自动切换亮/暗主题 |
## 页面操作菜单
| 操作 | 说明 |
|------|------|
| 撤回 | `Ctrl+Z` 撤销上一步 |
| 删除页面 | 进入垃圾桶 |
| 移动到... | 移动到指定页面下成为子页面,或移动到其他工作空间 |
| 嵌入到... | 嵌入为页面引用 |
| 添加为公共模板 | 仅多人空间 |
| 复制页面链接 | 带/不带标题 |
| 行内页面引用 | 复制行内引用链接 |
| 嵌入页面引用 | 复制嵌入引用链接 |
| 导出页面 | 多种格式 |
| 页面历史 | 浏览/恢复历史版本(付费空间) |
| 字数统计 | 总字数、块数、分类统计 |
| 待办列表统计 | 完成进度 |
---
## mnote 实现状态
> **从 `pageOptionIsSupported``pageOptionHint` 函数判断(`layout.rs:427-447`**
### ✅ 已接通(UI 完全实现)
| 功能 | key | hint |
|------|-----|------|
| 自适应宽度 | `wideLayout` | 主内容列宽度立即变化(760px↔980px)|
| 小字体 | `smallText` | 正文排版更紧凑 |
| 标题自动编号 | `showHeadingNumbers` | 标题前显示顺序编号 |
| 布局密度 | `layoutDensity` | 段落与列表间距会变化 |
| 页面字体 | `pageFont` | 当前页面字体会切换 |
| 暗色模式 | CSS | `ssr/styles.rs` 暗色样式定义 |
| 删除页面(进垃圾桶) | `tree.resource.archive` | `resource_trash.rs` |
| 移动到... | `tree` 命令 | `routes/tree.rs` |
| 字数统计 + 待办统计 | `computeLivePageStats()` | 实时计算 wordCount/blockCount/todoTotal |
### 🔌 已注册但待接线(UI 选项可见,功能未完全接通)
| 功能 | key | hint |
|------|-----|------|
| 标题目录(TOC | `showToc` | "当前 Rust 壳还没有正式目录面板" |
| 编辑保护 | `protectEditing` | "当前主编辑器只显示降级说明" |
| 折叠反向引用 | `collapseBacklinks` | "当前 Rust 壳未挂回链面板" |
| 隐藏子页面 | `hideChildPages` | "当前页面壳还没有子页面块显隐" |
| 显示块引用数字 | `showBlockRefCount` | "当前页面壳未显示块引用计数" |
> 全部 page options 的 UI 开关面板实现在 `layout.rs:3760-3775`,每个 option 渲染为 toggle switchsupported 的显示蓝色开关,unsupported 的显示灰色开关+提示。`page_aggregate/builder.rs` 提供 page_options 的读写链。
+55
View File
@@ -0,0 +1,55 @@
# 页面引用(双向链接)
> 来源:https://www.wolai.com/wolai/... (页面引用)
>
> 实现状态:**数据层已实现,UI 层未完成** ✅ `[[` 引用和双向链接的块类型存在 ❌ 用户端创建交互未实现
## 核心概念
两种页面组织方式:**层级**(父子关系)和**引用**(双向链接)。
页面引用是双向链接(bi-directional link):A 引用了 B,则 B 页面下方会显示"引用的页面"列表。
## 创建方式
| 方式 | 操作 |
|------|------|
| `[[` 快捷输入 | 输入 `[[` 紧跟搜索词,定位到页面并创建引用 |
| `#` 快捷输入 | 空格后输入 `#` + 搜索词(类似标签)|
| 文本样式工具条 | 选中文本 → 工具条中 `[[` 图标 |
| 快捷键 | 选中文本 → `Ctrl/Cmd + Shift + R` |
## 预引用 & 创建新页面
使用工具条或快捷键时:
1. 匹配已有页面 → 直接引用
2. 无匹配 → 弹出选项:
- **创建新页面"xxx"**:直接创建
- **预引用**:创建"准备状态"引用,标题后显示 "(创建)",点击后弹出"创建页面"或"取消引用"
## 引用预览和别名
- 鼠标悬浮行内页面引用可预览
- 预览小窗顶部可设置别名(最长 100 字),别名后显示小箭头
---
## mnote 实现状态
### ✅ 已实现
| 功能 | 证据 |
|------|------|
| 页面引用块类型 | `EditorBlockType::PageReference``editor/model.rs:20`|
| 块引用块类型 | `EditorBlockType::BlockReference``editor/model.rs:21`|
| 引用标记策略 | `ReferenceTokenStrategy::{DoubleBracket, DoubleParen, InlineChip}``editor/model.rs:26-30`|
| AI 工具中的引用创建 | `mnote.block.insert_after` 等 Herem 工具可操作引用块 |
### ❌ 未实现
| 功能 | 说明 |
|------|------|
| `[[` 快捷输入创造引用 | 用户端 `[[` → 搜索 → 引用流程未实现 |
| `#` 快捷输入 | 未实现 |
| 选中文本 → 工具条 → 创建引用 | 文本工具条无引用按钮 |
| 预引用 & 创建新页面 | 无 "预引用"行为 |
| 引用预览/别名 | 无 |
| 被引用页面的"反向引用"列表 | 无页面底部的引用列表 |
+138
View File
@@ -0,0 +1,138 @@
# 快捷命令菜单
> 来源:https://www.wolai.com/wolai/... (快捷命令菜单)
> 用途:mnote `/` 命令菜单完整功能参考
>
> 实现状态:**部分实现** — 基础块类型命令可用,进阶块/行内元素/媒体/快速输入多数缺失
## mnote 实现状态
### ✅ 已实现的快捷命令对应
| 类别 | 覆盖情况 |
|------|---------|
| **基础块** | ✅ 文本、标题、列表、数字列表、待办列表、引述文字、分割线、代码片段,均通过 Tiptap 扩展注册(`EditorBlockType`|
| **行内样式** | ✅ 粗体/斜体/下划线/删除线/行内代码/链接 |
| **数学公式** | ✅ LaTeX 数学公式(Tiptap 扩展)|
| **页面引用** | ✅ `PageReference` / `BlockReference` 块类型 |
| **思维导图** | ✅ `/mindmap` 命令可用 |
### ❌ 未实现的快捷命令
| 类别 | 缺失 |
|------|------|
| **进阶块** | 按钮、进度条、数据表格、Mermaid 绘图、仪表数字、会议 |
| **行内元素** | Font Awesome 图标、脚注、注释 |
| **媒体** | 嵌入 Bilibili/腾讯/优酷/YouTube、嵌入第三方应用 |
| **快速输入** | 时间/日期/now 快捷命令 |
| **快速分栏** | 2/3/4/5 列分栏 |
| **快速功能** | 页面关系图入口设置、导入文件/CSV |
| **文字/背景色快捷命令** | `/red`, `/blue` 等颜色拼音命令 |
> **注意**:当前 leptos-tiptap island 使用 block handle shell`+` 按钮)而非 `/` slash 命令菜单。Tiptap 的 `SlashCommand` / `Suggestion` 扩展未注册。
## 核心交互
编辑状态下键入 **`/`(斜杠)**唤起快捷命令菜单,中文输入时顿号也会触发。键入 `/` 后继续输入内容可过滤菜单选项。
例如:`/tp` + Enter 即可插入图片。
## 完整命令列表
### 基础块
| 块类型 | 快捷命令(拼音/英文) |
|--------|----------------------|
| 文本 | `/wb /wenben /text /plain` |
| 待办列表 | `/dblb /daiban /todo` |
| 标题 H1-H4 | `/bt /biaoti /title /head /h1 /h2 /h3 /h4` |
| 列表(无序) | `/lb /liebiao /wuxu /wx /bullet` |
| 数字列表(有序) | `/szlb /shuzi /number` |
| 折叠列表 | `/zdlb /toggle` |
| 引述文字(blockquote | `/yswz /yinshuwenzi /quote` |
| 着重文字(callout | `/zzwz /zhuozhongwenzi /callout` |
| 分割线 | `/fgx /fengexian` |
| 代码片段 | `/dmpd /daimapianduan /code` |
| 数学公式 | `/sxgs /shuxuegongshi /latex /math` |
### 进阶块
| 块类型 | 快捷命令 |
|--------|---------|
| 按钮(模板按钮) | `/mban /mubananniu /template` |
| 进度条 | `/jdt /jindutiao /progressbar` |
| 简单表格 | `/jdbg /jiandanbiaoge /simpletable /table` |
| 数据表格(database | `/sjbg /shujubiaoge /database` |
| Mermaid 绘图 | `/mermaid` |
| 仪表数字 | `/ybsz /dashboard` |
| 会议 | `/hy /meeting` |
### 块转换
`/zhw + 块名称拼音首字缩写`,例如转换为"列表"输入 `/zhwlb`
可转换类型:文本、待办列表、标题、列表、数字列表、折叠列表、引述文字、着重文字、代码片段、数学公式
### 行内元素
| 类型 | 快捷命令 |
|------|---------|
| 行内数学公式 | `/hngs` |
| Emoji 表情 | `/bq /biaoqing /emoji` |
| Font Awesome 图标 | `/tb /fa /fontawesome` |
| 脚注 | `/jz /jiaozhu /footnote` |
| 注释 | `/zs /zhushi` |
### 媒体与附件
| 类型 | 快捷命令 |
|------|---------|
| 图片 | `/tp /tupian /img /image /picture` |
| 文件附件 | `/wj /wenjian /file` |
| 视频 | `/sp /video` |
| 音频 | `/yp /audio` |
| 嵌入 Bilibili | `/bzsp /bilibili` |
| 嵌入 腾讯视频 | `/txsp /tencent` |
| 嵌入 优酷 | `/yksp /youku` |
| 嵌入 YouTube | `/ygsp /youtube` |
| 嵌入第三方应用 | `/qrnr /embed` |
| 网址书签 | `/wzsq /bookmark` |
### 插入引用
| 类型 | 快捷命令 |
|------|---------|
| 页面引用链接 | `/ymyylj` |
| 行内块引用 | `/hnkyy` |
| 嵌入块引用 | `/qrkyy` |
### 快速输入
| 类型 | 快捷命令 |
|------|---------|
| 快速输入时间 | `/sj /shijian /time` |
| 快速输入日期 | `/rq /riqi /date` |
| 快速输入现在(日期+时间) | `/xz /xianzai /now` |
### 快速分栏
| 操作 | 快捷命令 |
|------|---------|
| 2 列分栏 | `/2` |
| 3 列分栏 | `/3` |
| 4 列分栏 | `/4` |
| 5 列分栏 | `/5` |
### 快速功能
| 操作 | 快捷命令 |
|------|---------|
| 个人设置 | `/grsz /per` |
| 空间设置 | `/kjsz /works` |
| 打开搜索 | `/sou /search` |
| 打开页面关系图 | `/ymgxt /network /diagram` |
| 导入文件 | `/drwj` |
| 导入数据表格(CSV | `/drbg` |
| 退出登录 | `/quit /exit /logout` |
### 文字颜色/背景色
`/ + 颜色拼音/缩写/英文`,如 `/hs``/red` 为红色。
+57
View File
@@ -0,0 +1,57 @@
# 右侧边栏
> 来源:https://www.wolai.com/wolai/... (右侧边栏)
> 用途:mnote 双栏编辑行为参考
>
> 实现状态:**部分实现** ✅ 上下文菜单有"在右侧边栏打开"动作 ❌ 右侧面板 UI 完整度待确认
## 基本操作
| 操作 | 快捷键 |
|------|--------|
| 在右侧边栏打开链接 | `Alt/Opt + 点击` |
| 打开/关闭右侧边栏 | `Alt/Opt + Q` |
| 切换主/右侧编辑区 | `Alt/Opt + \`` |
| 扩展右侧边栏为整页 | `Alt/Opt + F` |
## 顶部操作栏
1. 关闭右侧边栏
2. 后退
3. 前进
4. 展开为整页
5. 切换到主编辑区(左右互换)
## 调整大小
- 左右拖动左边线调整宽度
- 双击左边线均分两侧宽度
## 跨栏拖拽
右侧边栏和主编辑区中的块可互相拖拽:
| 操作 | 说明 |
|------|------|
| 移动到这里 | 从一边移动到另一边 |
| 拷贝副本到这里 | 复制到另一边 |
| 嵌入块引用 | 嵌入引用到另一边 |
| 行内块引用 | 停留片刻变为行内引用 |
---
## mnote 实现状态
### ✅ 已实现
| 功能 | 证据 |
|------|------|
| "在右侧边栏打开" 上下文菜单 | 文件树/页面树/附件菜单均有 `open-right` 动作(`layout.rs:3127, 3136, 3144, 3164`|
| 快捷键提示 | `Alt + Enter` 在搜索结果中提示右侧边栏打开(`layout.rs:3643, 3670`|
| 事件派发 | `dispatchSidebarEvent('tree.page.open-right', ...)` 等事件(`layout.rs:2961, 2975`|
### ❌ / Partial
| 功能 | 说明 |
|------|------|
| 右侧面板容器 UI | 事件已派发,但双栏编辑面板的具体实现待确认 |
| 跨栏拖拽 | 未实现 |
| 右侧边栏编辑 | 未确认 |
+43
View File
@@ -0,0 +1,43 @@
# 垃圾桶
> 来源:https://www.wolai.com/wolai/... (垃圾桶)
> 用途:mnote 回收站行为参考
>
> 实现状态:**核心已实现** ✅ 恢复/彻底删除/搜索 存在 UI 行为差异
## 入口
左侧边栏最下方打开"垃圾桶"。
## 功能
- 已删除页面存放在垃圾桶中
- 支持搜索关键词查找垃圾桶中的页面
- **恢复**:恢复到"我的页面"列表下
- **彻底删除**:不可撤销,无法找回
## 操作确认
- 默认打开"操作确认"开关
- 打开时:恢复/彻底删除需要二次确认
- 关闭时:直接执行
---
## mnote 实现状态
### ✅ 已实现
| 功能 | 证据 |
|------|------|
| 删除页面进垃圾桶 | `tree.resource.archive``routes/tree.rs`|
| 恢复(回到原位置)| `tree.resource.restore` + 恢复位置记录(`design/04-tree-domain/process/4-28`|
| 彻底删除 | `tree.resource.purge``routes/resource_trash.rs`|
| 垃圾桶搜索 | 已有搜索功能 |
| 资源(附件/mindmap/table)垃圾桶 | `resource_trash.rs` 覆盖 3 种资源类型 |
### ❌ / Partial (相对于 Wolai)
| 功能 | 说明 |
|------|------|
| 操作确认开关 | 未确认是否实现 |
| 恢复后的 reveal/focus | `design/04-tree-domain/process/4-28` 已计划 |
| 双浏览器 no-refresh | `design/04-tree-domain/process/4-34` 进行中 |
+6 -1
View File
@@ -1,6 +1,11 @@
# design 设计稿索引
> 更新时间:2026-05-14
> 更新时间:2026-05-19
>
> 当前产品口径:`VSCode 简化版工作区 + tiptap 的 Markdown 前端编辑器 + Hermes/Reasonix agent + simplemindmap/office 插件 + Wolai 主题 Web 壳 + 鉴权控制面`
>
> 当前上位主线:local-first workspace 是默认数据形态,本地 `.md` 是页面正文真相,Convex / 服务端只作为控制面、cloud source、compat 和 sync replica。入口设计见:
> `/mnt/Data1T/mnote/design/02-convex-rust-long-term-architecture/done/2-2-local-first-workspace-convex-control-plane-v1.md`
>
> 状态口径以当前仓库真实代码为准:
> - `[done]`:对应阶段或收口目标已经在当前主线代码中成立
@@ -1,9 +1,15 @@
# 2 [process] Convex 保留前提下的 Tree-First Graph 长期架构方案 v1
# 2 [recycle][legacy] Convex 保留前提下的 Tree-First Graph 长期架构方案 v1
> 更新时间:2026-04-22
> 更新时间:2026-05-18
>
> 2026-05-18 口径更新:
> - 本文保留为历史过渡判断:它解释“不要在没有替代控制面、迁移工具和兼容策略时硬拆 Convex”。
> - 当前新增能力的上位方向已由 `design/02-convex-rust-long-term-architecture/done/2-2-local-first-workspace-convex-control-plane-v1.md` 覆盖:本地文件夹是早期产品默认数据真相,Rust kernel 是唯一语义真相,Convex / 服务端降级为账号、分享、同步、协作和 AI 隔离控制面。
> - 因此,本文中“Convex 作为默认存储 / 实时 / 文件底座”的表述只代表旧代码过渡态和历史决策背景,不再作为新增功能默认方向。
>
> 当前优先级入口:
> - `/mnt/Data1T/mnote/design/01-05-current-priority-overview.md`
> - `/mnt/Data1T/mnote/design/02-convex-rust-long-term-architecture/done/2-2-local-first-workspace-convex-control-plane-v1.md`
>
> 关联文档:
> - `/mnt/Data1T/mnote/design/01-tree-first-graph-kernel/process/1-tree-first-graph-kernel-v1.md`
@@ -17,7 +23,7 @@
> **当 mnote 沿着 Tree-First Graph 与 Rust 主导路线继续重构时,是否要拆掉当前本地自托管 Convex。**
本文件给出的结论是:
本文件在 2026-04-22 给出的历史结论是:
- **不建议把 Convex 从当前主线中拆掉。**
- **长期要收口的是“语义主导权”和“统一执行面”,不是物理上把 Convex 替换掉。**
@@ -250,7 +256,7 @@ Rust Web 不是第二套业务内核。
长期固定以下原则:
### 5.1 拆 Convex 主底座
### 5.1 历史阶段:不无计划硬拆 Convex 主底座
- 不为了“Rust 化”先拆掉当前自托管 Convex。
- 不引入新的第二主数据库去与 Convex 长期双写对抗。
@@ -427,7 +433,7 @@ Rust Web 不是第二套业务内核。
## 8.1 当前已完成
- [x] 固定长期口径:不拆 Convex,保留为 storage / realtime substrate。
- [x] 固定历史过渡口径:不无计划硬拆 Convex旧主链保留为 storage / realtime substrate。
- [x] 固定长期口径:Rust 作为 tree-first graph 的 semantic owner。
- [x] Sidebar 主链恢复为 Convex `useQuery` live subscription 优先,HTTP fallback 只作兜底。
- [x] `/api/sidebar` 已接入 Rust query envelope,并通过 Convex transport 执行。
@@ -487,10 +493,14 @@ Rust Web 不是第二套业务内核。
## 9. 最终固定口径
截至当前仓库状态,可以固定为:
截至 2026-04-22 仓库状态,本文曾固定为:
> **mnote 的长期路线不是拆掉 Convex,而是在 Convex 继续作为底层 substrate 的前提下,让 Rust 逐步拿回 tree-first graph 的 query、command、projection 与 realtime 语义主导权。**
截至 2026-05-18,当前新增能力的上位口径已调整为:
> **mnote 的早期产品路线是 local-first workspace:本地文件夹是默认数据真相,Rust kernel 是唯一语义真相,Convex / 服务端降级为账号、分享、同步、协作和 AI 隔离控制面。**
当前已经完成的是:
> **主路径边界已基本纠正,tree shell 已降级为显式实验增强,Sidebar 与第一批 tree command 已进入 Convex substrate + Rust envelope 主链。**
+2
View File
@@ -2,6 +2,8 @@
本目录用于在本机通过 Docker Compose 启动 Convex backend + dashboard,并使用 backend 自身的持久化卷存储(包含数据库与文件存储)。
在 local-first 方案下,这套 Convex 自托管环境定位为控制面、cloud source、compat 和可选 sync replica 运行底座;页面正文、默认附件和本地 AI 会话全文不再以它作为早期产品默认数据真相。
## 启动
1. 确保仓库根目录 `.env.all` 存在(全局唯一 env 文件)。
+2
View File
@@ -3,6 +3,8 @@ name: mnote-convex
services:
backend:
# 说明:官方自托管镜像。需要时可将 :latest 固定为特定版本。
# local-first 下该卷只承载控制面、cloud source、compat 和 sync replica
# 不作为本地默认页面正文、附件或 AI 会话全文真相。
image: ghcr.io/get-convex/convex-backend@sha256:2143ad479a997802e74ac52f5f1ce3d6e75309b1965e36069d1c939166e210eb
stop_grace_period: 10s
stop_signal: SIGINT
+1
View File
@@ -9,6 +9,7 @@
"dev:hot": "node scripts/dev-hot.js",
"desktop:local": "node scripts/desktop-local.js",
"desktop:electron": "node desktop-electron/dev-runner.js",
"check:local-first-convex-guard": "node scripts/check-local-first-convex-guard.js",
"build:desktop:next": "node scripts/prepare-desktop-next.js",
"dist:win": "pnpm run build:desktop:next && electron-builder --win nsis"
},
+237
View File
@@ -0,0 +1,237 @@
#!/usr/bin/env node
"use strict";
const fs = require("node:fs");
const path = require("node:path");
function parseArgs(argv) {
const args = { fixture: "", out: "" };
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];
if (arg === "--fixture") {
args.fixture = argv[++index] || "";
continue;
}
if (arg === "--out") {
args.out = argv[++index] || "";
continue;
}
if (arg === "--help" || arg === "-h") {
console.log("用法:node scripts/export-convex-workspace-to-local.js --fixture <fixture.json> --out <dir>");
process.exit(0);
}
throw new Error(`未知参数:${arg}`);
}
if (!args.fixture) throw new Error("缺少 --fixture");
if (!args.out) throw new Error("缺少 --out");
return args;
}
function ensureDir(dir) {
fs.mkdirSync(dir, { recursive: true });
}
function writeUtf8(filePath, content) {
ensureDir(path.dirname(filePath));
fs.writeFileSync(filePath, content, "utf8");
}
function sanitizeName(name) {
return String(name || "untitled")
.trim()
.replace(/[<>:"/\\|?*\u0000-\u001f]/g, "_")
.replace(/\s+/g, " ")
.replace(/\.+$/g, "")
.trim() || "untitled";
}
function toMarkdownFilename(title) {
return `${sanitizeName(title)}.md`;
}
function loadFixture(filePath) {
return JSON.parse(fs.readFileSync(filePath, "utf8"));
}
function buildDocumentIndex(documents) {
const byId = new Map();
documents.forEach((doc) => {
byId.set(String(doc.id), doc);
});
return byId;
}
function buildMarkdownPath(doc, documentsById) {
const segments = [];
let current = doc;
while (current) {
segments.unshift(sanitizeName(current.title || current.id || "untitled"));
const parentId = current.parent_id || current.parentId || null;
current = parentId ? documentsById.get(String(parentId)) : null;
}
return path.posix.join("pages", ...segments) + ".md";
}
function buildAssetPath(markdownPath, fileName) {
const base = markdownPath.replace(/\.md$/i, ".assets");
return path.posix.join(base, sanitizeName(fileName));
}
function findDocumentForAsset(asset, documents) {
const directId = String(asset.document_id || asset.documentId || asset.page_id || asset.pageId || "").trim();
if (directId) return directId;
const assetId = String(asset.id || "").trim();
if (!assetId) return "";
const doc = documents.find((item) => {
const content = typeof item.content === "string" ? item.content : "";
const rawText = typeof item.raw_text === "string" ? item.raw_text : "";
const editorText = typeof item.editor_document === "string" ? item.editor_document : "";
const tiptapText = typeof item.tiptap_document === "string" ? item.tiptap_document : "";
return [content, rawText, editorText, tiptapText].some((text) => text.includes(assetId));
});
return doc ? String(doc.id) : "";
}
function decodeAssetContent(asset) {
if (typeof asset.contentBase64 === "string" && asset.contentBase64) {
return Buffer.from(asset.contentBase64, "base64");
}
if (typeof asset.content === "string") {
return Buffer.from(asset.content, "utf8");
}
return Buffer.from("", "utf8");
}
function rewriteAssetUrls(markdown, assetPathById) {
return String(markdown || "").replace(/\/api\/media\/sign\?assetId=([^)\s"'&#]+)/g, (_, assetId) => {
return assetPathById.get(String(assetId)) || _;
});
}
function serializePageOptions(doc) {
return {
wideLayout: doc.wide_layout ?? null,
useSmallText: doc.use_small_text ?? null,
showHeadingNumbers: doc.show_heading_numbers ?? null,
showToc: doc.show_toc ?? null,
showStructure: doc.show_structure ?? null,
protectEditing: doc.protect_editing ?? null,
showWordCount: doc.show_word_count ?? null,
collapseBacklinks: doc.collapse_backlinks ?? null,
pageFont: doc.page_font ?? null,
layoutDensity: doc.layout_density ?? null,
hideChildPages: doc.hide_child_pages ?? null,
showBlockRefCount: doc.show_block_ref_count ?? null,
embedDefaultBlockId: doc.embed_default_block_id ?? null,
};
}
function main() {
const { fixture: fixturePath, out } = parseArgs(process.argv.slice(2));
const fixture = loadFixture(fixturePath);
const documents = Array.isArray(fixture.documents) ? fixture.documents : [];
const mediaAssets = Array.isArray(fixture.mediaAssets) ? fixture.mediaAssets : [];
const aiSessions = Array.isArray(fixture.aiSessions) ? fixture.aiSessions : [];
fs.rmSync(out, { recursive: true, force: true });
ensureDir(out);
const docsById = buildDocumentIndex(documents);
const markdownPathByDocId = new Map();
const assetPathById = new Map();
const pageIds = {};
const pageOptions = {};
const resourceIndex = { version: 1, assets: {} };
documents.forEach((doc) => {
const markdownPath = buildMarkdownPath(doc, docsById);
markdownPathByDocId.set(String(doc.id), markdownPath);
pageIds[markdownPath] = `local-mdid:${doc.id}`;
pageOptions[`local-mdid:${doc.id}`] = serializePageOptions(doc);
});
mediaAssets.forEach((asset) => {
const ownerDocId = findDocumentForAsset(asset, documents) || String(documents[0]?.id || "");
const ownerMarkdownPath = markdownPathByDocId.get(ownerDocId) || "pages/attachments.md";
const relativePath = buildAssetPath(ownerMarkdownPath, asset.fileName || asset.name || asset.id);
assetPathById.set(String(asset.id), relativePath);
resourceIndex.assets[String(asset.id)] = {
fileName: sanitizeName(asset.fileName || asset.name || asset.id),
relativePath,
documentId: ownerDocId || null,
};
});
documents.forEach((doc) => {
const markdownPath = markdownPathByDocId.get(String(doc.id));
const assetRoot = markdownPath.replace(/\.md$/i, ".assets");
const markdownDir = path.posix.dirname(markdownPath);
const markdownBody = rewriteAssetUrls(
typeof doc.content === "string"
? doc.content
: String(doc.raw_text || doc.editor_document || doc.tiptap_document || ""),
new Map(Array.from(assetPathById.entries()).map(([assetId, absolutePath]) => [
assetId,
path.posix.relative(markdownDir, absolutePath),
])),
);
const frontmatter = [
"---",
`title: ${String(doc.title || doc.id || "untitled")}`,
`mnote_id: ${String(doc.id)}`,
"---",
"",
].join("\n");
writeUtf8(path.join(out, markdownPath), `${frontmatter}${markdownBody}`);
ensureDir(path.join(out, assetRoot));
});
mediaAssets.forEach((asset) => {
const relativePath = assetPathById.get(String(asset.id));
if (!relativePath) return;
const target = path.join(out, relativePath);
writeUtf8(target, decodeAssetContent(asset));
});
aiSessions.forEach((session) => {
const sessionId = sanitizeName(session.sessionId || session.id || "session");
const shareId = String(session.shareId || session.share_id || "").trim();
const visibility = String(session.visibility || "").trim();
const dir = shareId || visibility === "shared"
? path.join(out, "ai-sessions", "shared", sanitizeName(shareId || "share"))
: path.join(out, "ai-sessions", "private");
ensureDir(dir);
const filePath = path.join(dir, `${sessionId}.jsonl`);
const events = Array.isArray(session.events) ? session.events : [];
const lines = events.map((event) => JSON.stringify(event)).join("\n");
writeUtf8(filePath, lines ? `${lines}\n` : "");
});
writeUtf8(path.join(out, ".mnote", "page-ids.json"), JSON.stringify({ version: 1, pages: pageIds }, null, 2));
writeUtf8(path.join(out, ".mnote", "page-options.json"), JSON.stringify({ version: 1, pages: pageOptions }, null, 2));
writeUtf8(path.join(out, ".mnote", "resource-index.json"), JSON.stringify(resourceIndex, null, 2));
writeUtf8(
path.join(out, ".mnote", "workspace.json"),
JSON.stringify(
{
workspaceId: fixture.workspace?.id || "exported-workspace",
ownerId: fixture.workspace?.ownerId || fixture.workspace?.owner_id || "unknown",
createdAt: new Date().toISOString(),
capabilities: ["local_files", "ai_sessions", "exported_from_convex"],
},
null,
2,
),
);
console.log(JSON.stringify({ ok: true, out }, null, 2));
}
if (require.main === module) {
try {
main();
} catch (error) {
console.error(error instanceof Error ? error.stack || error.message : String(error));
process.exit(1);
}
}
@@ -8,6 +8,20 @@ const { chromium } = require("playwright");
const BASE_URL = process.env.MNOTE_WEB_SMOKE_BASE_URL || "http://127.0.0.1:3000";
const UI_TIMEOUT_MS = Number(process.env.UI_TIMEOUT_MS || 10_000);
const ACTOR_ID = `smoke-user-${process.pid}-${Date.now()}`;
const MANAGED_DATA_ROOT = "/mnt/Data1T/Mnote_data";
function resolveChromiumExecutablePath() {
const explicit = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH || "";
if (explicit && fs.existsSync(explicit)) return explicit;
return [
"/usr/bin/google-chrome-stable",
"/usr/bin/google-chrome",
"/snap/bin/chromium",
"/usr/bin/chromium",
"/usr/bin/chromium-browser",
].find((candidate) => fs.existsSync(candidate)) || "";
}
function fileUrl(filePath) {
return `file://${filePath.split(path.sep).map((part, index) => (
@@ -15,6 +29,20 @@ function fileUrl(filePath) {
)).join("/")}`;
}
function managedActorDir(actorId) {
return path.join(MANAGED_DATA_ROOT, "users", actorId, "workspaces", "my-space");
}
function writeWorkspaceManifest(root, ownerId) {
fs.mkdirSync(path.join(root, ".mnote"), { recursive: true });
fs.writeFileSync(path.join(root, ".mnote", "workspace.json"), JSON.stringify({
workspaceId: `local-ws-${path.basename(root).replace(/[^a-zA-Z0-9_-]/g, "_")}`,
ownerId,
createdAt: new Date(0).toISOString(),
capabilities: ["local_files", "tree_commands", "markdown_edit", "asset_upload"],
}, null, 2), "utf8");
}
async function main() {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "mnote-main-local-folder-"));
const otherRoot = fs.mkdtempSync(path.join(os.tmpdir(), "mnote-main 本地 #other-"));
@@ -23,11 +51,17 @@ async function main() {
fs.writeFileSync(path.join(root, "docs", "child.md"), "# Child Page\n", "utf8");
fs.writeFileSync(path.join(root, "plain.txt"), "plain asset\n", "utf8");
fs.writeFileSync(path.join(otherRoot, "OTHER.md"), "# Other Root\n", "utf8");
writeWorkspaceManifest(root, ACTOR_ID);
writeWorkspaceManifest(otherRoot, ACTOR_ID);
const browser = await chromium.launch({ headless: true });
const executablePath = resolveChromiumExecutablePath();
const browser = await chromium.launch({
headless: true,
...(executablePath ? { executablePath } : {}),
});
const context = await browser.newContext({
extraHTTPHeaders: {
"x-mnote-actor-id": "user_real",
"x-mnote-actor-id": ACTOR_ID,
"x-mnote-actor-type": "user",
},
});
@@ -40,6 +74,34 @@ async function main() {
try {
await page.goto(BASE_URL, { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
await page.locator('[data-testid="mnote-create-default-local-workspace"]').waitFor({
state: "visible",
timeout: UI_TIMEOUT_MS,
});
await page.locator('[data-testid="mnote-open-local-folder-empty"]').waitFor({
state: "visible",
timeout: UI_TIMEOUT_MS,
});
await page.locator('[data-testid="mnote-create-default-local-workspace"]').click({
timeout: UI_TIMEOUT_MS,
});
const managedRoot = managedActorDir(ACTOR_ID);
await page.waitForURL((url) => {
return url.origin === new URL(BASE_URL).origin &&
url.pathname === "/" &&
url.searchParams.get("sourceKind") === "local_folder" &&
url.searchParams.get("rootUri") === fileUrl(managedRoot) &&
url.searchParams.get("treeView") === "filetree";
}, { timeout: UI_TIMEOUT_MS });
assert(
fs.existsSync(path.join(managedRoot, ".mnote", "workspace.json")),
"创建我的空间应写入受管 workspace manifest",
);
assert(
fs.existsSync(path.join(managedRoot, "pages", "我的空间.md")),
"创建我的空间应写入默认 Markdown 首页",
);
await page.locator('[data-mnote-action="open-local-folder"]').click({ timeout: UI_TIMEOUT_MS });
await page.locator('[data-testid="mnote-local-folder-dialog"]').waitFor({
state: "visible",
@@ -57,7 +119,8 @@ async function main() {
url.searchParams.get("treeView") === "filetree";
}, { timeout: UI_TIMEOUT_MS });
const recentLocalRoots = await page.evaluate(() => {
const raw = window.localStorage.getItem("mnote.localFolder.recentRoots") || "[]";
const actorId = document.body.getAttribute("data-mnote-actor-id") || "";
const raw = window.localStorage.getItem(`mnote.localFolder.recentRoots:${encodeURIComponent(actorId)}`) || "[]";
return JSON.parse(raw);
});
assert.equal(recentLocalRoots[0], fileUrl(root), "打开本地文件夹后应记录最近 rootUri");
@@ -107,11 +170,16 @@ async function main() {
url.searchParams.get("treeView") === "filetree";
}, { timeout: UI_TIMEOUT_MS });
const updatedRecentLocalRoots = await page.evaluate(() => {
const raw = window.localStorage.getItem("mnote.localFolder.recentRoots") || "[]";
const actorId = document.body.getAttribute("data-mnote-actor-id") || "";
const raw = window.localStorage.getItem(`mnote.localFolder.recentRoots:${encodeURIComponent(actorId)}`) || "[]";
return JSON.parse(raw);
});
assert.equal(updatedRecentLocalRoots[0], fileUrl(otherRoot), "切到其他本地文件夹后应把它放到最近目录首位");
assert.equal(updatedRecentLocalRoots[1], fileUrl(root), "之前打开过的本地目录应保留在最近目录列表中");
const legacyRecentLocalRoots = await page.evaluate(() => {
return window.localStorage.getItem("mnote.localFolder.recentRoots");
});
assert.equal(legacyRecentLocalRoots, null, "最近本地目录不能写入跨用户共享的 legacy localStorage key");
await page.locator('.tree-row[data-row-id="local:markdown:OTHER.md"]').waitFor({
state: "visible",
timeout: UI_TIMEOUT_MS,
@@ -136,11 +204,20 @@ async function main() {
!url.searchParams.has("rootUri");
}, { timeout: UI_TIMEOUT_MS });
await page.goto(BASE_URL, { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
await page.waitForURL((url) => {
return url.pathname === "/" &&
url.searchParams.get("sourceKind") === "local_folder" &&
url.searchParams.get("rootUri") === fileUrl(otherRoot) &&
url.searchParams.get("treeView") === "filetree";
}, { timeout: UI_TIMEOUT_MS });
console.log("task164 desktop hot local folder main entry smoke passed");
} finally {
await browser.close();
fs.rmSync(root, { recursive: true, force: true });
fs.rmSync(otherRoot, { recursive: true, force: true });
fs.rmSync(path.join(MANAGED_DATA_ROOT, "users", ACTOR_ID), { recursive: true, force: true });
}
}
@@ -0,0 +1,158 @@
#!/usr/bin/env node
const assert = require("assert");
const fs = require("fs");
const http = require("http");
const net = require("net");
const os = require("os");
const path = require("path");
const { spawn } = require("child_process");
const { chromium } = require("playwright");
const UI_TIMEOUT_MS = Number(process.env.UI_TIMEOUT_MS || 15_000);
function resolveChromiumExecutablePath() {
const explicit = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH || "";
if (explicit && fs.existsSync(explicit)) return explicit;
return [
"/usr/bin/google-chrome-stable",
"/usr/bin/google-chrome",
"/snap/bin/chromium",
"/usr/bin/chromium",
"/usr/bin/chromium-browser",
].find((candidate) => fs.existsSync(candidate)) || "";
}
function fileUrl(filePath) {
return `file://${filePath.split(path.sep).map((part, index) => (
index === 0 ? "" : encodeURIComponent(part)
)).join("/")}`;
}
function pickPort() {
return new Promise((resolve, reject) => {
const server = net.createServer();
server.listen(0, "127.0.0.1", () => {
const address = server.address();
const port = address && typeof address === "object" ? address.port : 0;
server.close(() => resolve(port));
});
server.on("error", reject);
});
}
function waitForHttpOk(url, timeoutMs) {
const deadline = Date.now() + timeoutMs;
return new Promise((resolve, reject) => {
const tick = () => {
const request = http.get(url, (response) => {
response.resume();
if (response.statusCode >= 200 && response.statusCode < 500) {
resolve();
return;
}
retry();
});
request.on("error", retry);
request.setTimeout(1_000, () => {
request.destroy();
retry();
});
};
const retry = () => {
if (Date.now() > deadline) {
reject(new Error(`server_not_ready: ${url}`));
return;
}
setTimeout(tick, 250);
};
tick();
});
}
async function main() {
const port = await pickPort();
const baseUrl = `http://127.0.0.1:${port}`;
const dataRoot = fs.mkdtempSync(path.join(os.tmpdir(), "mnote-managed-workspace-"));
const actorId = `no-convex-smoke-${process.pid}-${Date.now()}`;
const managedRoot = path.join(dataRoot, "users", actorId, "workspaces", "my-space");
const server = spawn("cargo", ["run", "-p", "mnote-web", "--bin", "mnote-web"], {
cwd: path.join(__dirname, "..", "rust"),
env: {
...process.env,
MNOTE_WEB_BIND: `127.0.0.1:${port}`,
MNOTE_WEB_PUBLIC_BIND: `127.0.0.1:${port}`,
MNOTE_WEB_ENABLE_LEGACY_NEXT_COMPAT: "0",
MNOTE_LOCAL_WORKSPACE_BASE_DIR: dataRoot,
CONVEX_SELF_HOSTED_URL: "http://127.0.0.1:9",
NEXT_PUBLIC_CONVEX_URL: "http://127.0.0.1:9",
},
stdio: ["ignore", "pipe", "pipe"],
});
let stderr = "";
server.stderr.on("data", (chunk) => {
stderr += chunk.toString();
});
const executablePath = resolveChromiumExecutablePath();
const browser = await chromium.launch({
headless: true,
...(executablePath ? { executablePath } : {}),
});
const context = await browser.newContext({
extraHTTPHeaders: {
"x-mnote-actor-id": actorId,
"x-mnote-actor-type": "user",
},
});
const page = await context.newPage();
try {
await waitForHttpOk(`${baseUrl}/health`, 60_000);
await page.goto(baseUrl, { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
await page.locator('[data-testid="mnote-create-default-local-workspace"]').click({
timeout: UI_TIMEOUT_MS,
});
await page.waitForURL((url) => {
return url.pathname === "/" &&
url.searchParams.get("sourceKind") === "local_folder" &&
url.searchParams.get("rootUri") === fileUrl(managedRoot);
}, { timeout: UI_TIMEOUT_MS });
assert(fs.existsSync(path.join(managedRoot, ".mnote", "workspace.json")), "manifest 应落盘");
assert(fs.existsSync(path.join(managedRoot, "pages", "我的空间.md")), "默认首页应落盘");
await page.locator("#__MNOTE_PAGE_AGGREGATE__").waitFor({
state: "attached",
timeout: UI_TIMEOUT_MS,
});
await page.reload({ waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
await page.locator("#__MNOTE_PAGE_AGGREGATE__").waitFor({
state: "attached",
timeout: UI_TIMEOUT_MS,
});
const aggregate = await page.locator("#__MNOTE_PAGE_AGGREGATE__").textContent({
timeout: UI_TIMEOUT_MS,
});
assert(
aggregate && aggregate.includes("local_markdown.content"),
"刷新后仍应读取本地 Markdown page aggregate",
);
console.log("task166 local-first managed workspace no-convex smoke passed");
} finally {
await browser.close();
server.kill("SIGINT");
fs.rmSync(dataRoot, { recursive: true, force: true });
if (server.exitCode == null) {
await new Promise((resolve) => server.once("exit", resolve));
}
if (server.exitCode && server.exitCode !== 130 && server.exitCode !== null) {
process.stderr.write(stderr);
}
}
}
main().catch((error) => {
console.error(error);
process.exit(1);
});
@@ -0,0 +1,208 @@
#!/usr/bin/env node
const assert = require("assert");
const fs = require("fs");
const http = require("http");
const net = require("net");
const os = require("os");
const path = require("path");
const { spawn } = require("child_process");
const TIMEOUT_MS = Number(process.env.UI_TIMEOUT_MS || 15_000);
function pickPort() {
return new Promise((resolve, reject) => {
const server = net.createServer();
server.listen(0, "127.0.0.1", () => {
const address = server.address();
const port = address && typeof address === "object" ? address.port : 0;
server.close(() => resolve(port));
});
server.on("error", reject);
});
}
function waitForHttpOk(url, timeoutMs) {
const deadline = Date.now() + timeoutMs;
return new Promise((resolve, reject) => {
const tick = () => {
const request = http.get(url, (response) => {
response.resume();
if (response.statusCode >= 200 && response.statusCode < 500) {
resolve();
return;
}
retry();
});
request.on("error", retry);
request.setTimeout(1_000, () => {
request.destroy();
retry();
});
};
const retry = () => {
if (Date.now() > deadline) {
reject(new Error(`server_not_ready: ${url}`));
return;
}
setTimeout(tick, 250);
};
tick();
});
}
function actorHeaders(actorId) {
return {
"content-type": "application/json",
"x-mnote-actor-id": actorId,
"x-mnote-actor-type": "user",
};
}
async function requestJson(baseUrl, actorId, pathname, options = {}) {
const response = await fetch(`${baseUrl}${pathname}`, {
method: options.method || "GET",
headers: actorHeaders(actorId),
body: options.body == null ? undefined : JSON.stringify(options.body),
});
const payload = await response.json().catch(() => null);
assert(
response.ok,
`${options.method || "GET"} ${pathname} failed ${response.status}: ${JSON.stringify(payload)}`,
);
return payload;
}
async function loadAggregate(baseUrl, actorId, documentId, rootUri) {
const url = new URL(`/api/page-aggregate/${encodeURIComponent(documentId)}`, baseUrl);
url.searchParams.set("sourceKind", "local_folder");
url.searchParams.set("rootUri", rootUri);
const payload = await requestJson(
baseUrl,
actorId,
`${url.pathname}${url.search}`,
);
return payload.result;
}
async function main() {
const port = await pickPort();
const baseUrl = `http://127.0.0.1:${port}`;
const dataRoot = fs.mkdtempSync(path.join(os.tmpdir(), "mnote-local-p3-"));
const actorId = `p3-smoke-${process.pid}-${Date.now()}`;
const managedRoot = path.join(dataRoot, "users", actorId, "workspaces", "my-space");
const documentId = "local-mdid:my-space-home";
const markdownPath = path.join(managedRoot, "pages", "我的空间.md");
const server = spawn("cargo", ["run", "-p", "mnote-web", "--bin", "mnote-web"], {
cwd: path.join(__dirname, "..", "rust"),
env: {
...process.env,
MNOTE_WEB_BIND: `127.0.0.1:${port}`,
MNOTE_WEB_PUBLIC_BIND: `127.0.0.1:${port}`,
MNOTE_WEB_ENABLE_LEGACY_NEXT_COMPAT: "0",
MNOTE_LOCAL_WORKSPACE_BASE_DIR: dataRoot,
CONVEX_SELF_HOSTED_URL: "http://127.0.0.1:9",
NEXT_PUBLIC_CONVEX_URL: "http://127.0.0.1:9",
},
stdio: ["ignore", "pipe", "pipe"],
});
let stderr = "";
server.stderr.on("data", (chunk) => {
stderr += chunk.toString();
});
try {
await waitForHttpOk(`${baseUrl}/health`, 60_000);
const created = await requestJson(baseUrl, actorId, "/api/local-folder/workspaces/default", {
method: "POST",
body: {},
});
const rootUri = created.workspace.rootUri;
assert(rootUri, "创建默认本地工作区应返回 rootUri");
const firstAggregate = await loadAggregate(baseUrl, actorId, documentId, rootUri);
assert.equal(firstAggregate.head.title, "我的空间");
await requestJson(baseUrl, actorId, "/api/documents/title", {
method: "POST",
body: {
documentId,
sourceKind: "local_folder",
rootUri,
title: "P3 标题",
},
});
const afterTitle = await loadAggregate(baseUrl, actorId, documentId, rootUri);
const conflictDetectionKey = afterTitle.body.conflictDetectionKey || afterTitle.body.conflict_detection_key;
assert(conflictDetectionKey, "标题更新后应能读取新的 conflictDetectionKey");
await requestJson(baseUrl, actorId, "/api/documents/save", {
method: "POST",
body: {
documentId,
sourceKind: "local_folder",
rootUri,
conflictDetectionKey,
content: [
{
type: "heading",
props: { level: 1 },
content: [{ type: "text", text: "正文标题" }],
},
{
type: "paragraph",
content: [{ type: "text", text: "正文已保存" }],
},
],
},
});
await requestJson(baseUrl, actorId, "/api/documents/options", {
method: "POST",
body: {
documentId,
sourceKind: "local_folder",
rootUri,
options: {
wideLayout: true,
showToc: true,
showHeadingNumbers: true,
},
},
});
const finalAggregate = await loadAggregate(baseUrl, actorId, documentId, rootUri);
assert.equal(finalAggregate.head.title, "P3 标题", "frontmatter title 应优先于正文 H1");
assert.equal(finalAggregate.layout.pageOptions.wideLayout, true);
assert.equal(finalAggregate.layout.pageOptions.showToc, true);
assert.equal(finalAggregate.layout.pageOptions.showHeadingNumbers, true);
assert(
JSON.stringify(finalAggregate.body.content).includes("正文已保存"),
"page aggregate 应从本地 markdown 恢复正文",
);
const markdown = fs.readFileSync(markdownPath, "utf8");
assert(markdown.includes("title: P3 标题"), "标题应写入 frontmatter");
assert(markdown.includes("# 正文标题"), "正文 H1 应写回 markdown");
assert(markdown.includes("正文已保存"), "正文段落应写回 markdown");
const options = fs.readFileSync(path.join(managedRoot, ".mnote", "page-options.json"), "utf8");
assert(options.includes("showToc"), "页面设置应写入 .mnote/page-options.json");
console.log("task167 local markdown title/body/options no-convex smoke passed");
} finally {
server.kill("SIGINT");
fs.rmSync(dataRoot, { recursive: true, force: true });
if (server.exitCode == null) {
await new Promise((resolve) => server.once("exit", resolve));
}
if (server.exitCode && server.exitCode !== 130 && server.exitCode !== null) {
process.stderr.write(stderr);
}
}
}
main().catch((error) => {
console.error(error);
process.exit(1);
});
@@ -19,6 +19,7 @@ const DOCX_MIME = "application/vnd.openxmlformats-officedocument.wordprocessingm
const PNG_MIME = "image/png";
const TEST_EMAIL = "mnote.e2e@example.com";
const TEST_PASSWORD = "MnoteE2E123!";
// 说明:该 smoke 仍在验证 Convex media / OnlyOffice 的 cloud upload 兼容入口,不是 local-first 默认上传路径。
const PROBE_DOCX_PATH =
process.env.MNOTE_ONLYOFFICE_PROBE_DOCX ||
"/tmp/mnote-onlyoffice-manual/mnote-onlyoffice-upload-test.docx";
@@ -6,6 +6,7 @@ const { chromium } = require("playwright");
const BASE_URL = (process.env.MNOTE_UI_BASE_URL || "http://127.0.0.1:3001").replace(/\/+$/, "");
const CONVEX_URL = (process.env.NEXT_PUBLIC_CONVEX_URL || process.env.CONVEX_SELF_HOSTED_URL || "http://127.0.0.1:3210").replace(/\/+$/, "");
const e2ePassword = () => process.env.MNOTE_E2E_PASSWORD || ["Mnote", "E2E", "123!"].join("");
// 说明:该 smoke 覆盖 convex-source trash 兼容路径,不代表 local-first 默认文件回收策略。
async function requestJson(request, path, init = {}) {
const response = await request.fetch(`${BASE_URL}${path}`, {
@@ -10,6 +10,7 @@ const TASK = "task428-filetree-bulk-delete-selection-smoke";
const BASE_URL = (process.env.MNOTE_UI_BASE_URL || "http://127.0.0.1:3001").replace(/\/+$/, "");
const CONVEX_URL = (process.env.NEXT_PUBLIC_CONVEX_URL || process.env.CONVEX_SELF_HOSTED_URL || "http://127.0.0.1:3210").replace(/\/+$/, "");
const e2ePassword = () => process.env.MNOTE_E2E_PASSWORD || ["Mnote", "E2E", "123!"].join("");
// 说明:该 smoke 覆盖 convex-source 批量删除兼容路径,不代表 local-first 默认 File Tree 主链。
const UI_TIMEOUT_MS = Number(process.env.MNOTE_SMOKE_UI_TIMEOUT_MS || 30_000);
const OUTPUT_DIR = path.join(process.cwd(), "tmp", TASK);
const RESULT_PATH = path.join(OUTPUT_DIR, "result.json");
@@ -10,6 +10,7 @@ const TASK = "task430-vscode-explorer-stage7-smoke";
const BASE_URL = (process.env.MNOTE_UI_BASE_URL || "http://127.0.0.1:3000").replace(/\/+$/, "");
const CONVEX_URL = (process.env.NEXT_PUBLIC_CONVEX_URL || process.env.CONVEX_SELF_HOSTED_URL || "http://127.0.0.1:3210").replace(/\/+$/, "");
const e2ePassword = () => process.env.MNOTE_E2E_PASSWORD || ["Mnote", "E2E", "123!"].join("");
// 说明:该 smoke 覆盖 convex-source Explorer 兼容路径;local-first 默认主链另有 local workspace smoke。
const UI_TIMEOUT_MS = Number(process.env.MNOTE_SMOKE_UI_TIMEOUT_MS || 30_000);
const OUTPUT_DIR = path.join(process.cwd(), "tmp", TASK);
const RESULT_PATH = path.join(OUTPUT_DIR, "result.json");
@@ -10,6 +10,7 @@ const TASK = "task433-filetree-trash-file-asset-dual-browser-no-refresh-smoke";
const BASE_URL = (process.env.MNOTE_UI_BASE_URL || "http://127.0.0.1:3001").replace(/\/+$/, "");
const AUTH_BASE_URL = (process.env.MNOTE_AUTH_BASE_URL || BASE_URL).replace(/\/+$/, "");
const e2ePassword = () => process.env.MNOTE_E2E_PASSWORD || ["Mnote", "E2E", "123!"].join("");
// 说明:该 smoke 仍在验证 convex-source 的 file asset trash 兼容链路,不是 local-first 默认资源流。
const UI_TIMEOUT_MS = Number(process.env.MNOTE_SMOKE_UI_TIMEOUT_MS || 35_000);
const OUTPUT_DIR = path.join(process.cwd(), "tmp", TASK);
const RESULT_PATH = path.join(OUTPUT_DIR, "result.json");
@@ -11,6 +11,7 @@ const BASE_URL = (process.env.MNOTE_UI_BASE_URL || "http://127.0.0.1:3001").repl
const AUTH_BASE_URL = (process.env.MNOTE_AUTH_BASE_URL || BASE_URL).replace(/\/+$/, "");
const CONVEX_URL = (process.env.NEXT_PUBLIC_CONVEX_URL || process.env.CONVEX_SELF_HOSTED_URL || "http://127.0.0.1:3210").replace(/\/+$/, "");
const e2ePassword = () => process.env.MNOTE_E2E_PASSWORD || ["Mnote", "E2E", "123!"].join("");
// 说明:该 smoke 覆盖 convex-source mindmap/table trash 兼容链路,不代表 local-first 默认资源真相。
const UI_TIMEOUT_MS = Number(process.env.MNOTE_SMOKE_UI_TIMEOUT_MS || 35_000);
const OUTPUT_DIR = path.join(process.cwd(), "tmp", TASK);
const RESULT_PATH = path.join(OUTPUT_DIR, "result.json");
@@ -9,6 +9,9 @@ const BASE_URL = (process.env.MNOTE_WEB_SMOKE_BASE_URL || "http://127.0.0.1:3000
const UI_TIMEOUT_MS = Number(process.env.MNOTE_SMOKE_UI_TIMEOUT_MS || 30_000);
const OUTPUT_DIR = path.join(process.cwd(), "tmp", "task435-local-folder-watch-no-reload-smoke");
const RESULT_PATH = path.join(OUTPUT_DIR, "result.json");
const CHROMIUM_EXECUTABLE_PATH = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH
|| ["/usr/bin/google-chrome-stable", "/usr/bin/google-chrome", "/snap/bin/chromium"]
.find((candidate) => fs.existsSync(candidate));
function assert(condition, message) {
if (!condition) {
@@ -32,6 +35,21 @@ function localMdDocumentId(relativePath) {
return `local-md:${relativePath.replaceAll("/", "~2F")}`;
}
function writeWorkspaceManifest(root, ownerId) {
const metadataDir = path.join(root, ".mnote");
fs.mkdirSync(metadataDir, { recursive: true });
fs.writeFileSync(
path.join(metadataDir, "workspace.json"),
`${JSON.stringify({
workspaceId: `local-ws:${ownerId}:task435`,
ownerId,
createdAt: new Date().toISOString(),
capabilities: ["local_files", "markdown_edit"],
}, null, 2)}\n`,
"utf8",
);
}
async function quickLogin(page) {
await page.goto(`${BASE_URL}/auth`, { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
const quickLoginButton = page.getByRole("button", { name: "测试账号快速登录" });
@@ -100,12 +118,15 @@ async function run() {
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
const root = fs.mkdtempSync(path.join(os.tmpdir(), "mnote-local-watch-no-reload-"));
fs.mkdirSync(path.join(root, "docs"), { recursive: true });
fs.mkdirSync(path.join(root, ".mnote"), { recursive: true });
writeWorkspaceManifest(root, "user_real");
fs.writeFileSync(path.join(root, "README.md"), "# Local Root\n", "utf8");
fs.writeFileSync(path.join(root, "docs", "stable.md"), "# Stable Page\n", "utf8");
fs.writeFileSync(path.join(root, "docs", "stable-asset.txt"), "stable asset", "utf8");
const browser = await chromium.launch({ headless: true });
const browser = await chromium.launch({
headless: true,
...(CHROMIUM_EXECUTABLE_PATH ? { executablePath: CHROMIUM_EXECUTABLE_PATH } : {}),
});
const context = await browser.newContext({
viewport: { width: 1280, height: 860 },
extraHTTPHeaders: {
@@ -10,6 +10,9 @@ const BASE_URL = (process.env.MNOTE_WEB_SMOKE_BASE_URL || "http://127.0.0.1:3000
const UI_TIMEOUT_MS = Number(process.env.MNOTE_SMOKE_UI_TIMEOUT_MS || 30_000);
const OUTPUT_DIR = path.join(process.cwd(), "tmp", "task436-local-markdown-open-document-external-change-smoke");
const RESULT_PATH = path.join(OUTPUT_DIR, "result.json");
const CHROMIUM_EXECUTABLE_PATH = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH
|| ["/usr/bin/google-chrome-stable", "/usr/bin/google-chrome", "/snap/bin/chromium"]
.find((candidate) => fs.existsSync(candidate));
function fileUrl(localPath) {
return `file://${localPath}`;
@@ -37,6 +40,21 @@ function markdown(title, lines) {
].join("\n");
}
function writeWorkspaceManifest(root, ownerId) {
const metadataDir = path.join(root, ".mnote");
fs.mkdirSync(metadataDir, { recursive: true });
fs.writeFileSync(
path.join(metadataDir, "workspace.json"),
`${JSON.stringify({
workspaceId: `local-ws:${ownerId}:task436`,
ownerId,
createdAt: new Date().toISOString(),
capabilities: ["local_files", "markdown_edit"],
}, null, 2)}\n`,
"utf8",
);
}
async function quickLogin(page) {
await page.goto(`${BASE_URL}/auth`, { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
const quickLoginButton = page.getByRole("button", { name: "测试账号快速登录" });
@@ -99,6 +117,37 @@ async function typeDirtyText(page, text) {
await waitForEditorText(page, text.trim());
}
async function callMarkdownEdit(root, relativePath, search, replace) {
const response = await fetch(`${BASE_URL}/api/hermes/tools/mnote/call`, {
method: "POST",
headers: {
"content-type": "application/json",
"x-mnote-actor-id": "user_real",
"x-mnote-actor-type": "user",
},
body: JSON.stringify({
toolName: "mnote.doc.markdown_edit",
workspaceId: "local-ws:user_real:task436",
documentId: localMdDocumentId(relativePath),
sourceKind: "local_folder",
rootUri: fileUrl(root),
sessionId: `sess-task436-${Date.now()}`,
runId: `run-task436-${Date.now()}`,
toolCallId: `call-task436-${Date.now()}`,
traceId: `trace-task436-${Date.now()}`,
idempotencyKey: `idem-task436-${Date.now()}`,
dryRun: false,
args: {
operations: [{ search, replace }],
},
}),
});
const payload = await response.json().catch(() => null);
assert.equal(response.status, 200, `AI markdown_edit 应成功写入: ${JSON.stringify(payload)}`);
assert.equal(payload?.result?.source, "local_folder", `AI markdown_edit 应走 local_folder: ${JSON.stringify(payload)}`);
return payload;
}
async function runStep(label, navigationEvents, action) {
const before = navigationEvents.length;
await action();
@@ -117,15 +166,21 @@ async function run() {
const files = {
clean: "clean-sync.md",
dirty: "dirty-conflict.md",
aiDirty: "dirty-ai-conflict.md",
rename: "rename-open.md",
delete: "delete-open.md",
};
writeWorkspaceManifest(root, "user_real");
fs.writeFileSync(path.join(root, files.clean), markdown("Clean Sync", ["initial clean"]), "utf8");
fs.writeFileSync(path.join(root, files.dirty), markdown("Dirty Conflict", ["initial dirty"]), "utf8");
fs.writeFileSync(path.join(root, files.aiDirty), markdown("Dirty AI Conflict", ["initial ai dirty"]), "utf8");
fs.writeFileSync(path.join(root, files.rename), markdown("Rename Open", ["initial rename"]), "utf8");
fs.writeFileSync(path.join(root, files.delete), markdown("Delete Open", ["initial delete"]), "utf8");
const browser = await chromium.launch({ headless: true });
const browser = await chromium.launch({
headless: true,
...(CHROMIUM_EXECUTABLE_PATH ? { executablePath: CHROMIUM_EXECUTABLE_PATH } : {}),
});
const context = await browser.newContext({
viewport: { width: 1280, height: 860 },
extraHTTPHeaders: {
@@ -171,6 +226,23 @@ async function run() {
assert(runtime.text.includes(localToken), "dirty 冲突时不应静默覆盖用户正在编辑的内容");
}));
await openDocument(page, root, files.aiDirty);
await waitForEditorText(page, "initial ai dirty");
await page.waitForTimeout(300);
navigationEvents.length = 0;
steps.push(await runStep("dirty 文档 AI 后台写入后进入冲突提示", navigationEvents, async () => {
const localToken = `local-ai-dirty-${Date.now()}`;
const aiToken = `ai-background-${Date.now()}`;
await typeDirtyText(page, ` ${localToken}`);
await callMarkdownEdit(root, files.aiDirty, "initial ai dirty", `initial ai dirty ${aiToken}`);
await waitForEditorStatus(page, "external-change-conflict");
const runtime = await readEditorRuntime(page);
assert(runtime.error.includes("本地 Markdown 文件已在外部更新"), `AI 写入冲突提示不正确: ${JSON.stringify(runtime)}`);
assert(runtime.text.includes(localToken), "AI 写入冲突时不应静默覆盖用户正在编辑的内容");
const saved = fs.readFileSync(path.join(root, files.aiDirty), "utf8");
assert(saved.includes(aiToken), "AI 后台写入应已落盘,供后续合并处理");
}));
await openDocument(page, root, files.rename);
await waitForEditorText(page, "initial rename");
await page.waitForTimeout(300);
@@ -0,0 +1,240 @@
#!/usr/bin/env node
"use strict";
const assert = require("node:assert");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const { chromium } = require("playwright");
const BASE_URL = (process.env.MNOTE_WEB_SMOKE_BASE_URL || "http://127.0.0.1:3000").replace(/\/+$/, "");
const UI_TIMEOUT_MS = Number(process.env.MNOTE_SMOKE_UI_TIMEOUT_MS || 30_000);
const OUTPUT_DIR = path.join(process.cwd(), "tmp", "task443-local-markdown-asset-upload-smoke");
const RESULT_PATH = path.join(OUTPUT_DIR, "result.json");
const CHROMIUM_EXECUTABLE_PATH = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH
|| ["/usr/bin/google-chrome-stable", "/usr/bin/google-chrome", "/snap/bin/chromium"]
.find((candidate) => fs.existsSync(candidate));
function fileUrl(localPath) {
return `file://${localPath}`;
}
function localMdDocumentId(relativePath) {
return `local-md:${relativePath.replaceAll("/", "~2F")}`;
}
function documentUrl(root, relativePath) {
const url = new URL(`${BASE_URL}/documents/${encodeURIComponent(localMdDocumentId(relativePath))}`);
url.searchParams.set("sourceKind", "local_folder");
url.searchParams.set("rootUri", fileUrl(root));
url.searchParams.set("treeView", "filetree");
return url.toString();
}
function writeWorkspaceManifest(root, ownerId) {
const metadataDir = path.join(root, ".mnote");
fs.mkdirSync(metadataDir, { recursive: true });
fs.writeFileSync(
path.join(metadataDir, "workspace.json"),
`${JSON.stringify({
workspaceId: `local-ws:${ownerId}:task443`,
ownerId,
createdAt: new Date().toISOString(),
capabilities: ["local_files", "markdown_edit", "asset_upload"],
}, null, 2)}\n`,
"utf8",
);
}
async function quickLogin(page) {
await page.goto(`${BASE_URL}/auth`, { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
const quickLoginButton = page.getByRole("button", { name: "测试账号快速登录" });
if (await quickLoginButton.count()) {
await quickLoginButton.click({ timeout: UI_TIMEOUT_MS });
await page.waitForURL((url) => url.pathname === "/", { timeout: UI_TIMEOUT_MS });
}
}
async function openDocument(page, root, relativePath) {
await page.goto(documentUrl(root, relativePath), { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
await page.locator('[data-testid="mnote-leptos-tiptap-island-editor-root"]').first().waitFor({
state: "visible",
timeout: UI_TIMEOUT_MS,
});
await page.locator(".document-pane[data-pane-role=\"primary\"] .editor-surface .ProseMirror").first().waitFor({
state: "visible",
timeout: UI_TIMEOUT_MS,
});
}
async function uploadLocalAsset(page, root, documentId, fileName, mimeType, bytes, kind) {
return await page.evaluate(
async ({ rootUri, documentId, fileName, mimeType, bytes, kind }) => {
const form = new FormData();
form.append("rootUri", rootUri);
form.append("documentId", documentId);
form.append("kind", kind);
form.append("file", new File([new Uint8Array(bytes)], fileName, { type: mimeType }));
const response = await fetch("/api/local-folder/assets/upload", {
method: "POST",
body: form,
});
const payload = await response.json().catch(() => null);
if (!response.ok || !payload || payload.ok !== true) {
throw new Error(`upload_failed_${response.status}:${JSON.stringify(payload)}`);
}
return payload.asset;
},
{
rootUri: fileUrl(root),
documentId,
fileName,
mimeType,
bytes: Array.from(bytes),
kind,
},
);
}
async function fetchAggregate(page, root, documentId) {
return await page.evaluate(
async ({ rootUri, documentId }) => {
const url = new URL(`/api/page-aggregate/${encodeURIComponent(documentId)}`, window.location.origin);
url.searchParams.set("sourceKind", "local_folder");
url.searchParams.set("rootUri", rootUri);
const response = await fetch(url.toString(), { cache: "no-store" });
const payload = await response.json().catch(() => null);
if (!response.ok || !payload || payload.ok !== true) {
throw new Error(`aggregate_failed_${response.status}:${JSON.stringify(payload)}`);
}
return payload.result;
},
{ rootUri: fileUrl(root), documentId },
);
}
async function saveBody(page, root, documentId, workspaceId, expectedFileVersion, imagePath, attachmentPath) {
return await page.evaluate(
async ({ rootUri, documentId, workspaceId, expectedFileVersion, imagePath, attachmentPath }) => {
const response = await fetch("/api/page-body/write", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
documentId,
workspaceId,
sourceKind: "local_folder",
rootUri,
expectedFileVersion,
contentFormat: "editorBlocks",
editorSource: "task443-smoke",
content: [
{ type: "heading", props: { level: 1 }, content: [{ type: "text", text: "Asset Smoke" }] },
{ type: "image", props: { src: imagePath, alt: "task443 图片", title: "task443 图片" } },
{ type: "media", props: { name: "task443-spec.pdf", sourcePath: attachmentPath } },
],
}),
});
const payload = await response.json().catch(() => null);
if (!response.ok || !payload || payload.ok !== true) {
throw new Error(`save_failed_${response.status}:${JSON.stringify(payload)}`);
}
return payload.result;
},
{ rootUri: fileUrl(root), documentId, workspaceId, expectedFileVersion, imagePath, attachmentPath },
);
}
async function main() {
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
const root = fs.mkdtempSync(path.join(os.tmpdir(), "mnote-task443-local-assets-"));
const relativePath = "README.md";
const documentId = localMdDocumentId(relativePath);
writeWorkspaceManifest(root, "user_real");
fs.writeFileSync(
path.join(root, relativePath),
["---", "title: Asset Smoke", "---", "", "# Asset Smoke", "", "初始正文", ""].join("\n"),
"utf8",
);
const browser = await chromium.launch({
headless: true,
executablePath: CHROMIUM_EXECUTABLE_PATH,
});
const context = await browser.newContext({
viewport: { width: 1280, height: 860 },
extraHTTPHeaders: {
"x-mnote-actor-id": "user_real",
"x-mnote-actor-type": "user",
},
});
const page = await context.newPage();
try {
await quickLogin(page);
await openDocument(page, root, relativePath);
const imageAsset = await uploadLocalAsset(
page,
root,
documentId,
"task443-image.png",
"image/png",
Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]),
"image",
);
const attachmentAsset = await uploadLocalAsset(
page,
root,
documentId,
"task443-spec.pdf",
"application/pdf",
Buffer.from("%PDF-1.4\n% task443\n", "utf8"),
"attachment",
);
const aggregate = await fetchAggregate(page, root, documentId);
await saveBody(
page,
root,
documentId,
aggregate.identity.workspaceId || aggregate.identity.workspace_id || "",
aggregate.body.fileVersion || aggregate.body.conflictDetectionKey || null,
imageAsset.sourcePath,
attachmentAsset.sourcePath,
);
await openDocument(page, root, relativePath);
await page.locator(".document-pane[data-pane-role=\"primary\"] .editor-surface .ProseMirror").first().waitFor({
state: "visible",
timeout: UI_TIMEOUT_MS,
});
const restoredAggregate = await fetchAggregate(page, root, documentId);
const restoredBody = JSON.stringify(restoredAggregate.body && restoredAggregate.body.content || []);
assert(restoredBody.includes("README.assets/task443-image.png"), restoredBody);
assert(restoredBody.includes("README.assets/task443-spec.pdf"), restoredBody);
const markdown = fs.readFileSync(path.join(root, relativePath), "utf8");
assert(markdown.includes("![task443 图片](README.assets/task443-image.png)"), markdown);
assert(markdown.includes("[task443-spec.pdf](README.assets/task443-spec.pdf)"), markdown);
assert(!markdown.includes("/api/media/"), markdown);
assert(!markdown.includes("assetId="), markdown);
assert(fs.existsSync(path.join(root, "README.assets", "task443-image.png")));
assert(fs.existsSync(path.join(root, "README.assets", "task443-spec.pdf")));
await page.waitForFunction(() => {
const tree = document.getElementById("sidebar-file-tree-root");
const text = tree ? tree.textContent || "" : "";
return text.includes("task443-image.png") && text.includes("task443-spec.pdf");
}, null, { timeout: UI_TIMEOUT_MS });
fs.writeFileSync(RESULT_PATH, `${JSON.stringify({
ok: true,
root,
markdownPath: path.join(root, relativePath),
imageSourcePath: imageAsset.sourcePath,
attachmentSourcePath: attachmentAsset.sourcePath,
}, null, 2)}\n`, "utf8");
console.log(`task443 local markdown asset upload smoke passed: ${RESULT_PATH}`);
} finally {
await browser.close();
}
}
main().catch((error) => {
console.error(error && error.stack ? error.stack : error);
process.exit(1);
});
@@ -0,0 +1,101 @@
#!/usr/bin/env node
"use strict";
const assert = require("node:assert/strict");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const { spawnSync } = require("node:child_process");
const repoRoot = path.resolve(__dirname, "..");
const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), "mnote-convex-export-"));
const fixturePath = path.join(tmpRoot, "fixture.json");
const outRoot = path.join(tmpRoot, "local-workspace");
const fixture = {
workspace: {
id: "ws_legacy_1",
name: "旧云端空间",
ownerId: "user_1",
},
documents: [
{
id: "doc_root",
title: "Project",
parent_id: null,
sort_order: 1,
content: "# Project\n\n![logo](/api/media/sign?assetId=asset_logo)\n",
wide_layout: true,
created_at: "2026-05-01T00:00:00.000Z",
updated_at: "2026-05-02T00:00:00.000Z",
},
{
id: "doc_child",
title: "Child Spec",
parent_id: "doc_root",
sort_order: 1,
content: "Child body with [file](/api/media/sign?assetId=asset_pdf).\n",
created_at: "2026-05-01T00:00:00.000Z",
updated_at: "2026-05-02T00:00:00.000Z",
},
],
mediaAssets: [
{
id: "asset_logo",
fileName: "logo.png",
contentBase64: Buffer.from("PNG-FIXTURE").toString("base64"),
},
{
id: "asset_pdf",
fileName: "spec.pdf",
content: "PDF-FIXTURE",
},
],
aiSessions: [
{
sessionId: "sess_1",
visibility: "private",
events: [
{ eventType: "session.created", sessionId: "sess_1", userId: "user_1" },
{ eventType: "run.completed", sessionId: "sess_1", status: "completed" },
],
},
],
};
fs.writeFileSync(fixturePath, JSON.stringify(fixture, null, 2), "utf8");
const result = spawnSync(
process.execPath,
[
path.join(repoRoot, "scripts", "export-convex-workspace-to-local.js"),
"--fixture",
fixturePath,
"--out",
outRoot,
],
{ cwd: repoRoot, encoding: "utf8" },
);
assert.equal(result.status, 0, result.stderr || result.stdout);
const projectMd = fs.readFileSync(path.join(outRoot, "pages", "Project.md"), "utf8");
const childMd = fs.readFileSync(path.join(outRoot, "pages", "Project", "Child Spec.md"), "utf8");
const pageIds = JSON.parse(fs.readFileSync(path.join(outRoot, ".mnote", "page-ids.json"), "utf8"));
const pageOptions = JSON.parse(fs.readFileSync(path.join(outRoot, ".mnote", "page-options.json"), "utf8"));
const resourceIndex = JSON.parse(fs.readFileSync(path.join(outRoot, ".mnote", "resource-index.json"), "utf8"));
const sessionJsonl = fs.readFileSync(path.join(outRoot, "ai-sessions", "private", "sess_1.jsonl"), "utf8");
assert.match(projectMd, /mnote_id: doc_root/);
assert.match(projectMd, /!\[logo\]\(Project\.assets\/logo\.png\)/);
assert.match(childMd, /\[file\]\(Child Spec\.assets\/spec\.pdf\)/);
assert.equal(pageIds.pages["pages/Project.md"], "local-mdid:doc_root");
assert.equal(pageIds.pages["pages/Project/Child Spec.md"], "local-mdid:doc_child");
assert.equal(pageOptions.pages["local-mdid:doc_root"].wideLayout, true);
assert.equal(resourceIndex.assets.asset_logo.relativePath, "pages/Project.assets/logo.png");
assert.match(sessionJsonl, /"eventType":"run.completed"/);
assert.equal(fs.readFileSync(path.join(outRoot, "pages", "Project.assets", "logo.png"), "utf8"), "PNG-FIXTURE");
assert.equal(fs.readFileSync(path.join(outRoot, "pages", "Project", "Child Spec.assets", "spec.pdf"), "utf8"), "PDF-FIXTURE");
fs.rmSync(tmpRoot, { recursive: true, force: true });
console.log(JSON.stringify({ ok: true, smoke: "task444-convex-workspace-export-local-fixture" }, null, 2));