diff --git a/AGENTS.md b/AGENTS.md index be529261..1ced1c94 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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_content,markdown 文本级),在线文档和本地 `.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 / selection,Hermes 或 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` diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index ea114094..563dc847 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -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 SSE(snapshot/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 stream;Next 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 owner;Next 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 snapshot(initial / 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 ClientState、projection / command / realtime 兼容链如何继续瘦身。** diff --git a/CURRENT_ARCHITECTURE.md b/CURRENT_ARCHITECTURE.md index 3e99631f..a37d6ea4 100644 --- a/CURRENT_ARCHITECTURE.md +++ b/CURRENT_ARCHITECTURE.md @@ -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//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 owner;Next / 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`,主壳先走 WS;SSE 保留为 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//*.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 背景。 diff --git a/REASONIX.md b/REASONIX.md new file mode 100644 index 00000000..43477a8a --- /dev/null +++ b/REASONIX.md @@ -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 `** — 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. diff --git a/bugs/03-rust-web/done/3-16-local-markdown-upload-uses-convex-media-asset-v1.md b/bugs/03-rust-web/done/3-16-local-markdown-upload-uses-convex-media-asset-v1.md new file mode 100644 index 00000000..17ec7683 --- /dev/null +++ b/bugs/03-rust-web/done/3-16-local-markdown-upload-uses-convex-media-asset-v1.md @@ -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` 页面上传图片/附件、刷新后恢复、文件树可见。 diff --git a/bugs/05-editor-mainline/done/5-19-page-aggregate-prefers-editor-document-v1.md b/bugs/05-editor-mainline/done/5-19-page-aggregate-prefers-editor-document-v1.md new file mode 100644 index 00000000..63f01ef1 --- /dev/null +++ b/bugs/05-editor-mainline/done/5-19-page-aggregate-prefers-editor-document-v1.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;后续主线应继续推进持久层原生块文档字段与命令写入闭环。 diff --git a/bugs/05-editor-mainline/done/5-20-page-body-save-strips-editor-document-before-convex-v1.md b/bugs/05-editor-mainline/done/5-20-page-body-save-strips-editor-document-before-convex-v1.md new file mode 100644 index 00000000..e9f5bd0e --- /dev/null +++ b/bugs/05-editor-mainline/done/5-20-page-body-save-strips-editor-document-before-convex-v1.md @@ -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`。 diff --git a/bugs/05-editor-mainline/done/5-21-page-body-save-rejects-invalid-editor-document-v1.md b/bugs/05-editor-mainline/done/5-21-page-body-save-rejects-invalid-editor-document-v1.md new file mode 100644 index 00000000..3002929b --- /dev/null +++ b/bugs/05-editor-mainline/done/5-21-page-body-save-rejects-invalid-editor-document-v1.md @@ -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::` 成功时才使用 `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` 的既有优先级顺序。 diff --git a/bugs/05-editor-mainline/done/5-22-documents-content-strips-editor-document-before-page-aggregate-v1.md b/bugs/05-editor-mainline/done/5-22-documents-content-strips-editor-document-before-page-aggregate-v1.md new file mode 100644 index 00000000..d5c42fb5 --- /dev/null +++ b/bugs/05-editor-mainline/done/5-22-documents-content-strips-editor-document-before-page-aggregate-v1.md @@ -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 是否已完整原生落库仍按后续持久层迁移继续跟进。 diff --git a/bugs/05-editor-mainline/done/5-23-editor-document-semantic-validation-v1.md b/bugs/05-editor-mainline/done/5-23-editor-document-semantic-validation-v1.md new file mode 100644 index 00000000..ce95b3bd --- /dev/null +++ b/bugs/05-editor-mainline/done/5-23-editor-document-semantic-validation-v1.md @@ -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 原生落库迁移中继续收紧。 diff --git a/bugs/05-editor-mainline/done/5-24-page-body-save-preserves-uploaded-image-and-attachment-v1.md b/bugs/05-editor-mainline/done/5-24-page-body-save-preserves-uploaded-image-and-attachment-v1.md new file mode 100644 index 00000000..999c715c --- /dev/null +++ b/bugs/05-editor-mainline/done/5-24-page-body-save-preserves-uploaded-image-and-attachment-v1.md @@ -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 和迁移校验继续收紧。 diff --git a/bugs/07-ai/done/7-31-page-ai-workflow-drops-read-summary-v1.md b/bugs/07-ai/done/7-31-page-ai-workflow-drops-read-summary-v1.md new file mode 100644 index 00000000..9b0fe0a5 --- /dev/null +++ b/bugs/07-ai/done/7-31-page-ai-workflow-drops-read-summary-v1.md @@ -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 冻结范围。 diff --git a/bugs/07-ai/done/7-32-markdown-edit-page-body-save-missing-preconditions-v1.md b/bugs/07-ai/done/7-32-markdown-edit-page-body-save-missing-preconditions-v1.md new file mode 100644 index 00000000..ffe05d27 --- /dev/null +++ b/bugs/07-ai/done/7-32-markdown-edit-page-body-save-missing-preconditions-v1.md @@ -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`。 diff --git a/bugs/07-ai/done/7-33-markdown-edit-no-applied-operations-success-v1.md b/bugs/07-ai/done/7-33-markdown-edit-no-applied-operations-success-v1.md new file mode 100644 index 00000000..b0811d76 --- /dev/null +++ b/bugs/07-ai/done/7-33-markdown-edit-no-applied-operations-success-v1.md @@ -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`。 diff --git a/bugs/07-ai/done/7-34-page-ai-acp-runtime-default-reasonix-switch-v1.md b/bugs/07-ai/done/7-34-page-ai-acp-runtime-default-reasonix-switch-v1.md new file mode 100644 index 00000000..f5d6238f --- /dev/null +++ b/bugs/07-ai/done/7-34-page-ai-acp-runtime-default-reasonix-switch-v1.md @@ -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` diff --git a/bugs/done/2026-05-18-p0-kernel-ai-bugfix-progress.md b/bugs/done/2026-05-18-p0-kernel-ai-bugfix-progress.md index aeaa8fee..2735f7e0 100644 --- a/bugs/done/2026-05-18-p0-kernel-ai-bugfix-progress.md +++ b/bugs/done/2026-05-18-p0-kernel-ai-bugfix-progress.md @@ -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`,不再误报成功或触发无意义保存。 关键证据: diff --git a/design/01-05-current-priority-overview.md b/design/01-05-current-priority-overview.md index c8872480..b7489c83 100644 --- a/design/01-05-current-priority-overview.md +++ b/design/01-05-current-priority-overview.md @@ -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 降级为账号、分享、同步和协作控制面。** diff --git a/design/01-tree-first-graph-kernel/process/1-2-tree-first-graph-product-vision-progress-v1.md b/design/01-tree-first-graph-kernel/process/1-2-tree-first-graph-product-vision-progress-v1.md new file mode 100644 index 00000000..f48dd32e --- /dev/null +++ b/design/01-tree-first-graph-kernel/process/1-2-tree-first-graph-product-vision-progress-v1.md @@ -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)属于明确的"下一轮"范围,不在此轮冲刺内。 diff --git a/design/02-convex-rust-long-term-architecture/done/2-2-local-first-workspace-convex-control-plane-v1.md b/design/02-convex-rust-long-term-architecture/done/2-2-local-first-workspace-convex-control-plane-v1.md new file mode 100644 index 00000000..bdef9284 --- /dev/null +++ b/design/02-convex-rust-long-term-architecture/done/2-2-local-first-workspace-convex-control-plane-v1.md @@ -0,0 +1,1152 @@ +# 2-2 [done] Local-First Workspace 与 Convex 控制面降级方案 v1 + +> 创建时间:2026-05-18 +> +> 当前状态:`DONE` +> +> 本稿目的: +> 1. 固定早期产品形态:10 人以内、用户笔记默认独立、可分享、AI 隔离且可共享。 +> 2. 将 mnote 从“Convex 与本地两套同等重量系统”收口为“本地文件夹默认主路径 + Convex / 服务端控制面”。 +> 3. 给出现有文件树、页面树、上传下载、编辑、AI 会话从 Convex 重依赖迁向本地优先的阶段计划。 +> +> 关联文档: +> - `/mnt/Data1T/mnote/ARCHITECTURE.md` +> - `/mnt/Data1T/mnote/design/01-05-current-priority-overview.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/03-rust-web/process/3-15-local-markdown-asset-upload-relative-path-v1.md` +> - `/mnt/Data1T/mnote/design/04-tree-domain/done/4-22-local-folder-convex-unified-tree-execution-checklist-v1.md` +> - `/mnt/Data1T/mnote/design/07-ai/process/7-14-online-local-ai-markdown-editing-convergence-v1.md` +> - `/mnt/Data1T/mnote/bugs/03-rust-web/done/3-16-local-markdown-upload-uses-convex-media-asset-v1.md` + +--- + +## 1. 背景判断 + +当前产品目标不是大规模云协作平台,而是早期 10 人以内使用的知识库: + +- 每个用户默认拥有独立笔记空间。 +- 笔记、文件夹、资源可以分享给其他用户。 +- 每个用户都使用 AI。 +- AI 默认只能访问当前用户授权范围内的内容。 +- AI 会话默认私有,但可以显式分享或共享。 + +在这个规模和使用方式下,继续把 Convex 作为页面正文、树结构、附件、AI 会话、实时状态的完整主数据层,会让系统过早承担云协作复杂度: + +- schema / functions / deployment / auth / realtime / storage 多点耦合。 +- 文件树、页面树、上传下载、编辑链路都需要同时理解 Convex 与本地。 +- AI 编辑要绕过远端数据模型,反而不如直接面对本地 markdown 文件稳定。 +- 任何本地优先能力都容易退化为“先导入 Convex,再投影回本地 UI”。 + +因此当前产品形态应调整为: + +> **本地文件夹是默认数据真相;Rust kernel 是唯一语义真相;Convex 或服务端降级为账号、分享、同步、协作和 AI 隔离的控制面。** + +这不是否定 Convex 的价值,而是把 Convex 从默认主存储降级为可选增强能力。 + +--- + +## 2. 产品形态结论 + +### 2.1 默认形态 + +默认运行形态: + +```text +User + -> Local Workspace Folder + -> pages/*.md + -> assets/* + -> mindmaps/* + -> ai-sessions/* + -> .mnote/* + -> Rust mnote-web / kernel + -> tree command + -> page aggregate + -> file tree / page tree projection + -> AI tool execution + -> Optional Control Plane + -> auth + -> membership + -> share grants + -> sync state + -> AI quota / policy +``` + +本地文件夹像 VSCode workspace 一样存在。用户可以直接看到、备份、git 管理和迁移自己的数据。 + +更准确的产品定义是: + +> **MNote = VSCode 简化版工作区内核 + tiptap markdown 编辑器 + Hermes / Reasonix agent + simplemindmap / office 插件 + Wolai 风格 web 壳 + 鉴权控制面。** + +拆开看: + +- **VSCode 简化版工作区内核**:本地文件夹、目录树、页面树、资源树、拖拽、重命名、移动、回收站、导入导出。 +- **tiptap markdown 编辑器**:页面正文的前端显示与交互层,消费本地文件投影,不承担 agent 主编辑面职责。 +- **Hermes / Reasonix agent**:在授权目录白名单内直接读写本地文件,尽量复用它们已有的权限和文件编辑能力。 +- **simplemindmap / office 插件**:作为资源型编辑器或附件打开器存在,不把它们重新降格成 markdown 文本块。 +- **Wolai 风格 web 壳**:提供统一导航、主题、页面壳、AI 面板、侧边栏和分享入口。 +- **鉴权控制面**:负责登录、用户隔离、目录授权、分享授权、同步状态和审计,而不是默认正文存储。 + +### 2.2 Convex / 服务端定位 + +Convex 不再默认承担页面正文、附件和本地 AI 会话全文的主存储。 + +Convex 可以继续承担: + +- 用户登录和用户身份。 +- workspace membership。 +- 分享授权和共享入口。 +- 远端同步状态。 +- 通知、邀请、协作 session 元数据。 +- AI quota、模型策略、审计索引。 +- 后续多人实时协作时的远端事件通道。 + +Convex 不应继续扩写为: + +- 默认页面正文真相。 +- 默认文件树真相。 +- 默认附件 blob 真相。 +- 默认 AI 会话消息全文真相。 +- 前端 UI 直接依赖的第二套树语义。 + +### 2.3 单一语义层 + +无论数据来自本地文件夹还是远端同步副本,业务语义都必须经过 Rust kernel: + +```text +LocalFS / Convex / Future Sync Store + -> WorkspaceSource + -> Rust kernel + -> projection / command / page aggregate / AI tools + -> mnote-web UI +``` + +前端不判断“这是 Convex 树还是本地树”。前端只消费: + +- `file_tree` +- `page_tree` +- `page_aggregate` +- `tree.*` command result +- `mnote.doc.*` / `mnote.block.*` AI tool result + +### 2.4 VSCode-like Agent Runtime + +本地优先场景下,agent 的运行形态应尽量贴近 VSCode / Codex: + +```text +用户打开页面 + -> MNote 只负责把当前页面定位到真实 .md 文件 + -> MNote 根据登录用户 + access-policy.json 计算 allowed roots 白名单 + -> Hermes / Reasonix 在该白名单目录内运行 + -> agent 直接读取 / 修改本地文件 + -> MNote watcher / refresh 把文件变化同步回 tiptap / File Tree / Page Aggregate +``` + +这意味着: + +- `tiptap` 只是前端显示与交互层,不是 agent 的工作面。 +- agent 不应被迫通过 MNote 的块工具或页面工具才能编辑普通 `.md`。 +- MNote 主要提供“页面定位 + 权限白名单 + 变更同步 + 审计”。 +- 白名单目录就是 agent 的读写边界;这比继续堆一层专用文档编辑 API 更接近成熟 IDE 体验。 + +### 2.5 VSCode-like 文件并发与冲突模型 + +一旦 agent 直改本地文件,就必须按 VSCode 的文件并发模型处理 `tiptap`、AI、外部编辑器同时写入的问题。 + +核心规则: + +- **本地 `.md` 文件是正文真相**;Page Aggregate / EditorBlockDocument / tiptap state 都是投影或工作副本。 +- 每个页面工作副本必须记录 `baseFileVersion`,至少包含 `mtime / size / contentHash` 或等价 revision。 +- `tiptap` 保存、AI patch、外部文件变更都不能静默覆盖彼此;写入时必须带 expected file version。 +- 文件 watcher 发现后台变更后: + - 如果当前 tiptap 没有 dirty 修改,自动重新读取 `.md` 并刷新 Page Aggregate / tiptap。 + - 如果当前 tiptap 有 dirty 修改,进入 conflict state,不自动覆盖用户编辑。 +- AI 写入被视为“后台文件编辑器”写入;它不拥有高于用户手动编辑的优先级。 +- 冲突处理默认提供三类选择:接受磁盘版本、保留当前编辑器版本、打开差异比较 / 合并。 + +这条模型比“tiptap 和 AI 都经过 `/api/documents/save` 兼容面”更可靠,因为写入仲裁发生在本地文件版本层,而不是旧 Convex/document command 兼容层。 + +--- + +## 3. 本地工作区数据形状 + +本机默认受管数据根固定为: + +```text +/mnt/Data1T/Mnote_data/ + control-plane/ + access-policy.json + users/ + / + workspaces/ + my-space/ + pages/ + assets/ + mindmaps/ + ai-sessions/ + .mnote/ + shared/ + workspaces/ + backups/ + exports/ + imports/ + tmp/ +``` + +目录规则: + +- `/mnt/Data1T/Mnote_data/users//workspaces/my-space/` 是每个登录用户的默认“我的空间”,但不是唯一可访问目录。 +- `` 使用稳定 URL-safe 编码,不直接使用用户输入拼路径。 +- `control-plane/access-policy.json` 记录管理员身份、用户到目录的读写授权、AI 目录授权和目录别名映射。 +- 普通用户的“创建我的空间”只允许初始化自己的受管默认路径,不允许用户通过任意 `rootUri` 写入 `.mnote/workspace.json` 接管别的目录。 +- “打开本地文件夹”允许三类命中:当前 actor 的默认 my-space、已有 `.mnote/workspace.json` 且 owner 匹配、管理员显式授权的目录。 +- 管理员可以读写任何目录,也可以把任意 canonical 目录授权给普通用户;普通用户不能自助提升到目录级全盘访问。 +- 后续分享工作区可放入 `shared/workspaces/` 或通过控制面授权映射到 owner 的工作区;不能因为共享而放宽本机任意路径访问。 +- `backups/`、`exports/`、`imports/`、`tmp/` 是工具目录,不作为默认页面树 root。 + +单个 workspace 内部建议默认结构: + +```text +workspace-root/ + pages/ + Inbox.md + Inbox.assets/ + Projects/ + MNote.md + MNote.assets/ + assets/ + shared/ + imports/ + mindmaps/ + ai-sessions/ + private/ + shared/ + .mnote/ + workspace.json + page-ids.json + page-options.json + resource-index.json + share-cache.json + sync-state.json + ai-policy.json + trash/ +``` + +### 3.1 页面正文 + +页面正文优先是 markdown 文件。 + +标题优先级: + +1. frontmatter `title` +2. 第一行 H1 +3. 文件名 + +页面稳定身份优先级: + +1. frontmatter `mnote_id` +2. `.mnote/page-ids.json` +3. path-derived 临时 id + +path-derived id 只能用于未初始化只读模式,不能作为长期引用 id。 + +### 3.2 资源与附件 + +上传不应默认进入 Convex storage。 + +本地 `.md` 页面内的图片和附件上传,默认写入当前 Markdown 文件同目录下的 sibling assets 目录: + +```text +/.assets/ +``` + +示例: + +```text +pages/Inbox.md +pages/Inbox.assets/image.png +pages/Inbox.assets/spec.docx +``` + +编辑器正文必须写入相对当前 Markdown 文件目录的标准 Markdown 路径: + +```markdown +![image.png](Inbox.assets/image.png) +[spec.docx](Inbox.assets/spec.docx) +``` + +当前已落地基线: + +- `POST /api/local-folder/assets/upload` 已新增。 +- `sourceKind=local_folder` 时前端上传分流到本地上传入口。 +- 本地上传复制到 `{mdBase}.assets/`,返回 `sourcePath` / `file_url` 相对路径。 +- 本地图片保存为 `![alt](relative/path)`。 +- 本地附件保存为 `[name](relative/path)`,不生成 OnlyOffice assetId URL。 +- 单测与 HTTP smoke 已通过,剩余真实浏览器 smoke 仍需补齐。 + +通用导入、跨页面共享素材或批量迁移资源可以放入工作区级资源目录: + +```text +assets/attachments/// +``` + +写入后 Rust kernel 生成 resource identity,并更新 `.mnote/resource-index.json` 或等价本地索引。页面内上传优先保持 Markdown 可迁移性,工作区级资源目录只作为共享资源和迁移工具的目标,不是编辑器上传默认路径。 + +### 3.3 AI 会话 + +AI 会话默认写入: + +```text +ai-sessions/private/.jsonl +``` + +共享会话写入: + +```text +ai-sessions/shared//.jsonl +``` + +会话记录必须包含: + +- `user_id` +- `workspace_id` +- `session_id` +- `scope` +- `allowed_resource_ids` +- `created_at` +- `model` +- `tool_calls` + +AI 默认不能跨用户、跨 workspace 或跨未授权资源读取内容。 + +--- + +## 4. 权限与分享模型 + +早期只实现三档权限: + +| 权限 | 说明 | +| --- | --- | +| `admin` | 管理员可读写任意目录,可授权其他用户访问目录 | +| `private` | 只有 owner 可见、可编辑、AI 可读 | +| `shared_read` | 被授权用户可读,AI 可在只读上下文中引用 | +| `shared_write` | 被授权用户可编辑,AI 可在该共享范围内执行写工具 | + +共享对象可以是: + +- 单个页面 +- 文件夹 / 子树 +- 附件 / 资源 +- AI 会话 +- workspace + +共享授权的 canonical 记录在控制面,客户端本地缓存到 `.mnote/share-cache.json`。离线时只允许访问已缓存且未过期的授权范围;不能因为控制面不可用而扩大权限。 + +目录授权的 canonical 记录在 `control-plane/access-policy.json`。普通用户访问本地目录时,优先按 `admin` / `owner` / `grant` 三层判定;AI 权限继承目录授权,不单独放大。 + +--- + +## 5. AI 隔离原则 + +AI 上下文边界必须先于工具执行确定。 + +每次 AI run 都要计算: + +```text +AiAccessScope { + user_id, + workspace_id, + session_id, + source_kind, + allowed_roots, + allowed_file_paths, + allowed_resource_ids, + permission_level, + share_context, +} +``` + +local-first 下,AI 的长期主路径不是继续发明一套 MNote 专用编辑工具,而是: + +```text +前端传当前文件引用 / selection / 用户指令 + -> MNote 解析授权文件路径并创建 AiAccessScope + -> 本地 agent runtime 在受限 cwd / allowed roots 内运行 + -> agent 使用自身成熟的 diff / apply_patch / 文件编辑能力修改 .md + -> MNote 记录 changed_files / diff / audit,并刷新 File Tree / Page Aggregate +``` + +进一步贴近 VSCode 的运行要求是: + +- Hermes / Reasonix 自身已有的权限设置优先复用;MNote 不重复发明第二套编辑能力。 +- `allowed_roots` 就是 agent 可见目录白名单;普通用户只拿到自己的 `my-space` 或显式授权目录,管理员可拿到更大白名单。 +- 页面 AI 面板默认只传“当前页面文件引用 + 可选 selection + 当前 root 白名单”,不把整个 page aggregate 或 block tree 作为必需前置。 +- agent 后台编辑完成后,MNote 只做文件变更侦测与 UI 重投影,让 tiptap 跟着文件刷新,而不是让 tiptap 成为写入真相。 + +因此 `mnote.doc.fetch`、`mnote.doc.markdown_edit`、`mnote.block.*` 不再被描述为 local-first 普通 Markdown 编辑的必经主路径。它们只保留三类价值: + +- 远端 / cloud agent 无法直接访问本机文件时的受控读写代理。 +- 兼容旧页面 AI / Hermes tool 调用。 +- 复杂结构对象或共享权限场景下的受控专用入口。 + +文件解析、agent 启动、内置 patch/diff 写入、兼容 mnote tools、搜索和附件读取都必须验证 `AiAccessScope`;权限边界由 MNote 控制,编辑算法优先交给 agent runtime 自身。 + +当前需要纳入迁移盘点的 AI / Hermes 工具入口: + +| 文件 | 当前职责 | local-first 迁移要求 | +| --- | --- | --- | +| `rust/crates/mnote-web/src/routes/hermes_tools.rs` | `mnote` 兼容 tool manifest / call / audit 路由,分发 `mnote.doc.*`、`mnote.block.*`、`mnote.page.*`、artifact 工具 | 不再作为 local-first 普通 Markdown 编辑主入口;兼容调用前仍解析 `AiAccessScope`,审计记录必须区分 local / cloud / shared,并记录 changed_files / diff 摘要 | +| `rust/crates/mnote-web/src/hermes_tools/manifest.rs` | 输出 Hermes tool manifest 与 input schema | manifest 要明确:本地默认优先 agent 原生 diff / apply_patch;`mnote.doc.*` 是兼容 / 远端代理工具;所有读写工具标注权限要求;本地附件不得改写成 Convex media asset | +| `rust/crates/mnote-web/src/hermes_tools/doc.rs` | `mnote.doc.fetch`、`mnote.doc.find`、`mnote.doc.plan_update`、`mnote.doc.markdown_edit` | 作为 cloud / remote agent / compat fallback 保留;local `.md` 写入时必须保留相对链接与 `{mdBase}.assets/`;读写前校验 `AiAccessScope` | +| `rust/crates/mnote-web/src/hermes_tools/block.rs` | `mnote.block.fetch/replace/insert_after/delete/move_after` 与 `mnote.doc.apply_block_ops` | 从当前 local-first 主线降级为复杂结构 / 历史兼容辅助;普通 Markdown 编辑不再要求走块级工具;若继续开放,必须校验共享写权限和 allowed target blocks | +| `rust/crates/mnote-web/src/hermes_tools/page.rs` | `mnote.page.get/save/update_title/update_options` 粗粒度页面工具 | `mnote.page.save` 不应成为默认正文编辑入口;local source 下标题/设置写入 `.md` frontmatter / `.mnote/page-options.json` 主路径;普通正文改写优先由 agent 文件 patch 完成 | +| `rust/crates/mnote-web/src/hermes_tools/artifact.rs` | AI 摘要、AI note artifact 类工具 | artifact 默认写入 local workspace / `ai-sessions` / `.mnote` 侧车文件;只有分享或同步开启时才进入控制面 | +| `rust/crates/mnote-web/src/routes/page_ai_workflow.rs` | 页面 AI 兼容门面,自然语言编辑曾转 `mnote.doc.markdown_edit` | local source 下不作为主路径,不再加厚 fast-path;普通正文编辑主路径应直接给 agent 授权文件引用并使用其内置 patch/diff,不得退回 Convex 写入或 media asset | +| `rust/crates/mnote-web/src/ssr/pages/layout.rs` | 页面 AI 面板、会话列表、工具开关、附件打开 UI 的浏览器侧脚本 | UI 文案与状态要区分本地会话、共享会话和 cloud 会话;local 附件打开优先走相对文件/本地 serving,不默认 `/api/media/sign?assetId=` | + +默认规则: + +- 私有 AI 会话只能读写 owner 的授权资源。 +- 分享页面进入 AI 上下文时,只带该分享范围,不带整个 workspace。 +- 分享 AI 会话时,分享的是会话记录与被授权上下文,不自动分享 owner 的全部笔记。 +- `shared_read` 范围内的 AI 不允许执行写入、patch 或任何可修改文件的内置工具。 +- `shared_write` 范围内的 AI 写入必须产生 audit 记录,至少包含 actor、session、changed_files 和 diff 摘要。 + +--- + +## 6. 当前项目迁移总原则 + +迁移目标不是新增一套 local UI,而是降低 Convex 在主链中的位置。 + +硬约束: + +- 不新增 `/api/local-*` 平行主链。 +- 不新增本地专用文件树 UI。 +- 不让本地文件夹先导入 Convex 再渲染。 +- 不让前端直接拼本地 path / Convex record 成树。 +- 不把页面正文、文件树、AI 会话继续设计成两套同等真相。 +- 树、页面元数据和共享权限写操作继续走 `tree.*` / `page.*` 语义入口;普通 Markdown 正文编辑允许由受限 agent runtime 直接对授权 `.md` 文件执行 diff / patch,MNote 负责权限、审计和刷新。 + +允许的过渡: + +- Convex executor 和 LocalFS executor 并存。 +- Convex 旧 route 通过 adapter 转入 Rust kernel。 +- 局部页面仍可先读 Convex,再逐步切到 LocalFS。 +- 控制面仍可临时使用 Convex Auth 与 membership。 + +--- + +## 7. 迁移阶段 + +### Phase 0:盘点并冻结新 Convex 扩写 + +目标:防止继续把新功能绑到 Convex 主数据层。 + +任务: + +- 标记所有当前 Convex 依赖路径:文件树、页面树、上传、下载、正文保存、页面设置、AI 会话、分享授权。 +- 新增功能默认禁止直接写 Convex documents / files / sessions。 +- 新设计必须先判断是否应落到 `WorkspaceSource` / Rust kernel / LocalFS。 + +验收: + +- 有一份 Convex 依赖矩阵。 +- 新增设计稿不再把 Convex 称为默认存储底座。 +- `ARCHITECTURE.md` 与当前优先级文档能指向本稿。 + +### Phase 1:本地工作区成为默认入口 + +目标:打开应用后默认进入 local workspace,而不是依赖 Convex workspace。 + +任务: + +- `desktop:hot` 默认加载最近本地 workspace。 +- 没有最近 workspace 时显示“打开文件夹 / 创建工作区”。 +- 登录态是访问本地工作区的门禁;分享、同步、云能力在登录基础上继续由控制面授权。 +- `.mnote/workspace.json` 写入 workspace id、owner、本地 capabilities。 + +验收: + +- 断开 Convex 后,本地工作区仍可打开。 +- 文件树、页面树、文档阅读态可用。 +- UI 明确展示当前 source 是本地工作区。 + +### Phase 2:文件树与页面树切到 LocalFS 默认源 + +目标:文件树、页面树默认从本地文件夹扫描和投影。 + +任务: + +- LocalFolderSource 输出 canonical snapshot。 +- File Tree 显示目录、`.md`、附件、mindmap、普通文件。 +- Page Tree 只显示页面导航语义,优先纳入 `.md`。 +- Convex workspace 继续作为可选 source executor。 + +验收: + +- 同一个 Explorer surface 可以渲染 local 与 Convex。 +- 文件树 / 页面树 UI 没有 source-specific 分支。 +- local rename / move / delete preflight 不依赖 Convex。 + +### Phase 3:页面编辑保存本地化 + +目标:正文编辑默认写回 `.md` 文件。 + +任务: + +- `page_aggregate` 从本地 markdown 解析生成。 +- `page.body.save` 对 local source 写入 markdown 文件。 +- 页面设置写入 `.mnote/page-options.json` 或 frontmatter。 +- 标题修改按规则更新 frontmatter / H1 / 文件名,避免三处不同步。 +- 保留 Convex save executor,但不作为默认。 + +验收: + +- Convex 不可用时仍可编辑、保存、刷新恢复。 +- AI `mnote.doc.markdown_edit` 与手动编辑写同一份 `.md`。 +- 页面标题、正文、页面设置刷新后一致。 + +### Phase 4:上传下载转成本地资源操作 + +目标:上传、拖入、下载都以本地文件为默认资源模型。 + +任务: + +- 页面内上传默认复制到 `/.assets/`,并在正文中写入相对路径。 +- 工作区级导入或跨页面共享素材可以复制到 `assets/`,并建立 resource identity。 +- `sourceKind=local_folder` 的编辑器上传不得调用 `/api/media/upload`。 +- `sourceKind=convex_workspace` 或后续 cloud source 仍可继续走远端 media upload executor。 +- 下载普通文件时直接读取本地文件。 +- 下载页面时导出 `.md` 或打包子树。 +- Convex storage 只用于远端同步或共享副本。 + +验收: + +- 本地附件上传不需要 Convex。 +- 本地 `.md` 页面上传图片后,落盘正文是 `![alt](relative/path)`。 +- 本地 `.md` 页面上传附件后,落盘正文是 `[name](relative/path)`。 +- 上传目标目录不越过 local root,冲突文件名递增,不覆盖已有文件。 +- 文件树能显示上传后的资源。 +- 刷新页面后图片 / 附件块能从相对路径恢复。 +- 删除页面或附件进入 `.mnote/trash/` 或系统回收策略。 + +### Phase 5:AI 会话本地化与隔离 + +目标:AI 会话默认按用户 / workspace / scope 本地持久化。 + +任务: + +- 私有会话写入 `ai-sessions/private`。 +- 共享会话写入 `ai-sessions/shared` 并绑定 share grant。 +- AI tool executor 每次调用都校验 `AiAccessScope`。 +- 只把必要的 session metadata / share metadata 同步到控制面。 + +验收: + +- 用户 A 的 AI 不读取用户 B 私有笔记。 +- 只读分享上下文中 AI 不能写。 +- 共享 AI 会话不会隐式共享整个 workspace。 + +### Phase 6:分享与同步控制面收口 + +目标:Convex 从主数据层降级为控制面和可选同步服务。 + +任务: + +- 控制面只保存 user、workspace membership、share grants、sync state、AI policy。 +- 页面正文和附件只在用户启用同步时产生远端副本。 +- 共享页面通过控制面授权,再由同步层分发或拉取内容。 +- 多人实时协作作为后续能力,不进入早期默认路径。 + +验收: + +- 本地个人使用不需要 Convex。 +- 创建、打开、编辑本地工作区需要登录;开启分享 / 同步 / 云协作时进一步需要控制面授权。 +- 关闭同步后,用户数据仍完整保留在本地工作区。 + +### Phase 7:退役 Convex 主存储路径 + +目标:清理历史 Convex documents / files / sessions 主链。 + +任务: + +- 将旧 `documents.*` route 降级为 compat adapter。 +- 将 Convex page body 写入改为 sync replica 写入。 +- 删除前端直接依赖 Convex 文档结构的路径。 +- 保留必要迁移工具,把旧 Convex workspace 导出为本地 workspace。 + +验收: + +- 新建、编辑、上传、下载、AI 编辑不依赖 Convex。 +- Convex 只在登录、分享、同步、协作能力开启时参与。 +- 旧 Convex 数据可以迁移到本地文件夹。 + +--- + +## 8. 可执行 Checklist + +本 checklist 用于后续迁移执行。状态只记录本稿方向下的真实主线,不回填历史已完成但不再作为目标模型的 Convex-only 能力。 + +### 8.1 P0:口径冻结与依赖矩阵 + +- [x] 新增本稿,固定 local-first workspace 作为早期产品默认形态。 +- [x] `ARCHITECTURE.md` 已加入 2026-05-18 local-first 口径。 +- [x] `design/01-05-current-priority-overview.md` 已把 local-first workspace 提升为第一优先级。 +- [x] 建立 Convex 依赖矩阵,至少覆盖: + - [x] File Tree source / projection / command。 + - [x] Page Tree source / projection / command。 + - [x] Page Aggregate read / save / options。 + - [x] 编辑器图片 / 附件上传。 + - [x] 普通文件下载 / 页面导出。 + - [x] AI session / message / tool call 存储。 + - [x] share grants / membership / auth。 +- [x] 给新增设计和实现加检查规则:默认不得把新功能直接绑定到 Convex documents / media / sessions 主存储。 + +#### P0.1 Convex 依赖矩阵(2026-05-18 代码盘点) + +| 范围 | 当前 Convex / 远端依赖证据 | local-first 迁移动作 | 当前状态 | +| --- | --- | --- | --- | +| Workspace source 默认值 | `rust/crates/core-protocol/src/kernel.rs` 已有 `WorkspaceSourceKind` / `WorkspaceSource`,但测试仍断言 `convex_workspace`;`rust/crates/mnote-web/src/ssr/pages/layout.rs::currentSourceKind()` 默认返回 `convex_workspace`;`rust/crates/mnote-web/src/routes/tree.rs` command context 默认也会回到 `convex_workspace`。 | P1/P2 把最近本地 workspace / `local_folder` 设为默认入口;Convex workspace 只作为显式 cloud source。 | 未迁移;有 local source 能力,但默认仍偏 Convex。 | +| File Tree source / projection / command | `rust/crates/mnote-web/src/routes/tree.rs` 已按 `sourceKind=local_folder` 分流到 `load_local_folder_file_tree_snapshot` / `execute_local_tree_command`,Convex 分支仍依赖 `workspaces:ensureDefaultWorkspace`、`documents:createWithParentReference`、`documents:updateTitle`、`documents:move`;`rust/crates/mnote-web/src/routes/web_shell.rs::load_file_tree_html` 仍可从 Convex sidebar dataset 投影 file tree。 | LocalFS 作为默认 `WorkspaceSource`;同一 Explorer surface 只消费 Rust projection;Convex executor 保留为可选 source,不再驱动默认首屏。 | 部分完成:本地 projection/command 已存在,默认入口和若干 UI 分支未收口。 | +| Page Tree source / projection / command | `rust/crates/mnote-web/src/routes/tree.rs` 与 `rust/crates/mnote-web/src/routes/web_shell.rs` 已支持 `load_local_folder_page_tree_snapshot`,但缺省 source 和 Convex tree command fixture 仍指向 `documents.*`。 | Page Tree 默认从本地 `.md` 页面语义投影;页面树命令统一走 `tree.*`,source-specific 执行藏在 executor。 | 部分完成:本地只读/命令测试存在,默认 source 未切。 | +| Page Aggregate read | `rust/crates/mnote-web/src/routes/web_shell.rs::build_page_aggregate_snapshot` 对 local source 调 `resolve_local_markdown_page_aggregate`;Convex / fixture 路径仍读 `documents:getMeta`、`documents:getContent`。 | `page_aggregate` 默认 provenance 为 `local_markdown.content`;Convex documents 仅作为 cloud source / sync replica 读取。 | 部分完成:local aggregate 已有,主默认仍未整体切换。 | +| Page body save / options | `rust/crates/mnote-web/src/routes/documents.rs` 在 `sourceKind=local_folder` 时写 `save_local_markdown_page` / `update_local_page_options`;Convex 路径仍通过 `page.body.save` 进入 `rust/crates/mnote-web/src/transport/convex.rs`,映射到 `documents:updateContent`。 | `page.body.save` 按 `WorkspaceSource` 选择 LocalFS executor;Convex `documents:updateContent` 改为同步副本或 cloud source executor,不再是默认正文保存。 | 部分完成:local save/options 已存在,Convex save 仍是 legacy 主分支之一。 | +| 编辑器图片 / 附件上传 | `rust/crates/mnote-web/src/routes/local_folder_source.rs::upload_local_markdown_asset` 已落盘到 sibling assets;`rust/crates/mnote-web/src/routes/media.rs` 仍提供 `/api/media/upload`、`mediaAssets:generateUploadUrl`、`mediaAssets:createWithStorage`、`mediaAssets:getById`;`rust/crates/mnote-web/src/ssr/pages/layout.rs` 仍保留 `/api/media/sign?assetId=` 与 OnlyOffice assetId 路径。 | local source 下编辑器上传只走 `/api/local-folder/assets/upload`,正文写相对 Markdown 链接;Convex media 只用于 cloud source / 同步 / 分享副本。 | 部分完成:本地上传基线完成,浏览器 smoke 与 assetId 打开链路仍需收口。 | +| 普通文件下载 / 页面导出 | `rust/crates/mnote-web/src/ssr/pages/layout.rs` 对 local asset 已有 `sourceKind=local_folder` 判断,但文件打开、签名、OnlyOffice URL 仍存在 `assetId` / `/api/media/sign` 路径;`rust/crates/mnote-web/src/routes/resource_trash.rs` 仍大量操作 `mediaAssets:*`。 | 普通本地文件下载直接读取 local root 内路径;页面导出 `.md`,子树导出目录与 assets;Convex media 下载只作为 cloud source 能力。 | 未完成:本地打开有局部判断,完整下载/导出模型未闭环。 | +| AI tool 读取 / 写入页面 | `rust/crates/mnote-web/src/routes/hermes_tools.rs` 分发 `mnote.doc.*` / `mnote.block.*`;`rust/crates/mnote-web/src/hermes_tools/doc.rs::doc_markdown_edit` 和 `rust/crates/mnote-web/src/hermes_tools/block.rs` 最终仍走 `page.body.save`,在线路径会落到 Convex `documents:updateContent`。 | tool context 必须携带 `sourceKind/rootUri/workspaceId/documentId/AiAccessScope`;local source 写入同一份 `.md`,并保留 `{mdBase}.assets/` 相对链接。 | 部分完成:`mnote.doc.markdown_edit` local source 已写同一份 `.md` 并保留相对链接;`AiAccessScope` 权限收口仍未完成。 | +| AI session / message / tool call 存储 | `convex/schema.ts` 与 `convex/aiSessions.ts` 定义 runtime run/event/session;`rust/crates/mnote-web/src/routes/hermes_client.rs` 调用 `aiSessions:upsertRuntimeRun`、`appendRuntimeEvent`、`listRuntimeRuns`、`listRuntimeEvents`、`renameRuntimeSession`、`autoTitleRuntimeSession`、`deleteRuntimeSession`、`searchRuntimeSessions`。 | 私有会话默认写 `ai-sessions/private/*.jsonl`;共享会话写 `ai-sessions/shared//*.jsonl`;Convex 只同步必要 metadata / share metadata / quota policy。 | 未完成:当前会话主存储仍是 Convex aiSessions。 | +| share grants / membership / auth | `rust/crates/mnote-web/src/context.rs` 与 `rust/crates/mnote-web/src/transport/convex.rs` 处理 auth context / `__convexAuthJWT`;`rust/crates/mnote-web/src/routes/hermes_client.rs` 和 `routes/hermes_tools.rs` 使用 `ensure_authenticated`;`rust/crates/mnote-web/src/routes/tree.rs` 仍调用 `workspaces:ensureDefaultWorkspace`。 | Convex 保留为控制面:auth、membership、share grants、sync state、AI policy;本地打开和编辑需要登录 actor,并由 `.mnote/workspace.json.owner_id` 限定 root 归属;分享/同步继续要求控制面授权。 | 部分符合目标:auth 适合留在控制面,但当前仍有部分 AI / workspace 主路径待收紧。 | +| Smoke / 测试依赖 | `scripts/task159-auth-entry-smoke.js` 依赖 Convex password auth;`scripts/task175-rust-upload-entry-smoke.js`、`task427`、`task428`、`task433`、`task434` 等覆盖 Convex media / trash / workspace 行为;Rust 测试中仍断言 `documents:*`、`mediaAssets:*`、`aiSessions:*`。 | 后续新增 smoke 默认先覆盖 local workspace;Convex smoke 改名为 cloud/control-plane/compat 类,避免被误认为默认主链验收。 | 未完成:测试口径仍混合,需要随 P1-P7 分批改名和补 local smoke。 | + +#### P0.2 新增设计 / 实现检查规则(2026-05-18 已落地) + +已新增 `scripts/check-local-first-convex-guard.js`,并在 `package.json` 暴露 `check:local-first-convex-guard`。该检查默认扫描相对 `HEAD` 的新增行,也支持: + +- `--base `:检查相对指定 git ref 的新增行。 +- `--staged`:检查 staged diff。 +- `--files `:检查指定文件全文,便于局部验证。 + +检查规则: + +- 设计稿检查:新增 `design/**/*.md` 若把 Convex 描述为默认 documents / media / sessions 主存储,会要求改成 `WorkspaceSource` / Rust kernel / LocalFS 默认路径,或明确标注为 cloud source / compat / sync replica。 +- 代码检查:新增 `rust/crates/mnote-web/src/**/*.rs` 运行时代码不得在非 Convex adapter 路径直接新增 `documents:*`、`mediaAssets:*`、`aiSessions:*` 主存储调用;确有 cloud / compat 例外时,必须在同一新增行加入 `local-first-allow-convex-main-storage` 并说明原因。 +- 测试口径:新增 smoke 默认优先覆盖 `sourceKind=local_folder`;Convex 专项测试需在文件名或标题中标注 `cloud`、`control-plane`、`compat` 或 `convex-source`。 + +### 8.2 P1:本地工作区入口 + +- [x] 登录后,`desktop:hot` 默认优先加载当前用户最近本地 workspace。 + - 证据:`SIDEBAR_TREE_JS::autoOpenRecentLocalRootOnHome` 已能在已登录主壳根路径、无显式 source/workspace/page 参数时读取当前 actor 作用域的 `mnote.localFolder.recentRoots:` 并跳转最近 `rootUri`;`task164-desktop-hot-local-folder-main-entry-smoke.js` 已断言不写入跨用户共享的 legacy key。 +- [x] 登录后,没有最近 workspace 时展示“打开文件夹 / 创建工作区”,不要求先进入 Convex workspace。 + - 说明:登录是访问本地文件的门禁;未登录 `/` 和未登录 `sourceKind=local_folder` 必须跳 `/auth`,避免注册用户或匿名上下文绕过用户隔离直接访问本机任意目录。 +- [x] `.mnote/workspace.json` 写入稳定 `workspace_id`、`owner_id`、`created_at`、`capabilities`。 + - 目录:默认写入 `/mnt/Data1T/Mnote_data/users//workspaces/my-space/.mnote/workspace.json`。 + - 边界:普通用户创建入口只初始化受管默认路径;任意外部 `rootUri` 只有在管理员授权或 owner 已存在时才能打开,不能被普通登录用户直接初始化或接管。 +- [x] 登录状态是本地文件访问、用户空间隔离、分享、同步、云端协作的共同门禁;Convex 不再是正文/附件主存储,但 auth 仍是本地 workspace 访问控制面。 + - 证据:`sourceKind=local_folder` 根入口继续要求 auth;本地文件读写入口新增 `.mnote/workspace.json.ownerId` 与当前 `actorId` 匹配校验;`RequestContext` 已支持从同源 `mnote_actor_id` / `mnote_actor_type` cookie 回填 actor,避免浏览器 `fetch` / `EventSource` 只能依赖手工 header。 + - 边界:当前 `RequestContext` 的可信身份仍来自网关/上游 auth 注入或同源 actor cookie,后续真实账号体系要继续收紧 header 伪造边界。 +- [x] `sourceKind=local_folder` 的 root 入口保持登录要求,未登录会跳转 `/auth`。 + - 证据:`rust/crates/mnote-web/src/routes/gateway.rs::root_entry` 在 source 分流前执行 auth gate;`root_entry_redirects_anonymous_viewer_to_auth` 覆盖匿名根入口。 +- [x] LocalFolderSource / local API 必须校验当前登录 actor 是否被允许访问传入 `rootUri`。 + - 要求:不能只靠前端隐藏入口;`/api/tree/*`、`/api/documents/*`、`/api/local-folder/assets/upload`、`/api/page-aggregate/*` 等所有 local source 入口都必须拒绝未授权 root。 + - 初始策略:每个用户有自己的默认本地“我的空间”;`.mnote/workspace.json.owner_id` 必须匹配当前 actor,或 root 必须位于当前 actor 的受管 workspace 根目录下,或当前 actor 是管理员,或 rootUri 落在管理员显式授权目录内。 + - 安全风险:如果只要求“已登录”但不校验 root 归属,任意注册用户仍可能传入本机任意 `file://` 目录并执行树命令或删除文件。 + - 已覆盖入口:`/` local root、`/documents/{id}`、`/api/page-aggregate/{id}`、`/api/documents/save|title|options`、`/api/tree/commands`、`/tree` debug shell、`/api/tree/local-folder-watch`、`/api/local-folder/events`、`/api/local-folder/assets/upload`。 + - 证据:`ensure_local_workspace_access` / `ensure_local_workspace_read_access` 统一解析 canonical root,并按 admin / owner / grant 三层授权;`local_workspace_access_rejects_owner_mismatch` 覆盖非 owner 拒绝,`local_workspace_access_allows_admin_without_manifest` 覆盖管理员可访问任意目录,`local_workspace_access_policy_grants_read_and_write_separately` 覆盖 read/write grant 分离,`tree_command_local_folder_rejects_non_owner_root` 覆盖已登录非 owner 不能执行本地 tree 写命令。 + - 浏览器证据:`node scripts/task164-desktop-hot-local-folder-main-entry-smoke.js` 已在 `desktop:hot` 下通过;smoke 显式预置 owner manifest,覆盖 actor 作用域最近目录、切换本地 root、刷新回到最近本地 root。 + - 边界:Hermes tools 直接绝对路径读写属于 P5 `AiAccessScope` 收口,不计入本项完成范围。 +- [x] Convex 不可用时,本地 workspace 入口仍可进入文档页。 + - 证据:`page_aggregate_endpoint_returns_local_markdown_readonly_snapshot`、`document_shell_renders_local_markdown_with_same_sidebar_surfaces`、`document_shell_renders_local_markdown_attachment_name_in_html` 在 `convex_url=None` 的 Rust app 下通过;测试前显式创建 `.mnote/workspace.json` owner manifest。 +- [x] smoke 覆盖:断开 Convex 后创建默认本地 workspace、打开 `.md`、刷新仍可读。 + - 证据:`node scripts/task166-local-first-managed-workspace-no-convex-smoke.js` 启动独立 mnote-web,使用临时 `MNOTE_LOCAL_WORKSPACE_BASE_DIR` 和不可用 Convex URL,浏览器点击“创建我的空间”,验证 manifest / 默认 `.md` 落盘,并在刷新后读取 `local_markdown.content` page aggregate。 + +### 8.3 P2:LocalFS File Tree / Page Tree 默认 source + +- [x] 既有 `4-22` 已完成 Local Folder / Convex 统一树源执行基线。 +- [x] 将 LocalFS 明确设为默认 `WorkspaceSource`。 + - 证据:`root_entry` 在无显式 `sourceKind/workspaceId/pageId` 时渲染 local-first landing,不再先调用 `workspaces:ensureDefaultWorkspace`;`SIDEBAR_TREE_JS::currentSourceKind()` 在首页无显式 source 参数时默认返回 `local_folder`。 +- [x] File Tree 默认从 local root 扫描目录、`.md`、普通文件、mindmap、附件。 + - 证据:`load_local_folder_file_tree_snapshot` 通过 `scan_directory` 输出统一 `file_tree` projection;`tree_shell_filetree_mode_can_open_local_folder_readonly_snapshot` 与 `task164-desktop-hot-local-folder-main-entry-smoke.js` 已通过。 +- [x] Page Tree 默认只消费页面导航语义,优先纳入 `.md`。 + - 证据:`load_local_folder_page_tree_snapshot` 通过 `scan_markdown_page_tree` 只纳入 Markdown 页面语义;`tree_shell_page_mode_can_open_local_folder_md_only_snapshot` 已通过。 +- [x] `tree.node.create` 在 local source 下创建本地 `.md` 或文件夹。 + - 证据:`execute_local_tree_command` 已将 `create` / `createFolder` 分发到 `create_local_markdown_page` / `create_local_folder`;`cargo test -p mnote-web local_folder -- --nocapture` 覆盖本地 tree command 创建链路。 +- [x] `tree.node.rename` 在 local source 下执行文件 rename,并保持 page id 稳定。 + - 证据:`rename_local_markdown_page` / `rename_local_directory` 更新 `.mnote/page-ids.json`;`local_frontmatter_mnote_id_survives_rename_and_move` 与 `tree_command_local_folder_create_rename_copy_trash_restore_and_purge_use_same_endpoint` 已通过。 +- [x] `tree.subtree.move` 在 local source 下执行文件系统 move,并保持 local root 边界。 + - 证据:`move_local_markdown_page` / `move_local_directory` 使用 root 内目标目录并拒绝自身/后代移动;`tree_command_local_folder_create_rename_copy_trash_restore_and_purge_use_same_endpoint` 已通过。 +- [x] `tree.node.delete` 在 local source 下进入 `.mnote/trash/` 或系统回收策略。 + - 证据:`trash_local_markdown_page` / `trash_local_raw_file` 写入 `.mnote/trash` 与 `trash-index.json`;`tree_command_local_folder_asset_trash_restore_and_purge_use_trash_index` 已通过。 +- [x] 文件树 / 页面树 UI 不新增 local-only 分支,只消费 projection。 + - 证据:local / Convex source 均通过 Rust projection 渲染同一个 `sidebar-file-tree-root` / `sidebar-tree-root` surface;local asset 打开通过 row identity 分流,不新增第二套 local-only 文件树 UI。 +- [x] smoke 覆盖 local create / rename / move / delete / refresh。 + - 证据:`cargo test -p mnote-web local_folder -- --nocapture` 覆盖 local create / rename / move / trash / restore / purge;`task164` 覆盖 local root 打开与刷新回到最近 workspace;`task436` 覆盖刷新 / watcher 同步。 + +### 8.4 P3:Page Aggregate 本地 Markdown 读写闭环 + +- [x] `page_aggregate` 已能从 `documents.content` / local markdown content 投影 `blockDocument`。 +- [x] local source 下 `page_aggregate` provenance 标记为 local markdown,而不是 Convex documents。 + - 证据:`resolve_local_markdown_page_aggregate` 输出 `projection_source="local_markdown.content"`;`task166-local-first-managed-workspace-no-convex-smoke.js` 刷新后验证 page aggregate 包含 `local_markdown.content`。 +- [x] `page.body.save` 对 local source 写回 `.md` 文件。 + - 证据:`documents.rs` local source 分支调用 `save_local_markdown_page`;`local_markdown_save_preserves_frontmatter_and_writes_basic_blocks`、`local_markdown_save_writes_image_blocks_as_markdown_images` 已通过。 +- [x] 页面设置写入 `.mnote/page-options.json` 或 frontmatter,规则只能有一个主路径。 + - 证据:`update_local_page_options` 写 `.mnote/page-options.json`;`local_page_options_metadata_flows_into_page_aggregate` 与 `local_folder_documents_save_title_and_options_write_to_disk` 已通过。 +- [x] 标题修改规则固定为 frontmatter `title` > H1 > 文件名,并避免三处长期分叉。 + - 证据:`parse_markdown_page` 读取顺序为 frontmatter `title`、第一行 H1、文件名;`task167-local-markdown-title-body-options-no-convex-smoke.js` 已验证 frontmatter 标题优先于正文 H1。 +- [x] 保存时检查 mtime / content hash,外部编辑冲突不得静默覆盖。 + - 证据:`save_local_markdown_page` 校验 `expected_conflict_detection_key`;`local_markdown_save_rejects_stale_external_file_change` 已通过。 +- [x] AI `mnote.doc.markdown_edit` 与手动编辑写同一份 `.md`。 + - 证据:`hermes_tools_markdown_edit_local_folder_writes_same_markdown_file` 在 `sourceKind=local_folder/rootUri=file://...` 下写回同一份 `README.md`,并保留 `README.assets/photo.png` 相对链接,不改写为 `/api/media` 或 `assetId`。 +- [x] smoke 覆盖本地标题、正文、页面设置、刷新恢复。 + - 证据:`node scripts/task167-local-markdown-title-body-options-no-convex-smoke.js` 在不可用 Convex URL 下创建受管本地 workspace,调用 `/api/documents/title`、`/api/documents/save`、`/api/documents/options`,再读取 page aggregate 与落盘 `.md` / `.mnote/page-options.json`。 +- [x] 将 `/api/documents/save` 从长期写入口降级为 compat adapter;新增或收口到 local-first `page.body.write` / kernel-native 写命令,写入目标是 `.md` 文件而不是 Convex documents 主存储。 + - 证据:`POST /api/page-body/write` 已新增;local source 的 `/api/documents/save` 只构造 `PageBodyWriteRequest` 并调用 `write_local_markdown_page_body`,返回 `canonicalCommand=page.body.write` / `compatCommand=page.body.save`。 +- [x] `page.body.write` 必须接收 `expectedFileVersion` / `baseContentHash`,拒绝基于旧文件版本的静默覆盖。 + - 证据:`PageBodyWriteRequest` 支持 `expectedFileVersion/baseContentHash`;`write_local_markdown_page_body` 复用本地文件版本校验,`local_folder_documents_save_rejects_stale_expected_file_version` 已覆盖。 +- [x] `Page Aggregate` 写侧区分 canonical markdown 与 runtime cache:local-first 下 `.md` 是正文真相,`EditorBlockDocument` 可以作为 `.mnote/cache` 或内存投影缓存,但不能成为第二正文真相。 + - 证据:local source 写入链只落盘 `.md` 和 `.mnote/page-options.json`;`EditorBlockDocument` 作为 `contentFormat=editorBlocks` 输入被序列化为 markdown,不写成第二正文真相。 +- [x] tiptap 保存链不再直接依赖 `documents/save` 兼容面决定 revision;它应提交基于当前 `baseFileVersion` 的 markdown 写入意图,由 LocalFS executor 做版本校验。 + - 证据:local source editor bootstrap / 动态 pane bootstrap 均指向 `/api/page-body/write`,payload 显式带 `expectedFileVersion`、`contentFormat=editorBlocks`、`editorSource=tiptap`。 +- [x] 文件 watcher 发现 AI / 外部编辑器写入后,若 tiptap clean 则自动刷新,若 tiptap dirty 则进入 conflict state。 + - 证据:`scripts/task436-local-markdown-open-document-external-change-smoke.js` 覆盖 clean 自动刷新与 dirty 进入 `external-change-conflict`。 +- [x] smoke 覆盖:tiptap dirty 时 AI 后台修改同一 `.md`,前台不得静默覆盖任一方,必须显示冲突或保留可合并状态。 + - 证据:`node scripts/task436-local-markdown-open-document-external-change-smoke.js` 已通过 dirty 文档 AI 后台写入冲突场景。 +- [x] smoke 覆盖:tiptap clean 时 AI 后台修改同一 `.md`,前台 page aggregate / tiptap 自动同步到新内容。 + - 证据:`node scripts/task436-local-markdown-open-document-external-change-smoke.js` 已通过 clean 文档外部写入自动刷新场景。 + +### 8.5 P4:本地上传 / 下载 / 资源 + +- [x] 已新增 `POST /api/local-folder/assets/upload`。 +- [x] 本地 `.md` 上传图片 / 附件已分流,不再调用 `/api/media/upload`。 +- [x] 本地上传复制到 `/.assets/`。 +- [x] 本地上传返回 `sourcePath` / `file_url` 相对路径。 +- [x] 本地附件插入普通相对路径链接,不生成 OnlyOffice assetId URL。 +- [x] 本地图片保存为标准 Markdown 图片语法。 +- [x] 单测覆盖本地上传目标路径、冲突递增、local markdown 图片 / 附件 roundtrip。 +- [x] HTTP smoke 覆盖真实 multipart 上传与落盘。 +- [x] 浏览器 smoke 覆盖本地 `.md` 页面上传图片、保存、刷新后恢复。 + - 证据:`node scripts/task443-local-markdown-asset-upload-smoke.js` 已验证图片上传到 sibling `README.assets/`、通过 `/api/page-body/write` 保存为相对 markdown 图片,并刷新后从 Page Aggregate 恢复。 +- [x] 浏览器 smoke 覆盖本地 `.md` 页面上传附件、保存、刷新后恢复。 + - 证据:`node scripts/task443-local-markdown-asset-upload-smoke.js` 已验证附件上传到 sibling `README.assets/`、保存为相对 markdown 链接,并刷新后从 Page Aggregate 恢复。 +- [x] 浏览器 smoke 覆盖上传后的 `{mdBase}.assets/` 资源在 File Tree 可见。 + - 证据:`scripts/task443-local-markdown-asset-upload-smoke.js` 已补强断言 `sidebar-file-tree-root` 中出现 `task443-image.png` 与 `task443-spec.pdf`。 +- [x] 下载普通本地文件时直接读取 local root 内文件。 + - 证据:`GET /api/local-folder/files/open` 已新增并先校验 actor 对 `rootUri` 的访问权,再拒绝绝对路径 / `..` 越界;`cargo test -p mnote-web local_file_open_rejects_root_escape -- --nocapture` 已通过。 +- [x] 下载页面时导出 `.md`,下载子树时打包目录与 assets。 + - 证据:单个本地 `.md` 页面和普通文件通过 `/api/local-folder/files/open?download=true` 直接读取 root 内文件;旧 Convex 子树迁移 / 打包由 `scripts/export-convex-workspace-to-local.js` 输出目录与 sibling assets。 +- [x] 删除页面时同步处理 sibling assets 的策略明确:保留、移动到 trash 或提示用户。 + - 策略:当前 local source 删除 Markdown 页面只移动 `.md` 到 `.mnote/trash/`,sibling `{mdBase}.assets/` 默认保留,避免误删仍被其他页面相对引用的资源;后续若要联动移动 assets,必须先做引用扫描和确认 UI。 + +### 8.6 P5:AI 会话本地化与权限隔离 + +#### P5.1 Agent 文件访问 scope 与兼容 manifest + +- [x] 定义 `AiAccessScope` 的 Rust 类型和序列化格式,至少包含 `user_id/workspace_id/session_id/source_kind/permission_level/allowed_roots/allowed_file_paths/allowed_resource_ids/share_context`。 + - 证据:`core_protocol::AiAccessScope`、`AiAccessPermissionLevel`、`AiShareContext` 已定义并导出;`cargo test -p core-protocol ai_access_scope -- --nocapture` 已通过。 +- [x] 页面 AI / ACP run 创建前,把当前 `rootUri + documentId + selection` 解析成受控文件引用,并把 agent cwd 限制到授权 workspace。 + - 证据:local source run payload 收敛为 `fileReference + aiAccessScope + selection`;`AcpClient::spawn_with_env` 在授权 root 存在时以该 root 作为 cwd。 +- [x] Hermes / Reasonix runtime 启动参数显式带上白名单目录(等价于 IDE workspace roots),避免 agent 能看见 root 之外的路径。 + - 证据:local ACP run 注入 `MNOTE_AI_ALLOWED_ROOTS_JSON`、`MNOTE_AI_WORKSPACE_ROOT`、`MNOTE_AI_ACCESS_SCOPE_JSON`;`cargo test -p mnote-web acp_runtime -- --nocapture` 已通过。 +- [x] 本地 agent runtime 优先使用自身成熟的 diff / apply_patch / 文件编辑能力;MNote 不再把 `mnote.doc.markdown_edit` 描述为 local-first 唯一主路径。 + - 证据:local source 跳过 `page-ai/block-edit-workflow` fast path;manifest 已把 `mnote.doc.markdown_edit` 描述为兼容 / 远端代理 fallback。 +- [x] MNote 记录每次 AI 写入的 `sourceKind`、`workspaceId`、`documentId`、`permissionLevel`、`shareId`、`changedFiles`、`diffSummary`。 + - 证据:共享本地 AI 会话创建已写入 `audit.shared_write`,包含 `sourceKind/workspaceId/documentId/permissionLevel/shareId/changedFiles/diffSummary`;`cargo test -p mnote-web hermes_client_local_shared -- --nocapture` 已通过。 +- [x] `routes/hermes_tools.rs` 仍在兼容 tool dispatch 前解析并校验 `AiAccessScope`。 + - 证据:兼容 tool dispatch 前已解析 `aiAccessScope.permissionLevel=shared_read` 并拒绝写工具;`doc_fetch/doc_find/aggregate_value` 已按 `allowedResourceIds` 拒绝越界读取。 +- [x] `hermes_tools/manifest.rs` 为保留的兼容读工具标注 `readOnly=true` 或等价 annotations。 + - 证据:manifest 读工具 annotations 已带 `readOnly=true`;`cargo test -p mnote-web manifest -- --nocapture` 已通过。 +- [x] `hermes_tools/manifest.rs` 为保留的兼容写工具标注 `requiresWritePermission=true`,并说明本地默认优先 agent 原生 patch/diff。 + - 证据:manifest 写工具 annotations 已带 `requiresWritePermission=true`,`mnote.doc.markdown_edit` 文案明确 local-first 优先 agent 原生 patch/diff。 +- [x] manifest schema 明确 local source 下附件引用保持相对路径,不允许改写为 Convex media asset 或 OnlyOffice assetId。 + - 证据:manifest 与 local markdown edit 测试均覆盖 local source 相对附件路径保持;`hermes_tools_markdown_edit_local_folder_writes_same_markdown_file` 已通过。 + +#### P5.2 文件级读取 / 编辑与 `mnote.doc.*` 兼容层 + +- [x] 前端普通页面 AI 只传当前文件引用、可选 selection 和用户指令,不再构造完整 page context 作为默认输入。 + - 证据:local source 下 `build_run_upstream_body` 把 instructions 收敛为文件引用 scope,并裁掉 `pageText/pageXml/contextBlocks`。 +- [x] 本地 runtime 接收真实授权 `.md` 文件路径或等价 file handle,并在 allowed roots 内执行 patch/diff。 + - 证据:local source ACP run 注入授权 root 与 `fileReference`,并把 agent cwd 限制到授权 workspace。 +- [x] 写入后 MNote 通过文件 watcher 或显式 refresh 重新生成 Page Aggregate / File Tree,不要求 agent 调 `mnote.doc.markdown_edit` 才能刷新。 + - 证据:`task436` 已覆盖后台文件写入后 clean 自动刷新、dirty 冲突。 +- [x] tiptap island 作为显示层消费刷新后的 Page Aggregate;后台文件编辑后前台页面无需经过专用 MNote AI 工具回写。 + - 证据:local folder watcher / refresh 链路触发 Page Aggregate 重新拉取,tiptap clean 状态自动同步。 +- [x] 兼容 `hermes_tools/doc.rs::doc_fetch` 执行前校验 `AiAccessScope` 的读权限。 + - 证据:`ensure_ai_scope_resource_allowed` 按 `aiAccessScope.allowedResourceIds` 校验当前 `documentId`;`cargo test -p mnote-web hermes_tools_doc_fetch_rejects_out_of_scope_ai_resource -- --nocapture` 已通过。 +- [x] 兼容 `doc_fetch` 对 local `.md` 返回 markdown 时保留相对附件路径。 + - 证据:local `.md` 读取不走 Convex media rewrite,直接返回 markdown / block projection 中的相对路径;`hermes_tools_markdown_edit_local_folder_writes_same_markdown_file` 覆盖相对附件路径不被改写。 +- [x] `doc_fetch` 对 shared scope 只返回授权页面 / 子树 / selection,不扩展到整个 workspace。 + - 证据:`doc_fetch` 是单文档读取工具,并已按 `allowedResourceIds` 拒绝越界 documentId;selection/block scope 继续只返回显式选择块。 +- [x] `doc_find` 搜索范围受 `allowed_resource_ids` 限制。 + - 证据:`doc_find` 入口同样调用 `ensure_ai_scope_resource_allowed`,只在当前授权 documentId 的 Page Aggregate 内搜索。 +- [x] `plan_update` 只做 dry-run,不产生任何本地文件或 Convex 写入。 + - 证据:`hermes_tools_plan_update_and_block_move_after_are_dry_run_only` 覆盖该约束。 +- [x] 兼容 `doc_markdown_edit` 执行前校验写权限,`shared_read` 必须返回明确禁止写错误。 + - 证据:`routes/hermes_tools.rs` 在兼容 tool dispatch 前统一识别 `aiAccessScope.permissionLevel=shared_read`,非 dry-run 写工具返回 `mnote_tool_shared_read_write_forbidden`;`cargo test -p mnote-web hermes_tools_markdown_edit_shared_read_is_forbidden -- --nocapture` 已通过。 +- [x] 兼容 `doc_markdown_edit` 对 local source 写同一份 `.md` 文件,不经 Convex。 +- [x] 兼容 `doc_markdown_edit` 保留 `{mdBase}.assets/` 相对链接,不把图片 / 附件改写为 `/api/media/*` 或 `/onlyoffice?...assetId=...`。 +- [x] 兼容 `doc_markdown_edit` 写入后回读,确认最终 markdown 与落盘文件一致。 + - 证据:`hermes_tools_markdown_edit_local_folder_writes_same_markdown_file`。 + +#### P5.3 `mnote.block.*` 与 `mnote.doc.apply_block_ops`(延后 / 可选) + +- [x] 当前 local-first 普通 Markdown 编辑不再把 `mnote.block.*` 或 `mnote.doc.apply_block_ops` 列为主路径验收项。 +- [x] 若后续继续开放 `block_fetch`,执行前校验读权限和目标 block 所属 resource。 + - 证据:块工具读取 Page Aggregate 前会经过 `aggregate_value`,该入口已按 `AiAccessScope.allowedResourceIds` 校验当前 documentId。 +- [x] 若后续继续开放 `block_replace` / `block_insert_after` / `block_delete` / `block_move_after`,执行前校验写权限。 + - 证据:`routes/hermes_tools.rs` 在 dispatch 前对所有非 read tool 执行 shared_read 写禁用;块写工具自身继续要求 idempotency / dryRun。 +- [x] 若后续继续开放 `doc_apply_block_ops`,执行前校验 `allowedTargetBlockIds` 与 `allowed_resource_ids`。 + - 证据:`doc_apply_block_ops` 既经过 `aggregate_value` 的 resource scope 校验,也复用 `ensure_allowed_target` / `allowedTargetBlockIds` 约束。 +- [x] local source 下块级写入必须回到统一 page aggregate / markdown save 路径,不允许新增 Convex-only 写链。 + - 证据:local source 下普通 Markdown 编辑主路径不再要求块工具;若经兼容 markdown/page 工具写入,已回到 `write_local_markdown_page_body` / `page.body.write`。 +- [x] `shared_read` 下所有块级写工具返回禁止写错误。 + - 证据:写工具 dispatch 前的 shared_read 守卫覆盖 `mnote.block.*` 与 `mnote.doc.apply_block_ops` 等所有非 read tool。 +- [x] `shared_write` 下所有块级写工具产生 audit 记录。 + - 证据:所有 mnote tool call 统一经过 `execute_mnote_tool_call` 的 started/completed/failed audit;shared_write 会保留 toolName、workspaceId、documentId、session/run/toolCallId。 +- [x] 复杂块、resource 块、mindmap 块、table 块继续默认不可由 AI 精确写入,除非后续单独开放工具合同。 + - 证据:块写工具保留复杂 / 嵌套块拒绝测试,普通 local-first Markdown 编辑不把复杂 resource 块精确写入列为主路径。 + +#### P5.4 `mnote.page.*` 与 artifact 工具 + +- [x] `mnote.page.get` 执行前校验读权限。 + - 证据:`mnote.page.get` 复用 `ensure_ai_scope_resource_allowed` 按 `allowedResourceIds` 校验读 scope;`cargo test -p mnote-web hermes_tools_page_get_rejects_out_of_scope_ai_resource -- --nocapture` 已通过。 +- [x] `mnote.page.save` 降级为粗粒度兜底,不作为普通正文编辑默认入口。 + - 证据:manifest 已将 `mnote.page.save` 标记为粗粒度兼容兜底;local-first 普通正文编辑主路径仍是 agent 文件 patch/diff 或 `/api/page-body/write`。 +- [x] local source 下 `mnote.page.save` 若仍保留,必须写 `.md` / frontmatter / `.mnote/page-options.json`,不得写 Convex documents。 + - 证据:`hermes_tools/page.rs::page_command` 对 `sourceKind=local_folder` 分流到 `write_local_markdown_page_body` / `update_local_markdown_title` / `update_local_page_options`;`cargo test -p mnote-web hermes_tools_page_save_local_folder_writes_markdown_file -- --nocapture` 已通过。 +- [x] `update_title` 的 local source 写入规则与 Page Aggregate 标题规则一致。 + - 证据:local source `mnote.page.update_title` 复用 `update_local_markdown_title`,即写 frontmatter title 并与 Page Aggregate 标题优先级一致。 +- [x] `update_options` 的 local source 写入 `.mnote/page-options.json` 或 frontmatter,不能出现第二套页面设置真相。 + - 证据:local source `mnote.page.update_options` 复用 `update_local_page_options`,写 `.mnote/page-options.json`。 +- [x] `artifact.create_summary` / `artifact.create_ai_note` 默认写入本地 `ai-sessions` 或 `.mnote/artifacts`,只有分享 / 同步开启时才同步 metadata 到控制面。 + - 证据:local source 下 artifact 工具写 `.mnote/artifacts/*.json`,不调用 Convex;`cargo test -p mnote-web hermes_tools_artifact_summary_local_folder_writes_sidecar_file -- --nocapture` 已通过。 + +#### P5.5 AI session 存储与 UI + +- [x] 私有 AI 会话默认写入 `ai-sessions/private/.jsonl`。 + - 证据:local source session create / ACP run / event 均写 private JSONL;`cargo test -p mnote-web hermes_client_local_acp -- --nocapture` 已通过。 +- [x] 共享 AI 会话写入 `ai-sessions/shared//.jsonl`。 + - 证据:`shared_write` 写入 shared JSONL 并记录 `audit.shared_write`;`shared_read` 创建写会话返回 403。`cargo test -p mnote-web hermes_client_local_shared -- --nocapture` 已通过。 +- [x] AI session metadata 可以同步到控制面,但消息全文默认不写入 Convex。 + - 证据:local source 分支返回 `persistence=local_ai_session_jsonl` 并绕过 Convex `aiSessions:*`;cloud/acp 非 local source 仍走 `convex_acp_runtime_store`。 +- [x] 页面 AI 面板区分 local / shared / cloud session,不再把 `source=acp` 简化显示为“Convex”。 + - 证据:`layout.rs` 中新增 `pageAiSessionStorageLabel`,会话归一化和创建流程透传 `persistence/sessionStorage/permissionLevel/shareId`;历史列表与当前会话状态显示“本地私有 / 共享会话 / 云端会话”。`cargo test -p mnote-web page_ai_session_ui_labels_local_shared_and_cloud_storage -- --nocapture` 已通过。 +- [x] `page_ai_workflow` 仅作为兼容门面,local source 下不走 fast-path;普通正文编辑主路径直接给 agent 授权文件引用并使用其内置 patch/diff,`mnote.doc.markdown_edit` 只作为兼容 / 远端代理 fallback。 + - 证据:`pageAiTryBlockEditWorkflow` 在 `currentSourceKind() === 'local_folder'` 时直接返回 `false`;`page_ai_fast_path_is_not_local_first_main_path` 覆盖该约束。 +- [x] AI 会话分享不得隐式分享整个 workspace。 + - 证据:共享 session 创建只接收 `shareId/permissionLevel` 并写入 `ai-sessions/shared//`;写入 audit 记录 shareId,不扩展 workspace 全量授权。 + +#### P5.6 验收 + +- [x] 单测覆盖 agent 文件引用解析在 local source 下不能越过 `allowed_roots / allowed_file_paths`。 + - 证据:`acp_runtime_env_limits_local_source_to_allowed_root` 覆盖普通用户 run 只包含自己的授权 root,不包含其他用户目录。 +- [x] 单测覆盖 `shared_read` 下禁止 agent patch / 写工具 / 兼容 `mnote.doc.markdown_edit` 写入。 + - 证据:`cargo test -p mnote-web hermes_tools_markdown_edit_shared_read_is_forbidden -- --nocapture` 已通过。 +- [x] 单测覆盖兼容 `mnote.doc.markdown_edit` 在 local source 下保留 `{mdBase}.assets/` 相对路径。 + - 证据:`hermes_tools_markdown_edit_local_folder_writes_same_markdown_file`。 +- [x] 若继续开放块工具,再补 `mnote.block.*` 写工具在 out-of-scope block 上拒绝的单测。 + - 证据:`hermes_tools_block_tools_selection_scope_rejects_out_of_scope_targets` 与 `hermes_tools_markdown_edit_rejects_selection_out_of_scope` 已覆盖 selection / target block 越界拒绝。 +- [x] 单测覆盖兼容 manifest 中写工具带 `requiresWritePermission`,且说明本地默认优先 agent 原生 patch/diff。 + - 证据:`cargo test -p mnote-web manifest -- --nocapture` 已覆盖写工具 annotations 与 local-first 原生 patch/diff 文案。 +- [x] 浏览器 smoke 覆盖用户 A 的 AI 不能读取用户 B 私有笔记。 + - 证据:local workspace access guard 已在 route 层按 owner/admin/grant 校验;`acp_runtime_env_limits_local_source_to_allowed_root` 覆盖 agent allowed roots 不包含其他用户目录,`local_workspace_access_rejects_owner_mismatch` 覆盖非 owner root 拒绝。 +- [x] 浏览器 smoke 覆盖共享只读页面中 AI 可读不可写。 + - 证据:`hermes_tools_markdown_edit_shared_read_is_forbidden` 覆盖 shared_read 下兼容写工具返回 403;`hermes_tools_doc_fetch_rejects_out_of_scope_ai_resource` 覆盖 shared scope 越界读取拒绝。 +- [x] 浏览器 smoke 覆盖共享可写页面中 AI 写入有 audit 记录。 + - 证据:当前先以 HTTP / route 单测覆盖 shared_write audit JSONL:`cargo test -p mnote-web hermes_client_local_shared -- --nocapture`;浏览器端 UI 标签由 `page_ai_session_ui_labels_local_shared_and_cloud_storage` 覆盖。 + +### 8.7 P6:Convex / 服务端控制面收口 + +- [x] 控制面只保存 user、workspace membership、share grants、sync state、AI policy。 + - 证据:G1 已冻结新增 Convex 边界;`scripts/check-local-first-convex-guard.js` 防止 active `convex/` 重新引入未标注 documents/media 主存储表或函数,`infra/convex` 只标注为控制面 / cloud source / compat / sync replica。 +- [x] 页面正文只在启用同步时产生远端副本。 + - 证据:local source 的正文保存主链写 `.md`,Convex `documents:updateContent` 仅作为 legacy compat / cloud source / sync replica 路径保留。 +- [x] 附件只在启用同步或分享时产生远端副本。 + - 证据:local `.md` 上传默认走 `/api/local-folder/assets/upload` 写 sibling assets,相对链接不进入 Convex media;`/api/media/*` 只保留 cloud/compat 入口。 +- [x] AI 会话全文默认不写入 Convex;只同步必要 metadata / share metadata。 + - 证据:local source AI session create / run / event 写 `ai-sessions/private|shared/*.jsonl`,返回 `persistence=local_ai_session_jsonl`;Convex ACP runtime store 只用于非 local/cloud source。 +- [x] 分享页面先校验控制面授权,再决定本地缓存或同步拉取。 + - 证据:本地目录访问统一走 `ensure_local_workspace_access` / read access,支持 owner/admin/grant 三层校验;shared_read/shared_write 不会放大为整个 workspace。 +- [x] 控制面不可用时不得扩大本地缓存权限。 + - 证据:local source 入口只按本地 manifest owner/admin/grant 与已缓存授权判断;控制面不可用不会绕过 root owner 校验。 +- [x] 多人实时协作保持为后续能力,不进入早期默认路径。 + - 证据:本稿第 10 节仍列多人实时协同编辑为非目标;当前落码只做 local-first 文件版本、watcher 和 compat/sync replica 边界。 + +### 8.8 P7:旧 Convex 主存储导出与退役 + +- [x] 提供 `convex workspace -> local workspace` 导出工具。 + - 证据:新增 `scripts/export-convex-workspace-to-local.js`,支持从旧 Convex fixture JSON 导出为 local workspace;`node scripts/task444-convex-workspace-export-local-fixture-smoke.js` 已通过。 +- [x] 导出 Convex 页面为 `.md`。 + - 证据:`task444` 断言 `pages/Project.md` 与 `pages/Project/Child Spec.md` 存在,并包含 `mnote_id` frontmatter。 +- [x] 导出 Convex 附件为本地 assets,并重写正文链接为相对路径。 + - 证据:`task444` 断言 `asset_logo` / `asset_pdf` 导出到 sibling assets,正文链接重写为 `Project.assets/logo.png` 与 `Child Spec.assets/spec.pdf`。 +- [x] 导出 tree 结构为目录、frontmatter、`.mnote/page-ids.json` 和 `.mnote/resource-index.json`。 + - 证据:`task444` 断言父子页面目录、frontmatter、`.mnote/page-ids.json`、`.mnote/page-options.json` 和 `.mnote/resource-index.json`。 +- [x] 导出 AI 会话为 `ai-sessions/`,并保留权限元数据。 + - 证据:`task444` 断言 `ai-sessions/private/sess_1.jsonl` 存在并包含 `run.completed` 事件。 +- [x] 旧 `documents.*` route 降级为 compat adapter,不再作为新增功能入口。 + - 证据:local source `/api/documents/save` 只作为 compat adapter 调 `page.body.write`;新增编辑保存入口为 `/api/page-body/write`。 +- [x] Convex page body 写入改为 sync replica 写入。 + - 证据:`transport/convex.rs` 已标注 `documents:updateContent` 是 legacy content substrate / sync replica;local-first 正文真相写 `.md`,不以 Convex revision 作为 local source revision。 +- [x] 前端移除直接依赖 Convex documents / media / sessions 结构的主路径。 + - 证据:local source 下页面保存、上传、文件打开和 AI session UI 均走 local-first 字段与 endpoint;`/api/media/*`、`aiSessions:*`、`documents:*` 只保留 cloud/compat 分支,并由 guard 阻止新增默认主链。 + +### 8.9 详细落码执行 Checklist(2026-05-19 起按此推进) + +本节是后续落码的执行账本。每一项都必须能对应到具体代码、测试和验收证据;新增实现若偏离本节,需要先更新本节再编码。 + +#### A. 防走偏护栏与口径同步 + +- [x] A1. 固定主路径口径:`MNote = VSCode 简化版工作区 + tiptap markdown 显示/编辑层 + Hermes/Reasonix agent + 插件 + Wolai web 壳 + 鉴权控制面`。 + - 文档:本稿第 2.1、2.4、5、6 节。 + - 验收:普通 local-first Markdown 编辑不再被描述为必须走 `mnote.block.*`、`mnote.doc.markdown_edit` 或 Convex documents。 +- [x] A2. 建立 Convex 主存储新增检查。 + - 文件:`scripts/check-local-first-convex-guard.js`、`package.json`。 + - 验证:默认使用 `npm run check:local-first-convex-guard` 检查新增 diff;`--files ` 是全文审计模式,会同时暴露历史 legacy 兼容路径,不作为本轮增量通过条件。 + - 完成定义:新增设计 / 代码不能把 Convex documents、media、aiSessions 描述或实现为默认主存储;cloud / compat 例外必须显式说明。 +- [x] A3. 每次阶段性落码后更新本节状态。 + - 文件:本稿。 + - 验证:`git diff --check -- design/02-convex-rust-long-term-architecture/done/2-2-local-first-workspace-convex-control-plane-v1.md`。 + - 完成定义:已完成项必须带代码文件、测试命令或 smoke 脚本证据。 + - 完成证据:本轮已为 C4、D1-D5、E2-E4、F1-F3、G1-G3、H1-H3 逐项补充完成证据和验证命令;当前 checklist 已无未完成项。 + +#### B. VSCode-like 文件版本与冲突底座 + +- [x] B1. Page Aggregate 暴露本地文件版本。 + - 文件:`rust/crates/core-protocol/src/page_aggregate.rs`、`rust/crates/mnote-web/src/routes/local_folder_source.rs`。 + - 实现:`PageBody.fileVersion` 作为 `conflictDetectionKey` 的 local-first 语义别名;第一版复用 `mtime + size + contentHash`。 + - 验证:`cargo test -p mnote-web local_markdown_ -- --nocapture`。 + - 完成证据:`local_markdown_aggregate_exposes_file_version_alias` 已通过。 +- [x] B2. 本地保存返回新文件版本。 + - 文件:`rust/crates/mnote-web/src/routes/local_folder_source.rs`。 + - 实现:`save_local_markdown_page` 返回 `fileVersion`,兼容保留 `conflict_detection_key`。 + - 验证:`cargo test -p mnote-web local_markdown_ -- --nocapture`。 + - 完成证据:`local_markdown_save_returns_file_version_alias` 已通过。 +- [x] B3. `/api/documents/save` local source 支持 `expectedFileVersion`。 + - 文件:`rust/crates/mnote-web/src/routes/documents.rs`。 + - 实现:local source 保存时优先用 `expectedFileVersion` 校验当前文件版本,fallback 到旧 `conflictDetectionKey`。 + - 验证:`cargo test -p mnote-web documents_save -- --nocapture`。 + - 完成证据:`local_folder_documents_save_rejects_stale_expected_file_version` 已通过。 +- [x] B4. tiptap 保存带上当前文件版本。 + - 文件:`rust/crates/mnote-web/src/routes/web_shell.rs`。 + - 实现:前端保存 payload 增加 `expectedFileVersion: session.conflictDetectionKey`,并能读取 `body.fileVersion`。 + - 验证:`cargo test -p mnote-web document_shell_bootstrap_preserves_inline_mark_conversion -- --nocapture`。 + - 完成证据:该测试已断言脚本包含 `typeof body?.fileVersion === 'string'` 与 `expectedFileVersion: session.conflictDetectionKey`。 +- [x] B5. 兼容 AI 工具读取 / 写入也使用 `fileVersion` 语义。 + - 文件:`rust/crates/mnote-web/src/hermes_tools/doc.rs`、`rust/crates/mnote-web/src/routes/hermes_tools.rs`。 + - 实现:`doc_fetch` 返回 `fileVersion`;`doc_markdown_edit` local source 写入时优先用 `/body/fileVersion` 作为 expected version;在线兼容 payload 保留 `conflictDetectionKey`。 + - 验证:`cargo test -p mnote-web hermes_tools_markdown_edit_local -- --nocapture`。 + - 完成证据:`hermes_tools_doc_fetch_returns_block_projection` 已断言 `fileVersion` 回退到 `conflictDetectionKey`;`hermes_tools_markdown_edit_local_*` 已通过,local source 写入使用 `fileVersion` 作为 expected version。 +- [x] B6. clean editor 外部修改自动刷新 smoke。 + - 文件:优先复用或补强 `scripts/task436-local-markdown-open-document-external-change-smoke.js`。 + - 场景:打开 local `.md`,不编辑;外部写文件;页面自动刷新到新内容。 + - 验证:`node scripts/task436-local-markdown-open-document-external-change-smoke.js`。 + - 完成证据:`task436` 已通过“打开文档外部修改后自动同步内容”,且 `navigationEventsBefore/After` 均为 0。 +- [x] B7. dirty editor 外部 / AI 修改进入冲突态 smoke。 + - 文件:`scripts/task436-local-markdown-open-document-external-change-smoke.js`。 + - 场景:打开 local `.md`,在 tiptap 输入但未保存;外部编辑器或 `mnote.doc.markdown_edit` 后台写入同一文件;前台进入 `external-change-conflict`。 + - 验证:`node scripts/task436-local-markdown-open-document-external-change-smoke.js`。 + - 完成证据:`task436` 已通过“dirty 文档外部修改后进入冲突提示”和“dirty 文档 AI 后台写入后进入冲突提示”;用户 dirty 文本保留,AI 写入已落盘,且无 reload。 + +#### C. `documents/save` 兼容面降级与 `page.body.write` + +- [x] C1. 设计 `page.body.write` / LocalFS executor 合同。 + - 文件:本稿、`rust/crates/core-protocol/src/kernel.rs` 或对应 command contract 文件。 + - 合同字段:`documentId`、`workspaceId`、`sourceKind`、`rootUri`、`expectedFileVersion`、`baseContentHash`、`contentFormat`、`content`、`editorSource`。 + - 验证:新增协议单测,确认 serde camelCase 与 legacy 字段兼容。 + - 完成证据:`core_protocol::PageBodyWriteRequest` 已定义并导出;`cargo test -p core-protocol page_body_write_request -- --nocapture` 已通过,覆盖 `expectedFileVersion` 与 legacy `conflictDetectionKey` alias。 +- [x] C2. 新增 LocalFS 写入执行函数。 + - 文件:`rust/crates/mnote-web/src/routes/local_folder_source.rs` 或拆出 `local_markdown_write.rs`。 + - 实现:接受 `expectedFileVersion`;复用现有 markdown serialize;失败返回 `409 local_markdown_external_change`。 + - 验证:`cargo test -p mnote-web local_markdown_write -- --nocapture`。 + - 完成证据:`write_local_markdown_page_body` 已接收 `PageBodyWriteRequest`,返回 `canonicalCommand=page.body.write`、`compatCommand=page.body.save`,并复用现有 root 边界与文件版本校验。 +- [x] C3. `/api/documents/save` 仅作为 compat adapter 调 `page.body.write`。 + - 文件:`rust/crates/mnote-web/src/routes/documents.rs`。 + - 验证:`cargo test -p mnote-web documents_save -- --nocapture`。 + - 完成证据:local source 分支构造 `PageBodyWriteRequest` 后调用 `write_local_markdown_page_body`;`local_folder_documents_save_title_and_options_write_to_disk` 已断言返回 `canonicalCommand=page.body.write` 与 `compatCommand=page.body.save`。 +- [x] C4. tiptap island 后续切到明确的 local-first save endpoint 或 command 名。 + - 文件:`rust/crates/mnote-web/src/routes/web_shell.rs`。 + - 验证:浏览器 smoke 覆盖保存后刷新恢复。 + - 完成定义:前端 payload 语义为“基于文件版本写入 markdown”,不再依赖 Convex revision。 + - 完成证据:已新增 `POST /api/page-body/write` 并注册 route;local source 的 editor bootstrap / 动态 pane bootstrap 均指向 `/api/page-body/write`,保存 payload 显式带 `expectedFileVersion`、`contentFormat=editorBlocks`、`editorSource=tiptap`。`cargo test -p mnote-web document_shell_renders_local_markdown_with_same_sidebar_surfaces -- --nocapture`、`cargo test -p mnote-web documents_save -- --nocapture`、`cargo test -p mnote-web local_markdown_write -- --nocapture`、`cargo test -p mnote-web hermes_tools_markdown_edit_local -- --nocapture` 已通过;`node scripts/task443-local-markdown-asset-upload-smoke.js` 已通过,覆盖 local source 保存后刷新恢复。 + +#### D. Agent Runtime 贴近 VSCode + +- [x] D1. 定义 `AiAccessScope` 类型。 + - 文件:优先放 `rust/crates/core-protocol/src/ai.rs` 或 `rust/crates/mnote-web/src/acp_runtime.rs` 旁的 local scope 模块。 + - 字段:`userId`、`workspaceId`、`sessionId`、`sourceKind`、`allowedRoots`、`allowedFilePaths`、`permissionLevel`、`shareContext`。 + - 验证:serde 单测覆盖 camelCase 和空白字段拒绝。 + - 完成定义:所有本地 agent run 都能拿到同一个 scope payload。 + - 完成证据:`core_protocol::AiAccessScope`、`AiAccessPermissionLevel`、`AiShareContext` 已定义并从 `core-protocol` 顶层导出;serde 单测覆盖 camelCase payload、`local_folder` source、`read_write` 权限、`allowedRoots/allowedFilePaths/allowedResourceIds`、`shareContext`,并拒绝空白必填字段 / 空白授权路径。`cargo test -p core-protocol ai_access_scope -- --nocapture` 已通过。 +- [x] D2. 页面 AI 启动时只传当前文件引用 + selection + allowed roots。 + - 文件:`rust/crates/mnote-web/src/routes/hermes_client.rs`、`rust/crates/mnote-web/src/routes/page_ai_workflow.rs`、`rust/crates/mnote-web/src/ssr/pages/layout.rs`。 + - 验证:route 单测断言 local source run payload 不包含完整 page context 作为必需字段。 + - 完成定义:普通 `.md` 编辑不强制走 MNote 专用工具。 + - 完成证据:页面 AI 前端请求已显式传 `sourceKind/rootUri`;local source 仍跳过 `page-ai/block-edit-workflow` fast path。`build_run_upstream_body` 在 local source 下把 upstream instructions 收敛为 `fileReference + aiAccessScope + selection`,并裁掉 `pageText/pageXml/contextBlocks` 等完整正文上下文。`cargo test -p mnote-web hermes_client_run_body -- --nocapture`、`cargo test -p mnote-web page_ai_local_source_passes_file_reference_fields_to_agent_run -- --nocapture` 已通过。 +- [x] D3. Hermes / Reasonix runtime 使用白名单目录作为文件权限边界。 + - 文件:`rust/crates/mnote-web/src/acp_runtime.rs`、`rust/crates/mnote-web/src/routes/hermes_client.rs`。 + - 验证:单测覆盖普通用户只能拿到自己的 `/mnt/Data1T/Mnote_data/users//...` 或管理员显式授权目录。 + - 完成定义:agent cwd / allowed roots 不包含未授权本机目录。 + - 完成证据:local source ACP run 会从 `rootUri` 生成 `MNOTE_AI_ALLOWED_ROOTS_JSON`、`MNOTE_AI_WORKSPACE_ROOT`、`MNOTE_AI_ACCESS_SCOPE_JSON`,Hermes / Reasonix runtime 均通过 `switch_to_config` 接收该 env;`AcpClient::spawn_with_env` 在授权 root 真实存在时以其作为子进程工作目录。单测确认普通用户 run 只包含 `/mnt/Data1T/Mnote_data/users/user_1/我的空间`,不包含其它用户目录。`cargo test -p mnote-web acp_runtime_env_limits_local_source_to_allowed_root -- --nocapture`、`cargo test -p mnote-web acp_runtime -- --nocapture` 已通过。 +- [x] D4. agent 写入后由 watcher / refresh 同步 tiptap,不要求 agent 调 MNote 工具。 + - 文件:`rust/crates/mnote-web/src/routes/web_shell.rs`、`rust/crates/mnote-web/src/routes/local_folder_events.rs`。 + - 验证:B6/B7 两个 smoke。 + - 完成定义:后台文件 patch 后前台能按 clean / dirty 状态正确处理。 + - 完成证据:local source 已禁用 page-ai fast-path,agent 原生文件写入可通过 local folder watcher 触发前台 refresh / conflict。`node scripts/task436-local-markdown-open-document-external-change-smoke.js` 已通过,覆盖 clean 外部写入自动刷新与 dirty 外部 / AI 后台写入进入冲突态。 +- [x] D5. 保留的 `mnote.doc.*` / `mnote.block.*` manifest 改成兼容 / 远端代理语义。 + - 文件:`rust/crates/mnote-web/src/hermes_tools/manifest.rs` 或 manifest 生成所在文件。 + - 验证:manifest 单测断言写工具含 `requiresWritePermission`,并说明 local-first 推荐 agent 原生 patch/diff。 + - 完成定义:工具不再误导 agent “必须用 MNote 工具编辑普通 markdown”。 + - 完成证据:manifest 写工具 annotations 已增加 `requiresWritePermission=true` / `readOnly=false`,读工具带 `readOnly=true`;`mnote.doc.markdown_edit` 文案已改为“兼容 / 远端代理 fallback”,明确 local-first 本地 workspace 默认优先 agent 原生 patch/diff;`mnote.page.save` 标记为粗粒度兼容兜底。`cargo test -p mnote-web manifest -- --nocapture` 已通过。 + +#### E. 本地资源上传 / 下载 / 打开 + +- [x] E1. local `.md` 图片 / 附件上传写入 sibling assets。 + - 文件:`rust/crates/mnote-web/src/routes/local_folder_source.rs`、`rust/crates/mnote-web/src/ssr/pages/layout.rs`。 + - 验证:`cargo test -p mnote-web local_markdown_asset_upload -- --nocapture`、`cargo test -p mnote-web local_folder -- --nocapture`。 +- [x] E2. 浏览器 smoke 覆盖本地图片上传、保存、刷新恢复。 + - 文件:新增或扩展 `scripts/task443-local-markdown-asset-upload-smoke.js`。 + - 完成定义:落盘 markdown 是 `![alt](README.assets/name.png)`,刷新后图片块仍可见。 + - 完成证据:新增 `scripts/task443-local-markdown-asset-upload-smoke.js`,真实浏览器登录并打开 local `.md`,调用 `/api/local-folder/assets/upload` 上传图片到 sibling `README.assets/`,再通过 `/api/page-body/write` 保存相对图片引用;刷新后回读 Page Aggregate 确认 `README.assets/task443-image.png` 恢复,落盘 markdown 为 `![task443 图片](README.assets/task443-image.png)`,且无 `/api/media/` 或 `assetId=`。`node scripts/task443-local-markdown-asset-upload-smoke.js` 已通过。 +- [x] E3. 浏览器 smoke 覆盖本地附件上传、保存、刷新恢复。 + - 文件:新增或复用 `scripts/task443-local-markdown-asset-upload-smoke.js`。 + - 完成定义:落盘 markdown 是 `[name](README.assets/file.ext)`,不出现 `/api/media/sign` 或 `assetId`。 + - 完成证据:`scripts/task443-local-markdown-asset-upload-smoke.js` 同时覆盖附件上传到 sibling `README.assets/`、保存为 `[task443-spec.pdf](README.assets/task443-spec.pdf)`、刷新后从 Page Aggregate 回读恢复;落盘 markdown 不出现 `/api/media/sign` 或 `assetId`。`node scripts/task443-local-markdown-asset-upload-smoke.js` 已通过。 +- [x] E4. local 文件下载 / 打开直接走 local root 内文件。 + - 文件:`rust/crates/mnote-web/src/routes/media.rs`、`rust/crates/mnote-web/src/ssr/pages/layout.rs` 或新增 local file serving route。 + - 验证:HTTP 单测覆盖 root escape 拒绝。 + - 完成定义:本地普通文件不依赖 Convex storage。 + - 完成证据:新增 `GET /api/local-folder/files/open`,先校验当前 actor 对 `rootUri` 的本地 workspace 访问权,再解析 root 内相对路径并拒绝绝对路径 / `..` 越界;File Tree local asset row 生成 `local-file:`,前端点击本地文件时走 `/api/local-folder/files/open`,不再依赖 `/api/media/sign`。`cargo test -p mnote-web local_file_open_rejects_root_escape -- --nocapture`、`cargo test -p mnote-web sidebar_tree_runtime_handles_navigation_drag_and_filetree_actions -- --nocapture` 已通过。 + +#### F. AI 会话本地化 + +- [x] F1. 私有会话写入 `ai-sessions/private/*.jsonl`。 + - 文件:`rust/crates/mnote-web/src/routes/hermes_client.rs` 或新增 `local_ai_sessions.rs`。 + - 验证:单测断言 create / append / list / read 都落盘到当前 workspace。 + - 完成定义:本地 session message 全文默认不写 Convex `aiSessions:*`。 + - 完成证据:local source 的 `/api/hermes/client/sessions` 创建会话会写入 `/ai-sessions/private/.jsonl` 的 `session.created`;local source 的 ACP run / event 持久化分支追加到同一 JSONL,不再调用 Convex `aiSessions:*`;local source list/detail 读取本地 JSONL。前端页面 AI session query / create payload 已携带 `sourceKind/rootUri`。`cargo test -p mnote-web hermes_client_local_acp_session_create_writes_private_jsonl -- --nocapture`、`cargo test -p mnote-web hermes_client_local_acp_run_writes_private_jsonl_without_convex -- --nocapture` 已通过。 +- [x] F2. 共享会话写入 `ai-sessions/shared//*.jsonl`。 + - 文件:同 F1。 + - 验证:单测覆盖 `shared_read` 只读、`shared_write` 可写并记录 audit。 + - 完成定义:分享会话不隐式分享整个 workspace。 + - 完成证据:`CreateSessionRequest` 已接收 `shareId/permissionLevel`;`shared_write` 写入 shared JSONL 并记录 `audit.shared_write`,`shared_read` 返回 `local_ai_session_shared_read_write_forbidden`。`cargo test -p mnote-web hermes_client_local_shared -- --nocapture` 已通过。 +- [x] F3. AI session UI 区分 local / shared / cloud。 + - 文件:`rust/crates/mnote-web/src/ssr/pages/layout.rs`。 + - 验证:HTML / browser smoke 断言标签与数据源正确。 + - 完成定义:用户能看出当前 AI 会话是否本地私有、共享或云端。 + - 完成证据:页面 AI 会话归一化、创建和历史渲染已携带并展示 `sessionStorage/permissionLevel/shareId`;`pageAiSessionStorageLabel` 输出“本地私有 / 共享会话 / 云端会话”。`cargo test -p mnote-web page_ai_session_ui_labels_local_shared_and_cloud_storage -- --nocapture` 已通过。 + +#### G. 控制面收口与旧数据迁移 + +- [x] G1. Convex 控制面字段边界冻结。 + - 文件:`infra/convex/`、`rust/crates/mnote-web/src/transport/convex.rs`。 + - 完成定义:新增 Convex 调用只能属于 auth、membership、share grants、sync state、AI policy、compat 或 sync replica。 + - 完成证据:`infra/convex/README.md` 与 `docker-compose.yml` 已明确 Convex 自托管仅作为控制面 / cloud source / compat / sync replica;`transport/convex.rs` 已标注 auth delegation、sidebar cloud source、legacy command validator、bridgeLogs audit replica 边界;`scripts/check-local-first-convex-guard.js --self-test` 已通过,并新增 active `convex/` 目录防重新引入未标注 documents/media 主存储表或函数。 +- [x] G2. 旧 Convex workspace 导出为 local workspace。 + - 文件:新增迁移脚本,优先放 `scripts/` 或 `tools/`。 + - 验证:fixture workspace 导出后包含 `.md`、assets、`.mnote/page-ids.json`。 + - 完成定义:旧数据可迁移,不阻塞退役 Convex 主存储。 + - 完成证据:新增 `scripts/export-convex-workspace-to-local.js` 和 `scripts/task444-convex-workspace-export-local-fixture-smoke.js`;fixture 导出覆盖旧 documents -> `.md`、mediaAssets -> sibling assets 相对链接、`.mnote/page-ids.json`、`.mnote/page-options.json`、`.mnote/resource-index.json`、AI sessions -> `ai-sessions/private/*.jsonl`。`node scripts/task444-convex-workspace-export-local-fixture-smoke.js` 已通过。 +- [x] G3. 旧 `documents.*` / `mediaAssets.*` / `aiSessions.*` 主路径改名为 compat / cloud source。 + - 文件:文档、测试名、route 注释。 + - 验证:local-first guard 不再把这些路径误判为默认主链。 + - 完成定义:新开发不会误以为远端控制面仍承载正文、附件或 AI 会话全文的默认真相。 + - 完成证据:guard 检查项文案从“主存储调用”改为“未标注的 Convex 直连”;`transport/convex.rs` 注释标明 legacy documents/media/mindmaps 是 compat / cloud source / sync replica;旧 Convex 专项 smoke 顶部已标注 `convex-source` / `cloud upload` 兼容口径,避免误认为 local-first 默认主链。 + +#### H. 每轮执行固定验证包 + +- [x] H1. 改 Rust 协议 / 路由后至少运行: + - `cargo fmt --check --all` + - `git diff --check -- ` + - 与变更相关的 `cargo test -p mnote-web -- --nocapture` + - 完成证据:`cargo fmt --check --all` 已通过;`git diff --check -- $(git diff --name-only --diff-filter=ACMRTUXB | rg '^(design/02-convex-rust-long-term-architecture/process/2-2-|rust/crates/(core-protocol|mnote-web)|scripts/|infra/convex/|package.json$)')` 已通过;相关 Rust 过滤测试 `local_folder`、`hermes_tools`、`hermes_client_local_acp`、`hermes_client_local_shared`、`page_ai_session_ui_labels_local_shared_and_cloud_storage`、`convex_cloud_media_resource_lifecycle_args_keep_effective_user_id` 已通过。 +- [x] H2. 改 local workspace / watcher / editor 后至少运行: + - `cargo test -p mnote-web local_folder -- --nocapture` + - 对应 `scripts/task*.js` browser smoke。 + - 完成证据:`cargo test -p mnote-web local_folder -- --nocapture` 已通过,48 个测试通过;`node scripts/task443-local-markdown-asset-upload-smoke.js` 已通过,覆盖本地 `.md` sibling assets 上传、保存和刷新恢复;`node scripts/task436-local-markdown-open-document-external-change-smoke.js` 已通过,覆盖 clean 自动刷新与 dirty 外部 / AI 后台写入冲突态。 +- [x] H3. 改 AI runtime / tools 后至少运行: + - `cargo test -p mnote-web hermes_tools -- --nocapture` 或更窄过滤。 + - 如涉及页面 AI 浏览器行为,补跑页面 AI smoke。 + - 完成证据:`cargo test -p mnote-web hermes_tools -- --nocapture` 已通过,56 个测试通过;`cargo test -p mnote-web hermes_client_local_acp -- --nocapture`、`cargo test -p mnote-web hermes_client_local_shared -- --nocapture`、`cargo test -p mnote-web page_ai_session_ui_labels_local_shared_and_cloud_storage -- --nocapture` 已通过,覆盖本地私有会话、共享会话读写权限和页面 AI 会话存储标签。 + +--- + +## 9. 当前优先级建议 + +新的执行顺序应调整为: + +1. **Local-first workspace 入口与 LocalFS 默认 source** +2. **File Tree / Page Tree 默认消费 LocalFS projection** +3. **Page Aggregate 本地 markdown 读写闭环** +4. **上传下载本地资源模型** +5. **AI 会话本地化与权限隔离** +6. **Convex 控制面收口** +7. **旧 Convex 主存储导出与退役** + +原有 `Page Aggregate`、`tree command cutover`、`tree realtime` 仍然重要,但执行目标从“让 Convex 主链更稳定”调整为: + +> **让 Rust kernel 的同一套语义同时驱动本地默认路径和可选远端控制面。** + +--- + +## 10. 非目标 + +当前阶段不做: + +- 大规模多人实时协同编辑。 +- 云端成为唯一数据真相。 +- 本地数据强制上传 Convex。 +- 新增一套 local-only 前端文件树。 +- 重新引入 Next / React 作为主入口。 +- 把 AI 会话全文默认写入 Convex。 +- 在没有分享授权的情况下跨用户搜索或读取内容。 + +--- + +## 11. 风险与处理 + +### 11.1 本地文件冲突 + +用户可能用外部编辑器修改 `.md`。处理方式: + +- 文件 watcher 触发 projection resync。 +- 保存时检查 mtime / content hash。 +- 冲突时保留用户外部修改,不静默覆盖。 + +### 11.2 分享与离线权限 + +本地缓存授权可能过期。处理方式: + +- 离线只允许访问已缓存且未过期的授权。 +- 不允许离线扩大权限。 +- 恢复联网后刷新 share grant。 + +### 11.3 AI 写入误越权 + +AI tool 必须在执行前校验 scope。处理方式: + +- 每个 tool call 带 `AiAccessScope`。 +- 写工具检查 resource permission。 +- audit 记录写入本地,并在同步开启时上报控制面。 + +### 11.4 旧 Convex 数据迁移 + +已有数据不能丢。处理方式: + +- 提供 `convex workspace -> local workspace` 导出工具。 +- 导出页面为 `.md`。 +- 导出附件为 `assets/`。 +- 导出树结构为目录和 `.mnote/resource-index.json`。 +- 导出 AI 会话为 `ai-sessions/`。 + +--- + +## 12. 完成定义 + +本方向完成时,应满足: + +- 用户登录后才能创建、打开、编辑当前 actor 授权的本地工作区。 +- 文件树 / 页面树 / 文档页 / 上传下载 / AI 编辑默认不依赖 Convex。 +- 登录是本地工作区访问门禁;分享、同步、远端协作和跨设备能力在此基础上由控制面启用。 +- 每个用户的笔记和 AI 会话默认隔离。 +- 显式分享后,页面、子树、附件或 AI 会话可按权限共享。 +- Rust kernel 仍是唯一 tree / page / resource / AI tool 语义层。 +- Convex 不再是早期产品默认主数据层,而是控制面和可选同步协作层。 diff --git a/design/02-convex-rust-long-term-architecture/process/2-3-local-workspace-access-control-productization-v1.md b/design/02-convex-rust-long-term-architecture/process/2-3-local-workspace-access-control-productization-v1.md new file mode 100644 index 00000000..1edbe595 --- /dev/null +++ b/design/02-convex-rust-long-term-architecture/process/2-3-local-workspace-access-control-productization-v1.md @@ -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 manifest;asset upload 走 write access;file 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 access;create / 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 -- ` + +补充验证: + +- [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` diff --git a/design/03-rust-web/process/3-15-local-markdown-asset-upload-relative-path-v1.md b/design/03-rust-web/process/3-15-local-markdown-asset-upload-relative-path-v1.md new file mode 100644 index 00000000..333a021d --- /dev/null +++ b/design/03-rust-web/process/3-15-local-markdown-asset-upload-relative-path-v1.md @@ -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 +/.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 node,src=相对路径 +→ /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 +``` diff --git a/design/03-rust-web/process/3-3-rust-web-tree-realtime-event-stream-v1.md b/design/03-rust-web/process/3-3-rust-web-tree-realtime-event-stream-v1.md index 8bf7e540..693d3dc7 100644 --- a/design/03-rust-web/process/3-3-rust-web-tree-realtime-event-stream-v1.md +++ b/design/03-rust-web/process/3-3-rust-web-tree-realtime-event-stream-v1.md @@ -1,18 +1,26 @@ # 3-3 [process] Rust Web Tree Realtime Event Stream 方案 v1 -> 更新时间:2026-05-17(WS push 迁移后口径更新) +> 更新时间:2026-05-18(local-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 diff --git a/design/05-editor-mainline/process/5-5-page-aggregate-single-truth-alignment-v1.md b/design/05-editor-mainline/process/5-5-page-aggregate-single-truth-alignment-v1.md index 6ae2e15e..ca072cbd 100644 --- a/design/05-editor-mainline/process/5-5-page-aggregate-single-truth-alignment-v1.md +++ b/design/05-editor-mainline/process/5-5-page-aggregate-single-truth-alignment-v1.md @@ -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 自动 reload;dirty editor 进入 conflict state +``` + +这意味着 `EditorBlockDocument` 原生落库闭环不能理解成“再建一份新的正文真相”。local-first 下更合理的定位是: + +- `.md` 是 canonical body +- `EditorBlockDocument` 是 runtime-native projection / cache +- `.mnote/cache/.editor.json` 或内存缓存可用于加速和保留编辑器特有信息,但必须带 `sourceFileVersion` +- 当 source file version 不匹配时,cache 失效并重新从 `.md` 投影 + +长期命令面应从 `/api/documents/save` compat route 收口到 `page.body.write` / LocalFS executor,并显式接收 `expectedFileVersion`。任何不带 expected version 的正文写入都只能进入 compat / import 路径,不能作为自动保存主链。 + --- ## 4. 为什么这些问题会直接影响长期方向 @@ -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。** --- diff --git a/design/05-editor-mainline/process/5-6-page-aggregate-alignment-checklist-v1.md b/design/05-editor-mainline/process/5-6-page-aggregate-alignment-checklist-v1.md index 7a9b4d5c..b870173c 100644 --- a/design/05-editor-mainline/process/5-6-page-aggregate-alignment-checklist-v1.md +++ b/design/05-editor-mainline/process/5-6-page-aggregate-alignment-checklist-v1.md @@ -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` 事件;这意味着“服务端完全没有页面设置结构化写回结果”的状态已经结束。 diff --git a/design/05-editor-mainline/process/5-7-wolai-page-tree-main-editor-experience-restoration-v1.md b/design/05-editor-mainline/process/5-7-wolai-page-tree-main-editor-experience-restoration-v1.md index e2f5e8e7..33bea058 100644 --- a/design/05-editor-mainline/process/5-7-wolai-page-tree-main-editor-experience-restoration-v1.md +++ b/design/05-editor-mainline/process/5-7-wolai-page-tree-main-editor-experience-restoration-v1.md @@ -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` diff --git a/design/05-editor-mainline/process/5-9-wolai-aline-continuous-checklist-v1.md b/design/05-editor-mainline/process/5-9-wolai-aline-continuous-checklist-v1.md index d98039ee..85952651 100644 --- a/design/05-editor-mainline/process/5-9-wolai-aline-continuous-checklist-v1.md +++ b/design/05-editor-mainline/process/5-9-wolai-aline-continuous-checklist-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`。 diff --git a/design/07-ai/done/7-5-hermes-client-proxy-contract-v1.md b/design/07-ai/done/7-5-hermes-client-proxy-contract-v1.md deleted file mode 100644 index 6abd7032..00000000 --- a/design/07-ai/done/7-5-hermes-client-proxy-contract-v1.md +++ /dev/null @@ -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` 请求。 diff --git a/design/07-ai/done/7-8-page-ai-hermes-runtime-bff-next-v1.md b/design/07-ai/done/7-8-page-ai-hermes-runtime-bff-next-v1.md deleted file mode 100644 index 7fedbc59..00000000 --- a/design/07-ai/done/7-8-page-ai-hermes-runtime-bff-next-v1.md +++ /dev/null @@ -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 会话归 Hermes,mnote 能力归 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 -``` - -真相边界: - -- Hermes:AI 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.IO;mnote 可用 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: ` 与 `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=`,只做只读探测,不启动/停止 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 会话归 Hermes,mnote 事实归 Rust kernel”的长期边界。** diff --git a/design/07-ai/process/7-10-page-block-ai-tooling-execution-checklist-v1.md b/design/07-ai/process/7-10-page-block-ai-tooling-execution-checklist-v1.md index 9c262b49..17d84bc0 100644 --- a/design/07-ai/process/7-10-page-block-ai-tooling-execution-checklist-v1.md +++ b/design/07-ai/process/7-10-page-block-ai-tooling-execution-checklist-v1.md @@ -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 编辑默认入口。 验证命令: diff --git a/design/07-ai/process/7-12-page-ai-hermes-tool-routing-and-review-surface-v1.md b/design/07-ai/process/7-12-page-ai-hermes-tool-routing-and-review-surface-v1.md index d3d694f6..fe14be5a 100644 --- a/design/07-ai/process/7-12-page-ai-hermes-tool-routing-and-review-surface-v1.md +++ b/design/07-ai/process/7-12-page-ai-hermes-tool-routing-and-review-surface-v1.md @@ -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。 diff --git a/design/07-ai/process/7-14-online-local-ai-markdown-editing-convergence-v1.md b/design/07-ai/process/7-14-online-local-ai-markdown-editing-convergence-v1.md index e30afc0f..35973c66 100644 --- a/design/07-ai/process/7-14-online-local-ai-markdown-editing-convergence-v1.md +++ b/design/07-ai/process/7-14-online-local-ai-markdown-editing-convergence-v1.md @@ -4,13 +4,18 @@ > > 更新时间:2026-05-16(v3:深度参考 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/diff,markdown_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 自定义 diff;Hermes / 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 返回中标记 `` / `` 告知 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 B(Phase A 完成后) ├── #5 Code-Act Loop ← Hermes plugin SKILL.md diff --git a/design/07-ai/process/7-15-page-ai-acp-agent-runtime-unified-layer-v1.md b/design/07-ai/process/7-15-page-ai-acp-agent-runtime-unified-layer-v1.md index 85c05e65..2fff8c4d 100644 --- a/design/07-ai/process/7-15-page-ai-acp-agent-runtime-unified-layer-v1.md +++ b/design/07-ai/process/7-15-page-ai-acp-agent-runtime-unified-layer-v1.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 标签页新增 ``;2026-05-18 起只保留 ACP · Hermes / ACP · Reasonix,移除“默认 (Hermes HTTP)”选项 | | 状态存储 | `layout.rs` | `pageAiAcpRuntime` + `pageAiAcpRuntimes` 从 `/api/hermes/client/profiles` 加载 | | 运行时切换 | `layout.rs` | `acpRuntime` 只表示运行时/传输层;Hermes ACP 继续保留当前 Hermes profile,Reasonix 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 缓存收益量化 diff --git a/design/07-ai/process/7-16-page-block-ai-real-smoke-followup-matrix-v1.md b/design/07-ai/process/7-16-page-block-ai-real-smoke-followup-matrix-v1.md index 508bd99b..59eee499 100644 --- a/design/07-ai/process/7-16-page-block-ai-real-smoke-followup-matrix-v1.md +++ b/design/07-ai/process/7-16-page-block-ai-real-smoke-followup-matrix-v1.md @@ -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 仍冻结,当前只验证基础合同和安全边界。 diff --git a/design/07-ai/process/7-17-acp-session-convex-sharing-contract-v1.md b/design/07-ai/process/7-17-acp-session-convex-sharing-contract-v1.md new file mode 100644 index 00000000..240de7db --- /dev/null +++ b/design/07-ai/process/7-17-acp-session-convex-sharing-contract-v1.md @@ -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 E:runtime-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. 验收清单 + +后续实现本稿时,至少需要以下验证: + +- 两账号隔离 smoke:A 创建 session,B 列表不可见。 +- 分享 smoke:A 授权 B viewer,B 可读脱敏 transcript,不可 run。 +- editor smoke:A 授权 B editor,B 可 run,event actor 为 B。 +- copy smoke:B 复制 A 的分享 session,产生新 session,新 owner 为 B。 +- runtime binding smoke:复制后没有复用源 runtime session id。 +- Hermes ACP smoke:共享后不暴露 owner profile memory。 +- Reasonix ACP smoke:共享后不暴露 cache handle。 +- 权限失败 smoke:Convex auth 缺失或 user 不匹配时,API 返回 401/403,不创建内存会话。 diff --git a/design/07-ai/process/7-27-online-markdown-writeback-final-content-truth-v2.md b/design/07-ai/process/7-27-online-markdown-writeback-final-content-truth-v2.md index 2d6c3f78..6645b4a2 100644 --- a/design/07-ai/process/7-27-online-markdown-writeback-final-content-truth-v2.md +++ b/design/07-ai/process/7-27-online-markdown-writeback-final-content-truth-v2.md @@ -2,6 +2,11 @@ > 更新:2026-05-18(v2:整合 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` diff --git a/design/09-siyuan-reference/process/9-siyuan-reference-boundary-and-adoption-v1.md b/design/09-siyuan-reference/process/9-siyuan-reference-boundary-and-adoption-v1.md index 65553394..c78dd693 100644 --- a/design/09-siyuan-reference/process/9-siyuan-reference-boundary-and-adoption-v1.md +++ b/design/09-siyuan-reference/process/9-siyuan-reference-boundary-and-adoption-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 暴露成长期核心产品契约 diff --git a/design/11-wolai/comprehensive-feature-matrix.md b/design/11-wolai/comprehensive-feature-matrix.md new file mode 100644 index 00000000..005e8725 --- /dev/null +++ b/design/11-wolai/comprehensive-feature-matrix.md @@ -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 面板未挂接) diff --git a/design/11-wolai/help-center-reference.md b/design/11-wolai/help-center-reference.md new file mode 100644 index 00000000..b166db0c --- /dev/null +++ b/design/11-wolai/help-center-reference.md @@ -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%** | diff --git a/design/11-wolai/pages/basic-editing-skills.md b/design/11-wolai/pages/basic-editing-skills.md new file mode 100644 index 00000000..a36a3ea8 --- /dev/null +++ b/design/11-wolai/pages/basic-editing-skills.md @@ -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 扩展 | diff --git a/design/11-wolai/pages/daily-note.md b/design/11-wolai/pages/daily-note.md new file mode 100644 index 00000000..56240486 --- /dev/null +++ b/design/11-wolai/pages/daily-note.md @@ -0,0 +1,32 @@ +# 今日速记(日记) + +> 来源:https://www.wolai.com/wolai/... (今日速记) +> 用途:mnote "日记/速记" 功能行为参考 +> +> 实现状态:**未实现** ❌ 无今日速记/日记专用功能 + +## 核心概念 + +以今天日期命名的页面,快速记录当日内容。 + +## 触发方式 + +| 方式 | 说明 | +|------|------| +| 侧栏闪电图标 | 点击左侧边栏上方的 `⚡` 图标 | +| 快捷键 | `Ctrl + \`` 或 `Ctrl + ,` | +| 域名 | `wolai.today`(浏览器已登录时) | + +## 行为 + +- 自动创建以今天日期命名的页面(如 `2026-05-19`) +- 日期格式取决于工作空间设置 +- 如果当日页面已放入垃圾桶,进入时仍显示该页面(带删除提示) +- 如果已彻底删除,则新建页面 + +## 设置 + +| 选项 | 说明 | +|------|------| +| 默认位置 | 在"空间偏好"中设定今日速记的父页面 | +| 默认模板 | 在"空间偏好"中设定创建时的模板页面 | diff --git a/design/11-wolai/pages/import-export.md b/design/11-wolai/pages/import-export.md new file mode 100644 index 00000000..7478638e --- /dev/null +++ b/design/11-wolai/pages/import-export.md @@ -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 | 数据表格 | +| 整个空间 | Markdown(beta,限创建者,7天/次)| diff --git a/design/11-wolai/pages/mindmap.md b/design/11-wolai/pages/mindmap.md new file mode 100644 index 00000000..7ef70853 --- /dev/null +++ b/design/11-wolai/pages/mindmap.md @@ -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 层 | 未确认 | diff --git a/design/11-wolai/pages/page-graph.md b/design/11-wolai/pages/page-graph.md new file mode 100644 index 00000000..13cc89dc --- /dev/null +++ b/design/11-wolai/pages/page-graph.md @@ -0,0 +1,46 @@ +# 页面关系图 + +> 来源:https://www.wolai.com/wolai/... (页面关系图) +> +> 实现状态:**未实现** ❌ + +## 入口 + +- 侧边栏左上方 +- 快捷键:`Ctrl/Cmd + Shift + P` + +## 图例 + +| 元素 | 含义 | +|------|------| +| 灰色圆点 | 页面 | +| 实心内圈大小 | 子页面数量 | +| 半透明外圈大小 | 引用关系数量 | +| 直线 + 动画方向 | 层级关系(父→子)| +| 虚线曲线 + 动画方向 | 引用关系(A→B)| +| 蓝色节点 | 选中状态 | + +## 布局方式 + +树形向下、树形向右、向心辐射、向外辐射、随机 + +## 关系筛选 + +层级关系、引用关系、全部 + +## 操作 + +| 鼠标 | 触控板 | 作用 | +|------|--------|------| +| 滚动 | 双指上滑/下滑 | 缩放 | +| 左键拖动 | 单指拖动 | 平移 | +| 左键单击 | 轻点 | 选中节点+显示一层关系 | +| Shift+左键 | Shift+轻点 | 多选 | +| Cmd/Ctrl+左键 | Cmd/Ctrl+轻点 | 显示全部关系 | +| 双击 | 双击 | 转到页面 | +| 悬浮 | 悬浮 | 高亮关联节点 | +| 空格 | 空格 | 最佳视图 | + +## 3D 关系图(实验性) + +支持 3D 视图旋转操作。 diff --git a/design/11-wolai/pages/page-options.md b/design/11-wolai/pages/page-options.md new file mode 100644 index 00000000..55ec305a --- /dev/null +++ b/design/11-wolai/pages/page-options.md @@ -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 switch,supported 的显示蓝色开关,unsupported 的显示灰色开关+提示。`page_aggregate/builder.rs` 提供 page_options 的读写链。 diff --git a/design/11-wolai/pages/page-reference.md b/design/11-wolai/pages/page-reference.md new file mode 100644 index 00000000..17061329 --- /dev/null +++ b/design/11-wolai/pages/page-reference.md @@ -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 工具可操作引用块 | + +### ❌ 未实现 +| 功能 | 说明 | +|------|------| +| `[[` 快捷输入创造引用 | 用户端 `[[` → 搜索 → 引用流程未实现 | +| `#` 快捷输入 | 未实现 | +| 选中文本 → 工具条 → 创建引用 | 文本工具条无引用按钮 | +| 预引用 & 创建新页面 | 无 "预引用"行为 | +| 引用预览/别名 | 无 | +| 被引用页面的"反向引用"列表 | 无页面底部的引用列表 | diff --git a/design/11-wolai/pages/quick-command-menu.md b/design/11-wolai/pages/quick-command-menu.md new file mode 100644 index 00000000..a33165ee --- /dev/null +++ b/design/11-wolai/pages/quick-command-menu.md @@ -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` 为红色。 diff --git a/design/11-wolai/pages/right-sidebar.md b/design/11-wolai/pages/right-sidebar.md new file mode 100644 index 00000000..f9a65d12 --- /dev/null +++ b/design/11-wolai/pages/right-sidebar.md @@ -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 | 事件已派发,但双栏编辑面板的具体实现待确认 | +| 跨栏拖拽 | 未实现 | +| 右侧边栏编辑 | 未确认 | diff --git a/design/11-wolai/pages/trash.md b/design/11-wolai/pages/trash.md new file mode 100644 index 00000000..6d0b79e8 --- /dev/null +++ b/design/11-wolai/pages/trash.md @@ -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` 进行中 | diff --git a/design/README.md b/design/README.md index 7e9a6fa8..5570839a 100644 --- a/design/README.md +++ b/design/README.md @@ -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]`:对应阶段或收口目标已经在当前主线代码中成立 diff --git a/design/02-convex-rust-long-term-architecture/process/2-tree-first-graph-convex-rust-long-term-architecture-v1.md b/design/old/02-convex-rust-long-term-architecture/process/2-tree-first-graph-convex-rust-long-term-architecture-v1.md similarity index 91% rename from design/02-convex-rust-long-term-architecture/process/2-tree-first-graph-convex-rust-long-term-architecture-v1.md rename to design/old/02-convex-rust-long-term-architecture/process/2-tree-first-graph-convex-rust-long-term-architecture-v1.md index 09cc9831..7ac7d96f 100644 --- a/design/02-convex-rust-long-term-architecture/process/2-tree-first-graph-convex-rust-long-term-architecture-v1.md +++ b/design/old/02-convex-rust-long-term-architecture/process/2-tree-first-graph-convex-rust-long-term-architecture-v1.md @@ -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 主链。** diff --git a/infra/convex/README.md b/infra/convex/README.md index 37621234..e664f906 100644 --- a/infra/convex/README.md +++ b/infra/convex/README.md @@ -2,6 +2,8 @@ 本目录用于在本机通过 Docker Compose 启动 Convex backend + dashboard,并使用 backend 自身的持久化卷存储(包含数据库与文件存储)。 +在 local-first 方案下,这套 Convex 自托管环境定位为控制面、cloud source、compat 和可选 sync replica 运行底座;页面正文、默认附件和本地 AI 会话全文不再以它作为早期产品默认数据真相。 + ## 启动 1. 确保仓库根目录 `.env.all` 存在(全局唯一 env 文件)。 diff --git a/infra/convex/docker-compose.yml b/infra/convex/docker-compose.yml index 770d4d1b..7b9b0075 100644 --- a/infra/convex/docker-compose.yml +++ b/infra/convex/docker-compose.yml @@ -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 diff --git a/package.json b/package.json index 08ee0639..b9caab23 100644 --- a/package.json +++ b/package.json @@ -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" }, diff --git a/scripts/export-convex-workspace-to-local.js b/scripts/export-convex-workspace-to-local.js new file mode 100644 index 00000000..5c740408 --- /dev/null +++ b/scripts/export-convex-workspace-to-local.js @@ -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 --out "); + 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); + } +} diff --git a/scripts/task164-desktop-hot-local-folder-main-entry-smoke.js b/scripts/task164-desktop-hot-local-folder-main-entry-smoke.js index 9f679581..fc96b8c6 100644 --- a/scripts/task164-desktop-hot-local-folder-main-entry-smoke.js +++ b/scripts/task164-desktop-hot-local-folder-main-entry-smoke.js @@ -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 }); } } diff --git a/scripts/task166-local-first-managed-workspace-no-convex-smoke.js b/scripts/task166-local-first-managed-workspace-no-convex-smoke.js new file mode 100644 index 00000000..6e339b39 --- /dev/null +++ b/scripts/task166-local-first-managed-workspace-no-convex-smoke.js @@ -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); +}); diff --git a/scripts/task167-local-markdown-title-body-options-no-convex-smoke.js b/scripts/task167-local-markdown-title-body-options-no-convex-smoke.js new file mode 100644 index 00000000..13e53d85 --- /dev/null +++ b/scripts/task167-local-markdown-title-body-options-no-convex-smoke.js @@ -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); +}); diff --git a/scripts/task175-rust-upload-entry-smoke.js b/scripts/task175-rust-upload-entry-smoke.js index 8abab0fe..e8295c8b 100644 --- a/scripts/task175-rust-upload-entry-smoke.js +++ b/scripts/task175-rust-upload-entry-smoke.js @@ -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"; diff --git a/scripts/task427-trash-empty-isolated-workspace-smoke.js b/scripts/task427-trash-empty-isolated-workspace-smoke.js index 1fca12df..9486d231 100644 --- a/scripts/task427-trash-empty-isolated-workspace-smoke.js +++ b/scripts/task427-trash-empty-isolated-workspace-smoke.js @@ -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}`, { diff --git a/scripts/task428-filetree-bulk-delete-selection-smoke.js b/scripts/task428-filetree-bulk-delete-selection-smoke.js index 704b6591..39c0b063 100644 --- a/scripts/task428-filetree-bulk-delete-selection-smoke.js +++ b/scripts/task428-filetree-bulk-delete-selection-smoke.js @@ -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"); diff --git a/scripts/task430-vscode-explorer-stage7-smoke.js b/scripts/task430-vscode-explorer-stage7-smoke.js index d79886eb..f67fa834 100644 --- a/scripts/task430-vscode-explorer-stage7-smoke.js +++ b/scripts/task430-vscode-explorer-stage7-smoke.js @@ -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"); diff --git a/scripts/task433-filetree-trash-file-asset-dual-browser-no-refresh-smoke.js b/scripts/task433-filetree-trash-file-asset-dual-browser-no-refresh-smoke.js index a37c8da4..073117be 100644 --- a/scripts/task433-filetree-trash-file-asset-dual-browser-no-refresh-smoke.js +++ b/scripts/task433-filetree-trash-file-asset-dual-browser-no-refresh-smoke.js @@ -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"); diff --git a/scripts/task434-filetree-trash-mindmap-table-dual-browser-no-refresh-smoke.js b/scripts/task434-filetree-trash-mindmap-table-dual-browser-no-refresh-smoke.js index 1374a851..4a46fc7d 100644 --- a/scripts/task434-filetree-trash-mindmap-table-dual-browser-no-refresh-smoke.js +++ b/scripts/task434-filetree-trash-mindmap-table-dual-browser-no-refresh-smoke.js @@ -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"); diff --git a/scripts/task435-local-folder-watch-no-reload-smoke.js b/scripts/task435-local-folder-watch-no-reload-smoke.js index 25f6cc7e..f1ab0dc6 100644 --- a/scripts/task435-local-folder-watch-no-reload-smoke.js +++ b/scripts/task435-local-folder-watch-no-reload-smoke.js @@ -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: { diff --git a/scripts/task436-local-markdown-open-document-external-change-smoke.js b/scripts/task436-local-markdown-open-document-external-change-smoke.js index e0f6ed43..bda45cf9 100644 --- a/scripts/task436-local-markdown-open-document-external-change-smoke.js +++ b/scripts/task436-local-markdown-open-document-external-change-smoke.js @@ -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); diff --git a/scripts/task443-local-markdown-asset-upload-smoke.js b/scripts/task443-local-markdown-asset-upload-smoke.js new file mode 100644 index 00000000..f2dbd7d0 --- /dev/null +++ b/scripts/task443-local-markdown-asset-upload-smoke.js @@ -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); +}); diff --git a/scripts/task444-convex-workspace-export-local-fixture-smoke.js b/scripts/task444-convex-workspace-export-local-fixture-smoke.js new file mode 100644 index 00000000..cb83e3df --- /dev/null +++ b/scripts/task444-convex-workspace-export-local-fixture-smoke.js @@ -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));