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