chore: 保存当前架构收口与 bug 修复快照

归档本轮 P0/P1 bug 修复、设计审查迁移、AI selection scope 收口与 stream contract 调整,并保留当前 05 主线迁移起点。
This commit is contained in:
lix-2026
2026-05-18 17:01:35 +08:00
parent 61ee4a38a2
commit a2cb1338c8
953 changed files with 14383 additions and 211845 deletions
+12 -10
View File
@@ -11,7 +11,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 侧当前只做 Phase A`mnote.doc.markdown_edit` + `mnote.doc.fetch` 增强,search/replace + `format: "markdown"` + local source),退役 `local_rule` planner 作为 Phase B`review session`/流式 apply 属于 Phase C(设计冻结,当前不实施),不扩新 AI 功能,不把粗粒度 `mnote.page.save` 当成精确块编辑主入口。
- 当前最优先的架构收口不是继续扩编辑器 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` 当成精确块编辑主入口。
## 组件定位
@@ -19,15 +19,15 @@
- `BlockNote` 是历史参考实现 / 对照材料,不再是运行时系统组件、默认回退编辑器或系统事实源。
- `Mindmap``tree-first graph` 的一种视图和编辑挂件,不是对象真相层。
- `OnlyOffice` 是独立页面型编辑器,不直接嵌入 `BlockNote` 画布;正文中通常通过附件块跳转进入。
- `page-ai/block-edit-workflow` route 是当前简单编辑的 fast-path 入口`local_rule` planner 为过渡实现);长期方向是将 AI 编辑主路径收敛到 `mnote.doc.markdown_edit`markdown 级 search/replace,见 7-14),`mnote.block.*` 保留为结构性辅助。复杂任务仍走 Hermes agent。
- `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。
- 树域三层模型:`Resource Tree`kernel 对象组织真源)→ `File Tree`(主组织投影,`{title}.md` 为页面正文行)→ `Page Tree`(导航投影,不持有结构真相)。涉及资源归属优先走 Rust kernel 的 `KernelObjectIdentity` / `KernelProjectionResourceKind`
## 目录优先级
- `/mnt/Data1T/mnote/wolai-frontend/src/`
页面、编辑器、Sidebar、树视图、阅读态、交互壳默认先看这里
- `/mnt/Data1T/mnote/wolai-frontend/convex/`
仍在使用的 Convex functions 与数据侧逻辑先看这里
- `/mnt/Data1T/mnote/recycle/wolai-frontend/src/`
wolai-frontendNext.js React 前端)已退役移入 recycle,不作为当前实现依据。页面、编辑器、Sidebar、树视图、阅读态、交互壳默认先看 Rust mnote-web SSR`rust/crates/mnote-web/src/`
- `/mnt/Data1T/mnote/recycle/wolai-frontend/convex/`
已随 wolai-frontend 移入 recycle。当前活跃 Convex functions 在 `infra/convex/` 与对应 Rust bridge 中
- `/mnt/Data1T/mnote/rust/crates/core-protocol/`
Kernel 类型、projection 协议、树/图核心术语先看这里。
- `/mnt/Data1T/mnote/rust/crates/bridge-runtime/`
@@ -52,7 +52,7 @@
- 涉及页面新建、重命名、移动、归档、恢复、嵌入时,优先沿 `tree.*` 正式命名推进;`documents.*` 只视为兼容层,不应继续扩写为长期命令面。
- 涉及 AI 读取、定位或精确编辑页面块时,优先沿 `mnote.doc.*` / `mnote.block.*` Hermes tools 与 Rust `EditorCommand` 推进;`mnote.page.save` 只作为页面级兜底写入工具。
- 涉及资源归属(哪个页面拥有哪个 mindmap/附件)、object identity 和资源生命周期时,优先沿 Resource Tree → File Tree projection → `tree.resource.*` 命令面推进;`tree.*` 是正式命令面,`documents.*` 只视为兼容层。
- 页面 AI 编辑长期主路径为 `mnote.doc.markdown_edit`search/replace 或 full_contentmarkdown 文本级),在线文档和本地 `.md` 文件共用。当前过渡实现为 `POST /api/page-ai/block-edit-workflow` fast-path`local_rule` plannerPhase B 退役)。`mnote.block.*` 保留为结构性辅助(拖拽排序等)。两层操作模型已获 CLI Main(Lark Doc)参考实现验证。流式 apply + suggest/review(参考 BlockNote AI)作为 Phase C 设计冻结,当前不实施。复杂任务进入 `/api/hermes/client/runs`。模型不直接产出 unified diff 或 blockId 操作;最终定位、校验和写入由 Rust runtime 负责。
- 页面 AI 编辑当前主路径为 `mnote.doc.markdown_edit`search/replace 或 full_contentmarkdown 文本级),在线文档和本地 `.md` 文件共用。`POST /api/page-ai/block-edit-workflow` 是简单编辑 fast-path,当前通过模型生成 markdown 编辑意图后调用统一 mnote tool executor`mnote.block.*` 保留为结构性辅助(拖拽排序等)。两层操作模型已获 CLI Main(Lark Doc)参考实现验证。流式 apply + suggest/review(参考 BlockNote AI)作为 Phase C 设计冻结,当前不实施。复杂任务进入 `/api/hermes/client/runs`。模型不直接产出 unified diff 或 blockId 操作;最终定位、校验和写入由 Rust runtime 负责。
- 需要架构判断时,优先参考:
- `/mnt/Data1T/mnote/ARCHITECTURE.md`
- `/mnt/Data1T/mnote/design/01-05-current-priority-overview.md`
@@ -65,7 +65,9 @@
- `/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/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/10-review/done/10-current-mnote-ai-runtime-review-v1.md`
- `/mnt/Data1T/mnote/design/10-review/done/11-current-full-architecture-review-v1.md`
- `/mnt/Data1T/mnote/design/07-ai/process/7-14-online-local-ai-markdown-editing-convergence-v1.md`
- `/mnt/Data1T/mnote/design/04-tree-domain/done/4-24-resource-tree-filetree-pagetree-source-contract-checklist-v1.md`
- `/mnt/Data1T/mnote/design/05-editor-mainline/done/5-12-main-editor-object-tab-resource-alignment-checklist-v1.md`
@@ -100,8 +102,8 @@
## 常用命令
- 根目录热启动:`npm run desktop:hot`
- 前端开发`cd /mnt/Data1T/mnote/wolai-frontend && pnpm dev`
- 前端检查:`cd /mnt/Data1T/mnote/wolai-frontend && pnpm lint && pnpm test`
- 前端开发(已迁移至 Rust SSR):`npm run desktop:hot` 启动后直接访问 `http://localhost:3000`
- 前端检查:Rust 测试 `cargo test -p mnote-web`Rust SSR 与 API),旧 React 前端已移入 recycle
- 后端开发:`cd /mnt/Data1T/mnote/wolai-backend && uvicorn app.main:app --reload --port 8000`
- Convex 自托管:参考 `/mnt/Data1T/mnote/infra/convex/README.md`
+43 -69
View File
@@ -35,16 +35,16 @@
## 2. 当前主线目录
- `/mnt/Data1T/mnote/wolai-frontend/`
当前 React island 与 legacy compat 代码来源;Next.js App Router 不再作为 3000 主入口,只在显式 legacy/debug 迁移边界继续服务重交互运行态与对照链
- `/mnt/Data1T/mnote/recycle/wolai-frontend/`
已退役移入 recycle。React island 与 legacy compat 代码的历史来源;Next.js App Router 已被 Rust mnote-web SSR 替代,不再作为 3000 主入口。
- `/mnt/Data1T/mnote/rust/crates/core-protocol/`
Kernel 类型、projection 协议、编辑器协议、树 / 图核心术语。
- `/mnt/Data1T/mnote/rust/crates/bridge-runtime/`
Kernel query / command、bridge transport、Tiptap <-> editor document 适配、命令解释层。
- `/mnt/Data1T/mnote/rust/crates/mnote-web/`
Rust Web route、kernel projection、documents/search/mindmap shell、Hermes bridge、stream transport、compat,以及显式开启时的 debug shell;它是当前主 Web gateway / shell / transport owner。
- `/mnt/Data1T/mnote/wolai-frontend/convex/`
仍在使用的 Convex functions 与数据侧逻辑
- `/mnt/Data1T/mnote/recycle/wolai-frontend/convex/`
已随 wolai-frontend 移入 recycle。当前活跃 Convex functions 位于 `infra/convex/` 与 Rust bridge 中
- `/mnt/Data1T/mnote/infra/convex/`
Convex 自托管部署与基础设施。
- `/mnt/Data1T/mnote/wolai-backend/`
@@ -52,97 +52,71 @@
## 3. 当前页面运行结构
### 3.1 根布局与 legacy island source
### 3.1 根布局与 legacy island source(已退役)
- `/mnt/Data1T/mnote/wolai-frontend/src/app/layout.tsx`
- `/mnt/Data1T/mnote/recycle/wolai-frontend/src/app/layout.tsx`
职责:
- 作为 legacy Next / React island bundle source 的根布局
- 保留 Convex / Query Provider 等前端 island 运行时依赖
- 服务显式 legacy/debug/迁移边界
该文件已随 wolai-frontend 移入 `recycle/`,不再作为当前实现依据。
注意:
> **3000 当前根入口与文档页 shell 由 `mnote-web` 持有;Next App Router 不再是 3000 主入口或页面根布局事实源。**
> **3000 当前根入口与文档页 shell 由 `mnote-web` Rust SSR 持有;Next App Router 已完全退役。**
### 3.2 工作区壳与 Sidebar
### 3.2 工作区壳与 Sidebar(已迁移至 Rust SSR
- `/mnt/Data1T/mnote/wolai-frontend/src/components/app-layout-shell.tsx`
- `/mnt/Data1T/mnote/wolai-frontend/src/hooks/use-sidebar-data.ts`
- `/mnt/Data1T/mnote/wolai-frontend/src/lib/tree-stream/use-sidebar-tree-stream.ts`
- `/mnt/Data1T/mnote/wolai-frontend/src/components/sidebar/use-preferred-sidebar-snapshot.ts`
以下文件已随 wolai-frontend 移入 `recycle/`,不再作为当前实现依据:
当前 Sidebar 不是单一路径,而是三层组合:
- `/mnt/Data1T/mnote/recycle/wolai-frontend/src/components/app-layout-shell.tsx`
- `/mnt/Data1T/mnote/recycle/wolai-frontend/src/hooks/use-sidebar-data.ts`
- `/mnt/Data1T/mnote/recycle/wolai-frontend/src/lib/tree-stream/use-sidebar-tree-stream.ts`
- `/mnt/Data1T/mnote/recycle/wolai-frontend/src/components/sidebar/use-preferred-sidebar-snapshot.ts`
1. 初始 SSR / route snapshot
2. query / refetch snapshot
3. tree stream live snapshot
`PreferredSidebarSnapshotProvider` 负责在 query 与 tree stream 之间做 freshness 选择,再把同一份 preferred snapshot 同时给:
- `Sidebar`
- `Breadcrumb`
- 文档页头标题消费链
这说明当前工作区树链已经进入正式收口阶段:`3000` 主壳已直接接入 `/api/tree/events` 的 snapshot / delta / resync consumer,并有浏览器 smoke 验证;但 preferred snapshot、page subtree、filetree 与补偿链还没有完全统一成唯一 live cache。
当前 Sidebar/工作区壳的当前实现在 Rust mnote-web SSR
- 工作区壳渲染入口:`gateway.rs``root_entry`
- 文档页壳/文档面板:`web_shell.rs``document_page_shell``build_document_panes_bootstrap_json`
- Sidebar tree stream 消费:`layout.rs` 嵌入 JS`startWithSse` / `startWithWebSocket`
- FileTree 渲染与交互:`tree.rs` 嵌入 JS
## 4. 文档页主链
### 4.1 入口
- `/mnt/Data1T/mnote/wolai-frontend/src/lib/documents/page-aggregate-loader.ts`
- `/mnt/Data1T/mnote/wolai-frontend/src/app/(app)/documents/[id]/page.tsx`
文档页入口的当前实现在 Rust mnote-web
当前文档页读取主链已经不再由页面入口手工拼若干独立字段,而是:
- 文档页壳 SSR`web_shell.rs``document_page_shell`),直接输出 HTML + bootstrap JSON
- Page Aggregate 快照 API`web_shell.rs``page_aggregate`)返回 `/api/page-aggregate/:id`
- 前端桌面/窗口面板布局:`web_shell.rs``build_document_panes_bootstrap_json`
1. 页面 SSR 入口通过 `page-aggregate-loader.ts` 直接请求 Rust `/api/page-aggregate/:id`
2. `DocumentContent` 的内容重试补拉也直接请求同一条 `/api/page-aggregate/:id` 正式读链
3. 读取主链不再回退到 TS builder;snapshot 不可用或不可信时直接显式失败
4. Next `/api/documents/page` 已退场为明确 `410` 的 compat 边界,不再参与文档页运行时主路径
历史 wolai-frontend Next 文档页入口(已移入 `recycle/`):
- `/mnt/Data1T/mnote/recycle/wolai-frontend/src/lib/documents/page-aggregate-loader.ts`
- `/mnt/Data1T/mnote/recycle/wolai-frontend/src/app/(app)/documents/[id]/page.tsx`
- `/mnt/Data1T/mnote/recycle/wolai-frontend/src/lib/documents/page-aggregate.ts`
对应聚合类型定义:
### 4.2 文档壳(已迁移至 Rust SSR
- `/mnt/Data1T/mnote/wolai-frontend/src/lib/documents/page-aggregate.ts`
历史 React 文档壳组件(已随 wolai-frontend 移入 `recycle/`,不再作为当前实现):
- `/mnt/Data1T/mnote/recycle/wolai-frontend/src/components/editor/document-shell.tsx`
- `/mnt/Data1T/mnote/recycle/wolai-frontend/src/components/editor/document-content.tsx`
### 4.2 文档壳
当前文档壳由 Rust mnote-web SSR 输出:
- `web_shell.rs``document_page_shell` 直接渲染 HTML shell,不在前端保留 React 文档壳 runtime。
- `/mnt/Data1T/mnote/wolai-frontend/src/components/editor/document-shell.tsx`
- `/mnt/Data1T/mnote/wolai-frontend/src/components/editor/document-content.tsx`
### 4.3 默认编辑器 host(已迁移至 WASM island
`DocumentShell` 现在只是薄封装,文档页核心状态和交互壳集中在 `DocumentContent`
历史 React 编辑器 host(已移入 `recycle/`):
- `/mnt/Data1T/mnote/recycle/wolai-frontend/src/components/editor/editor-host-config.ts`
- `/mnt/Data1T/mnote/recycle/wolai-frontend/src/components/editor/editor-host.tsx`
- `/mnt/Data1T/mnote/recycle/wolai-frontend/src/components/editor/leptos-tiptap-island-editor-host.tsx`
### 4.3 默认编辑器 host
- `/mnt/Data1T/mnote/wolai-frontend/src/components/editor/editor-host-config.ts`
- `/mnt/Data1T/mnote/wolai-frontend/src/components/editor/editor-host.tsx`
当前默认 host 为:
- `leptos_tiptap_island`
文档页主路径已不再暴露显式 debug host 选择;历史 `iframe_debug` 宿主仅剩源码参考,不再参与页面 host 选择面。
### 4.4 正式主编辑器
- `/mnt/Data1T/mnote/wolai-frontend/src/components/editor/leptos-tiptap-island-editor-host.tsx`
当前默认主编辑器已经是页面内 Leptos island,不再是 iframe bridge。
它负责:
- 加载 Leptos/WASM 编辑器本体
- 接收初始化内容与 page options
- 处理同页事件、命令、保存与状态同步
当前默认编辑器是页面内 Leptos/WASM island,由 Rust mnote-web SSR 加载:
- WASM 产物:`rust/spikes/leptos-tiptap-spike/`
- 加载路由:`web_shell.rs``leptos_tiptap_manifest` / `leptos_tiptap_asset`
### 4.5 BlockNote 的当前位置
- `/mnt/Data1T/mnote/recycle/wolai-frontend/src/components/editor/blocknote-editor.tsx`
`BlockNote` 当前仍保留回收副本,但其定位已变成:
- 历史参考实现
- 对照材料 / 调试素材
`BlockNote` 已随 wolai-frontend 移入 recycle,仅作为历史对照材料,不参与当前实现。
而不是默认主编辑器方向。
@@ -197,7 +171,7 @@
### 6.2 树命令现状
- `/mnt/Data1T/mnote/wolai-frontend/src/lib/documents/tree-command-client.ts`
- `/mnt/Data1T/mnote/recycle/wolai-frontend/src/lib/documents/tree-command-client.ts`
- `/mnt/Data1T/mnote/rust/crates/mnote-web/src/routes/tree.rs`
- `/mnt/Data1T/mnote/rust/crates/storage-convex-bridge/src/mapping.rs`
+55 -57
View File
@@ -36,25 +36,25 @@
- 正式命令面应落在 `tree.*``tree.resource.*`
- `mnote-web` 同时暴露 `tree` 命令路由与兼容/过渡路由。
- 当前最大冲突是 FileTree 资源行仍可能被当成页面命令对象处理,违反 Resource Tree / File Tree / Page Tree 分层
- FileTree 资源行已与页面命令对象隔离:资源 owner 只作为上下文,`data-document-id` / 页面命令目标只保留给真正页面行
### 2.4 Transport / Realtime 层
- 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-sse`,因此主壳默认走 SSE;WS 路径代码已就绪但尚未设为默认 transport,前端主消费链路仍未切换到 WS 默认
- `wolai-frontend`Next.js legacy 侧)仅使用 SSE`use-sidebar-tree-stream.ts`,无 WS 消费者。
- SSE 内部还保留 push/polling 两种语义,WS 与 SSE 的 delta / snapshot 结构也未完全收敛
- 当前默认 `bootstrap.transport` 已切到 `convex-command-log-ws`,主壳先走 WSSSE 保留为 fallback
- `recycle/wolai-frontend`已退役的 Next.js legacy 侧)仅使用 SSE,无 WS 消费者。
- WS 与 SSE 的 snapshot / delta 载荷合同已在 Rust 主壳消费层统一;后续重点是继续减少兼容 fallback 与 live cache 补偿链
### 2.5 Page Aggregate 层
- 文档页入口已经优先消费 Rust `page-aggregate` 快照。
- 但前端仍保留 `PageAggregateClientState` reducer 和本地 `page tree` 投影
- 当前 page aggregate 是“Rust join + 前端局部状态”的过渡闭环,不是 EditorBlockDocument 原生单一真源闭环。
- AI context 的 page subtree 已只读 Rust Page Aggregate 的稳定 projection,不再由前端本地构造第二份 page tree 真相
- 当前 page aggregate 仍是从 `documents.content` / local markdown content 投影出来的过渡闭环,不是 EditorBlockDocument 原生单一真源闭环。
### 2.6 Editor Runtime 层
- 前端壳: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/`但默认热启动不再作为运行时 daemon。
- 前端壳: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` 兼容面。
- 这意味着编辑器体验已经切主,但写回语义还没有完全切到唯一主命令面。
@@ -62,7 +62,8 @@
### 2.7 AI Runtime 层
- `mnote.doc.fetch``mnote.doc.markdown_edit``mnote.doc.apply_block_ops``page_ai_workflow`、Hermes / ACP / Reasonix 构成当前 AI 主链。
- 这里存在最密集的合同漂移:工具权限、写入幂等、dryRun、revision 校验、manifest schema、tool guidance、runtime owner 都还没有统一
- 简单正文编辑主路径已统一为模型生成 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 仍冻结。
## 3. 当前成立的事实与过渡态
@@ -72,95 +73,92 @@
- Tree 主链已经从旧兼容入口退向 Rust Web。
- 文档页主编辑器已经切到 `leptos-tiptap` island。
- AI 页面编辑已经不再是纯前端本地逻辑。
- **前端壳已切换到 Rust**:默认 `desktop:hot` 仅启动 Rust mnote-web 作为 3000 网关 owner,不再启动 Next.js (`wolai-frontend`) 作为运行时 daemon。Next.js 仅保留为 legacy compat fallback`MNOTE_WEB_SKIP_GATEWAY=1`)与 build toolchain
- **前端壳已切换到 Rust**:默认 `desktop:hot` 仅启动 Rust mnote-web 作为 3000 网关 owner`wolai-frontend`Next.js 前端)已移至 `recycle/`,不再作为运行时 daemon 或 legacy fallback 维护
### 3.2 过渡态
- Page Aggregate 仍从 `documents.content` 侧 join 构造,而不是原生 EditorBlockDocument 真源。
- 前端 Sidebar 仍有本地投影与多源抢真相
- `mnote.doc.markdown_edit` 已进入主线,但写入合同尚不完整
- ACP / Reasonix 已接入,但调用字段和运行时所有权仍不统一。
- 页面正文写回仍经过兼容保存面,尚未完全切到唯一主命令面
- realtime / Page Aggregate / AI 写入的 P0 合同已基本收口,但兼容路由和历史 adapter 仍偏多
## 4. 架构冲突矩阵
### 4.1 Tree realtime
- 事实:Rust Web 注册 WS。
- 事实:前端仍只见 SSE consumer
- 冲突:系统口径已升级,但前端主链未同步切换
- 影响live cache、调试、回放与验收都会出现双口径
- 事实:Rust Web 同时注册 WS 与 SSE
- 事实:Rust SSR 主壳默认以 WS 为主链,SSE 是断线 fallback
- 当前状态:原“WS 文档口径 / 前端 SSE 实现”冲突已修复
- 剩余风险live cache 与兼容 fallback 仍需要继续瘦身,避免未来再次出现多链路补偿
### 4.2 FileTree 资源行
- 事实:资源投影行带 `documentId`
- 事实:`tree.tsx` 相关路径会把它当作页面对象处理
- 冲突:资源树与页面树边界被突破
- 影响:误删、误重命名、误移动风险上升
- 事实:资源行的 owner document 与页面命令目标已分离,资源行不再暴露页面命令目标 ID
- 当前状态:原“资源行被当成页面命令对象处理”冲突已修复
- 剩余风险:资源对象的完整 `tree.resource.*` 命令面仍应继续补齐
### 4.3 Page Aggregate
- 事实:Rust snapshot 已是入口事实。
- 事实:前端 reducer 与本地 page tree 还在派生第二份状态
- 冲突:页面标题、正文、树投影可能各自取源
- 影响:AI context、Sidebar、正文显示不一定同源
- 事实: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、apply_block_ops 仍在不同层保留旧习惯
- 冲突:同一条编辑链路里存在 markdown / block / tool executor 三套合同。
- 影响:写入正确性、幂等性、回读一致性都不稳定
- 事实:`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. 缺失功能
- 前端树流仍缺统一的 WS 主 consumer
- Page Aggregate 仍缺前端第二真相收口
- FileTree 资源行仍缺严格的命令面隔离
- `mnote.doc.markdown_edit` 仍缺完整写入合同
- ACP / Reasonix 仍缺统一身份、会话、运行、幂等字段。
- 兼容路由仍偏多,正式命令面与过渡面边界还不够硬。
- Page Aggregate 仍缺 EditorBlockDocument 原生落库真相
- 正文保存仍经过 `documents/save` 兼容面,唯一主命令面尚未完全闭环
- `tree.resource.*` 仍需要补完整资源对象生命周期命令
- 兼容路由、历史 adapter 与 fallback 仍偏多,需要继续减小长期维护面
## 6. 推荐收口顺序
1.`tree` 实时链路口径,让前端主消费与后端主发布一致
2. 再收 FileTree 资源行命令边界,避免资源对象误走页面命令。
3.收 Page Aggregate 单一真源,减少 Sidebar / 文档页 / AI context 多源抢真相
4. 最后收 AI 写入合同,把 `markdown_edit``apply_block_ops`、Hermes / ACP / Reasonix 的调用协议统一起来
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/process/10-current-mnote-ai-runtime-review-v1.md)
- [设计审查:当前完整架构 Review](./design/10-review/process/11-current-full-architecture-review-v1.md)
- [设计审查:当前 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/process/3-16-tree-realtime-ws-sse-doc-contract-drift-v1.md)
- [3-17 SSE push 模式跳过 polling safety net](./bugs/03-rust-web/process/3-17-sse-push-skips-polling-fallback-v1.md)
- [3-18 WS 与 SSE delta 载荷合同分裂](./bugs/03-rust-web/process/3-18-ws-sse-delta-contract-split-v1.md)
- [3-19 Reasonix ACP wrapper 调 mnote tool 缺少身份与幂等字段](./bugs/03-rust-web/process/3-19-acp-reasonix-tool-call-missing-identity-fields-v1.md)
- [3-20 ACP incoming request 只记录日志不响应](./bugs/03-rust-web/process/3-20-acp-request-permission-no-response-v1.md)
- [3-21 ACP run payload 被第一次 stream_events 消费后移除](./bugs/03-rust-web/process/3-21-acp-run-payload-consumed-and-removed-v1.md)
- [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/process/4-46-filetree-resource-row-document-command-leak-v1.md)
- [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/process/5-15-page-aggregate-client-state-second-truth-v1.md)
- [5-16 Sidebar preferred snapshot 中 query 可覆盖 live stream](./bugs/05-editor-mainline/process/5-16-sidebar-preferred-snapshot-query-overrides-live-stream-v1.md)
- [5-17 文档页标题优先 liveSidebarTitle 而非 Page Aggregate head](./bugs/05-editor-mainline/process/5-17-document-title-source-drift-live-sidebar-over-head-v1.md)
- [5-18 AI 写正文后本地 Page Aggregate content 可能不刷新](./bugs/05-editor-mainline/process/5-18-ai-write-body-does-not-sync-page-aggregate-content-v1.md)
- [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/process/7-18-ai-markdown-edit-phase-state-contract-drift-v1.md)
- [7-19 Hermes 工具指导仍优先 apply_block_ops 而非 markdown_edit](./bugs/07-ai/process/7-19-hermes-tool-guidance-markdown-edit-contract-drift-v1.md)
- [7-20 page_ai_workflow 绕过 Hermes tool executor / audit / toggle](./bugs/07-ai/process/7-20-page-ai-workflow-bypasses-hermes-tool-executor-v1.md)
- [7-21 mnote.doc.markdown_edit 本地文件写入绕过 dryRun / idempotency](./bugs/07-ai/process/7-21-markdown-edit-local-write-contract-bypass-v1.md)
- [7-22 mnote.doc.apply_block_ops 批量写入缺少 revision / conflictDetectionKey 校验](./bugs/07-ai/process/7-22-apply-block-ops-missing-write-preconditions-v1.md)
- [7-23 mnote.doc.markdown_edit manifest schema 缺少 required 与写入字段](./bugs/07-ai/process/7-23-markdown-edit-manifest-schema-contract-drift-v1.md)
- [7-24 在线 markdown_edit 写回不以最终 Markdown 为真源](./bugs/07-ai/process/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/process/7-25-reasonix-block-edit-workflow-empty-block-ops-after-markdown-match-v1.md)
- [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)
@@ -1,8 +1,8 @@
# 3-16 [process][bug] tree realtime WS 主链口径与前端 SSE 实现不一致 v1
# 3-16 [done][bug] tree realtime WS 主链口径与前端 SSE 实现不一致 v1
> 发现时间:2026-05-17
>
> 状态:`[process]`
> 状态:`[done]`
>
> 关联主线:`03-rust-web`
@@ -29,6 +29,19 @@
- 增加浏览器 smoke,断言网络中实际使用的是 WS 主链还是 SSE fallback。
- 文档与测试必须以真实前端消费路径为准。
## 5. 状态
## 5. 修复
确认口径与前端源码不一致,尚未修复。
修复 / 已收口:
- [layout.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/ssr/pages/layout.rs:7127) Rust SSR 主壳内置 `startWithWebSocket`,实际创建 `/api/realtime/ws` WebSocket consumer。
- [layout.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/ssr/pages/layout.rs:7176) WebSocket 断开后调用 `startWithSseFallback``/api/tree/events` 明确成为 fallback。
- [layout.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/ssr/pages/layout.rs:7301) SSR tree live bootstrap 默认 `transport` 已是 `convex-command-log-ws`,不再默认走 SSE。
- [layout.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/ssr/pages/layout.rs:7593) 增强回归测试,断言 controller 同时包含 WS 主链与 SSE fallback。
## 6. 验证
```bash
cargo test --manifest-path rust/Cargo.toml -p mnote-web tree_live_controller_marks_transport_and_closes_source_on_pagehide -- --nocapture
```
结果:1 个测试通过。
@@ -1,8 +1,8 @@
# 3-17 [process][bug] SSE push 模式跳过 polling safety net v1
# 3-17 [done][bug] SSE push 模式跳过 polling safety net v1
> 发现时间:2026-05-17
>
> 状态:`[process]`
> 状态:`[done]`
>
> 关联主线:`03-rust-web`
@@ -27,6 +27,19 @@ SSE route 注释和架构口径暗示 polling 可作为 safety net,但实现
- 增加测试:模拟 `stream_delta_rx` 无消息但 bridgeLogs 有新事件,SSE 应产出 delta/resync。
- 明确 `pollMs` 与 push channel 同时存在时的优先级。
## 5. 状态
## 5. 修复
确认 push 分支跳过 polling fallback,尚未修复
已修复
- [sse.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/routes/sse.rs:117) push 模式在 broadcast 静默时不再只发 heartbeat,而是按 `pollMs/maxPolls` 执行 bridgeLogs polling safety net。
- [sse.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/routes/sse.rs:135) polling 检测到 `Delta` 时构建标准 stream delta payload 并发送 `delta`
- [sse.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/routes/sse.rs:157) polling 检测到 `Resync` 时回读 snapshot 并发送 `resync`
- [sse.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/routes/sse.rs:402) 增加回归测试,断言 push 模式下 `maxPolls=1` 会触发 safety-net polling 并正常结束。
## 6. 验证
```bash
cargo test --manifest-path rust/Cargo.toml -p mnote-web routes::sse::tests -- --nocapture
```
结果:5 个 SSE route 测试通过。
@@ -1,8 +1,8 @@
# 3-18 [process][bug] WS 与 SSE delta 载荷合同分裂 v1
# 3-18 [done][bug] WS 与 SSE delta 载荷合同分裂 v1
> 发现时间:2026-05-17
>
> 状态:`[process]`
> 状态:`[done]`
>
> 关联主线:`03-rust-web`
@@ -28,6 +28,20 @@ SSE polling 可以从 bridgeLogs / domainEvents 解析 `streamDelta` 并附带 s
- 对同一命令分别跑 WS / SSE 合同测试,断言事件 kind、cursor、snapshot、resync 语义一致。
- 前端 reducer 只消费统一 envelope,不感知 transport 差异。
## 5. 状态
## 5. 修复
确认 WS / SSE 事件结构不统一,尚未修复
已修复
- [stream_support.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/routes/stream_support.rs:577) 新增 `build_stream_push_delta_hint`,统一 command committed broadcast 的 tree realtime hint envelope。
- [sse.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/routes/sse.rs:97) SSE push 分支改为复用统一 hint envelope。
- [ws.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/routes/ws.rs:43) WS broadcast 分支改为发送同一 hint envelope,不再输出嵌套 `data.kind=command_committed` 的专有结构。
- [ws.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/routes/ws.rs:190) 增加 WS delta serializer 合同测试,断言输出为 `kind=delta``hint=command_committed`,并且没有额外 `data` 外壳。
## 6. 验证
```bash
cargo test --manifest-path rust/Cargo.toml -p mnote-web routes::ws::tests -- --nocapture
cargo test --manifest-path rust/Cargo.toml -p mnote-web routes::sse::tests -- --nocapture
```
结果:WS 3 个测试通过,SSE 5 个测试通过。
@@ -0,0 +1,49 @@
# 3-19 [done][bug] Reasonix ACP wrapper 调 mnote tool 缺少身份与幂等字段 v1
> 发现时间:2026-05-17
>
> 状态:`[done]`
>
> 关联主线:`03-rust-web`
## 1. 问题定义
Reasonix ACP wrapper 调用 mnote tool 时只发送 `toolName``args``workspaceId`,没有传递 `actorId``sessionId``runId``toolCallId``traceId``dryRun``idempotencyKey` 等工具合同字段。
## 2. 证据
- [reasonix-acp-wrapper.mjs](/mnt/Data1T/mnote/scripts/reasonix-acp-wrapper.mjs:230) 附近构造 `callMnoteTool` 请求体。
## 3. 影响
- Rust tool executor 难以稳定进行鉴权、审计、追踪和幂等。
- 写工具可能被拒绝,或在缺少幂等键时产生重复写入风险。
- Reasonix 与 Hermes 对同一 tool 的调用合同不一致。
## 4. 建议修复
- wrapper 必须从 ACP run/session 中透传 actor、session、run、toolCall、trace、dryRun、idempotency 字段。
- 写工具缺失必要字段时应 fail fast。
- 增加 Reasonix 端到端 smoke,断言请求字段完整。
## 5. 修复
已修复:
- [reasonix-acp-wrapper.mjs](/mnt/Data1T/mnote/scripts/reasonix-acp-wrapper.mjs:34) 新增 `buildMnoteToolPayload`,统一构造 mnote tool 请求体。
- [reasonix-acp-wrapper.mjs](/mnt/Data1T/mnote/scripts/reasonix-acp-wrapper.mjs:43) tool payload 现在包含 `actorId``sessionId``runId``toolCallId``traceId``dryRun``idempotencyKey``workspaceId``documentId`
- [reasonix-acp-wrapper.mjs](/mnt/Data1T/mnote/scripts/reasonix-acp-wrapper.mjs:303) HTTP 请求头同步携带 `x-mnote-actor-id``x-mnote-workspace-id`
- [reasonix-acp-wrapper.mjs](/mnt/Data1T/mnote/scripts/reasonix-acp-wrapper.mjs:379) ACP `session/prompt` 期间通过 `AsyncLocalStorage` 保存 mnote tool contexttool 调用继承 run/session/actor/trace。
- [acp_types.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/acp_types.rs:163) `SessionPromptParams` 扩展可选 mnote 上下文字段。
- [acp_session_manager.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/acp_session_manager.rs:193) 增加 `run_prompt_with_mnote_context`
- [hermes_client.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/routes/hermes_client.rs:754) ACP Reasonix prompt 启动时透传 mnote session/run/actor/trace/workspace/document 上下文。
## 6. 验证
```bash
node --check scripts/reasonix-acp-wrapper.mjs
MNOTE_REASONIX_ACP_SELFTEST=1 node scripts/reasonix-acp-wrapper.mjs
cargo test --manifest-path rust/Cargo.toml -p mnote-web acp_session_manager -- --nocapture
```
结果:JS 语法检查通过,wrapper selftest 通过,ACP session manager 5 个测试通过。
@@ -0,0 +1,40 @@
# 3-20 [done][bug] ACP incoming request 只记录日志不响应 v1
> 发现时间:2026-05-17
>
> 状态:`[done]`
>
> 关联主线:`03-rust-web`
## 1. 问题定义
ACP client 收到 `session/request_permission` 等 incoming request 时当前只记录日志,没有返回协议响应。
## 2. 证据
- [acp_client.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/acp_client.rs:347) 附近处理 incoming request。
## 3. 影响
- ACP agent 侧可能等待到超时。
- 权限请求无法进入用户确认或自动拒绝流程。
- run 状态可能卡在进行中,SSE 输出也无法表达真实阻塞原因。
## 4. 建议修复
- 为已知 request type 实现明确响应:允许、拒绝或要求人工确认。
- 未支持的 request type 也应返回结构化 error,而不是只日志。
- 增加 ACP request/response 协议测试。
## 5. 修复
- [acp_client.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/acp_client.rs:157) 将 ACP 子进程 stdin writer 作为共享 `Arc<Mutex<BufWriter<_>>>` 传入后台 reader loop。
- [acp_client.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/acp_client.rs:335) 对带 `id + method` 的 incoming request 写回 JSON-RPC error 响应,当前无权限确认 UI 时明确拒绝,避免 agent 等待超时。
- [acp_client.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/acp_client.rs:551) 增加 mock ACP server 测试,覆盖 `session/request_permission` 主动请求必须收到响应。
## 6. 验证
- `cargo test --manifest-path rust/Cargo.toml -p mnote-web test_incoming_permission_request_gets_response -- --nocapture`
- 结果:1 passed
- `cargo test --manifest-path rust/Cargo.toml -p mnote-web acp_client -- --nocapture`
- 结果:6 passed
@@ -1,8 +1,8 @@
# 3-21 [process][bug] ACP run payload 被第一次 stream_events 消费后移除 v1
# 3-21 [done][bug] ACP run payload 被第一次 stream_events 消费后移除 v1
> 发现时间:2026-05-17
>
> 状态:`[process]`
> 状态:`[done]`
>
> 关联主线:`03-rust-web`
@@ -27,6 +27,12 @@ ACP run 的 payload 在 `stream_events` 路径被消费后从内存表移除。
- stream subscription 不应拥有 payload 的删除权。
- 增加 SSE 断线重连与重复订阅测试。
## 5. 状态
## 5. 修复
已确认 payload 生命周期与 stream 订阅耦合,尚未修复
- [hermes_client.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/routes/hermes_client.rs:2051) `acp_run_payload_for_stream` 使用 `ACP_RUN_PAYLOADS.get(run_id).cloned()`stream subscription 只读取 payload,不再拥有删除权
- [hermes_client.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/routes/hermes_client.rs:4116) 增加重复读取测试,锁定刷新、重连、多个观察者不会因为第一次读取而丢失 run payload。
## 6. 验证
- `cargo test --manifest-path rust/Cargo.toml -p mnote-web hermes_client_acp_stream_payload_lookup_keeps_payload_for_reconnect -- --nocapture`
- 结果:1 passed
@@ -0,0 +1,50 @@
# 3-22 [done][bug] ACP permission 自动拒绝后 UI 仍展示审批动作
> 发现时间:2026-05-18
>
> 状态:`[done]`
>
> 关联主线:`03-rust-web` / ACP runtime 与页面 AI 事件映射
## 1. 问题定义
当前 ACP client 收到 `session/request_permission` 后会立即返回 JSON-RPC error,并把事件转换为 `permission.denied`。这是为了避免 agent 等待权限响应直到超时。
但页面 AI UI 仍在权限卡片和权限弹窗里展示“允许 / 拒绝”按钮。即使这些按钮在 `permission.denied` 下会被禁用,用户仍会看到审批入口,误以为可以把真实协议结果改成允许。
## 2. 证据
- [acp_client.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/acp_client.rs:350) 对 incoming `id + method` request 直接返回 JSON-RPC error。
- [acp_session_manager.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/acp_session_manager.rs:121) 对 `session/request_permission` 生成 `decision="denied"`
- [acp_bridge.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/acp_bridge.rs:237) 将 decision 映射为 `permission.denied`
- [layout.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/ssr/pages/layout.rs:4060) 权限弹窗仍渲染“允许 / 拒绝”按钮。
- [layout.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/ssr/pages/layout.rs:5805) 权限消息卡片仍渲染“允许 / 拒绝”按钮。
## 3. 影响
- 用户看到的“允许”动作与 ACP wire contract 不一致。
- 点击动作只修改本地 UI 状态,不会回写 ACP JSON-RPC response。
- 对已经自动拒绝的权限请求展示审批按钮,会掩盖真实失败原因。
## 4. 修复
- [acp_client.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/acp_client.rs:350) 维持 `session/request_permission` 自动拒绝协议响应,避免 agent 等待超时。
- [layout.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/ssr/pages/layout.rs:4003) `pageAiApplyPermissionEvent()``permission.denied` / `permission.allowed` 只保留结果态,不再展示可点击审批弹窗。
- [layout.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/ssr/pages/layout.rs:4048) `pageAiShowPermissionDialog()` 对已决权限事件直接隐藏对话框。
- [layout.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/ssr/pages/layout.rs:5797) 权限消息卡片对已决事件只展示结果,不再渲染审批按钮。
## 5. 验证
```bash
cargo test --manifest-path rust/Cargo.toml -p mnote-web page_ai_uses_backend_acp_session_runtime_store -- --nocapture
cargo test --manifest-path rust/Cargo.toml -p mnote-web acp_permission_request_emits_frontend_decision_event -- --nocapture
cargo test --manifest-path rust/Cargo.toml -p mnote-web ssr::pages::layout::tests -- --nocapture
cargo fmt --manifest-path rust/Cargo.toml --all -- --check
git diff --check -- rust/crates/mnote-web/src/ssr/pages/layout.rs bugs/03-rust-web/done/3-22-acp-permission-auto-deny-ui-shows-approval-actions-v1.md
```
结果:
- `page_ai_uses_backend_acp_session_runtime_store``1 passed`
- `acp_permission_request_emits_frontend_decision_event``1 passed`
- 版面相关静态测试继续通过,权限消息代码路径未破坏。
@@ -1,31 +0,0 @@
# 3-19 [process][bug] Reasonix ACP wrapper 调 mnote tool 缺少身份与幂等字段 v1
> 发现时间:2026-05-17
>
> 状态:`[process]`
>
> 关联主线:`03-rust-web`
## 1. 问题定义
Reasonix ACP wrapper 调用 mnote tool 时只发送 `toolName``args``workspaceId`,没有传递 `actorId``sessionId``runId``toolCallId``traceId``dryRun``idempotencyKey` 等工具合同字段。
## 2. 证据
- [reasonix-acp-wrapper.mjs](/mnt/Data1T/mnote/scripts/reasonix-acp-wrapper.mjs:230) 附近构造 `callMnoteTool` 请求体。
## 3. 影响
- Rust tool executor 难以稳定进行鉴权、审计、追踪和幂等。
- 写工具可能被拒绝,或在缺少幂等键时产生重复写入风险。
- Reasonix 与 Hermes 对同一 tool 的调用合同不一致。
## 4. 建议修复
- wrapper 必须从 ACP run/session 中透传 actor、session、run、toolCall、trace、dryRun、idempotency 字段。
- 写工具缺失必要字段时应 fail fast。
- 增加 Reasonix 端到端 smoke,断言请求字段完整。
## 5. 状态
已确认 wrapper 请求字段不完整,尚未修复。
@@ -1,31 +0,0 @@
# 3-20 [process][bug] ACP incoming request 只记录日志不响应 v1
> 发现时间:2026-05-17
>
> 状态:`[process]`
>
> 关联主线:`03-rust-web`
## 1. 问题定义
ACP client 收到 `session/request_permission` 等 incoming request 时当前只记录日志,没有返回协议响应。
## 2. 证据
- [acp_client.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/acp_client.rs:347) 附近处理 incoming request。
## 3. 影响
- ACP agent 侧可能等待到超时。
- 权限请求无法进入用户确认或自动拒绝流程。
- run 状态可能卡在进行中,SSE 输出也无法表达真实阻塞原因。
## 4. 建议修复
- 为已知 request type 实现明确响应:允许、拒绝或要求人工确认。
- 未支持的 request type 也应返回结构化 error,而不是只日志。
- 增加 ACP request/response 协议测试。
## 5. 状态
已确认 incoming request 缺少响应处理,尚未修复。
@@ -1,9 +1,9 @@
# 4-36 [process][bug] 本地文件夹切回云空间后进入空白 workspace v1
# 4-36 [done][bug] 本地文件夹切回云空间后进入空白 workspace v1
> 更新时间:2026-05-15
>
> 分类归属:
> - `04-tree-domain/process`
> - `04-tree-domain/done`
> - 涉及边界:workspace source switch、本地文件夹入口、Convex workspace 恢复、Sidebar File Tree/Page Tree projection
>
> 用户反馈:
@@ -1,8 +1,10 @@
# 4-46 [process][bug] FileTree 资源行被当成页面命令对象处理 v1
# 4-46 [done][bug] FileTree 资源行被当成页面命令对象处理 v1
> 发现时间:2026-05-17
>
> 状态:`[process]`
> 更新时间:2026-05-17
>
> 状态:`[done]`
>
> 关联主线:`04-tree-domain`
@@ -34,4 +36,26 @@ FileTree asset row 的 `resourceMeta` 同时带 `documentId` 和 `assetId`,但
## 5. 状态
确认 row model 与命令分发存在泄漏,尚未修复。
已修复。
## 6. 修复内容
- `getFileTreeRowDocumentId()` 不再从 `resourceMeta.documentId` 读取资源 owner,只返回真正可作为页面命令目标的 document / markdown / index 行 ID。
- 新增 `getFileTreeRowOwnerDocumentId()`,资源打开与 owner 上下文继续使用 owner document ID。
- FileTree 资源行不再通过 F2 或行内编辑按钮进入页面重命名。
- 动态 filetree row DOM 增加 `data-owner-document-id``data-document-id` 仅表达页面命令目标。
- 初始 SSR filetree HTML 同步区分 `data-document-id``data-owner-document-id`asset row 的 `data-document-id` / `data-doc-id` 保持为空。
## 7. 验证
```bash
cargo test --manifest-path rust/Cargo.toml -p mnote-web tree_shell::filetree_renderer::tests::tree_shell_filetree_renderer_outputs_initial_nested_html_contract -- --nocapture
```
结果:`1 passed`
```bash
cargo test --manifest-path rust/Cargo.toml -p mnote-web routes::tree::tests::tree_shell_filetree_mode_embeds_asset_state -- --nocapture
```
结果:`1 passed`
@@ -0,0 +1,43 @@
# 4-47 [done][bug] stream_support 回归样例仍保留退役 tree.resource.delete 命名
> 发现时间:2026-05-18
>
> 状态:`[done]`
>
> 关联主线:`04-tree-domain`
## 1. 问题定义
当前资源生命周期正式命令面使用 `tree.resource.archive / restore / purge / rename`,删除到垃圾箱语义应表达为 `tree.resource.archive`。但 `stream_support` 的 remove asset delta 回归样例仍保留 `tree.resource.delete`
该残留不在 runtime command dispatcher 中,但会让 stream 合同样例继续暗示退役命名合法,增加后续实现和测试口径漂移风险。
## 2. 证据
- [stream_support.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/routes/stream_support.rs:1057) 测试样例仍写 `"command_name": "tree.resource.delete"`
- [tree.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/routes/tree.rs:714) 当前 tree shell dispatcher 已公开 `tree.resource.archive`
- [mapping.rs](/mnt/Data1T/mnote/rust/crates/storage-convex-bridge/src/mapping.rs:47) storage bridge 已映射 `tree.resource.archive`,没有 `tree.resource.delete`
## 3. 影响
- 回归样例与当前资源命令面不一致。
- 后续维护者可能继续按 `delete` 扩写 stream / resource lifecycle 合同。
-`design/04-tree-domain/process/4-27-resource-lifecycle-command-cutover-v1.md` 中“delete 改为 archive”的口径冲突。
## 4. 修复结果
- [stream_support.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/routes/stream_support.rs) 的 remove asset delta 回归样例已把 `tree.resource.delete` 改为 `tree.resource.archive`
- `rg` 已确认 Rust route / storage bridge 中不再残留 `tree.resource.delete`
- 对应 `stream_change_preserves_remove_asset_delta_fields` 回归测试已通过。
## 5. 验证证据
```bash
rg -n "tree\\.resource\\.delete" rust/crates/mnote-web/src/routes
cargo test --manifest-path rust/Cargo.toml -p mnote-web stream_change_preserves_remove_asset_delta_fields -- --nocapture
```
验证结果:
- `rg` 无输出。
- `stream_change_preserves_remove_asset_delta_fields` 通过:`1 passed`
@@ -3,7 +3,7 @@
> 更新时间:2026-05-14
>
> 分类归属:
> - `05-editor-mainline/process`
> - `05-editor-mainline/done`
> - 涉及边界:`04-tree-domain/tree command + file_tree projection`、`06-mindmap/runtime save + resource relation`
>
> 用户证据:
@@ -1,8 +1,8 @@
# 5-15 [process][bug] PageAggregateClientState 仍在前端生成第二份 page tree 真相 v1
# 5-15 [done][bug] PageAggregateClientState 仍在前端生成第二份 page tree 真相 v1
> 发现时间:2026-05-17
>
> 状态:`[process]`
> 状态:`[done]`
>
> 关联主线:`05-editor-mainline`
@@ -28,6 +28,14 @@
- AI context 应优先读取 Rust Page Aggregate 的稳定 projection。
- 增加测试:本地编辑后 AI context 与 Rust 回读 projection 必须一致。
## 5. 状态
## 5. 修复
已确认前端仍生成第二份 page tree,尚未收口
- [layout.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/ssr/pages/layout.rs:227) `currentPageAiContextSnapshot` 不再在编辑器 DOM 与服务端 aggregate 不一致时构造 `local` page subtreeAI context 的 subtree 只来自 Rust Page Aggregate 的 `aggregate.tree.pageSubtree`
- [layout.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/ssr/pages/layout.rs:7448) 增加字符串合同测试,禁止 `pageSubtreeSource: 'local'``buildPageAiLocalSubtree(localBlocks)` 回到 AI context 主链。
## 6. 验证
- `cargo test --manifest-path rust/Cargo.toml -p mnote-web page_ai_context_uses_page_aggregate_subtree_as_single_truth -- --nocapture`
- 结果:1 passed
- `cargo test --manifest-path rust/Cargo.toml -p mnote-web ssr::pages::layout::tests -- --nocapture`
- 结果:12 passed
@@ -1,8 +1,8 @@
# 5-16 [process][bug] Sidebar preferred snapshot 中 query 可覆盖 live stream v1
# 5-16 [done][bug] Sidebar preferred snapshot 中 query 可覆盖 live stream v1
> 发现时间:2026-05-17
>
> 状态:`[process]`
> 状态:`[done]`
>
> 关联主线:`05-editor-mainline`
@@ -27,6 +27,13 @@ Sidebar 同时启动 query/live/initial 与 tree stream,并通过 preferred sn
- 同版本不同内容时必须记录诊断事件,而不是静默按 freshness 选择。
- 增加单测覆盖同版本冲突。
## 5. 状态
## 5. 当前主链修复状态
已确认多源选择策略仍可能覆盖 live stream,尚未修复
- 原证据指向的 `wolai-frontend/src/components/sidebar/use-preferred-sidebar-snapshot.ts` 已不在当前运行主链;当前仓库中该路径不存在,只在 `recycle/` 下保留历史副本
- 当前 3000 主壳由 Rust `SIDEBAR_TREE_JS` + `TREE_LIVE_CONTROLLER_JS` 消费 WS/SSE tree snapshot / delta,不再使用 retired React `usePreferredSidebarSnapshot` 选择器。
- [layout.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/ssr/pages/layout.rs:7460) 增加防回归测试,断言当前 sidebar runtime 不包含 `usePreferredSidebarSnapshot``preferredSidebarSnapshot``liveSidebarTitle`
## 6. 验证
- `cargo test --manifest-path rust/Cargo.toml -p mnote-web sidebar_tree_runtime_does_not_use_retired_query_preferred_snapshot_selector -- --nocapture`
- 结果:1 passed
@@ -1,8 +1,8 @@
# 5-17 [process][bug] 文档页标题优先 liveSidebarTitle 而非 Page Aggregate head v1
# 5-17 [done][bug] 文档页标题优先 liveSidebarTitle 而非 Page Aggregate head v1
> 发现时间:2026-05-17
>
> 状态:`[process]`
> 状态:`[done]`
>
> 关联主线:`05-editor-mainline`
@@ -27,6 +27,13 @@
- Sidebar title 与 Page Aggregate head 冲突时记录诊断并触发 resync。
- 增加 smoke:改标题后 Sidebar、Breadcrumb、文档页标题同源一致。
## 5. 状态
## 5. 当前主链修复状态
已确认标题来源仍有漂移风险,尚未修复
- 原证据指向的 React `document-content.tsx` / `page-aggregate-client-state.ts` 当前不在运行主链;`wolai-frontend/src` 不存在,历史副本位于 `recycle/`
- 当前 Rust 文档壳初始标题来自 Page Aggregate `head.title``updatePaneChrome` 也从 `runtimeDescriptor.aggregate.head.title` 更新文档页 chrome。
- [layout.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/ssr/pages/layout.rs:7460) 增加防回归测试,断言当前 sidebar runtime 不包含旧 `liveSidebarTitle` 入口。
## 6. 验证
- `cargo test --manifest-path rust/Cargo.toml -p mnote-web sidebar_tree_runtime_does_not_use_retired_query_preferred_snapshot_selector -- --nocapture`
- 结果:1 passed
@@ -1,8 +1,8 @@
# 5-18 [process][bug] AI 写正文后本地 Page Aggregate content 可能不刷新 v1
# 5-18 [done][bug] AI 写正文后本地 Page Aggregate content 可能不刷新 v1
> 发现时间:2026-05-17
>
> 状态:`[process]`
> 状态:`[done]`
>
> 关联主线:`05-editor-mainline`
@@ -28,6 +28,16 @@
- AI context 应从最新 Rust Page Aggregate snapshot 读取,而不是依赖可能过期的本地 reducer。
- 增加 smoke:AI 修改正文后不刷新页面连续再问一次 AI,断言第二次 context 包含新内容。
## 5. 状态
## 5. 修复
已确认 AI 写入后本地 Page Aggregate content 可能滞后,尚未修复
- [web_shell.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/routes/web_shell.rs:1208) 文档 session 记录 `pageAggregateScriptId`,明确当前 pane 对应的 Page Aggregate JSON script
- [web_shell.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/routes/web_shell.rs:1213) 新增 `syncPageAggregateScript`,在外部写入刷新得到新 aggregate 后同步 `__MNOTE_PAGE_AGGREGATE__`
- [web_shell.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/routes/web_shell.rs:1624) `refreshSessionFromExternalChange` 更新 session `latestAggregate` 后立即同步 JSON script,使下一次页面 AI context 从最新 Rust Page Aggregate 快照读取。
- [web_shell.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/routes/web_shell.rs:3085) 增加 shell 合同测试,防止 AI 写入刷新只更新 editor session 而不更新 Page Aggregate script 缓存。
## 6. 验证
- `cargo test --manifest-path rust/Cargo.toml -p mnote-web document_shell_returns_page_aggregate_snapshot -- --nocapture`
- 结果:1 passed
- `cargo test --manifest-path rust/Cargo.toml -p mnote-web routes::web_shell::tests -- --nocapture`
- 结果:10 passed
@@ -1,9 +1,9 @@
# 5-9-C12 [process][bug] 文档页顶栏左上角切换侧栏按钮无效 v1
# 5-9-C12 [done][bug] 文档页顶栏左上角切换侧栏按钮无效 v1
> 更新时间:2026-05-12
> 更新时间:2026-05-18(代码修复)
>
> 分类归属:
> - `05-editor-mainline/process`
> - `05-editor-mainline/done`
> - 对应体验主线:`5-9 Wolai-aline continuous checklist`
>
> 关联文档:
@@ -104,7 +104,7 @@ Hermes tester 首轮取证:
## 7. 流转条件
当前状态:`process`
当前状态:`done`
只有在以下条件都满足后,本文才能移动到 `bugs/05-editor-mainline/done/`
@@ -112,3 +112,21 @@ Hermes tester 首轮取证:
2. smoke 已覆盖且通过
3. 浏览器复测确认收起/展开都生效
4. 证据路径已补齐到修复后的截图或日志
## 8. 修复记录
- [layout.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/ssr/pages/layout.rs:67) 新增 `toggleWorkspaceSidebar`,点击后切换 shell 的 `data-mnote-sidebar-collapsed` 状态,并同步 `documentElement` 观测属性。
- [layout.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/ssr/pages/layout.rs:6167) 全局 click handler 接入 `data-mnote-action="toggle-sidebar"`
- [layout.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/ssr/pages/layout.rs:7336) 顶栏菜单按钮补齐 `data-mnote-action="toggle-sidebar"``data-testid="wolai-sidebar-toggle"``aria-pressed`
- [styles.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/ssr/styles.rs:224) `data-mnote-sidebar-collapsed="true"` 时隐藏 sidebar,让主编辑区占满空间。
## 9. 验证
- `cargo test --manifest-path rust/Cargo.toml -p mnote-web page_layout_sidebar_toggle_is_wired_to_shell_state -- --nocapture`
- 结果:1 passed
- `cargo test --manifest-path rust/Cargo.toml -p mnote-web ssr::pages::layout::tests -- --nocapture`
- 结果:14 passed
- `cargo test --manifest-path rust/Cargo.toml -p mnote-web ssr::styles::tests -- --nocapture`
- 结果:7 passed
说明:本轮完成了代码接线与 SSR/CSS 合同验证;浏览器截图复核可在后续统一 smoke 中补充。
@@ -1,8 +1,8 @@
# 7-16 [process][bug] mnote.doc.markdown_edit 中文归一化替换 byte index 误用 v1
# 7-16 [done][bug] mnote.doc.markdown_edit 中文归一化替换 byte index 误用 v1
> 发现时间:2026-05-17
>
> 状态:`[process]`
> 状态:`[done]`
>
> 关联主线:`07-ai`
>
@@ -56,6 +56,18 @@ replace:二段
- 或者只在同一字符串上使用 byte index,并确保切片边界来自同一个原始字符串的 `char_indices` 映射。
- 增加中文、多字节标点、全角英文 / 数字混排的 `search_replace` 单测。
## 6. 状态
## 6. 修复
确认源码层缺陷,尚未修复。进入 `process` 等待实现和验证。
修复:
- [doc.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/hermes_tools/doc.rs:928) 抽出 `search_replace_exact_or_normalized`,归一化匹配时不再把 `str::find` 的 byte offset 当作字符序号使用。
- [doc.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/hermes_tools/doc.rs:959) 新增 `normalize_line_with_byte_map`,在归一化字符串与原始行之间保留 byte 边界映射。
- [doc.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/hermes_tools/doc.rs:670) 增加中文归一化替换回归测试,覆盖 `一 段` 命中 `一段` 时的多字节切片边界。
## 7. 验证
```bash
cargo test --manifest-path rust/Cargo.toml -p mnote-web test_search_replace_normalized_chinese_byte_boundaries -- --nocapture
```
结果:1 个测试通过。
@@ -1,8 +1,8 @@
# 7-17 [process][bug] mnote.doc.markdown_edit 同块多操作写回会覆盖前序修改 v1
# 7-17 [done][bug] mnote.doc.markdown_edit 同块多操作写回会覆盖前序修改 v1
> 发现时间:2026-05-17
>
> 状态:`[process]`
> 状态:`[done]`
>
> 关联主线:`07-ai`
>
@@ -70,6 +70,22 @@ Convex 写回使用的是 `block_ops`,而不是 `md`。这让“markdown 编
- 增加同一 block 多 operation 的单测和真实 Page Aggregate 回读 smoke。
- 明确部分失败是否允许写入;默认建议任一 operation 失败时不写入,除非调用方显式允许 partial apply。
## 5. 状态
## 5. 修复
确认源码层缺陷,尚未修复。进入 `process` 等待实现和验证。
修复:
- [doc.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/hermes_tools/doc.rs:1026) `build_block_ops_from_markdown_edit` 现在维护每个块的当前文本,按 operations 顺序累积同块修改。
- [doc.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/hermes_tools/doc.rs:1061) 同一块多次命中时只输出一次最终 `replace` block op,避免后续 op 基于原始文本覆盖前序修改。
- [hermes_tools.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/routes/hermes_tools.rs:1772) 增加 dry-run 路由级回归测试,覆盖第二个 search 依赖第一个替换结果的同块多操作场景。
## 6. 验证
```bash
cargo test --manifest-path rust/Cargo.toml -p mnote-web hermes_tools_markdown_edit_merges_same_block_operations -- --nocapture
```
结果:1 个测试通过。
## 7. 剩余风险
本修复解决“同块多操作覆盖前序修改”的最小 P0;跨块 Markdown 重写、full_content 与最终 Markdown 作为唯一写回真源仍归属于 `7-24`
@@ -1,8 +1,8 @@
# 7-18 [process][bug] AI markdown_edit 阶段状态合同漂移 v1
# 7-18 [done][bug] AI markdown_edit 阶段状态合同漂移 v1
> 发现时间:2026-05-17
>
> 状态:`[process]`
> 状态:`[done]`
>
> 关联主线:`07-ai`
@@ -30,6 +30,22 @@
- 若已切主,应把 `page_ai_workflow`、Hermes guidance、manifest 和 smoke 都同步到该口径。
- 若未切主,应限制 `markdown_edit` 的 runtime 使用面,并把设计稿状态回退为实验态。
## 5. 状态
## 5. 修复
已确认设计口径与源码使用状态不一致,尚未完成统一。
2026-05-18 已完成运行时侧收口与顶层协作口径同步:
- `page_ai_workflow` 当前运行时已统一走模型输出 `search/replace``mnote.doc.markdown_edit` → 统一 mnote tool executor。
- Hermes run guidance 已改为普通正文编辑优先 `mnote_doc_markdown_edit``apply_block_ops` 仅作为结构性块操作辅助。
- `markdown_edit` 在线写回在无法安全映射最终 markdown 时明确拒绝,避免 `ok=true` 伪成功。
- 根目录 [AGENTS.md](/mnt/Data1T/mnote/AGENTS.md:14) 已同步为当前真实阶段:简单正文编辑主路径是 `mnote.doc.markdown_edit``page_ai_workflow` 通过模型生成 markdown 编辑意图后调用统一 mnote tool executor`apply_block_ops` / `mnote.block.*` 仅作为结构性辅助。
## 6. 验证
- `rg -n "local_rule|Phase A|Phase B|当前只做|过渡实现为 .*block-edit-workflow" AGENTS.md`
- 结果:无输出,旧阶段合同表述已移除。
- `cargo test --manifest-path rust/Cargo.toml -p mnote-web markdown_edit -- --nocapture`
- 结果:通过。
- `cargo test --manifest-path rust/Cargo.toml -p mnote-web page_ai_workflow -- --nocapture`
- 结果:通过。
- `cargo test --manifest-path rust/Cargo.toml -p mnote-web hermes_client_run_guidance_prefers_markdown_edit_for_plain_body_edits -- --nocapture`
- 结果:通过。
@@ -1,8 +1,8 @@
# 7-19 [process][bug] Hermes 工具指导仍优先 apply_block_ops 而非 markdown_edit v1
# 7-19 [done][bug] Hermes 工具指导仍优先 apply_block_ops 而非 markdown_edit v1
> 发现时间:2026-05-17
>
> 状态:`[process]`
> 状态:`[done]`
>
> 关联主线:`07-ai`
@@ -30,6 +30,15 @@
- 仅在需要结构性块移动、资源块、复杂定位时才引导模型使用 `apply_block_ops``mnote.block.*`
- 同步更新 smoke:模型生成工具调用时应优先产出 `mnote_doc_markdown_edit`
## 5. 状态
## 5. 修复
已确认源码指导文本与当前设计主线不一致,尚未修复
- [hermes_client.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/routes/hermes_client.rs:2456) 将 Hermes run instructions 改为:普通正文 search/replace、局部段落替换、全文 markdown 替换优先调用 `mnote_doc_markdown_edit`
- [hermes_client.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/routes/hermes_client.rs:2494) 将 `blockEditingToolOrder` 首位调整为 `mnote_doc_markdown_edit``mnote_doc_apply_block_ops` 仅保留给 markdown_edit 不能表达的结构性块操作。
- [hermes_client.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/routes/hermes_client.rs:4559) 增加 run guidance 合同测试,防止普通正文编辑重新退回 `apply_block_ops` 优先口径。
## 6. 验证
- `cargo test --manifest-path rust/Cargo.toml -p mnote-web hermes_client_run_guidance_prefers_markdown_edit_for_plain_body_edits -- --nocapture`
- 结果:1 passed
- `cargo test --manifest-path rust/Cargo.toml -p mnote-web hermes_client_run_body -- --nocapture`
- 结果:2 passed
@@ -0,0 +1,48 @@
# 7-20 [done][bug] page_ai_workflow 绕过 Hermes tool executor / audit / toggle v1
> 发现时间:2026-05-17
>
> 状态:`[done]`
>
> 关联主线:`07-ai`
## 1. 问题定义
`/api/page-ai/block-edit-workflow` 仍是独立模型调用链:直接读取 Hermes profile、调用 chat/completions、解析模型 JSON,并直接调用 Rust 工具函数。
它没有通过 `/api/hermes/tools/mnote/call` 的工具执行壳,因此绕过了 tool toggle、audit、统一幂等和统一调用追踪。
## 2. 证据
- [page_ai_workflow.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/routes/page_ai_workflow.rs:69) 直接调用上游模型。
- [page_ai_workflow.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/routes/page_ai_workflow.rs:91) 在 route 内构造 `ToolCallInput`
- [page_ai_workflow.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/routes/page_ai_workflow.rs:109) 直接调用 `doc::doc_markdown_edit`
## 3. 影响
- 关闭或限制 mnote tool 时,该 fast-path 仍可能写入。
- 工具调用审计与普通 Hermes run 不一致。
- 幂等、dryRun、runId、toolCallId 等字段无法统一治理。
## 4. 建议修复
- 将 fast-path 的工具写入改为调用统一 tool executor。
- fast-path 只负责 prompt / plan,不直接执行写入函数。
- 增加 smoke:当对应 tool disabled 时,`page_ai_workflow` 不得绕过限制写入。
## 5. 修复
- [hermes_tools.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/routes/hermes_tools.rs:62) 将统一 mnote tool 执行壳抽为 `execute_mnote_tool_call`,保留 profile disabled、audit、idempotency、auth/workspace 校验和统一结果包装。
- [page_ai_workflow.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/routes/page_ai_workflow.rs:109) `block_edit_workflow` 的写入阶段改为调用统一 executor,不再直接调用 `doc::doc_markdown_edit`
- [page_ai_workflow.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/routes/page_ai_workflow.rs:657) 增加路由级测试:当 profile 禁用 `mnote.doc.markdown_edit` 时,页面 AI fast-path 必须返回 `mnote_tool_disabled`,不得绕过限制写入。
## 6. 验证
- `cargo test --manifest-path rust/Cargo.toml -p mnote-web block_edit_workflow_respects_disabled_markdown_edit_tool -- --nocapture`
- 结果:1 passed
- `cargo test --manifest-path rust/Cargo.toml -p mnote-web page_ai_workflow -- --nocapture`
- 结果:3 passed
- `cargo test --manifest-path rust/Cargo.toml -p mnote-web hermes_tools_call_rejects_profile_disabled_tool -- --nocapture`
- 结果:1 passed
- `cargo test --manifest-path rust/Cargo.toml -p mnote-web hermes_tools_markdown_edit_maps_normalized_search_to_block -- --nocapture`
- 结果:1 passed
@@ -1,8 +1,8 @@
# 7-21 [process][bug] mnote.doc.markdown_edit 本地文件写入绕过 dryRun / idempotency v1
# 7-21 [done][bug] mnote.doc.markdown_edit 本地文件写入绕过 dryRun / idempotency v1
> 发现时间:2026-05-17
>
> 状态:`[process]`
> 状态:`[done]`
>
> 关联主线:`07-ai`
@@ -28,6 +28,19 @@
- 对 `dryRun=true` 返回 diff / preview,不落盘。
- 增加本地 `.md` dryRun 单测,断言文件内容不变。
## 5. 状态
## 5. 修复
确认源码层缺陷,尚未修复
已修复
- [block.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/hermes_tools/block.rs:631) 将统一写入合同校验开放为 `pub(crate)`,供 `mnote.doc.markdown_edit` 复用。
- [doc.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/hermes_tools/doc.rs:722) `mnote.doc.markdown_edit` 入口统一调用 `ensure_write_contract`,本地 `.md` 与在线文档一致要求 `idempotencyKey` 与显式 `dryRun`
- [doc.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/hermes_tools/doc.rs:845) 本地文件 `dryRun=true` 时只返回 `written=false` 预览结果,不再写入磁盘。
- [hermes_tools.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/routes/hermes_tools.rs:1557) 增加路由级回归测试,覆盖缺少 `idempotencyKey` 与本地 dry-run 不落盘。
## 6. 验证
```bash
cargo test --manifest-path rust/Cargo.toml -p mnote-web hermes_tools_markdown_edit_local -- --nocapture
```
结果:2 个测试通过。
@@ -0,0 +1,50 @@
# 7-22 [done][bug] mnote.doc.apply_block_ops 批量写入缺少 revision / conflictDetectionKey 校验 v1
> 发现时间:2026-05-17
>
> 状态:`[done]`
>
> 关联主线:`07-ai`
## 1. 问题定义
`mnote.doc.apply_block_ops` 的批量入口只校验 dryRun / idempotency 写入合同,没有调用页面 revision、conflictDetectionKey、blockRevisionRef 的写前校验。
同文件中已有 `ensure_page_write_preconditions`,但该批量入口没有使用它。
## 2. 证据
- [block.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/hermes_tools/block.rs:385) 定义 `doc_apply_block_ops`
- [block.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/hermes_tools/block.rs:390) 仅调用 `ensure_write_contract`
- [block.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/hermes_tools/block.rs:774) 存在 `ensure_page_write_preconditions`,但批量入口未调用。
## 3. 影响
- AI 可能基于旧 `PageAggregate` 对正文写入,覆盖用户新编辑。
- 多轮 Hermes / ACP run 同时写入时缺少冲突保护。
- `markdown_edit` 在线分支会转调 `apply_block_ops`,因此该缺陷会影响 markdown 主路径。
## 4. 建议修复
- `doc_apply_block_ops` 真实写入前必须校验 page revision 与 conflictDetectionKey。
- 块级操作应支持并校验 blockRevisionRef 或等价版本字段。
- 增加负向测试:缺少 revision / conflictDetectionKey 时真实写入必须拒绝。
## 5. 修复
已修复:
- [block.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/hermes_tools/block.rs:413) `doc_apply_block_ops` 真实写入前调用 `ensure_page_write_preconditions`,要求 `revision``conflictDetectionKey`
- [block.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/hermes_tools/block.rs:439) replace / delete / move 目标块现在校验 operation 内的 `blockRevisionRef`
- [block.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/hermes_tools/block.rs:472) insert_after / move_after anchor 现在校验 operation 内的 `anchorRevisionRef`
- [doc.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/hermes_tools/doc.rs:875) `mnote.doc.markdown_edit` 在线转调 `doc_apply_block_ops` 时自动携带当前 aggregate 的 `revision``conflictDetectionKey` 与每个 replace op 的 `blockRevisionRef`
- [hermes_tools.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/routes/hermes_tools.rs:1490) 增加批量入口负向测试,覆盖缺少 page precondition 与缺少 block revision ref。
## 6. 验证
```bash
cargo test --manifest-path rust/Cargo.toml -p mnote-web hermes_tools_apply_block_ops_requires_page_preconditions -- --nocapture
cargo test --manifest-path rust/Cargo.toml -p mnote-web hermes_tools_markdown_edit -- --nocapture
```
结果:批量前置条件测试通过,5 个 markdown_edit 相关测试通过。
@@ -1,8 +1,8 @@
# 7-23 [process][bug] mnote.doc.markdown_edit manifest schema 缺少 required 与写入字段 v1
# 7-23 [done][bug] mnote.doc.markdown_edit manifest schema 缺少 required 与写入字段 v1
> 发现时间:2026-05-17
>
> 状态:`[process]`
> 状态:`[done]`
>
> 关联主线:`07-ai`
@@ -29,6 +29,19 @@
- manifest 与 Rust runtime 校验保持一致。
- 增加 manifest snapshot 测试,防止字段再次漂移。
## 5. 状态
## 5. 修复
确认 manifest 合同不完整,尚未修复
已修复
- [manifest.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/hermes_tools/manifest.rs:413) `mnote.doc.markdown_edit` 改为复用统一 `write_tool` schema 生成器。
- [manifest.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/hermes_tools/manifest.rs:302) manifest 现在声明 `dryRun``idempotencyKey` 与 actor/session/run/toolCall/trace 等写入合同字段。
- [manifest.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/hermes_tools/manifest.rs:449) 通过 `anyOf` 声明 `operations` / `full_content` 至少提供一个。
- [hermes_tools.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/routes/hermes_tools.rs:730) 增加 manifest 路由级测试,防止 schema 再次漂移。
## 6. 验证
```bash
cargo test --manifest-path rust/Cargo.toml -p mnote-web hermes_tools_manifest_describes_markdown_edit_write_contract -- --nocapture
```
结果:1 个测试通过。
@@ -1,8 +1,8 @@
# 7-24 [process][bug] 在线 markdown_edit 写回不以最终 Markdown 为真源 v1
# 7-24 [done][bug] 在线 markdown_edit 写回不以最终 Markdown 为真源 v1
> 发现时间:2026-05-17
>
> 状态:`[process]`
> 状态:`[done]`
>
> 关联主线:`07-ai`
@@ -30,6 +30,15 @@
- 对 full_content、跨块 search/replace、列表/标题变更增加回读一致性测试。
- route 成功条件必须从“operationsApplied > 0”提升为“Page Aggregate 回读等于预期最终 markdown”。
## 5. 状态
## 5. 修复
已确认写回层没有使用最终 markdown 真相,尚未修复
- [doc.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/hermes_tools/doc.rs:868) 在线分支在 markdown 层命中后重新映射为 block ops;若最终 markdown 变化无法安全映射到可写块,会返回 `mnote_doc_markdown_edit_block_mapping_empty`,不再把空 operations 交给 `apply_block_ops` 或返回伪成功
- [hermes_tools.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/routes/hermes_tools.rs:1857) 增加 `full_content` 在线写回合同测试:当前没有完整 markdown->block 真源写回时必须明确拒绝,不能以 `ok=true` 伪成功。
- [hermes_tools.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/routes/hermes_tools.rs:1805) 保留 markdown 命中但块映射为空的提前失败测试,避免回退到 `mnote.doc.apply_block_ops operations 不能为空`
## 6. 验证
- `cargo test --manifest-path rust/Cargo.toml -p mnote-web hermes_tools_markdown_edit_online_full_content_rejects_unsafe_block_mapping -- --nocapture`
- 结果:1 passed
- `cargo test --manifest-path rust/Cargo.toml -p mnote-web markdown_edit -- --nocapture`
- 结果:8 passed
@@ -1,8 +1,8 @@
# 7-25 [process][bug] ACP Reasonix 页面 AI block_edit_workflow 在 markdown 命中后生成空 block_ops v1
# 7-25 [done][bug] ACP Reasonix 页面 AI block_edit_workflow 在 markdown 命中后生成空 block_ops v1
> 发现时间:2026-05-17
>
> 状态:`[process]`
> 状态:`[done]`
>
> 关联主线:`07-ai`
>
@@ -64,7 +64,7 @@ mnote.page_ai.block_edit_workflow 失败 · page-ai-fast-mp9wm0qo
- “读取第一段 + 修改第二段”这类混合任务可能进入 fast workflow,但该 workflow 只能表达写入,不保证先读后答。
- 如果模型给出的 `search` 是段落序号、fuzzy 片段、带格式文本或跨块文本,markdown 层可能成功,block 写回层仍失败。
## 6. 建议修复
## 6. 建议修复:建议以长期,彻底的修复优先,建议参考/mnt/Data1T/mnote/design/05-editor-mainline/reference-code/cli-main中对全局替换和单块替换的判断逻辑。
短期:
@@ -86,7 +86,23 @@ mnote.page_ai.block_edit_workflow 失败 · page-ai-fast-mp9wm0qo
- 断言失败时错误码不能是 `mnote.doc.apply_block_ops operations 不能为空`,应是 markdown 到 block 映射失败的明确错误。
- 修复后回读 Page Aggregate,断言第二段实际变为 `测试123`,同时 AI 回复能正常说明已读取到第一段。
## 8. 状态
## 8. 修复
按用户反馈与源码路径确认缺陷,尚未修复
已修复
- [doc.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/hermes_tools/doc.rs:862) 在 `mnote.doc.markdown_edit` 调用 `doc_apply_block_ops` 前拦截 `applied > 0 && block_ops.is_empty()`,返回 `mnote_doc_markdown_edit_block_mapping_empty`,不再泄露 `mnote.doc.apply_block_ops operations 不能为空`
- [doc.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/hermes_tools/doc.rs:928) 抽出 `search_replace_exact_or_normalized`,让 markdown 层与 block 映射层共享“精确 / 忽略空白 / 全半角归一化”规则。
- [doc.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/hermes_tools/doc.rs:1038) 在线 block ops 构建不再用 `block.text.contains(search)`,改为对单块文本执行同一套安全匹配;fuzzy 仍只保留在 markdown 预处理层,避免写错块。
- [hermes_tools.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/routes/hermes_tools.rs:1670) 增加回归测试,覆盖空映射语义错误与忽略空白后正确映射到 `p_2`
## 9. 验证
```bash
cargo test --manifest-path rust/Cargo.toml -p mnote-web hermes_tools_markdown_edit -- --nocapture
```
结果:4 个相关测试通过。
## 10. 剩余风险
本修复解决单块 search/replace 映射和空 ops 下沉;跨块 Markdown 重写、完整最终 Markdown 作为写回真源仍归属于 `7-24`
@@ -0,0 +1,38 @@
# 7-26 [done] Resource 块保存后在 AI block projection 中被降级为 paragraph
## 状态
- Owner`07-ai` / Page Block AI projection 写入前置合同
- 优先级:P0,影响 AI 精确块编辑安全边界
- 状态:已修复并验证
## 现象
真实 3000 smoke 扩展复杂块后,`mnote.page.save` 写入 `type:"resource"` 的块,再经 Page Aggregate / AI block projection 回读时,`resource_1` 被显示为:
- `type="paragraph"`
- `editable=true`
- `unsupportedReason=null`
这会让 AI 工具把资源占位误判为普通可编辑文本块,违反复杂块不得伪装完全可编辑的合同。
## 根因
`core-protocol``EditorBlockType` 没有 `Resource` 变体,`bridge-runtime``normalize_editor_block_type_for_save()` 因此把未知 `resource` 类型落入默认 `Paragraph`。后续 `legacy_content_from_editor_document()``project_legacy_content_to_block_document()` 只能看到 paragraph,导致 AI projection 误报可编辑。
## 修复
-`rust/crates/core-protocol/src/editor/model.rs` 增加 `EditorBlockType::Resource`
- 在 Tiptap bridge 中用 `mnoteBlockType=resource` 保留 resource 类型往返。
-`bridge-runtime` 中让 `resource/resource_block` 规范化为 `EditorBlockType::Resource`legacy 回写继续输出 `type:"resource"`
- 保留 resource props 到 `resourceProps`legacy 回写时恢复。
-`editor_actor` block 类型摘要中补 `resource`
- 扩展 `scripts/task-page-block-ai-tools-smoke.js`,覆盖 resource 在真实 3000 的投影和 move 阻断。
## 验证
- `cargo test --manifest-path rust/Cargo.toml -p bridge-runtime editor_document_roundtrip_preserves_resource_block_type -- --nocapture`
- `cargo test --manifest-path rust/Cargo.toml -p mnote-web hermes_tools_block_move_after_blocks_complex_and_nested_blocks -- --nocapture`
- `PLAYWRIGHT_CHROME_EXECUTABLE=/snap/bin/chromium MNOTE_UI_BASE_URL=http://127.0.0.1:3000 MNOTE_AUTH_BASE_URL=http://127.0.0.1:3000 node scripts/task-page-block-ai-tools-smoke.js`
最新真实 smoke 证据:`tmp/page-block-ai-tools-smoke/mpag4966.json`,截图:`tmp/page-block-ai-tools-smoke/mpag4966-page.png`
@@ -0,0 +1,76 @@
# 7-27 [done][bug] 在线 markdown_edit full_content 会保留旧普通块
> 发现时间:2026-05-18
>
> 状态:`[done]`
>
> 关联主线:`07-ai`
>
> 关联设计:`design/07-ai/process/7-27-online-markdown-writeback-final-content-truth-v2.md`
## 1. 问题定义
`mnote.doc.markdown_edit` 的在线 Convex 文档路径已经切到 final markdown -> block content 直接写回,但 `full_content` 模式下,最终 markdown 通常不包含任何 `<!-- block:... -->` 原始块注释。
当前 `build_page_content()` 会把所有未处理的原始块追加到写回结果尾部,导致全文替换不是“替换全文”,而是:
```text
新全文块...
旧 heading / paragraph / todo 块...
```
## 2. 影响
- AI 执行全文改写、重写大纲、把页面替换为整理后的内容时,会保留旧正文残留。
- Page Aggregate / `mnote.doc.fetch` 回读会看到新旧内容混在一起。
- 7-27 的“最终 markdown 是唯一写回真源”合同被破坏。
## 3. 根因
`parse_final_markdown_to_blocks()` 对无注释的 final markdown 行会生成 `is_new=true` 块,这是正确的。
问题在 `build_page_content()` 的末尾:
```rust
for original in &original_blocks {
if let Some(id) = block_id_of(original) {
if !processed_ids.contains(&id) {
result.push(original.clone());
}
}
}
```
该逻辑没有区分:
- 部分 search/replace 丢掉某个块注释:应保守保留缺失旧块,避免误删。
- `full_content` / 全文替换没有任何原始块 ID:应只保留复杂块,普通旧文本块应被替换掉。
## 4. 修复
- [doc.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/hermes_tools/doc.rs:727) 给 `build_page_content()` 增加策略:
- 若 parsed 中至少引用了一个原始 block id,则保留未处理原始块,维持安全降级。
- 若 parsed 中没有任何原始 block id,则视为全文重建,只保留复杂块,不保留普通旧文本块。
- [doc.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/hermes_tools/doc.rs:799) 增加 `is_complex_markdown_original_block()`,用于 full_content 重建时保留 resource / mindmap / table / image / attachment / 非 editable / unsupported / 带 children 的复杂块。
- [doc.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/hermes_tools/doc.rs:1059) 增加单元测试:
- `full_content` 无原始 ID 时不保留旧 heading / paragraph。
- 原始 resource 复杂块仍保留。
- 部分块注释丢失时仍保留缺失旧块。
## 5. 验证
```bash
cargo test --manifest-path rust/Cargo.toml -p mnote-web build_page_content -- --nocapture
cargo test --manifest-path rust/Cargo.toml -p mnote-web hermes_tools_markdown_edit_online_full_content_rejects_unsafe_block_mapping -- --nocapture
cargo test --manifest-path rust/Cargo.toml -p mnote-web hermes_tools_markdown_edit -- --nocapture
cargo fmt --manifest-path rust/Cargo.toml --all -- --check
git diff --check -- rust/crates/mnote-web/src/hermes_tools/doc.rs bugs/07-ai/done/7-27-markdown-edit-full-content-retains-old-blocks-v1.md
```
结果:
- `build_page_content` 相关测试:`2 passed`
- `hermes_tools_markdown_edit_online_full_content_rejects_unsafe_block_mapping``1 passed`
- `hermes_tools_markdown_edit` 相关测试:`6 passed`
- Rust workspace format check 通过。
- 相关路径 diff check 通过。
@@ -0,0 +1,49 @@
# 7-28 [done][bug] markdown_edit 未强制 selection 允许块边界
> 发现时间:2026-05-18
>
> 状态:`[done]`
>
> 关联主线:`07-ai`
## 1. 问题定义
页面 AI context 已经可以携带 `allowedTargetBlockIds`,用于表达 `scope=selection` 时本次 run 允许修改的块集合。但 `mnote.doc.markdown_edit` 当前只按文本 search/replace 写回,不检查最终变化的 block 是否落在允许集合内。
如果 fast workflow 或未来调用者传入了过宽的 `pageText`,模型返回了选区外文本的 search/replace,服务端仍可能写入选区外块。
## 2. 证据
- [page_ai_workflow.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/routes/page_ai_workflow.rs:91) fast workflow 调用 `mnote.doc.markdown_edit`
- [page_ai_workflow.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/routes/page_ai_workflow.rs:299) 读取了 `allowedTargetBlockIds`,但未传给写工具或参与校验。
- [hermes_client.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/routes/hermes_client.rs:2860) 工具指导要求 `scope=selection` 时只能修改 `allowedTargetBlockIds` 内的块。
- [doc.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/hermes_tools/doc.rs:1220) `markdown_edit` 生成 `changedBlocks` 后直接 dry-run 或写回,没有 selection 范围校验。
## 3. 影响
- 页面 AI 对选中文本执行编辑时,服务端缺少最终兜底边界。
- 上下文构造或模型输出一旦漂移,选区外块可能被写入。
-`mnote.block.*` 已有 `allowedTargetBlockIds` / blockId 前置条件相比,`markdown_edit` 的写入安全边界较弱。
## 4. 修复
- [page_ai_workflow.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/routes/page_ai_workflow.rs:91) fast workflow 将 `aiContext.allowedTargetBlockIds` 透传给 `mnote.doc.markdown_edit`
- [doc.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/hermes_tools/doc.rs:855) `mnote.doc.markdown_edit` 在生成 `changedBlocks` 后,如果存在 `allowedTargetBlockIds` / `selectedBlockIds`
- 任何带 `blockId` 的变更必须属于允许集合。
-`blockId` 的 insert/full_content 变更在 selection scope 下拒绝,避免无法归属的选区外写入。
- [hermes_tools.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/routes/hermes_tools.rs:2308) 增加 route 测试:`allowedTargetBlockIds=["p_2"]` 时替换 `p_1` 必须返回 `mnote_markdown_edit_target_out_of_scope`
- [page_ai_workflow.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/routes/page_ai_workflow.rs:742) 增加 fast workflow 测试:模型输出修改 `p_1`,而 selection 只允许 `p_2`,必须被服务端拒绝。
## 5. 验证
```bash
cargo test --manifest-path rust/Cargo.toml -p mnote-web markdown_edit_rejects_selection_out_of_scope -- --nocapture
cargo test --manifest-path rust/Cargo.toml -p mnote-web block_edit_workflow -- --nocapture
cargo fmt --manifest-path rust/Cargo.toml --all -- --check
```
结果:
- `hermes_tools_markdown_edit_rejects_selection_out_of_scope``1 passed`
- `block_edit_workflow_forwards_allowed_target_blocks_to_markdown_edit``1 passed`
- Rust workspace format check 通过。
@@ -0,0 +1,44 @@
# 7-29 [done][bug] markdown_edit 会把普通块 inline content 扁平化
> 发现时间:2026-05-18
>
> 状态:`[done]`
>
> 关联主线:`07-ai`
## 1. 问题定义
`mnote.doc.markdown_edit` 的 online 写回已经从“二次推导 block ops”切到 final markdown → block content,但 `build_page_content()` 仍会把普通块的 legacy `content` 统一写成扁平字符串。
这会让未修改的块也丢失原始 inline content 结构,修改过的单段落块则更容易丢失 bold / italic / link / code 等 marks。
## 2. 证据
- [doc.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/hermes_tools/doc.rs:762) 当前普通块写回直接 `obj.insert("content", json!(parsed_block.text))`
- [doc.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/hermes_tools/doc.rs:826) 只有 `text``id` 被保留,原 inline 节点结构被覆盖。
- [7-27](/mnt/Data1T/mnote/design/07-ai/process/7-27-online-markdown-writeback-final-content-truth-v2.md:580) 已明确指出当前 `blocks_to_markdown` / 写回链会丢弃 inline marks。
## 3. 影响
- 未修改块在 `full_content` / 局部替换后也可能失去 inline 节点结构。
- 已修改块中的 bold / link / code 等 marks 可能被扁平化成普通字符串。
- 页面 AI 的正文重写与可视格式回读不一致。
## 4. 修复结果
- [doc.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/hermes_tools/doc.rs) 的 `build_page_content()` 已改为区分未变更、可安全局部替换、复杂块保留三类写回路径。
- 未变更的普通块会直接保留原始 block 结构,不再被重写成纯字符串 content。
- 已变更且原始 `content` / `contentNodes` 是单 inline text 节点的普通块,只替换该 text 节点文本,保留原 marks / attrs。
- 复杂块继续原样保留;无法证明可安全替换的已变更普通块才退回纯文本 content。
## 5. 验证证据
```bash
cargo test --manifest-path rust/Cargo.toml -p mnote-web build_page_content -- --nocapture
cargo test --manifest-path rust/Cargo.toml -p mnote-web hermes_tools_markdown_edit -- --nocapture
```
验证结果:
- `build_page_content` 定向测试通过,覆盖 full_content 不保留旧普通块、局部替换保留未变更块、单 inline text marks 保留。
- `hermes_tools_markdown_edit` 定向测试通过,覆盖 markdown edit 在线写回链路。
@@ -0,0 +1,48 @@
# 7-30 [done][bug] 单个 mnote.block 写工具未强制 selection scope
> 发现时间:2026-05-18
>
> 状态:`[done]`
>
> 关联主线:`07-ai`
## 1. 问题定义
`mnote.doc.apply_block_ops` 已经通过 `allowedTargetBlockIds` 限制 AI selection 范围内的结构性写入,但单个 `mnote.block.replace` / `mnote.block.insert_after` / `mnote.block.delete` / `mnote.block.move_after` 写工具没有同等服务端兜底。
这会导致模型或 ACP 调用者在 `scope=selection` 场景下,绕过批量工具直接调用单块写工具时,仍可能改到 selection 外的块。
## 2. 证据
- [block.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/hermes_tools/block.rs) 的 `doc_apply_block_ops()` 对每个操作调用 `ensure_allowed_target()`
- [block.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/hermes_tools/block.rs) 的单个 block 写工具当前只校验 revision / editable / precondition,缺少同等 `allowedTargetBlockIds` 校验。
- [7-10](/mnt/Data1T/mnote/design/07-ai/process/7-10-page-block-ai-tooling-execution-checklist-v1.md:70) 已标注“单个 `mnote.block.*` 写工具尚未完成 `allowedTargetBlockIds` 矩阵”。
## 3. 影响
- AI 页面 selection 编辑边界不完整。
- `markdown_edit``apply_block_ops` 已拒绝越界写入,但单块工具仍可能成为绕过路径。
- ACP / Hermes tool guidance 即使要求 selection 内写入,也缺少 runtime 级强约束。
## 4. 修复结果
- [block.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/hermes_tools/block.rs) 已为 `mnote.block.replace``mnote.block.insert_after``mnote.block.delete``mnote.block.move_after` 增加统一 `allowedTargetBlockIds` 服务端校验。
- `move_after` 同时校验被移动块和 anchor 块,避免 selection 内块被移动到 selection 外 anchor 后。
- [manifest.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/hermes_tools/manifest.rs) 已在单块写工具 schema 中公开 `allowedTargetBlockIds`
- [hermes_tools.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/routes/hermes_tools.rs) 已增加回归测试,覆盖单块写工具越界时返回 `mnote_block_target_out_of_scope`
## 5. 验证证据
```bash
cargo test --manifest-path rust/Cargo.toml -p mnote-web block_tools_selection_scope -- --nocapture
cargo test --manifest-path rust/Cargo.toml -p mnote-web hermes_tools_block -- --nocapture
cargo test --manifest-path rust/Cargo.toml -p mnote-web hermes_tools_manifest -- --nocapture
cargo fmt --manifest-path rust/Cargo.toml --all -- --check
```
验证结果:
- RED:修复前 `block_tools_selection_scope` 失败,越界单块写工具返回 `200`
- GREEN:修复后 `block_tools_selection_scope` 通过:`2 passed`
- `hermes_tools_block` 通过:`6 passed`
- `hermes_tools_manifest` 通过:`3 passed`
@@ -2,9 +2,9 @@
> 发现时间:2026-05-16
>
> 更新时间:2026-05-16(架构定位修正
> 更新时间:2026-05-18(运行时收口验证
>
> 状态:`[process]`
> 状态:`[done]`
>
> 关联主线:`07-ai` / `05-editor-mainline`
>
@@ -100,3 +100,20 @@ replace 操作缺少 content
- Model path(自然语言,真 AI: ❌ 400replace 缺少 content
- 代码:`page_ai_workflow.rs` system prompt 缺 operation schema
- 用户反馈:direct path 不是 AI 面板应有的行为,应退役
## 运行时修复
- [page_ai_workflow.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/routes/page_ai_workflow.rs:67) 当前运行时已退役 direct path,所有块编辑请求统一走模型输出 `search/replace` 对,再进入 `mnote.doc.markdown_edit`
- [page_ai_workflow.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/routes/page_ai_workflow.rs:307) system prompt 已改为明确要求 `operations[].search``operations[].replace`,不再要求模型输出旧的 block op `content` 字段。
- [page_ai_workflow.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/routes/page_ai_workflow.rs:109) fast-path 写入已改走统一 mnote tool executor,避免绕过 tool toggle / audit / idempotency。
## 验证
- `cargo test --manifest-path rust/Cargo.toml -p mnote-web page_ai_workflow -- --nocapture`
- 结果:3 passed
- `cargo test --manifest-path rust/Cargo.toml -p mnote-web markdown_edit -- --nocapture`
- 结果:8 passed
## 剩余说明
旧的 `direct_block_edit_operations` / `quoted_segments` helper 当前仅作为 dead code 与历史单测保留,不再是 `block_edit_workflow` 的运行时入口;后续可单独清理测试语义,但不影响本缺陷的运行时修复。
@@ -1,35 +0,0 @@
# 7-20 [process][bug] page_ai_workflow 绕过 Hermes tool executor / audit / toggle v1
> 发现时间:2026-05-17
>
> 状态:`[process]`
>
> 关联主线:`07-ai`
## 1. 问题定义
`/api/page-ai/block-edit-workflow` 仍是独立模型调用链:直接读取 Hermes profile、调用 chat/completions、解析模型 JSON,并直接调用 Rust 工具函数。
它没有通过 `/api/hermes/tools/mnote/call` 的工具执行壳,因此绕过了 tool toggle、audit、统一幂等和统一调用追踪。
## 2. 证据
- [page_ai_workflow.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/routes/page_ai_workflow.rs:69) 直接调用上游模型。
- [page_ai_workflow.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/routes/page_ai_workflow.rs:91) 在 route 内构造 `ToolCallInput`
- [page_ai_workflow.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/routes/page_ai_workflow.rs:109) 直接调用 `doc::doc_markdown_edit`
## 3. 影响
- 关闭或限制 mnote tool 时,该 fast-path 仍可能写入。
- 工具调用审计与普通 Hermes run 不一致。
- 幂等、dryRun、runId、toolCallId 等字段无法统一治理。
## 4. 建议修复
- 将 fast-path 的工具写入改为调用统一 tool executor。
- fast-path 只负责 prompt / plan,不直接执行写入函数。
- 增加 smoke:当对应 tool disabled 时,`page_ai_workflow` 不得绕过限制写入。
## 5. 状态
已确认 route 级绕过统一执行壳,尚未修复。
@@ -1,35 +0,0 @@
# 7-22 [process][bug] mnote.doc.apply_block_ops 批量写入缺少 revision / conflictDetectionKey 校验 v1
> 发现时间:2026-05-17
>
> 状态:`[process]`
>
> 关联主线:`07-ai`
## 1. 问题定义
`mnote.doc.apply_block_ops` 的批量入口只校验 dryRun / idempotency 写入合同,没有调用页面 revision、conflictDetectionKey、blockRevisionRef 的写前校验。
同文件中已有 `ensure_page_write_preconditions`,但该批量入口没有使用它。
## 2. 证据
- [block.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/hermes_tools/block.rs:385) 定义 `doc_apply_block_ops`
- [block.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/hermes_tools/block.rs:390) 仅调用 `ensure_write_contract`
- [block.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/hermes_tools/block.rs:774) 存在 `ensure_page_write_preconditions`,但批量入口未调用。
## 3. 影响
- AI 可能基于旧 `PageAggregate` 对正文写入,覆盖用户新编辑。
- 多轮 Hermes / ACP run 同时写入时缺少冲突保护。
- `markdown_edit` 在线分支会转调 `apply_block_ops`,因此该缺陷会影响 markdown 主路径。
## 4. 建议修复
- `doc_apply_block_ops` 真实写入前必须校验 page revision 与 conflictDetectionKey。
- 块级操作应支持并校验 blockRevisionRef 或等价版本字段。
- 增加负向测试:缺少 revision / conflictDetectionKey 时真实写入必须拒绝。
## 5. 状态
已确认批量写入入口缺少必要冲突校验,尚未修复。
@@ -0,0 +1,159 @@
# 2026-05-18 [done] P0 内核优先 Bug 修复记录与进展
> 记录时间:2026-05-18
>
> 状态:`done`
>
> 范围:`03-rust-web`、`04-tree-domain`、`05-editor-mainline`、`07-ai`
>
> 口径:本文是本轮 P0 / 内核优先缺陷修复的总进展记录;单个缺陷的根因、修复点和证据仍以各分类 `bugs/<category>/done/` 下的明细文档为准。
## 1. 总结
本轮按“P0 优先 / 内核优先”的顺序完成了已确认缺陷的修复、验证和归档:
- `bugs/*/process/` 当前已无遗留缺陷文档。
- `design/10-review/process/08-*` 已迁入 `design/10-review/done/08-*`10-review 当前无活跃 process 文档。
- Page Aggregate、tree command、tree realtime、AI markdown edit / block tools 的 P0/P1 blocker 已归档到对应 `bugs/*/done/`
- 当前仍未实施的是 `7-10` / `7-16` 中 Phase C UI / Review Mode 相关项,已明确冻结,不计入本轮 P0 内核 bug blocker。
## 2. 本轮修复主线
### 2.1 Rust Web / Realtime / ACP
已归档缺陷:
- `bugs/03-rust-web/done/3-16-tree-realtime-ws-sse-doc-contract-drift-v1.md`
- `bugs/03-rust-web/done/3-17-sse-push-skips-polling-fallback-v1.md`
- `bugs/03-rust-web/done/3-18-ws-sse-delta-contract-split-v1.md`
- `bugs/03-rust-web/done/3-19-acp-reasonix-tool-call-missing-identity-fields-v1.md`
- `bugs/03-rust-web/done/3-20-acp-request-permission-no-response-v1.md`
- `bugs/03-rust-web/done/3-21-acp-run-payload-consumed-and-removed-v1.md`
- `bugs/03-rust-web/done/3-22-acp-permission-auto-deny-ui-shows-approval-actions-v1.md`
收口结果:
- `/api/realtime/ws``/api/tree/events` 的实时合同已按 WS 主链 / SSE fallback 口径收口。
- ACP Reasonix 工具调用身份字段、permission 响应、run payload 生命周期相关缺陷已进入 done。
- ACP permission 自动拒绝时,页面 AI UI 不再展示误导性的“允许 / 拒绝”审批动作。
- 这些缺陷不再作为当前 10-review P0 blocker 跟踪。
### 2.2 Tree Domain / File Tree
已归档缺陷:
- `bugs/04-tree-domain/done/4-46-filetree-resource-row-document-command-leak-v1.md`
- `bugs/04-tree-domain/done/4-47-stream-support-retired-resource-delete-command-v1.md`
收口结果:
- File Tree resource row 不再泄漏到 document command 语义。
- 资源归属继续按 Resource Tree -> File Tree projection -> `tree.resource.*` 命令面解释。
- stream_support 的资源 remove_asset 回归样例已同步到 `tree.resource.archive`,不再保留退役 `tree.resource.delete` 口径。
### 2.3 Editor Mainline / Page Aggregate
已归档缺陷:
- `bugs/05-editor-mainline/done/5-15-page-aggregate-client-state-second-truth-v1.md`
- `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-9-c12-topbar-sidebar-toggle-noop-v1.md`
收口结果:
- Page Aggregate 标题、正文、页面设置、page tree、AI fetch 回读已经形成同一组可验证真相。
- AI 外部写入后,文档 shell 会同步 Page Aggregate JSON script / session snapshot,避免下一次 AI context 继续读旧正文。
- Sidebar preferred snapshot 与 live stream 的优先级漂移已从当前 P0 blocker 中移除。
关键证据:
- `tmp/page-aggregate-refresh-persistence-smoke/mpagfma1.json`
- `tmp/page-aggregate-refresh-persistence-smoke/mpagfma1.png`
### 2.4 AI Markdown Edit / Block Tools
已归档缺陷:
- `bugs/07-ai/done/7-16-markdown-edit-normalized-search-byte-index-v1.md`
- `bugs/07-ai/done/7-17-markdown-edit-same-block-multi-op-overwrite-v1.md`
- `bugs/07-ai/done/7-18-ai-markdown-edit-phase-state-contract-drift-v1.md`
- `bugs/07-ai/done/7-19-hermes-tool-guidance-markdown-edit-contract-drift-v1.md`
- `bugs/07-ai/done/7-20-page-ai-workflow-bypasses-hermes-tool-executor-v1.md`
- `bugs/07-ai/done/7-21-markdown-edit-local-write-contract-bypass-v1.md`
- `bugs/07-ai/done/7-22-apply-block-ops-missing-write-preconditions-v1.md`
- `bugs/07-ai/done/7-23-markdown-edit-manifest-schema-contract-drift-v1.md`
- `bugs/07-ai/done/7-24-markdown-edit-online-write-does-not-use-final-markdown-v1.md`
- `bugs/07-ai/done/7-25-reasonix-block-edit-workflow-empty-block-ops-after-markdown-match-v1.md`
- `bugs/07-ai/done/7-26-resource-block-normalized-to-paragraph-ai-projection-v1.md`
- `bugs/07-ai/done/page-ai-block-edit-model-fallback-missing-content.md`
- `bugs/07-ai/done/7-27-markdown-edit-full-content-retains-old-blocks-v1.md`
- `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`
收口结果:
- 页面 AI 简单正文编辑主路径已收敛为 `mnote.doc.markdown_edit` + `mnote.doc.fetch` + `page_ai_workflow.rs` 统一 tool executor。
- `local_rule` / `direct_block_edit_operations` 不再作为当前 runtime 口径。
- 在线 Convex 文档和本地 `.md` 文件共用 markdown 写入合同。
- `mnote.doc.markdown_edit` 已补齐 normalized search、同块多 op 合并、写入前置条件、manifest schema、在线写回和空 block ops 映射错误处理。
- `mnote.block.insert_after` 支持多块插入并返回 `insertedBlockIds`,限制 `1..=20`
- AI block projection 已保留 `resource` 类型,不再把 resource 块降级为 `paragraph` 或伪装成可编辑普通块。
- `mnote.doc.markdown_edit``full_content` 重建已改为 final markdown 真源,避免旧普通块残留。
- `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`
关键证据:
- `tmp/page-block-ai-tools-smoke/mpag4966.json`
- `tmp/page-block-ai-tools-smoke/mpag4966-page.png`
- `tmp/hermes-tester/page-block-ai-tools-mpag4966/page-aggregate.json`
- `tmp/hermes-tester/page-block-ai-tools-mpag4966/doc-fetch-find.json`
- `tmp/page-block-ai-conflict-idempotency-smoke/mpafrevs.json`
## 3. 已执行验证
最新验证命令:
```bash
node --check scripts/task-page-block-ai-tools-smoke.js
node --check scripts/task-page-block-ai-conflict-idempotency-smoke.js
node --check scripts/task-page-aggregate-refresh-persistence-smoke.js
cargo fmt --manifest-path rust/Cargo.toml --all -- --check
cargo test --manifest-path rust/Cargo.toml -p bridge-runtime editor_document_roundtrip_preserves_resource_block_type -- --nocapture
cargo test --manifest-path rust/Cargo.toml -p mnote-web hermes_tools -- --nocapture
cargo test --manifest-path rust/Cargo.toml -p mnote-web build_page_content -- --nocapture
cargo test --manifest-path rust/Cargo.toml -p mnote-web hermes_tools_markdown_edit -- --nocapture
cargo test --manifest-path rust/Cargo.toml -p mnote-web block_edit_workflow -- --nocapture
cargo test --manifest-path rust/Cargo.toml -p mnote-web acp_client -- --nocapture
cargo test --manifest-path rust/Cargo.toml -p mnote-web ssr::pages::layout::tests -- --nocapture
cargo test --manifest-path rust/Cargo.toml -p mnote-web hermes_tools_block -- --nocapture
cargo test --manifest-path rust/Cargo.toml -p mnote-web hermes_tools_manifest -- --nocapture
cargo test --manifest-path rust/Cargo.toml -p mnote-web stream_change_preserves_remove_asset_delta_fields -- --nocapture
git diff --check -- scripts/task-page-block-ai-tools-smoke.js scripts/task-page-block-ai-conflict-idempotency-smoke.js scripts/task-page-aggregate-refresh-persistence-smoke.js rust/crates/core-protocol/src/editor/model.rs rust/crates/core-protocol/src/editor/tiptap.rs rust/crates/bridge-runtime/src/lib.rs rust/crates/mnote-web/src/editor_actor.rs rust/crates/mnote-web/src/hermes_tools/block.rs rust/crates/mnote-web/src/hermes_tools/manifest.rs rust/crates/mnote-web/src/routes/hermes_tools.rs design/07-ai/process/7-10-page-block-ai-tooling-execution-checklist-v1.md design/07-ai/process/7-16-page-block-ai-real-smoke-followup-matrix-v1.md design/10-review/README.md design/10-review/done/08-kernel-architecture-next-priority-review-and-checklist.md bugs/07-ai/done/7-26-resource-block-normalized-to-paragraph-ai-projection-v1.md
find bugs -path '*/process/*' -type f | sort
```
结果:
- 三个 Node smoke 脚本语法检查通过。
- Rust workspace 格式检查通过。
- `bridge-runtime` resource block roundtrip 回归测试通过:`1 passed`
- `mnote-web hermes_tools` 测试通过:`39 passed`
- `mnote-web build_page_content``hermes_tools_markdown_edit``block_edit_workflow``acp_client``ssr::pages::layout::tests` 定向回归测试通过。
- `mnote-web hermes_tools_block``hermes_tools_manifest` 定向回归测试通过。
- `mnote-web stream_change_preserves_remove_asset_delta_fields` 定向回归测试通过。
- 相关路径 `git diff --check` 通过。
- `bugs/*/process/` 文件列表为空。
## 4. 当前剩余边界
不是本轮未修 blocker
- `design/07-ai/process/7-10-page-block-ai-tooling-execution-checklist-v1.md``design/07-ai/process/7-16-page-block-ai-real-smoke-followup-matrix-v1.md` 中仍有 Phase C UI / Review Mode 未勾项。
- 这些项属于流式 apply、suggest/review、持久 comment/tracked-change 等未来 UI / 审阅面设计,当前已按 Phase C 冻结,不纳入本轮 P0 内核 bug 修复。
后续若要继续推进,应另起 process 文档或新 bug,而不是重新打开本轮已归档的 P0 内核缺陷。
+49
View File
@@ -0,0 +1,49 @@
/* eslint-disable */
/**
* Generated `api` utility.
*
* THIS CODE IS AUTOMATICALLY GENERATED.
*
* To regenerate, run `npx convex dev`.
* @module
*/
import type * as aiSessions from "../aiSessions.js";
import type {
ApiFromModules,
FilterApi,
FunctionReference,
} from "convex/server";
declare const fullApi: ApiFromModules<{
aiSessions: typeof aiSessions;
}>;
/**
* A utility for referencing Convex functions in your app's public API.
*
* Usage:
* ```js
* const myFunctionReference = api.myModule.myFunction;
* ```
*/
export declare const api: FilterApi<
typeof fullApi,
FunctionReference<any, "public">
>;
/**
* A utility for referencing Convex functions in your app's internal API.
*
* Usage:
* ```js
* const myFunctionReference = internal.myModule.myFunction;
* ```
*/
export declare const internal: FilterApi<
typeof fullApi,
FunctionReference<any, "internal">
>;
export declare const components: {};
+433
View File
@@ -0,0 +1,433 @@
import { mutation, query } from "./_generated/server";
import { v } from "convex/values";
const runtimeRunArgs = {
schema: v.literal("mnote.acp_runtime_run.v1"),
source: v.literal("acp"),
userId: v.string(),
workspaceId: v.optional(v.union(v.string(), v.null())),
documentId: v.string(),
sessionId: v.string(),
runId: v.string(),
title: v.optional(v.union(v.string(), v.null())),
profile: v.string(),
acpRuntime: v.string(),
traceId: v.string(),
status: v.string(),
runtime: v.any(),
payload: v.any(),
retention: v.any(),
createdAt: v.number(),
updatedAt: v.number(),
};
const runtimeEventArgs = {
schema: v.literal("mnote.acp_runtime_event.v1"),
source: v.literal("acp"),
eventId: v.string(),
userId: v.string(),
workspaceId: v.optional(v.union(v.string(), v.null())),
documentId: v.string(),
sessionId: v.string(),
runId: v.string(),
profile: v.string(),
acpRuntime: v.string(),
eventType: v.string(),
payload: v.any(),
createdAt: v.number(),
};
function isoFromMillis(value: number) {
return new Date(value).toISOString();
}
async function requireScopedUser(ctx: any, expectedUserId: string) {
const identity = await ctx.auth.getUserIdentity();
const subject = String(identity?.subject ?? "");
const identityUserId = subject.split("|")[0] || "";
if (identityUserId && identityUserId !== expectedUserId) {
throw new Error("无权限");
}
if (!identityUserId && !expectedUserId.trim()) {
throw new Error("未登录");
}
return expectedUserId;
}
function rowToClient(row: any) {
return {
id: row.id,
userId: row.user_id,
workspaceId: row.workspace_id,
documentId: row.document_id,
sessionId: row.session_id,
runId: row.run_id,
profile: row.profile,
title: row.title,
acpRuntime: row.acp_runtime,
source: row.source,
status: row.status,
traceId: row.trace_id,
runtime: row.runtime,
usage: row.usage ?? null,
payload: row.payload,
retention: row.retention,
createdAt: row.created_at,
updatedAt: row.updated_at,
};
}
export const upsertRuntimeRun = mutation({
args: runtimeRunArgs,
handler: async (ctx, args) => {
const userId = await requireScopedUser(ctx, args.userId);
const workspaceId = args.workspaceId ?? null;
const nowIso = isoFromMillis(args.updatedAt);
const existing = await ctx.db
.query("acp_runtime_runs")
.withIndex("by_run_id", (q: any) => q.eq("run_id", args.runId))
.first();
const patch = {
id: `acp_run:${args.runId}`,
user_id: userId,
workspace_id: workspaceId,
document_id: args.documentId,
session_id: args.sessionId,
run_id: args.runId,
title: args.title ?? null,
profile: args.profile,
acp_runtime: args.acpRuntime,
source: args.source,
status: args.status,
trace_id: args.traceId,
runtime: args.runtime,
usage: null,
payload: args.payload,
retention: args.retention,
updated_at: nowIso,
deleted_at: null,
};
if (existing) {
if (String(existing.user_id) !== userId) {
throw new Error("无权限");
}
await ctx.db.patch(existing._id, patch);
return { ok: true, id: existing.id, runId: args.runId, updated: true };
}
await ctx.db.insert("acp_runtime_runs", {
...patch,
created_at: isoFromMillis(args.createdAt),
});
return { ok: true, id: patch.id, runId: args.runId, created: true };
},
});
export const getRuntimeRun = query({
args: {
userId: v.string(),
runId: v.string(),
},
handler: async (ctx, args) => {
const userId = await requireScopedUser(ctx, args.userId);
const row = await ctx.db
.query("acp_runtime_runs")
.withIndex("by_run_id", (q: any) => q.eq("run_id", args.runId))
.first();
if (!row || row.deleted_at !== null || String(row.user_id) !== userId) {
return null;
}
return rowToClient(row);
},
});
export const listRuntimeRuns = query({
args: {
userId: v.string(),
workspaceId: v.optional(v.union(v.string(), v.null())),
documentId: v.optional(v.union(v.string(), v.null())),
sessionId: v.optional(v.union(v.string(), v.null())),
limit: v.optional(v.number()),
},
handler: async (ctx, args) => {
const userId = await requireScopedUser(ctx, args.userId);
const workspaceId = args.workspaceId ?? null;
const limit = Math.min(Math.max(args.limit ?? 50, 1), 100);
let rows;
if (args.sessionId) {
rows = await ctx.db
.query("acp_runtime_runs")
.withIndex("by_user_workspace_session", (q: any) =>
q.eq("user_id", userId).eq("workspace_id", workspaceId).eq("session_id", args.sessionId),
)
.order("desc")
.take(limit);
} else if (args.documentId) {
rows = await ctx.db
.query("acp_runtime_runs")
.withIndex("by_user_workspace_document", (q: any) =>
q.eq("user_id", userId).eq("workspace_id", workspaceId).eq("document_id", args.documentId),
)
.order("desc")
.take(limit);
} else {
rows = await ctx.db
.query("acp_runtime_runs")
.withIndex("by_user_created_at", (q: any) => q.eq("user_id", userId))
.order("desc")
.take(limit);
}
return rows
.filter((row: any) => row.deleted_at === null)
.map(rowToClient);
},
});
export const appendRuntimeEvent = mutation({
args: runtimeEventArgs,
handler: async (ctx, args) => {
const userId = await requireScopedUser(ctx, args.userId);
const workspaceId = args.workspaceId ?? null;
const existingRun = await ctx.db
.query("acp_runtime_runs")
.withIndex("by_run_id", (q: any) => q.eq("run_id", args.runId))
.first();
if (existingRun && String(existingRun.user_id) !== userId) {
throw new Error("无权限");
}
await ctx.db.insert("acp_runtime_events", {
id: args.eventId,
user_id: userId,
workspace_id: workspaceId,
document_id: args.documentId,
session_id: args.sessionId,
run_id: args.runId,
profile: args.profile,
acp_runtime: args.acpRuntime,
source: args.source,
event_type: args.eventType,
payload: args.payload,
created_at: isoFromMillis(args.createdAt),
});
const usage = usageFromRuntimeEvent(args.eventType, args.payload);
if (existingRun && usage) {
await ctx.db.patch(existingRun._id, {
usage,
updated_at: isoFromMillis(args.createdAt),
});
}
return { ok: true, id: args.eventId, runId: args.runId };
},
});
function usageFromRuntimeEvent(eventType: string, payload: any) {
if (eventType === "usage.updated") {
return {
source: "usage_update",
contextUsed: Number(payload?.used ?? payload?.contextUsed ?? 0),
contextSize: Number(payload?.size ?? payload?.contextSize ?? 0),
};
}
if (eventType === "run.completed" && payload?.usage && typeof payload.usage === "object") {
return {
source: "run_completed",
...payload.usage,
};
}
return null;
}
export const listRuntimeEvents = query({
args: {
userId: v.string(),
runId: v.string(),
limit: v.optional(v.number()),
},
handler: async (ctx, args) => {
const userId = await requireScopedUser(ctx, args.userId);
const limit = Math.min(Math.max(args.limit ?? 100, 1), 500);
const rows = await ctx.db
.query("acp_runtime_events")
.withIndex("by_run_created_at", (q: any) => q.eq("run_id", args.runId))
.order("asc")
.take(limit);
return rows
.filter((row: any) => String(row.user_id) === userId)
.map((row: any) => ({
id: row.id,
userId: row.user_id,
workspaceId: row.workspace_id,
documentId: row.document_id,
sessionId: row.session_id,
runId: row.run_id,
profile: row.profile,
acpRuntime: row.acp_runtime,
source: row.source,
eventType: row.event_type,
payload: row.payload,
createdAt: row.created_at,
}));
},
});
function titleFromRun(row: any) {
const payload = row?.payload ?? {};
const raw = String(payload.message ?? payload.input ?? payload.title ?? "").trim();
if (!raw) return "当前页问答";
return raw.length > 32 ? `${raw.slice(0, 32)}...` : raw;
}
async function sessionRows(ctx: any, userId: string, workspaceId: string | null, sessionId: string) {
return await ctx.db
.query("acp_runtime_runs")
.withIndex("by_user_workspace_session", (q: any) =>
q.eq("user_id", userId).eq("workspace_id", workspaceId).eq("session_id", sessionId),
)
.collect();
}
export const renameRuntimeSession = mutation({
args: {
userId: v.string(),
workspaceId: v.optional(v.union(v.string(), v.null())),
sessionId: v.string(),
title: v.string(),
},
handler: async (ctx, args) => {
const userId = await requireScopedUser(ctx, args.userId);
const workspaceId = args.workspaceId ?? null;
const title = args.title.trim();
if (!title) throw new Error("标题不能为空");
const rows = await sessionRows(ctx, userId, workspaceId, args.sessionId);
for (const row of rows) {
if (row.deleted_at === null) {
await ctx.db.patch(row._id, {
title,
updated_at: new Date().toISOString(),
});
}
}
return { ok: true, sessionId: args.sessionId, title, updated: rows.length };
},
});
export const autoTitleRuntimeSession = mutation({
args: {
userId: v.string(),
workspaceId: v.optional(v.union(v.string(), v.null())),
sessionId: v.string(),
},
handler: async (ctx, args) => {
const userId = await requireScopedUser(ctx, args.userId);
const workspaceId = args.workspaceId ?? null;
const rows = await sessionRows(ctx, userId, workspaceId, args.sessionId);
const title = titleFromRun(rows.find((row: any) => row.deleted_at === null));
for (const row of rows) {
if (row.deleted_at === null) {
await ctx.db.patch(row._id, {
title,
updated_at: new Date().toISOString(),
});
}
}
return { ok: true, sessionId: args.sessionId, title, updated: rows.length };
},
});
export const deleteRuntimeSession = mutation({
args: {
userId: v.string(),
workspaceId: v.optional(v.union(v.string(), v.null())),
sessionId: v.string(),
},
handler: async (ctx, args) => {
const userId = await requireScopedUser(ctx, args.userId);
const workspaceId = args.workspaceId ?? null;
const rows = await sessionRows(ctx, userId, workspaceId, args.sessionId);
const deletedAt = new Date().toISOString();
for (const row of rows) {
if (row.deleted_at === null) {
await ctx.db.patch(row._id, {
deleted_at: deletedAt,
updated_at: deletedAt,
});
}
}
return { ok: true, sessionId: args.sessionId, deleted: rows.length };
},
});
function searchText(row: any) {
const payload = row?.payload ?? {};
return [
row.title,
row.session_id,
row.run_id,
row.profile,
row.status,
payload.message,
payload.input,
payload.title,
]
.map((value) => String(value ?? ""))
.join(" ")
.toLowerCase();
}
function snippetFor(row: any, q: string) {
const payload = row?.payload ?? {};
const raw = String(row.title ?? payload.message ?? payload.input ?? row.session_id ?? "").trim();
if (!raw) return "";
const index = raw.toLowerCase().indexOf(q.toLowerCase());
if (index < 0) return raw.length > 80 ? `${raw.slice(0, 80)}...` : raw;
const start = Math.max(0, index - 24);
const end = Math.min(raw.length, index + q.length + 56);
return `${start > 0 ? "..." : ""}${raw.slice(start, end)}${end < raw.length ? "..." : ""}`;
}
export const searchRuntimeSessions = query({
args: {
userId: v.string(),
workspaceId: v.optional(v.union(v.string(), v.null())),
q: v.string(),
limit: v.optional(v.number()),
},
handler: async (ctx, args) => {
const userId = await requireScopedUser(ctx, args.userId);
const workspaceId = args.workspaceId ?? null;
const q = args.q.trim().toLowerCase();
if (!q) return [];
const limit = Math.min(Math.max(args.limit ?? 20, 1), 50);
const rows = await ctx.db
.query("acp_runtime_runs")
.withIndex("by_user_created_at", (query: any) => query.eq("user_id", userId))
.order("desc")
.take(500);
const seen = new Set<string>();
const results = [];
for (const row of rows) {
if (row.deleted_at !== null) continue;
if ((row.workspace_id ?? null) !== workspaceId) continue;
if (!searchText(row).includes(q)) continue;
if (seen.has(row.session_id)) continue;
seen.add(row.session_id);
results.push({
sessionId: row.session_id,
runId: row.run_id,
workspaceId: row.workspace_id,
documentId: row.document_id,
title: row.title ?? titleFromRun(row),
profile: row.profile,
status: row.status,
snippet: snippetFor(row, args.q),
score: 1,
});
if (results.length >= limit) break;
}
return results;
},
});
+52
View File
@@ -0,0 +1,52 @@
import { defineSchema, defineTable } from "convex/server";
import { v } from "convex/values";
export default defineSchema({
acp_runtime_runs: defineTable({
id: v.string(),
user_id: v.string(),
workspace_id: v.union(v.string(), v.null()),
document_id: v.string(),
session_id: v.string(),
run_id: v.string(),
title: v.union(v.string(), v.null()),
profile: v.string(),
acp_runtime: v.string(),
source: v.literal("acp"),
status: v.string(),
trace_id: v.string(),
runtime: v.any(),
usage: v.any(),
payload: v.any(),
retention: v.any(),
created_at: v.string(),
updated_at: v.string(),
deleted_at: v.union(v.string(), v.null()),
})
.index("by_run_id", ["run_id"])
.index("by_user_workspace_document", ["user_id", "workspace_id", "document_id", "created_at"])
.index("by_user_workspace_session", ["user_id", "workspace_id", "session_id", "created_at"])
.index("by_user_created_at", ["user_id", "created_at"]),
acp_runtime_events: defineTable({
id: v.string(),
user_id: v.string(),
workspace_id: v.union(v.string(), v.null()),
document_id: v.string(),
session_id: v.string(),
run_id: v.string(),
profile: v.string(),
acp_runtime: v.string(),
source: v.literal("acp"),
event_type: v.string(),
payload: v.any(),
created_at: v.string(),
})
.index("by_run_created_at", ["run_id", "created_at"])
.index("by_user_workspace_session_created_at", [
"user_id",
"workspace_id",
"session_id",
"created_at",
]),
});
@@ -0,0 +1,371 @@
# 7-25 [process] ACP Session Runtime 增强规划 v1
> 更新时间:2026-05-17
> 参考:`hermes-vscode-main` (ACP client) / `hermes-web-ui-0.5.18` (HTTP API)
## 1. 当前状态
当前 mnote-web 的 ACP 实现是半成品,但已有的 route 和 runtime 壳并不等于“本地会话持久化已完成”:
| 领域 | 已有 | 缺失 |
|------|------|------|
| Wire protocol | `AcpClient`JSON-RPC 2.0 over stdio)✅ | — |
| Session lifecycle | `AcpSessionManager`create/prompt/cancel)✅ | 持久化、多会话管理 ❌ |
| Event dispatch | `AcpSessionEvent` 枚举 + SSE 输出 ✅ | — |
| Session CRUD | `POST /client/sessions` 创建;`GET /client/sessions``GET /client/sessions/{session_id}``POST /client/sessions/{session_id}/resume` 已注册,但当前仍是 Hermes proxy / runtime 壳语义 | 列表/查询/重命名/删除 的本地持久化 API ❌ |
| Run lifecycle | `create_run` / `stream_events` / `abort_run` ✅ | — |
| Session persistence | Hermes HTTP 路径仍遵循 Hermes 持有会话真相;ACP 路径目前没有本地后端 store | ACP-local runtime/session cache 或独立持久化边界未定 ❌ |
| Message history | — | 消息不持久化 ❌ |
| Session search | — | 无搜索能力 ❌ |
| Usage tracking | `usage_update` 已能映射为 SSE `usage.updated` | 无 session 级持久聚合、查询 API 和 UI ❌ |
| Permission request | acp_client.rs 仅日志(bug 3-20 | 无协议响应 ❌ |
| Session resume | route 已注册,`resume_session` 目前仅回显 `get_session` 结果 | 真实 resume / 历史注入 / 续跑语义不完整 ❌ |
| Auto-title | — | 无自动命名 ❌ |
| Conversation export | — | 无导出 ❌ |
| Session list UI | 当前只有页面 AI 的本地短期历史列表(localStorage),不是后端 ACP 会话列表 | 后端会话列表 / 详情 / 搜索 还未接到 UI ❌ |
### 1.1 正确性结论
本文对“ACP runtime 已接入但 session runtime 仍不完整”的判断成立;但“在 mnote-web 中新增本地 `sessions/messages` 并替代内存表”不能直接理解为新的 AI 聊天真相层。
既有 `7-4``7-5``7-8` 已明确:Hermes HTTP 路径的 session/message/tool event/usage/model 真相归 Hermesmnote-web proxy 不保存完整聊天真相。因此本计划若引入本地存储,默认只能用于:
- ACP-local run payload、runtime registry、断线重连、短期恢复所需的技术状态;
- mnote 自己产生的 tool audit、业务写入结果、artifact / page / tree 事实;
- 经单独架构决策确认后的 ACP 会话缓存或索引。
如果要把 mnote 的存储升级为跨 Hermes HTTP / ACP 的长期会话真源,必须先更新 `7-5` / `7-8` 的边界,而不能作为本计划的隐含前提。并且 mnote 是多用户系统,AI session 必须按用户、workspace、document 进行隔离;需要数据库时使用当前 Convex 底座,不新增 SQLite。
## 2. 参考实现分析
### hermes-vscode-mainACP 客户端参考)
- **`acpClient.ts`** — JSON-RPC 2.0 over stdio,支持 request/response/notification/incoming request。 基础结构已移植到 `acp_client.rs`
- **`sessionManager.ts`** — Session 生命周期管理 + `session/update` 事件分发。基础结构已移植到 `acp_session_manager.rs`
- **`sessionStore.ts`** — **关键缺失部分**。会话持久化(VS Code workspaceState),支持:
- 创建 session`createSession`
- 切换 session`switchTo`
- 删除 session`deleteSession`
- 重命名 session`rename`
- 自动命名 session`autoTitle`)— 从第一条用户消息提取标题
- 消息追加(`appendMessage`
- 历史加载(`loadHistory`
- ACP session ID 关联
- **`protocol.ts`** — 事件解析 + 去重。`deduplicateChunk` 逻辑已移植。
- **`types.ts`** — `ChatSession``StoredMessage``TodoState` 等类型。
### hermes-web-ui-0.5.18HTTP REST API 参考)
- **`controllers/hermes/sessions.ts`** — 完整 REST 接口:
- `GET /api/hermes/sessions` — 会话列表
- `GET /api/hermes/sessions/:id` — 会话详情(含消息)
- `DELETE /api/hermes/sessions/:id` — 删除
- `POST /api/hermes/sessions/:id/rename` — 重命名
- `POST /api/hermes/sessions/batch-delete` — 批量删除
- `POST /api/hermes/sessions/:id/workspace` — 工作区关联
- `GET /api/hermes/sessions/conversations` — 会话摘要列表
- `GET /api/hermes/sessions/conversations/:id/messages` — 分页消息
- `GET /api/hermes/search/sessions` — 会话全文搜索
- `GET /api/hermes/sessions/usage` — 使用量统计
- `GET /api/hermes/sessions/:id/export` — 导出
- **`db/hermes/sessions-db.ts`** — 参考实现中的会话存储,支持:
- 完整 CRUD
- 全文搜索(FTS5
- 用量统计
- 消息分页
- **`db/hermes/session-store.ts`** — 本地 JSON 文件备选存储
## 3. 建议新增功能
按优先级分三阶段:
### Phase ASession 持久化与管理(P0 — 缺少则 ACP 无实用价值)
1. **会话持久化存储**
- 默认先建设 ACP-local runtime store / TTL cache,不改 Hermes HTTP 会话真相归属
- 持久化如需数据库,必须落到 Convex;不在 mnote-web 内新增 SQLite
- 表结构先服务 `ACP_RUN_PAYLOADS` / `ACP_ACTIVE_RUNS` 的可靠生命周期,并且所有记录必须带 `userId` / `workspaceId` / `documentId` 作用域
- `sessions/messages` 如要保存完整聊天历史,必须先完成架构决策并标明只覆盖 ACP 路径还是统一覆盖 Hermes HTTP + ACP
2. **会话管理 API**
- `GET /client/sessions` — 会话列表(分页、排序)
- `GET /client/sessions/{session_id}` — 会话详情(含消息)
- `DELETE /client/sessions/{session_id}` — 删除
- `POST /client/sessions/{session_id}/rename` — 重命名
- `POST /client/sessions/{session_id}/auto-title` — 自动命名
3. **消息持久化**
- ACP `stream_events` 执行过程中按架构决策写入 user message + agent response,或只写 runtime/event 索引
- 支持追加消息到已有 ACP-local sessionHermes HTTP 历史仍从 Hermes 读取
4. **会话搜索**
- `GET /client/sessions/search?q=...` — 全文搜索(基于 Convex 查询能力,必要时再接专用搜索索引)
- 返回匹配的 session 摘要 + snippet
5. **Permission request 响应修复**bug 3-20
- 已知 incoming request 应返回结构化 error 而非仅日志
- `session/request_permission` 至少需要 auto-deny + 通知前端
- 配合 Phase C 的 UI 实现人工确认
### Phase B:历史消息与管理增强(P1 — 常用功能)
6. **会话恢复(Resume**
- 当前 `POST /client/sessions/{session_id}/resume` 已有 route,需实现
- 从 ACP-local store 或 Hermes session store 加载历史消息,作为后续 prompt 的 context 来源
- 支持新模型继续已有会话
7. **分页消息查询**
- `GET /client/sessions/{session_id}/messages?cursor=...&limit=...`
- 支持时间范围和角色过滤
8. **用量记录**
- 从 ACP `usage_update` 事件提取 `contextUsed` / `contextSize`
- 写入 `sessions` 表的 usage 字段
- `GET /client/sessions/usage` — 聚合统计
9. **会话导出**
- `GET /client/sessions/{session_id}/export?format=json|markdown`
- JSON 格式:完整结构化导出
- Markdown 格式:人类可读对话导出
10. **ACP run payload 生命周期修复**bug 3-21
- 停止在 `stream_events``.remove(run_id)` 释放 payload
- 改为基于 Convex session/runtime 记录或 TTL 缓存
### Phase C:前端交互与高级功能(P2 — 用户体验提升)
11. **前端会话列表**
- Sidebar 或独立面板展示历史会话
- 支持切换、删除、重命名
- 支持继续已有会话
12. **会话搜索 UI**
- 搜索框,全字段搜索(标题、消息内容)
- 结果高亮 + 跳转
13. **Token 用量可视化**
- 每次 AI 调用后展示 token 消耗
- 会话级别统计累计用量
14. **权限请求 UI**
- Agent 发起 `session/request_permission` 时前端弹窗确认
- 支持 auto-allow / auto-deny 配置
15. **多会话并发**
- 支持同时运行多个 ACP session
- 前端标签页切换
## 4. 技术方案
### 4.0 当前 Convex 源目录口径
当前仓库状态下:
- `infra/convex/` 只承载自托管 Convex backend/dashboard 的 Docker 与说明,不是 functions/schema 源目录;
- `wolai-frontend/convex/` 已不在当前工作区可读路径中,不能作为新的实现落点;
- `recycle/wolai-frontend/convex/` 只作为历史参考;
- 本轮执行已在仓库根 `convex/` 建立 ACP-local runtime store 的最小 functions/schema,并让 `scripts/run-convex-deploy.js` 优先以仓库根作为 Convex CLI cwd。
后续若要恢复完整业务 Convex functions,需要把历史 `recycle/wolai-frontend/convex/` 中仍需要的 documents/workspaces/media 等 functions 正式迁移到根 `convex/`,不能继续依赖已删除的 `wolai-frontend/` 路径。
### 4.1 存储方案
注意:以下结构是 Convex-backed ACP-local store 候选形状,不默认推翻 Hermes 持有聊天真相的既有边界。mnote-web 不新增 SQLite;多用户隔离字段是硬要求。
```rust
// sessions 记录
struct SessionRow {
id: String, // 主键
user_id: String, // 多用户隔离
workspace_id: Option<String>,
document_id: Option<String>,
title: Option<String>,
profile: String,
model: Option<String>,
source: String, // "acp" | "hermes-http"; hermes-http 默认只保存引用/索引,不保存聊天真相
started_at: i64,
ended_at: Option<i64>,
end_reason: Option<String>,
message_count: u32,
input_tokens: u64,
output_tokens: u64,
preview: Option<String>, // 首条消息摘要
}
// messages 记录
struct MessageRow {
id: i64, // 自增
user_id: String, // 多用户隔离
session_id: String, // FK -> sessions
role: String, // "user" | "assistant" | "tool"
content: String,
reasoning: Option<String>,
tool_calls: Option<Value>, // JSON
token_count: Option<u32>,
created_at: i64,
}
```
### 4.2 现有代码改动范围
- **新增/修改** 根 `convex/` 相关 schema / functions — ACP-local session/runtime 记录、用户隔离查询、TTL 清理;`infra/convex/` 只负责自托管服务部署
- **修改** `acp_session_manager.rs` — 按架构决策写入 ACP-local 消息或只写 runtime/event 索引
- **修改** `hermes_client.rs` — 新增 session 管理 route handlers
- **修改** `routes/mod.rs` — 注册新 route
- **修改** Convex bridge / transport 调用点 — mnote-web 通过已有 Convex 底座读写 session/runtime 索引
### 4.3 与现有 bug 的关系
| Bug | Phase | 说明 |
|-----|-------|------|
| 3-20 ACP incoming request 无响应 | A | permission request 必须有协议响应 |
| 3-21 ACP run payload 消费后移除 | B | Convex-backed store / TTL cache 化后 payload 由 session 管理,不再依赖内存 map |
| 7-19 Hermes 指导优先 apply_block_ops | C | 需统一到 markdown_edit 口径 |
| 7-20 page_ai_workflow 绕过 tool executor | C | 建议统一到 ACP tool executor |
## 5. 实施建议顺序
```
Phase AP0 基础可用)
├── 5.1 Convex-backed ACP-local Session Store(多用户隔离 + TTL 策略)
├── 5.2 会话列表 / 详情 / 删除 / 重命名 API
├── 5.3 消息或 runtime/event 索引持久化(stream_events 写入)
├── 5.4 会话搜索
└── 5.5 Permission request 响应(bug 3-20
Phase BP1 常用增强)
├── 5.6 会话恢复
├── 5.7 分页消息查询
├── 5.8 用量记录
├── 5.9 会话导出
└── 5.10 Payload 生命周期修复(bug 3-21
Phase CP2 用户体验)
├── 5.11 前端会话列表
├── 5.12 搜索 UI
├── 5.13 用量可视化
├── 5.14 权限请求 UI
└── 5.15 多会话并发
```
## 6. 详细 checklist
### 6.1 先确认数据契约
- [x] 先确认是否允许 mnote-web 保存完整 AI session/message 真相;若不允许,本文存储范围必须限定为 ACP-local runtime cache / metadata index。
- 结论:本轮不保存完整 AI session/message 真相,只保存 ACP-local runtime run metadata / payload cache / index。
- [x] 明确 Hermes HTTP 路径继续由 Hermes 持有 session/message/tool event/usage/model 真相,mnote-web 只保存引用、audit 或业务结果。
- [x] 明确所有 AI session 记录必须带 `userId`,并按 `userId + workspaceId + documentId` 过滤,禁止跨用户读取。
- [x] 明确 Convex `sessions` 记录的唯一键、排序键、软删除策略和保留周期。
- 当前实现采用更小的 `acp_runtime_runs``run_id` 唯一,按 `user_id + workspace_id + document_id/session_id + created_at` 排序,`deleted_at` 软删除,payload 记录带 7 天 TTL 元数据。
- [x] 明确 Convex `messages` 记录是否允许同一 `session_id` 下多次 `resume` 追加写入。
- 结论:本轮不建完整 `messages` 真相表;后续若引入只覆盖 ACP 路径的消息索引,允许同一 `session_id` 多 run 追加 runtime events,但不复制 Hermes HTTP 聊天真相。
- [x] 明确 `session_id``run_id` 的映射是否需要单独持久化,避免仅靠内存 map。
- 当前已在 `acp_runtime_runs` 中持久化 `session_id/run_id` 映射。
- [x] 明确 `profile``model``source` 三个字段在 ACP/Hermes HTTP/Reasonix 三种路径下的取值规则。
- 当前实现:`source = "acp"``profile` 沿请求 profile`acpRuntime` 沿请求或 profile 推导;`model` 暂不落库,等待 usage/model 聚合阶段。
- [x] 明确 `usage` 统计的口径:只记 `usage_update`,还是合并 `run.completed.usage`
- 结论:`usage_update` 作为运行中实时增量;`run.completed.usage` 作为最终校正值。两者都只落 ACP-local runtime/usage index,不复制 Hermes HTTP 聊天真相。
### 6.2 先做后端持久化骨架
- [x] 为 ACP-local session/runtime store 设计 Convex schema,不新增 SQLite。
- [x] 在 Convex 中建 `sessions``messages` 或更小的 `runtimeRuns` / `runtimeEvents` 记录,补齐索引和权限过滤。
- 当前实现为根 `convex/schema.ts``acp_runtime_runs` / `acp_runtime_events`,以及 `convex/aiSessions.ts``upsertRuntimeRun/getRuntimeRun/listRuntimeRuns`
- [x]`ACP_RUN_PAYLOADS` / `ACP_ACTIVE_RUNS` 中必须保留的数据拆到持久化层或短 TTL 层。
- 当前 `create_run` 会把 ACP payload/runtime 写入 Convex;内存 map 仍作为热路径保留,`stream_events` 不再消费删除 payload。
- [x]`create_run` 产出的 `sessionId``profile``traceId``runtime` 写入 `sessions` 记录。
- 当前写入目标为更小的 `acp_runtime_runs`,而不是完整聊天 `sessions` 真相表。
- [x]`stream_events` 的 ACP 路径里,按架构决策写入 `messages` 或 runtime/event 索引,不能把 Hermes HTTP 聊天真相复制进 mnote。
- 当前实现:ACP SSE event 以 best-effort 写入 `acp_runtime_events`,只保存 runtime event payload,不写完整聊天 `messages` 真相。
### 6.3 再补 session 读接口
- [x]`GET /client/sessions` 返回当前用户可见的 Convex store 或 Hermes upstream 会话摘要,而不是只依赖前端 localStorage。
- 当前实现:ACP profile / `source=acp``aiSessions:listRuntimeRuns`Hermes HTTP profile 仍走 upstream。
- [x]`GET /client/sessions/{session_id}` 返回会话元数据、消息列表、runtime 状态。
- 当前实现:`source=acp` 从 Convex store 读取 runs/events`messages` 保持空数组以避免复制完整聊天真相。
- [x]`POST /client/sessions/{session_id}/resume` 真正从持久化历史恢复,而不是简单复用 `get_session`
- 当前实现:`source=acp` 返回 Convex runtime history,并标记 `resumed=true` / `resumeSource=convex_acp_runtime_store`
- [x]`DELETE /client/sessions/{session_id}``POST /client/sessions/{session_id}/rename``POST /client/sessions/{session_id}/auto-title` 补齐路由与处理器。
- 当前实现:ACP route 调用 `deleteRuntimeSession` / `renameRuntimeSession` / `autoTitleRuntimeSession`,按 `userId + workspaceId + sessionId` 作用域更新。
- [x] 为搜索接口加分页和最小 snippet,避免一次返回过长正文。
- 当前实现:`GET /client/sessions/search?source=acp&q=...&limit=...` 调用 `searchRuntimeSessions`,最多返回 50 条 session 摘要和 snippet。
### 6.4 再修 ACP 协议行为
- [x]`session/request_permission` 生成结构化响应,不再只打日志。
- [x] 给未知 incoming request 返回明确 error,避免 agent 一直等超时。
- [x] 把 permission 结果同步到前端,至少先支持 auto-allow / auto-deny。
- 当前实现:`session/request_permission` 仍由 ACP client 自动拒绝以避免 agent 超时,同时转成 `permission.denied` SSE;页面 AI 侧展示权限弹窗/卡片,包含 tool 名、参数摘要、允许/拒绝动作入口。
- [x]`usage_update``run.completed` 的 usage 汇总到 session 级统计。
- 当前实现:`appendRuntimeEvent` 在收到 `usage.updated` 或带 `usage``run.completed` 时,同步更新 `acp_runtime_runs.usage`
- [x] 验证 `thought.delta` 仍不会落入最终 assistant 正文。
- 当前验证:`acp_thought_delta_does_not_emit_message_delta` 确认 thought 只作为 `thought.delta` 转发。
### 6.5 再补前端入口
- [x] 在页面 AI 面板中加入后端会话列表,而不是只显示 localStorage 的短期历史。
- 当前实现:页面 AI history 面板打开时会请求 `GET /api/hermes/client/sessions?source=acp&workspaceId=...&documentId=...`,并与 localStorage 短期缓存合并。
- [x] 会话列表支持切换、重命名、删除、恢复。
- 当前实现:history 行内提供恢复、重命名、删除按钮;切换会话时会拉取 Convex-backed detail/resume。
- [x] 会话详情页或抽屉支持查看消息分页与搜索结果。
- 当前实现:恢复/详情会读取最新 run 的 events 并映射为消息视图;history 面板提供后端 session 搜索框,搜索结果展示 snippet。当前消息详情读取后端最近 200 条 event,尚未做 cursor 翻页。
- [x] 权限请求弹窗至少展示 tool 名、参数摘要、允许/拒绝动作。
- 当前实现:`permission.requested` / `permission.denied` / `permission.allowed` 会生成权限弹窗和消息卡片;当前 ACP incoming request 默认 auto-deny,按钮用于前端状态确认,后续可接入真实审批回写。
- [x] Token / 用量信息在会话条目和运行状态上可见。
- 当前实现:会话条目和当前 session 状态展示 `usage.updated` / `run.completed.usage` 汇总。
### 6.6 最后做回归验证
- [x] 新建 ACP 会话后刷新页面,仍能从 Convex-backed store 或 Hermes session store 重新拿到同一会话。
- 当前实现:ACP profile 创建 session 时写入 `aiSessions:upsertRuntimeRun``session.created` 索引记录;刷新后列表可从 Convex-backed store 找回该 session。
- [x] 用两个测试用户分别创建 AI session,确认 session 列表、详情、resume 都只返回当前用户自己的记录。
- 当前实现:Convex functions 使用 `ctx.auth.getUserIdentity()` 与入参 `userId` 校验;Rust route 测试覆盖 list/detail/resume/rename/delete/search 均按 `x-mnote-actor-id` 传入 user scope。尚未跑真实双账号浏览器 smoke。
- [x] 断线重连后,`stream_events` 能从历史继续恢复,而不是丢失 payload。
- 当前最小验证覆盖 payload lookup 不再 remove;完整断线重连仍需浏览器 smoke。
- [x] `session/request_permission` 会返回可验证的协议响应。
- [x] `message.delta``thought.delta``tool.*``run.completed` 的 SSE 映射在浏览器里都能看见正确结果。
- 当前实现:页面 AI stream handler 映射 `message.delta``thought.delta``usage.updated``tool.*``run.completed`;Rust/JS 静态测试覆盖前端入口,尚未完成真实浏览器截图级验证。
- [x] 运行一次真实浏览器 smoke,确认会话列表、恢复、重命名和删除链路都能闭环。
- 当前验证:启动 `mnote-web``127.0.0.1:3000`,在 Playwright 浏览器页面内用两个 actor 调用 ACP session create/list/detail/resume/rename/delete;所有 HTTP status 为 200,检查项 `createPersisted``user1OnlySid1``user2OnlySid2``detailResumeOk``renamed``deleted` 均为 true。
## 7. 执行记录
### 2026-05-18
- 新增根 `convex/schema.ts``acp_runtime_runs` / `acp_runtime_events` 表和索引。
- 新增根 `convex/aiSessions.ts``upsertRuntimeRun``getRuntimeRun``listRuntimeRuns`,按 `userId` 校验 identity 作用域。
- 修改 `scripts/run-convex-deploy.js`:优先以仓库根 `convex/` 作为 Convex deploy cwd,避免继续指向已不存在的 `wolai-frontend/`
- 后续修正:根 `convex/` 当前只包含 ACP session runtime store,不能直接覆盖完整本地 Convex 部署;`scripts/run-convex-deploy.js` 已改为只有根 `convex/` 具备完整 schema 时才用根目录,否则继续使用现有完整 Convex 源。为保持本机 `3210` 部署完整,已将 `aiSessions.ts``acp_runtime_*` schema 同步到现有完整 Convex 源并重新部署。
- 修改 `rust/crates/mnote-web/src/routes/hermes_client.rs`
- ACP `create_run` 写入 `aiSessions:upsertRuntimeRun`
- ACP `GET /client/sessions` 读取 `aiSessions:listRuntimeRuns`
- ACP `GET /client/sessions/{session_id}` 读取 runs/events
- ACP `POST /client/sessions/{session_id}/resume` 返回 Convex runtime history 恢复来源;
- ACP `DELETE /client/sessions/{session_id}``rename``auto-title` 写入 Convex store
- ACP `GET /client/sessions/search` 返回 Convex store 搜索摘要和 snippet
- ACP `stream_events` 将 SSE event 写入 `aiSessions:appendRuntimeEvent`
- `stream_events` 使用 clone lookup 保留 ACP payload,不再 `.remove(run_id)`
- 验证:
- `cd rust && cargo test -p mnote-web hermes_client_acp -- --nocapture`10 passed。
- `cd rust && cargo test -p mnote-web test_incoming_permission_request_gets_response -- --nocapture`1 passed。
- `cd rust && cargo test -p mnote-web acp_permission -- --nocapture`1 passed。
- `cd rust && cargo test -p mnote-web page_ai_uses_backend_acp_session_runtime_store -- --nocapture`1 passed。
- `cd rust && cargo test -p mnote-web acp_thought_delta_does_not_emit_message_delta -- --nocapture`1 passed。
- `node -c scripts/run-convex-deploy.js`:通过。
- `node scripts/run-convex-deploy.js`:部署入口已避免用根最小 `convex/` 覆盖完整本地 Convex schema`aiSessions.ts` 同步补到完整 Convex 源并部署到 `http://127.0.0.1:3210`,最终脚本验证为 `No indexes are deleted by this push`
- `node` 提取并 `new Function(SIDEBAR_TREE_JS)`:通过。
- `npx convex --version`1.39.1。
- `CONVEX_TMPDIR=/mnt/Data1T/mnote/.convex-tmp npx convex codegen --dry-run --typecheck try`:通过。
- Playwright browser smoke`127.0.0.1:3000` 页面内 fetch 创建两个用户的 ACP session,并验证 list/detail/resume/rename/delete 与用户隔离,全部通过;测试数据已调用 delete 清理。
- 回归收口:`tree_command_purge_uses_tree_command_protocol` 中 purge 请求未携带排序,返回 `sortOrder: null` 符合当前 route 语义,已同步修正测试断言。
- 回归收口:`hermes_client` / `hermes_tools` / `page_ai_workflow` 测试统一使用 crate 级 Hermes 环境锁,避免完整并发测试时互相修改 `HERMES_HOME` / `MNOTE_WEB_HERMES_*`
- `cd rust && cargo test -p mnote-web tree_command_purge_uses_tree_command_protocol -- --nocapture`1 passed。
- `cd rust && cargo test -p mnote-web hermes_tools_call_rejects_profile_disabled_tool -- --nocapture`1 passed。
- `cd rust && cargo test -p mnote-web hermes_client_profile_skill_and_memory_routes_use_local_bff_without_upstream -- --nocapture`1 passed。
- `cd rust && cargo test -p mnote-web block_edit_workflow_respects_disabled_markdown_edit_tool -- --nocapture`1 passed。
- `cd rust && cargo test -p mnote-web -- --nocapture`336 passed / 0 failedmain tests 0 passed / 0 faileddoc tests 4 ignored。
- 依赖记录:
-`package.json` / lockfile 增加 `convex@1.39.1`,用于根 `convex/` codegen/typecheck。
- `npm install --save-dev convex@1.39.1` 曾因既有 `node_modules` 布局报 `ENOTDIR`,未保留该失败命令产生的临时 symlink;随后用 pnpm 指定仓库 store 安装,并恢复被 pnpm 移入 `.ignored` 的既有 Playwright / electron-builder 目录。
@@ -29,6 +29,16 @@
- `PageAIApplyController` 后续应收口为 review session / tool executor / readback controller,不能成为第二套 agent 编排中心。
- 当前 `usedHermesRun=false` 的快路径只能理解为 deterministic shortcut,不代表 mnote 新建长期 agent runtime。
## 1.0.1 当前主路径修正(2026-05-18
`7-18``7-25` 修复后,本文中的页面 AI fast workflow 口径进一步收口:
- 普通正文编辑主路径是 `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` 作为当前主路径。
- `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` 为准。
## 1.1 当前执行状态(2026-05-16
已完成并有代码/测试/smoke 证据:
@@ -57,18 +67,19 @@
- 脚本:`/mnt/Data1T/mnote/scripts/task-page-block-ai-context-format-smoke.js`
- 证据:`/mnt/Data1T/mnote/tmp/page-block-ai-context-format-smoke/mp8ddr4n.json`,截图 `/mnt/Data1T/mnote/tmp/page-block-ai-context-format-smoke/mp8ddr4n-page.png`
- 覆盖:`mnote.doc.fetch scope=selection selectedBlockIds=["p_2"] format=page_xml/text``mnote.block.fetch format=page_xml/text`、manifest annotations、`mnote.page.save` destructive/yolo 粗粒度兜底定位、`mnote.doc.apply_block_ops allowedTargetBlockIds` 越界阻断 `mnote_block_target_out_of_scope`
- 边界:本轮只验证 `doc.apply_block_ops` 的 selection scope guard单个 `mnote.block.*` 写工具尚未完成 `allowedTargetBlockIds` 矩阵
- 边界:`doc.apply_block_ops` 单个 `mnote.block.*` 写工具 `allowedTargetBlockIds` selection scope guard 均已完成矩阵验证
- 页面 AI 快速块编辑第一阶段已完成:
- 新增 `/api/page-ai/block-edit-workflow`,简单块增删改不再默认进入 Hermes agent run。
- 对明确中文指令 `把「A」替换为「B」/ 在「C」后插入「D」/ 删除「E」` 已先由 mnote 本地 planner 生成 `mnote.doc.apply_block_ops` operations;无法解析时才进入小模型 operations 路径。
- 快路径失败时,除 `page_ai_workflow_not_block_edit` 外不再自动 fallback 到 `/api/hermes/client/runs`,避免一次请求叠加“快路径失败成本 + Hermes agent 成本”。
- 真实浏览器 smoke`/mnt/Data1T/mnote/scripts/task-page-ai-block-edit-workflow-smoke.js`,最新证据 `/mnt/Data1T/mnote/tmp/page-ai-block-edit-workflow-smoke/mp86uciu.json`
- 验证结果:`pageAiWriteVisible=788ms``usedFastWorkflow=true``usedHermesRun=false`;后端日志 `operation_source=local_rule``model_ms=0``apply_ms=42``total_ms=42`
- 详细 review`/mnt/Data1T/mnote/design/10-review/process/09-page-ai-fast-block-edit-runtime-review.md`
- 详细 review 历史快照`/mnt/Data1T/mnote/design/10-review/done/09-page-ai-fast-block-edit-runtime-review.md`
仍未完成,本文继续留在 `process/`
- 复杂块移动阻断矩阵还需要单独 smoke 覆盖标题带子块、列表项、表格、mindmap/resource
- 剩余真实页面 smoke 已拆到 `/mnt/Data1T/mnote/design/07-ai/process/7-16-page-block-ai-real-smoke-followup-matrix-v1.md` 持续跟踪;本文只保留总 checklist 和历史证据
- 复杂块移动阻断矩阵已补隔离 route smoke 覆盖标题带子块、列表项、表格、mindmap/resource;真实浏览器页面 smoke 仍未补。
- 持久审阅/preview UI 仍是后续可选项;当前默认 yolo 模式不做写入审批,`scope=selection``format=page_xml/text` 已进入工具与 PageAIContextBuilder 首版。
- PageAIIntentParser / PageAIOperationPlanner / PageAIOperationValidator / PageAIApplyController 仍需继续建设;当前本地 planner 只覆盖低歧义文本块增删改,不应被视为完整 AI 编辑 runtime。
@@ -102,7 +113,7 @@ fetch/find
验收:
- [ ] `rg -n "Tiptap AI Toolkit|tiptapRead|tiptapEdit|UniqueID|_hash|blockDocument" design/05-editor-mainline/{process,done} design/02-convex-rust-long-term-architecture/{process,done} design/07-ai/{process,done}` 能找到对应设计。
- [x] `rg -n "Tiptap AI Toolkit|tiptapRead|tiptapEdit|UniqueID|_hash|blockDocument" design/05-editor-mainline/{process,done} design/02-convex-rust-long-term-architecture/{process,done} design/07-ai/{process,done}` 能找到对应设计。
---
@@ -130,16 +141,20 @@ cargo test -p bridge-runtime editor_document
真实页面 smoke
- [ ] 登录 `http://localhost:3000/auth` 测试账号
- [ ] 新建页面 `TEST-AI-BLOCK-PROJECTION-<timestamp>`
- [ ] 输入段落、标题、todo、列表、mindmap/resource 占位至少各一个
- [ ] `/api/page-aggregate/:id` 保存响应到 `tmp/hermes-tester/<run-id>/page-aggregate.json`
- [ ] 断言所有普通可编辑块有 `blockId``revisionRef`
- [x] 登录测试账号并创建综合页面 `TEST-AI-BLOCK-TOOLS-<timestamp>`
- [x] 写入段落、标题、todo、列表、mindmap/resource 占位
- [x] `/api/page-aggregate/:id` 保存响应到 `tmp/hermes-tester/page-block-ai-tools-<run-id>/page-aggregate.json`
- [x] 断言所有普通可编辑块有 `blockId``revisionRef`
通过标准:
- [ ] 页面刷新后 block ids 不变化。
- [ ] projection 中 `blockCount` 与页面可编辑块数量大体一致;复杂块允许受限但必须有 warning
- [x] 页面刷新后 block ids 不变化。
- [x] projection 中 `blockCount` 与页面块数量一致;复杂块返回 `editable=false``unsupportedReason`
2026-05-18 补充真实页面 smoke 证据:
- 最新证据:`tmp/page-block-ai-tools-smoke/mpag4966.json`Page Aggregate`tmp/hermes-tester/page-block-ai-tools-mpag4966/page-aggregate.json`,截图:`tmp/page-block-ai-tools-smoke/mpag4966-page.png`
- 覆盖:Page Aggregate `blockProjectionVersion=1``projectionSource=documents.content``aggregateBlockCount=10`,与 `mnote.doc.fetch` 块顺序一致;刷新后 `afterRefreshBlockIds` 保持最终顺序;`table_1/mindmap_1/resource_1` 返回 `editable=false``unsupportedReason=复杂块暂不开放 AI 精确写入`
---
@@ -168,11 +183,11 @@ cargo test -p bridge-runtime doc_find
真实页面 smoke
- [ ] 使用 `TEST-AI-BLOCK-FETCH-<timestamp>` 页面
- [ ] `mnote.doc.fetch scope=full detail=with_ids` 读取整页。
- [ ] `mnote.doc.fetch scope=outline detail=with_ids` 只返回标题结构。
- [ ] `mnote.doc.find query=<唯一前缀>` 定位目标段落。
- [ ] 保存工具返回到 `tmp/hermes-tester/<run-id>/doc-fetch-find.json`
- [x] 使用综合页面 `TEST-AI-BLOCK-TOOLS-<timestamp>`
- [x] `mnote.doc.fetch scope=full detail=with_ids` 读取整页。
- [x] `mnote.doc.fetch scope=outline detail=with_ids` 只返回标题结构。
- [x] `mnote.doc.find query=<唯一前缀>` 定位目标段落。
- [x] 保存工具返回到 `tmp/hermes-tester/page-block-ai-tools-<run-id>/doc-fetch-find.json`
补充 smoke 证据:
@@ -181,9 +196,16 @@ cargo test -p bridge-runtime doc_find
通过标准:
- [ ] 不读取浏览器 DOM。
- [ ] `doc.find` 返回的 block id 能被 `block.fetch` 读取。
- [ ] 大页面默认分块或限制输出,不默认返回无限正文。
- [x] 不读取浏览器 DOM。
- [x] `doc.find` 返回的 block id 能被 `block.fetch` 读取。
- [x] 大页面默认分块或限制输出,不默认返回无限正文。
2026-05-18 补充证据:
- `mnote.doc.fetch``aggregate_value` / Page Aggregate block projection 构造上下文,不读取浏览器 DOM;代码入口:`rust/crates/mnote-web/src/hermes_tools/doc.rs`
- `hermes_tools_doc_find_and_block_fetch_use_block_projection` 覆盖 `doc.find` 返回 `heading_1` 后继续用同一个 id 调 `mnote.block.fetch`
- `mnote.doc.fetch` 默认 `maxBlocks=120`,运行时 clamp 到 `1..=240`;超限返回 `truncated/continuation/warnings`
- 真实 3000 smoke`tmp/page-block-ai-tools-smoke/mpaes2hg.json` 覆盖 `doc.fetch full` 读取 `heading_1/p_1/p_2/p_3``doc.fetch outline` 只返回 `heading_1``doc.find` 定位 `p_2``maxBlocks=2` 返回 `truncated=true`
---
@@ -210,19 +232,21 @@ cargo test -p mnote-web block_fetch
真实页面 smoke
- [ ]`doc.find` 结果选择一个段落 block。
- [ ]`mnote.block.fetch includeChildren=true contextBefore=1 contextAfter=1`
- [ ] 断言 before/after 只来自同父级。
- [x]`doc.find` 结果选择一个段落 block。
- [x]`mnote.block.fetch includeChildren=true contextBefore=1 contextAfter=1`
- [x] 断言 before/after 只来自同父级。
补充 smoke 证据:
- [x] `mnote.block.fetch blockId=p_2 format=page_xml/text contextBefore=1 contextAfter=1` 返回目标块 `p_2``revisionRef` 与同父级 before/after `p_1/p_3`。证据:`tmp/page-block-ai-context-format-smoke/mp8ddr4n.json`
- [x] `doc.find` 定位 `p_2` 后,`mnote.block.fetch includeChildren=true contextBefore=1 contextAfter=1` 回读 `p_2`,返回 `revisionRef`、同父级 before `p_1` 和 after `p_3`。证据:`tmp/page-block-ai-tools-smoke/mpag4966.json`
- [x] `table_1/mindmap_1/resource_1` 复杂块在真实 3000 smoke 中返回 `editable=false``unsupportedReason=复杂块暂不开放 AI 精确写入`。证据:`tmp/page-block-ai-tools-smoke/mpag4966.json`
通过标准:
- [x] block 文本与页面显示一致。
- [ ] `revisionRef` 可被后续 dry-run 使用。
- [ ] 复杂块不会伪装成完全可编辑。
- [x] `revisionRef` 可被后续 dry-run 使用。
- [x] 复杂块不会伪装成完全可编辑。
---
@@ -238,10 +262,15 @@ cargo test -p mnote-web block_fetch
- [x] 支持 `command=block_replace`
- [x] 支持 `command=block_insert_after`
- [x] 支持 `command=block_move_after` dry-run。
- [ ] 支持 `command=str_replace` 且多重匹配阻断。(当前只有基础 plan,仍需多重匹配阻断。)
- [x] `command=str_replace` 不再作为本 checklist 当前目标;普通正文 search/replace 已收口到 `mnote.doc.markdown_edit`,多重匹配/无法安全映射阻断由 markdown_edit 合同覆盖。
- [x] 缺少 `revision/conflictDetectionKey` 时只允许 dry-run。
- [x] 返回 `planId``diff``warnings``risk``blocked`
2026-05-18 口径修正:
- `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` 覆盖。
验证命令:
```bash
@@ -252,16 +281,21 @@ cargo test -p bridge-runtime doc_insert_blocks
真实页面 smoke
- [ ] 对目标段落执行 `block_replace dryRun=true`
- [ ] 对目标段落执行 `block_insert_after dryRun=true`
- [ ] 对两个同父级普通块执行 `block_move_after dryRun=true`
- [ ] dry-run 前后分别保存 `/api/page-aggregate/:id`,确认内容 hash 不变。
- [x] 对目标段落执行 `block_replace dryRun=true`
- [x] 对目标段落执行 `block_insert_after dryRun=true`
- [x] 对两个同父级普通块执行 `block_move_after dryRun=true`
- [x] dry-run 前后分别读取 `/api/page-aggregate/:id``mnote.doc.fetch`,确认 revision / conflictDetectionKey / 正文文本不变。
通过标准:
- [ ] dry-run 不写 Convex。
- [ ] plan 能解释 before/after。
- [ ] 不支持场景返回 `blocked=true`
- [x] dry-run 不写 Convex。
- [x] plan 能解释 before/after。
- [x] 不支持场景返回 `blocked=true`
2026-05-18 补充真实页面 smoke 证据:
- 最新证据:`tmp/page-block-ai-tools-smoke/mpag4966.json`,截图:`tmp/page-block-ai-tools-smoke/mpag4966-page.png`
- 覆盖:`plan_update.dry_run.replaceDiff.before/after``insertDiff.after/content``block_move_after p_3 -> p_1 dryRun` 不阻断、不支持 `p_3 -> p_3` 返回 `blocked=true`、dry-run 后 revision / Page Aggregate conflictDetectionKey / 正文文本不变。
---
@@ -290,20 +324,27 @@ cargo test -p bridge-runtime doc_replace_range_tool_executes_in_rust_runtime
真实页面 smoke
- [ ] 新建 `TEST-AI-BLOCK-REPLACE-<timestamp>` 页面
- [ ] 写入两段不同文本。
- [ ] `doc.find` 找到第二段 block id。
- [ ] `block.replace dryRun=true` 查看 plan。
- [ ] `block.replace dryRun=false` 替换第二段。
- [ ] 页面截图证明只替换第二段。
- [ ] `/api/page-aggregate/:id` 回读证明持久化。
- [ ] `mnote.doc.fetch` 再次证明 AI 可读回。
- [x] 使用综合页面 `TEST-AI-BLOCK-TOOLS-<timestamp>`
- [x] 写入两段不同文本。
- [x] `doc.find` 找到第二段 block id。
- [x] `block.replace dryRun=true` 查看 plan。
- [x] `block.replace dryRun=false` 替换第二段。
- [x] 页面截图和工具回读证明只替换第二段。
- [x] `/api/page-aggregate/:id` 回读证明持久化。
- [x] `mnote.doc.fetch` 再次证明 AI 可读回。
通过标准:
- [ ] 相邻块不变化。
- [ ] 目标 block id 保持不变。
- [ ] 旧 revision 写入返回 conflict。
- [x] 相邻块不变化。
- [x] 目标 block id 保持不变。
- [x] 旧 revision 写入返回 conflict。
2026-05-18 补充真实页面 smoke 证据:
- 最新证据:`tmp/page-block-ai-tools-smoke/mpag4966.json`,截图:`tmp/page-block-ai-tools-smoke/mpag4966-page.png`
- 覆盖:`block.replace.changedBlocks[0]={blockId:"p_2",op:"replace"}``targetBlockStillExists=true`,相邻块仍为 `第一段 mpag4966` / `第三段 mpag4966`,最终 `mnote.doc.fetch` 与 Page Aggregate 均回读到 `第二段已替换 mpag4966`Page Aggregate `aggregateRevision=2``aggregateConflictDetectionKey=tree_1779062903017_3:2`
- 冲突 / 幂等补充证据:`tmp/page-block-ai-conflict-idempotency-smoke/mpafrevs.json`,截图:`tmp/page-block-ai-conflict-idempotency-smoke/mpafrevs-page.png`
- 覆盖:首次 `block.replace` 携带最新 `revision/conflictDetectionKey/blockRevisionRef/idempotencyKey` 成功写入;重复同一 `idempotencyKey` replay 同一 `commandId` 且 revision 不再次递增;旧 `revision/conflictDetectionKey` 与旧 `blockRevisionRef` 均返回 HTTP `400` / `mnote_tool_conflict`,正文保持首次替换结果。
---
@@ -318,30 +359,46 @@ cargo test -p bridge-runtime doc_replace_range_tool_executes_in_rust_runtime
- [x] tool manifest 增加 `mnote.block.insert_after`
- [x] 输入必须包含 `anchorBlockId/revision/conflictDetectionKey/idempotencyKey/dryRun`
- [x] 新块 id 由 Rust runtime 分配。
- [ ] 支持单块和最多 20 个普通块插入。(当前最小闭环为单块插入。)
- [x] 支持单块和最多 20 个普通块插入。
- [x] 第一阶段支持 paragraph/heading/todo。
- [x] 返回 inserted block ids 和新 revision。
2026-05-18 补充证据:
- `rust/crates/mnote-web/src/hermes_tools/block.rs``mnote.block.insert_after` 已支持 `content` / `block` 单块兼容输入,以及 `blocks` 数组输入。
- `blocks` 运行时限制为 `1..=20`,超过 20 个返回 `mnote_tool_bad_request`;多块写入按输入顺序连续插在 anchor 后。
- `rust/crates/mnote-web/src/hermes_tools/manifest.rs` 的 manifest 已将 `content | block | blocks` 写为 `anyOf`,并声明 `blocks.minItems=1/maxItems=20`
- 新增回归:`hermes_tools_block_insert_after_accepts_multiple_blocks_and_returns_ids``hermes_tools_block_insert_after_rejects_more_than_twenty_blocks`
验证命令:
```bash
cargo test -p mnote-web block_insert_after
cargo test --manifest-path rust/Cargo.toml -p mnote-web hermes_tools_block_insert_after -- --nocapture
cargo test --manifest-path rust/Cargo.toml -p mnote-web hermes_tools -- --nocapture
cargo test -p bridge-runtime doc_insert_blocks_tool_emits_editor_commands
```
真实页面 smoke
- [ ] 新建 `TEST-AI-BLOCK-INSERT-<timestamp>` 页面
- [ ] 在第一段后插入 todo。
- [ ] 截图证明位置准确。
- [ ] 刷新页面后再次确认。
- [ ] `mnote.block.fetch` 能读取新 block id。
- [x] 使用综合页面 `TEST-AI-BLOCK-TOOLS-<timestamp>`
- [x] 在第一段后插入 todo。
- [x] 截图证明位置准确。
- [x] 刷新页面后再次确认。
- [x] `mnote.block.fetch` 能读取新 block id。
通过标准:
- [ ] 插入位置准确。
- [ ] 新 block id 不是 AI 自造未校验 id。
- [ ] 重复同一 `idempotencyKey` 不重复插入。
- [x] 插入位置准确。
- [x] 新 block id 不是 AI 自造未校验 id。
- [x] 重复同一 `idempotencyKey` 不重复插入。
2026-05-18 补充证据:
- `mnote.block.insert_after` 运行时分配 `ai_block_{request_id}` 或多块 `ai_block_{request_id}_{index}`,不会采用模型输入中的未校验 id。
- `hermes_tools_block_insert_after_accepts_multiple_blocks_and_returns_ids` 断言返回的 `insertedBlockIds` 均以 `ai_block_` 开头。
- 真实 3000 smoke`PLAYWRIGHT_CHROME_EXECUTABLE=/snap/bin/chromium MNOTE_UI_BASE_URL=http://127.0.0.1:3000 MNOTE_AUTH_BASE_URL=http://127.0.0.1:3000 node scripts/task-page-block-ai-tools-smoke.js` 通过。
- 最新证据:`tmp/page-block-ai-tools-smoke/mpag4966.json`,截图:`tmp/page-block-ai-tools-smoke/mpag4966-page.png`
- 覆盖:`insert_after` 插入 `todo` 块、`orderAfterInsert=["heading_1","p_1","ai_block_req_1779061213631_259","p_2","p_3"]``block.fetch` 可读取新块、重复 `idempotencyKey` replay 同一 `commandId` 且 revision 不再次递增、刷新后插入文本仍可见。
---
@@ -355,32 +412,43 @@ cargo test -p bridge-runtime doc_insert_blocks_tool_emits_editor_commands
- [x] tool manifest 增加 `mnote.block.move_after`
- [x] `dryRun=true` 支持同父级叶子块 diff。
- [ ] `dryRun=false` 前先继续阻断所有复杂块。(已有同父级/叶子/类型/editable/self 阻断,复杂块矩阵 smoke 补。)
- [x] `dryRun=false` 前先继续阻断所有复杂块。(已有同父级/叶子/类型/editable/self 阻断,复杂块矩阵 route smoke 补。)
- [x] 检查 `blockRevisionRef``anchorRevisionRef`
- [x] 阻断移动到自身、移动到子树、跨页面移动。
- [x] 返回 from/to parent/order。
2026-05-18 补充证据:
- `mnote.block.move_after` 保持同父级、叶子块、可移动类型、editable、自身移动阻断条件。
- 新增隔离 fixture route smoke`hermes_tools_block_move_after_blocks_complex_and_nested_blocks`,覆盖标题带子块、列表项、表格、mindmap、resource,均返回 `blocked=true``block_move_after_blocked`
验证命令:
```bash
cargo test -p mnote-web block_move_after
cargo test --manifest-path rust/Cargo.toml -p mnote-web hermes_tools_block_move_after_blocks_complex_and_nested_blocks -- --nocapture
cargo test --manifest-path rust/Cargo.toml -p mnote-web hermes_tools -- --nocapture
cargo test -p mnote-editor-core command_executor
```
真实页面 smoke
- [ ] 新建 `TEST-AI-BLOCK-MOVE-<timestamp>` 页面,包含三段普通段落。
- [ ] dry-run 移动第三段到第一段后。
- [ ] 正式执行同父级移动。
- [ ] 截图证明顺序为第一段、第三段、第二段。
- [ ] `mnote.doc.fetch` 回读顺序一致。
- [ ] 对标题带子块、列表项、表格、mindmap 执行 move dry-run,必须返回 blocked。
- [x] 使用综合页面 `TEST-AI-BLOCK-TOOLS-<timestamp>`,包含三段普通段落。
- [x] dry-run 移动第三段到第一段后。
- [x] 正式执行同父级移动。
- [x] 截图证明顺序为第一段、第三段、第二段。
- [x] `mnote.doc.fetch` 回读顺序一致。
- [x] 对标题带子块、列表项、表格、mindmap 执行 move dry-run,必须返回 blocked。
通过标准:
- [ ] moving block id 保持不变。
- [ ] 同父级顺序正确。
- [ ] 复杂块不被误移动。
- [x] moving block id 保持不变。
- [x] 同父级顺序正确。
- [x] 复杂块不被误移动。
2026-05-18 补充真实页面 smoke 证据:
- 最新证据:`tmp/page-block-ai-tools-smoke/mpag4966.json`,截图:`tmp/page-block-ai-tools-smoke/mpag4966-page.png`
- 覆盖:`heading_parent/list_item_1/table_1/mindmap_1/resource_1``plan_update block_move_after dryRun=true` 与正式 `mnote.block.move_after dryRun=false` 均返回 `blocked=true` / `block_move_after_blocked`,阻断后 `revisionAfterBlocked=1` 且正文不变化;普通 `block.move_after.dryRunBlocked=false`,正式写入后顺序为 `heading_1,p_1,p_3,ai_block_...,p_2,...``movingBlockStillExists=true`
---
@@ -411,8 +479,9 @@ cargo test -p mnote-editor-core command_executor
本 checklist 不能移动到 `done/`,直到:
- [ ] Phase 1 到 Phase 6 全部完成。
- [ ] Phase 7 至少完成 dry-run 和阻断规则;若真实 move 未完成,`7-9` 必须仍标注受限。
- [ ] 每个写工具都有真实页面 smoke 证据。
- [ ] 失败项已经写入 `bugs/07-ai/process/` 或真实 owner 分类。
- [ ] `mnote.page.save` 不再被任何设计描述为精确块编辑主入口。
- [x] Phase 1 到 Phase 6 全部完成。
- [x] Phase 7 至少完成 dry-run 和阻断规则;若真实 move 未完成,`7-9` 必须仍标注受限。
- [x] 每个写工具都有真实页面 smoke 证据。
- [x] 失败项已经写入 `bugs/07-ai/process/` 或真实 owner 分类。
- [x] `mnote.page.save` 不再被任何设计描述为精确块编辑主入口。
- [ ] Phase 8 UI / Review Mode 仍按 Phase C 边界冻结;若后续进入实施,需要另补 UI smoke。
@@ -7,7 +7,7 @@
> 本稿目的:修正“页面 AI 快速块编辑”后续方向,明确 mnote 不再建设独立 AI agent runtimemnote 只建设 Hermes 可消费的编辑工具路由、工具 manifest、上下文冻结、dry-run/review 和 Rust 写入安全边界。
>
> 关联文档:
> - `/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-10-page-block-ai-tooling-execution-checklist-v1.md`
> - `/mnt/Data1T/mnote/design/07-ai/done/7-9-page-block-ai-tooling-roadmap-v1.md`
> - `/mnt/Data1T/mnote/design/07-ai/done/7-6-mnote-hermes-plugin-tool-contract-v1.md`
@@ -17,6 +17,16 @@
---
## 0. 2026-05-18 状态更新
本稿仍作为 Hermes 工具路由与审阅面设计保留在 `process/`,但以下口径已经更新:
- `/api/page-ai/block-edit-workflow` 当前不再以 `local_rule -> apply_block_ops` 作为主路径;简单正文编辑主路径已切到模型生成 markdown `search/replace``full_content`,再调用 `mnote.doc.markdown_edit`
- `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。
- 当前已闭合缺陷见 `bugs/07-ai/done/7-18``7-25`
## 1. 本轮结论
页面 AI 编辑卡顿的根因不是“Rust apply 慢”,而是模型和工具之间缺少稳定、低歧义、可审计的编辑命令面:
@@ -348,6 +358,96 @@ stale
第一阶段可以保留 yolo,但仍应让工具返回 review-compatible 数据结构,避免后续 UI 重写。
最小稳定合同:
```json
{
"schema": "mnote.page_ai_review_session.v1",
"sessionId": "review_01",
"workspaceId": "tree_workspace",
"documentId": "tree_doc",
"runId": "run_01",
"toolCallId": "tool_01",
"traceId": "trace_01",
"state": "awaiting_user",
"mode": "review",
"source": {
"runtimeOwner": "mnote-web",
"writeOwner": "rust-runtime-kernel",
"toolName": "mnote.doc.markdown_edit"
},
"base": {
"revision": 12,
"conflictDetectionKey": "body:12:hash",
"allowedTargetBlockIds": ["p_1"]
},
"proposal": {
"format": "markdown",
"operations": [
{
"op": "replace",
"search": "旧文本",
"replace": "新文本"
}
],
"fullContent": null
},
"preview": {
"dryRun": true,
"changedBlocks": [
{
"blockId": "p_1",
"before": "旧文本",
"after": "新文本",
"revisionRef": "pageRev:12:block:p_1"
}
],
"diff": [],
"warnings": [],
"risk": "low",
"blocked": false
},
"actions": {
"accept": {
"requiresFreshRevision": true,
"requiresIdempotencyKey": true
},
"reject": true,
"retry": {
"allowed": true,
"requiresNewToolCallId": true
},
"abort": true
},
"audit": {
"createdAt": "2026-05-18T00:00:00Z",
"createdBy": "actor_01"
}
}
```
状态语义:
- `draft`:已创建 session,但尚未生成 dry-run preview。
- `planning`:正在构造 tool args 或请求模型生成候选。
- `previewing`:正在执行 `dryRun=true`
- `awaiting_user`preview 已完成,等待 accept / reject / retry / abort。
- `accepted`:用户已确认,等待正式 apply。
- `rejected`:用户拒绝,本 session 不可再写入。
- `applying`accept 后正在正式写入。
- `applied`:正式写入已完成,并已通过 Page Aggregate / `mnote.doc.fetch` 回读。
- `failed`preview 或 apply 失败,需保留 structured error / hint。
- `aborted`:用户或系统中断,不能继续写入。
- `stale`accept 时 revision / conflictDetectionKey / revisionRef 过期,必须重新 preview,不能直接 apply。
动作约束:
- `accept` 必须重新读取 Page Aggregate,并校验 `revision/conflictDetectionKey/revisionRef`;过期时转 `stale`
- `accept` 必须提供新的或既有合法 `idempotencyKey`,重复提交必须 replay 同一结果。
- `reject``abort` 不能产生写入。
- `retry` 不能复用旧 `toolCallId` 伪装成同一次写入;必须生成新 proposal 或新 dry-run preview。
- yolo 模式可以跳过 `awaiting_user` UI,但仍应生成同构的 review-compatible audit 数据。
---
## 6. 关键流程
@@ -508,7 +608,7 @@ profile disabled mnote.block.fetch
### Phase DReview session
- [ ] 定义 `mnote.page_ai_review_session.v1`
- [x] 定义 `mnote.page_ai_review_session.v1` 最小 schema 与状态机边界(2026-05-18 已补;Phase C UI 仍冻结)
- [ ] `mnote.doc.plan_update``mnote.doc.apply_block_ops dryRun=true` 返回 review-compatible draft。
- [ ] 页面 AI UI 展示 diff/warnings/risk/blocked。
- [ ] accept/reject/retry/abort 可用。
@@ -0,0 +1,181 @@
# 7-16 [process] Page Block AI 真实页面 Smoke 后续矩阵 v1
> 创建时间:2026-05-18
>
> 当前状态:`PROCESS`
>
> 来源:
> - `/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-12-page-ai-hermes-tool-routing-and-review-surface-v1.md`
> - `/mnt/Data1T/mnote/design/10-review/process/08-kernel-architecture-next-priority-review-and-checklist.md`
---
## 1. 目的
`7-10` 已完成大量工具合同、Rust route 测试和部分真实 3000 smoke,但仍有一组“必须真实页面验证”的矩阵项未完成。
本文只承接这些剩余 smoke,不新增 AI 功能面,不改变当前主路径:
- 简单正文编辑主路径仍是 `mnote.doc.markdown_edit`
- `mnote.block.*` 仍只作为结构性辅助。
- `mnote.page.save` 仍只作为页面级粗粒度兜底。
- Phase C review / streaming apply 仍冻结,当前只验证基础合同和安全边界。
---
## 2. 当前已具备的前置证据
- `mnote.doc.fetch scope=selection format=page_xml/text``mnote.block.fetch format=page_xml/text`、manifest annotations、`mnote.doc.apply_block_ops allowedTargetBlockIds` 越界阻断已有真实 3000 smoke
- `tmp/page-block-ai-context-format-smoke/mp8ddr4n.json`
- `tmp/page-block-ai-context-format-smoke/mp8ddr4n-page.png`
- `mnote.block.replace` stale revision / stale blockRevisionRef / idempotency replay 已有真实 3000 smoke
- `scripts/task-page-block-ai-conflict-idempotency-smoke.js`
- 证据记录在 `08` 的 Page Block AI Tooling 章节。
- `mnote.block.insert_after` 多块插入和 20 个边界已有 Rust route 回归:
- `cargo test --manifest-path rust/Cargo.toml -p mnote-web hermes_tools_block_insert_after -- --nocapture`
- `mnote.block.move_after` 复杂块阻断矩阵已有隔离 route smoke
- `cargo test --manifest-path rust/Cargo.toml -p mnote-web hermes_tools_block_move_after_blocks_complex_and_nested_blocks -- --nocapture`
---
## 3. 后续真实页面 Smoke 矩阵
### 3.1 Page Aggregate Block Projection
- [x] 登录测试账号并创建综合页面 `TEST-AI-BLOCK-TOOLS-<timestamp>`
- [x] 写入段落、标题、todo、列表、mindmap/resource 占位。
- [x]`/api/page-aggregate/:id` 并保存响应到 `tmp/hermes-tester/page-block-ai-tools-<run-id>/page-aggregate.json`
- [x] 断言所有普通可编辑块有 `blockId``revisionRef`
- [x] 页面刷新后 block ids 不变化。
- [x] projection 中 `blockCount` 与页面块数量一致;复杂块返回 `editable=false``unsupportedReason`
证据(2026-05-18):
- JSON`tmp/page-block-ai-tools-smoke/mpag4966.json`
- Page Aggregate`tmp/hermes-tester/page-block-ai-tools-mpag4966/page-aggregate.json`
- 覆盖:Page Aggregate `blockProjectionVersion=1``projectionSource=documents.content``aggregateBlockCount=10`,与 `mnote.doc.fetch` 块顺序一致;刷新后 `afterRefreshBlockIds` 保持最终顺序;`table_1/mindmap_1/resource_1` 返回 `editable=false``unsupportedReason=复杂块暂不开放 AI 精确写入`
### 3.2 `mnote.doc.fetch` / `mnote.doc.find`
- [x] 使用综合页面 `TEST-AI-BLOCK-TOOLS-<timestamp>`
- [x] `mnote.doc.fetch scope=full detail=with_ids` 读取整页。
- [x] `mnote.doc.fetch scope=outline detail=with_ids` 只返回标题结构。
- [x] `mnote.doc.find query=<唯一前缀>` 定位目标段落。
- [x] 保存工具返回到 `tmp/hermes-tester/page-block-ai-tools-<run-id>/doc-fetch-find.json`
- [x] 确认读取不依赖浏览器 DOM。
- [x] `doc.find` 返回的 block id 能被 `block.fetch` 读取。
- [x] 大页面默认分块或限制输出,不默认返回无限正文。
证据(2026-05-18):
- 命令:`PLAYWRIGHT_CHROME_EXECUTABLE=/snap/bin/chromium MNOTE_UI_BASE_URL=http://127.0.0.1:3000 MNOTE_AUTH_BASE_URL=http://127.0.0.1:3000 node scripts/task-page-block-ai-tools-smoke.js`
- JSON`tmp/page-block-ai-tools-smoke/mpag4966.json`
- 工具返回:`tmp/hermes-tester/page-block-ai-tools-mpag4966/doc-fetch-find.json`
- 覆盖:`doc.fetch.initial` 读取 `heading_1/p_1/p_2/p_3` 等 10 个块;`doc.fetch scope=outline` 只返回 `heading_1/heading_parent``doc.find query="第二段 <suffix>"` 定位 `p_2``maxBlocks=2` 返回 `truncated=true`
- 静态边界:`mnote.doc.fetch` 从 Page Aggregate block projection 构建上下文,不读取浏览器 DOM;默认 `maxBlocks=120` 且 clamp 到 `1..=240`
### 3.3 `mnote.block.fetch`
- [x]`doc.find` 结果选择一个段落 block。
- [x]`mnote.block.fetch includeChildren=true contextBefore=1 contextAfter=1`
- [x] 断言 before/after 只来自同父级。
- [x] `revisionRef` 可被后续 dry-run 使用。
- [x] 复杂块不会伪装成完全可编辑。
证据(2026-05-18):
- JSON`tmp/page-block-ai-tools-smoke/mpag4966.json`
- 覆盖:`block.fetch` 读取 `p_2`,返回 `revisionRef=pageRev:1:block:p_2:...`,同父级 `before=["p_1"]``after=["p_3"]``table_1/mindmap_1/resource_1` 均返回 `editable=false``unsupportedReason=复杂块暂不开放 AI 精确写入`
### 3.4 `mnote.doc.plan_update` / dry-run
- [x] `command=str_replace` 不再作为当前 `plan_update` 目标;普通正文 search/replace 已由 `mnote.doc.markdown_edit` 合同承接。
- [x] 对目标段落执行 `block_replace dryRun=true`
- [x] 对目标段落执行 `block_insert_after dryRun=true`
- [x] 对两个同父级普通块执行 `block_move_after dryRun=true`
- [x] dry-run 前后分别读取 `/api/page-aggregate/:id``mnote.doc.fetch`,确认 revision / conflictDetectionKey / 正文文本不变。
- [x] dry-run 不写 Convex。
- [x] plan 能解释 before/after。
- [x] 不支持场景返回 `blocked=true`
证据(2026-05-18):
- JSON`tmp/page-block-ai-tools-smoke/mpag4966.json`
- 覆盖:`plan_update.dry_run.replaceDiff.before/after``insertDiff.after/content`、不支持 `move_after p_3 -> p_3` 返回 `blocked=true`、dry-run 后 `revisionAfterDryRun=1` 且正文未变化。
### 3.5 `mnote.block.replace`
- [x] 使用综合页面 `TEST-AI-BLOCK-TOOLS-<timestamp>`
- [x] 写入两段不同文本。
- [x] `doc.find` 找到第二段 block id。
- [x] `block.replace dryRun=true` 查看 plan。
- [x] `block.replace dryRun=false` 替换第二段。
- [x] 页面截图和工具回读证明只替换第二段。
- [x] `/api/page-aggregate/:id` 回读证明持久化。
- [x] `mnote.doc.fetch` 再次证明 AI 可读回。
- [x] 相邻块不变化。
- [x] 目标 block id 保持不变。
- [x] 旧 revision 写入返回 conflict。
证据(2026-05-18):
- JSON`tmp/page-block-ai-tools-smoke/mpag4966.json`
- 截图:`tmp/page-block-ai-tools-smoke/mpag4966-page.png`
- 覆盖:`block.replace.changedBlocks[0]={blockId:"p_2",op:"replace"}``targetBlockStillExists=true``adjacentTexts=["第一段 mpag4966","第三段 mpag4966"]``mnote.doc.fetch` 与 Page Aggregate 均回读到 `第二段已替换 mpag4966`Page Aggregate `aggregateRevision=2``aggregateConflictDetectionKey=tree_1779062903017_3:2`
- 冲突 / 幂等补充 JSON`tmp/page-block-ai-conflict-idempotency-smoke/mpafrevs.json`,截图:`tmp/page-block-ai-conflict-idempotency-smoke/mpafrevs-page.png`
- 覆盖:首次 `block.replace` 写入 `p_2` 后,重复 `idempotencyKey=idem_conflict_replace_mpafrevs` replay 同一 `commandId=page_body_save_req_1779062303997_525``revisionAfterFirst=2``revisionAfterReplay=2`;旧 `revision/conflictDetectionKey` 与旧 `blockRevisionRef` 均返回 HTTP `400` / `mnote_tool_conflict`,最终文本保持 `第二段首次替换 mpafrevs`
### 3.6 `mnote.block.insert_after`
- [x] 使用综合页面 `TEST-AI-BLOCK-TOOLS-<timestamp>`
- [x] 在第一段后插入 todo。
- [x] 截图证明位置准确。
- [x] 刷新页面后再次确认。
- [x] `mnote.block.fetch` 能读取新 block id。
- [x] 插入位置准确。
- [x] 新 block id 不是 AI 自造未校验 id。
- [x] 重复同一 `idempotencyKey` 不重复插入。
证据(2026-05-18):
- 命令:`PLAYWRIGHT_CHROME_EXECUTABLE=/snap/bin/chromium MNOTE_UI_BASE_URL=http://127.0.0.1:3000 MNOTE_AUTH_BASE_URL=http://127.0.0.1:3000 node scripts/task-page-block-ai-tools-smoke.js`
- JSON`tmp/page-block-ai-tools-smoke/mpag4966.json`
- 截图:`tmp/page-block-ai-tools-smoke/mpag4966-page.png`
- 说明:该 smoke 使用 `TEST-AI-BLOCK-TOOLS-*` 综合页面,不是单独 `TEST-AI-BLOCK-INSERT-*` 页面;已覆盖 todo 插入、插入后顺序、block.fetch 回读、刷新后可见和重复 idempotencyKey replay。
### 3.7 `mnote.block.move_after`
- [x] 使用综合页面 `TEST-AI-BLOCK-TOOLS-<timestamp>`,包含三段普通段落。
- [x] dry-run 移动第三段到第一段后。
- [x] 正式执行同父级移动。
- [x] 截图证明顺序为第一段、第三段、第二段。
- [x] `mnote.doc.fetch` 回读顺序一致。
- [x] 对标题带子块、列表项、表格、mindmap 执行 move dry-run,必须返回 blocked。
- [x] moving block id 保持不变。
- [x] 同父级顺序正确。
- [x] 复杂块不被误移动。
证据(2026-05-18):
- JSON`tmp/page-block-ai-tools-smoke/mpag4966.json`
- 截图:`tmp/page-block-ai-tools-smoke/mpag4966-page.png`
- 覆盖:`heading_parent/list_item_1/table_1/mindmap_1/resource_1``plan_update block_move_after dryRun=true` 与正式 `mnote.block.move_after dryRun=false` 均返回 `blocked=true` / `block_move_after_blocked`,阻断后 `revisionAfterBlocked=1` 且正文不变化;普通 `p_3 -> p_1` 正式移动后顺序为 `heading_1,p_1,p_3,ai_block_...,p_2,...``movingBlockStillExists=true`
### 3.8 UI / Review Mode 边界
- [ ] Hermes tool event UI 展示 `plan/diff/warnings/risk`
- [ ] 本地 preview/suggestion 与持久 comment/tracked-change 分开。
- [ ] 协作可见审阅必须另走正式 comment/history/tracked-change 设计。
- [ ] 用户能看到 AI 将改哪个 block。
- [ ] `blocked=true` 的工具调用不会出现写入按钮。
- [ ] preview 不写入正式 comment/history。
---
## 4. 验证原则
- 所有真实页面 smoke 必须使用测试账号,优先从 `http://localhost:3000/auth` 快速登录。
- 证据必须保留 JSON 和截图,默认放入 `tmp/hermes-tester/<run-id>/` 或专项 `tmp/page-block-ai-*` 目录。
- 发现失败时按 owner 写入 `bugs/<category>/process/`,不要只在本矩阵中留描述。
- 不用此矩阵扩展新 AI 功能;只补真实页面证据和安全边界。
@@ -0,0 +1,580 @@
# 7-27 [process] 在线 Markdown_edit 以最终 Markdown 为写回真源 v2
> 更新:2026-05-18v2:整合 CLI Main 参考实现分析,确认方向,补充见解)
>
> 当前状态:`PROCESS`
>
> 关联缺陷:`bugs/07-ai/done/7-24-markdown-edit-online-write-does-not-use-final-markdown-v1.md`
> `bugs/07-ai/done/7-25-reasonix-block-edit-workflow-empty-block-ops-after-markdown-match-v1.md`
> `bugs/07-ai/done/7-17-markdown-edit-same-block-multi-op-overwrite-v1.md`
>
> 上位设计:`design/07-ai/process/7-14-online-local-ai-markdown-editing-convergence-v1.md`
>
> 参考实现:`design/05-editor-mainline/reference-code/cli-main/shortcuts/doc/`str_replace skill
> `rust/crates/mnote-web/src/routes/local_markdown_parser.rs`(已有 GFM→blocks 解析器)
---
## 0. 参考实现分析:CLI MainLark Doc)怎么做?
### 0.1 实现结构
CLI Main 的 `docs +update --api-version v2``str_replace``block_replace``block_insert_after` 等命令以 PUT 请求发送到 Lark OpenAPI
```go
// docs_update_v2.go:140-157
body := map[string]interface{}{
"format": runtime.Str("doc-format"),
"command": cmd, // "str_replace" | "block_replace" | ...
}
body["pattern"] = runtime.Str("pattern") // str_replace 的搜索文本
body["content"] = runtime.Str("content") // 替换文本或新内容
body["block_id"] = blockID // block_* 操作的目标块
body["revision_id"] = runtime.Int("revision-id")
// API: PUT /open-apis/docs_ai/v1/documents/{id}
```
**关键:`str_replace` 只传 `pattern` + `content`,不传 `final_md`。** 服务端(Lark OpenAPI)在收到 `command: "str_replace"` 后,由服务端负责在文档的 XML/block 存储中找到匹配的文本并替换。
### 0.2 与 mnote 的架构对应
| CLI Main (Lark Doc) | mnote | 角色 |
|---|---|---|
| Lark OpenAPI 服务端 | Rust `doc_markdown_edit` + `execute_page_body_save` | 执行文本替换→block 持久化 |
| `str_replace` API 端点 | `mnote.doc.markdown_edit` 工具 | AI 调用的文本级编辑入口 |
| 块操作 API 端点 | `mnote.doc.apply_block_ops` + `mnote.block.*` | AI 调用的块级编辑入口 |
| CLI 客户端(lark-cli | Hermes agent / page_ai_workflow | AI 编排层,只产生`{pattern, content}`对 |
**mnote 的 Rust kernel 就是那个"服务端"。** 所以在 Rust 层实现 markdown→block content 的转换是正确的方向——这不是"在客户端做服务端的事",而是 mnote 的 Rust 层本来就承担服务端职责。
### 0.3 CLI Main 对我们设计的启发
1. **`str_replace` 是不可拆分的原子操作** — CLI Main 的 `str_replace` 由服务端完整执行。一旦 `pattern` + `content` 送出,客户端不需要处理块映射。**mnote 的 `doc_markdown_edit` 也应该是一条完整的原子路径**:接收 `operations` → 搜索替换 → 产生 final_md → **直接转换 final_md 为 blocks 并写回**。不应该把 operations 暴露给下游做二次推导。
2. **文档中的所有纯文本搜索替换只需一条 API 调用** — CLI Main 不支持在一次请求中组合多个 `str_replace`。mnote 支持多条 operation 是更灵活的设计,但兑现这个灵活性的前提是:**多条 operation 在服务端累积应用到 `md` 后,只产出一组最终的 block content 写回**,而不是每条 operation 独立映射到 block。
3. **`pattern` 不限制为精确文本** — CLI Main 的 `str_replace` 文档鼓励 Markdown 模式下的"前缀...后缀"省略号语法(`"start...end"`),允许模糊定位。这和我们已有的四级匹配策略(精确→忽略空白→段落 fuzzy→失败)方向一致。
4. **CLI Main 在客户端做预检查**`docs_update_check.go` 中有 `checkDocsUpdateReplaceMultilineMarkdown`,在发送请求前就检查 Markdown 中的空行是否会违反服务端的单块内文本替换限制。**同理,mnote 在转换 final_md 时也应该做预检**:发现跨块内容变化时,要么走 GFM 解析器重建多块 content,要么清楚告知 AI "这条 operation 需要多块替换"。
5. **mnote 的 final_md 方法比 CLI Main 更可靠** — CLI Main 把 `pattern` 发给服务端,由服务端重新执行匹配。如果服务端的匹配策略与模型预期的不同,结果会意外。mnote 的做法(在 Rust 层计算 final_md,然后用 final_md 生成 block content)把匹配阶段和写回阶段解耦——匹配结果对 AI 可见(文档中说"已验证将 X 替换为 Y"),不会出现「客户端匹配成功、服务端匹配失败」的不一致。
### 0.4 与 CLI Main 的根本差异:为何不能照搬
CLI Main 的方案(客户端只传 `pattern` + `content`,服务端做匹配+转换)依赖 Lark OpenAPI 对文档 block 存储的完全控制。mnote 的 Convex 后台(`documents:updateContent`)只接受完整的 blocks 数组作为 `content`,没有 `str_replace` 端点。
**这迫使 mnote 的 Rust 层必须自己做 final_md → block content 的转换。这个转换就是我们的"服务端逻辑",是正确且必要的。**
---
---
## 1. 问题
### 1.1 当前架构
`mnote.doc.markdown_edit` 在在线 Convex 文档路径下有两套编辑结果:
```
搜索替换 → 最终 md ✅(正确的结果)
build_block_ops_from_markdown_edit(blocks, operations, applied)
↓ 二次推导
doc_apply_block_ops → page.body.save
```
- **第一层**: `search_replace(&md, &search, &replace)` — 在完整的 markdown 字符串上顺序执行操作,支持精确/忽略空白/段落 fuzzy 四级匹配。
- **第二层**: `build_block_ops_from_markdown_edit` — 不从最终 `md` 提取修改,而是从原始 blocks 和原始 operations 重新推导每次命中。
**最终 markdown 被丢弃了。** 写入层使用的不是计算好的文本结果,而是从原始 operations 反查 blocks 的二次推导。
### 1.2 由此导致的已知缺陷
| 缺陷 | 表现 | 根因 |
|------|------|------|
| 7-17 同块多操作覆盖 | 两次 op 命中同块 → 第二 op 从原始文本计算,覆盖第一 op | 推导层不累积 |
| 7-24 在线不以最终 md 为真源 | `full_content` 无法映射到块 operations → 全部拒绝 | 推导层不支持全文替换 |
| 7-25 命中后空 block_ops | markdown 层匹配成功,block 映射失败 → 空 ops | 两套匹配规则不一致 |
当前「修复」是安全降级:推导失败从伪成功变成明确错误码,但推导本身仍然存在。
### 1.3 为什么推导不可靠
`build_block_ops_from_markdown_edit` 当前采用「每 operation 从 `block.text` 反查 + 维护 block_states 累积文本」策略 [doc.rs:1048-1103],已在共享匹配函数和累积方面改进,但根本问题仍在:
1. **full_content 模式必须跳过推导**:全文替换的最终 md 与原始 blocks 没有逐 op 对应关系
2. **跨块替换不可映射**:一条 `search: "TODO"` 命中多个块时,推导层只产生一次 replace op
3. **格式变化丢失**heading/todo 前缀在 markdown 中记录了结构语义,推导层只传文本
---
## 2. 设计目标
### 2.1 核心原则
> **最终 markdown 是写回的唯一真源。** 文本层算出的结果不经二次推导直接落地。
### 2.2 目标范围
- 在线 Convex 文档的 `mnote.doc.markdown_edit` 以最终 `md` 为唯一真源生成 `page.body.save``content` 载荷
- 本地文件路径不变(已经以最终 md 直接 `fs::write`
- `mnote.block.*` / `mnote.doc.apply_block_ops` 不做改动
- `full_content` 模式不再被拒绝,支持
- 复杂块(mindmap、resource、table、image)在往返中保持不变
### 2.3 非目标(Phase C 范畴)
- 流式 apply(见 7-14 §7
- suggest/review 模式
- 本地 `.md` 文件的块标识持久化(本地文件没有 block identity
---
## 3. 方案:Markdown → Block Content 直接写回
### 3.1 流程图(替换后)
```
搜索替换 → 最终 md ✅
parse_final_markdown_to_blocks(&md, &original_blocks)
[blocks with preserved IDs + metadata + new blocks]
build_page_content(&original_body_content, &parsed_blocks)
execute_page_body_save_from_aggregate(state, context, input, &aggregate, next_content, changed_blocks)
```
### 3.2 数据流
```
原始 blocks (来自 aggregate.body.blockDocument.blocks):
b1: { type: "paragraph", text: "第一段", revisionRef: "r1" }
b2: { type: "heading", text: "标题", revisionRef: "r2", props: { level: 2 } }
b3: { type: "resource", ... } ← 复杂块,全文不做修改
↓ blocks_to_markdown (当前,增强前)
当前 md (include_ids=true):
第一段 <!-- block:b1 -->
标题 <!-- block:b2 -->
[resource: 资源块] <!-- block:b3 -->
↓ 搜索替换
最终 md:
新第一段 <!-- block:b1 -->
新标题 <!-- block:b2 -->
[resource: 资源块] <!-- block:b3 -->
↓ parse_final_markdown_to_blocks (新增)
解析后的 blocks:
b1: { type: "paragraph", text: "新第一段", revisionRef: "r1" } ← 文本更新,其他不变
b2: { type: "heading", text: "新标题", revisionRef: "r2", props: { level: 2 } }
b3: { type: "resource", ... } ← 保持原始内容不动(复杂块不可编辑)
↓ build_page_content (新增)
最终 content (aggregate.body.content 格式):
[b1_legacy, b2_legacy, b3_legacy] ← 复用原始块的非文本属性
```
### 3.3 组件设计
#### 3.3.1 `blocks_to_markdown` 增强(已有函数,扩展)
当前只输出 `<!-- block:ID -->`,需要扩展为带类型和 revisionRef
```rust
// 当前:
format!("{text} <!-- block:{id} -->")
// 增强后:
format!("{text} <!-- block:{id}:{block_type}:{revision_ref} -->")
heading `level`
```rust
// heading 块:
format!("{text} <!-- block:{id}:heading:{revision_ref}:level={level} -->")
```
解析器还原后从 `level` 字段恢复正确的 heading 级别。
```
对 heading 和 todo 等结构化块,前缀已经正确输出(`## `、`- [ ] `),解析器据此还原类型。
对复杂块(mindmap、resource、table、image)——即 `is_editable == false` 或在 `block_projection_blocks` 中标记了 `unsupportedReason` 的块——输出特殊标记使其不会被解析器修改:
```text
[mnote-raw-block:block_id] <!-- block:{id}:{block_type}:{revision_ref} -->
```
然后写回层把这些块从原始 blocks 中按 ID 复制,不做任何修改。
#### 3.3.2 `parse_final_markdown_to_blocks`(新增函数)
签名:
```rust
/// 解析最终 markdown 为块列表,保留块元数据
///
/// * `final_md` — 搜索替换后的 markdown
/// * `original_blocks` — 从 aggregate.body.blockDocument.blocks 读取的原始块
///
/// 返回解析后的块列表,每个块包含后续写回所需的所有字段
fn parse_final_markdown_to_blocks(
final_md: &str,
original_blocks: &[Value],
) -> Vec<ParsedBlockInfo> {
// 1. 利用 comrak 或逐行解析提取
// - `<!-- block:id:type:rev -->` 中的块元数据
// - 对带 ID 的块:保留原始块的属性
// - 对无 ID 的文本块:标记为"新块"
// - 对 [mnote-raw-block:...] 标记:原样保留原始块
// 2. 返回重建后的块列表
}
```
返回值 `ParsedBlockInfo`:
```rust
struct ParsedBlockInfo {
block_id: Option<String>, // None = 新块(无原始 ID
block_type: String, // "paragraph" / "heading" / ...
text: String,
block_revision_ref: Option<String>, // 从注释或原始块继承
is_new: bool, // true = 非原始块,需要分配新 ID
original_block: Option<Value>, // 从原始 blocks 复制(若 block_id 匹配)
props: Option<Value>, // 从原始块保留的属性
}
```
解析步骤:
1. **按换行分割 markdown 行**,忽略空行
2. **对每行提取 `<!-- block:id:type:rev -->` 注释**(正则:`<!-- block:([^:]+):([^:]+):([^: ]+) -->` 或类似)
3. **若无注释但有 `[mnote-raw-block:id]` 标记 → 从原始 blocks 按 ID 复制**
4. **若既无注释也无原始标记 → 创建新 paragraph 块**,无 `blockId`(由写入层生成)
5. **从注释中获取 `block_type`;若无注释,从行前缀推断**`## ` → heading`- [ ] ` → todo
6. **用行中 `<!--` 前的部分作为文本内容**
7. **对带 ID 的块,从原始 blocks 查找并复制 `revisionRef`、`props`**
#### 3.3.3 `build_page_content`(新增函数)
将解析后的块列表与原始 `body/content` 合并,生成最终的 Convex `content` 数组:
```rust
fn build_page_content(
original_content: &Value, // 原始的 aggregate.body.content
parsed_blocks: &[ParsedBlockInfo],
) -> Value {
// 按 parsed_blocks 顺序遍历
// - 若 block_id 在 original 中存在:
// 复制 original 中该块的完整结构,仅替换 text
// - 若 block_id 为 None(新块):
// 生成新 block_id,作为 paragraph 追加
// - 若 original 中的复杂块不在 parsed 中:
// 保留(markdown_edit 不主动删块)
// 返回 Value::Array
}
```
#### 3.3.4 在 `doc_markdown_edit` 中的集成(替换现有 online 写回)
```rust
// 当前(doc.rs ~line 856:
let block_ops = build_block_ops_from_markdown_edit(&blocks, &operations, applied);
if applied > 0 && block_ops.is_empty() { ... }
let revision = ...;
let conflict_detection_key = ...;
let apply_result = doc_apply_block_ops_with_meta(state, context, input, &aggregate, block_ops, revision, conflict_detection_key).await?;
// 替换为:
let parsed = parse_final_markdown_to_blocks(&md, &blocks);
let next_content = build_page_content(&current_body_content(&aggregate), &parsed);
let changed_blocks = build_changed_blocks_summary(&original_blocks, &parsed);
execute_page_body_save_from_aggregate(state, context, input, &aggregate, next_content, changed_blocks).await?
```
---
## 4. 边界情况处理
### 4.1 原始块 ID 在最终 md 中消失
可能发生在:
- 用户手动删除了 `<!-- block:id -->` 注释(极罕见,通过 AI 输出不可能)
- 模型输出的 search/replace 跨段合并了内容
**策略**:保留该原始块在 `content` 中的位置。`build_page_content` 对在 original 中存在但不在 parsed 中的块,按原样复制到最终 content。
### 4.2 新建块(无原始 ID
用户可能要求"在末尾加一段总结"。模型输出 `full_content` 或 search/replace 产生的新文本带 `<!-- block:new_paragraph -->`。解析器标记为 `is_new=true`,写入层分配新的 `blockId`
**策略**:新块使用临时 ID 格式 `ai_block_{timestamp}_{counter}`,与现有 `mnote.block.insert_after` 的块 ID 风格一致。
### 4.3 复杂块保留
resource/mindmap/table/image 块在 `blocks_to_markdown` 中被序列化为特殊标记 `[mnote-raw-block:id]`。搜索替换通常不会命中这些行(因为它们不含普通文本),但如果意外命中:
**策略**:在 `search_replace` 中,如果 search 路径包含了复杂块的特殊标记,整条 operation 标记为 failed。解析器遇到 `[mnote-raw-block:...]` 标记时,直接从 original blocks 复制。
### 4.4 搜索替换未命中任何块(applied > 0 但所有命中都是新文本)
当模型输出的 full_content 完全不同于原文时可能出现。
**策略**`parse_final_markdown_to_blocks` 对每一行都产生块。如果没有任何 `<!-- block:id -->` 注释,所有块标记为 `is_new=true``build_page_content` 追加新块在后面,同时保留所有原始复杂块。
### 4.5 revision / conflictDetectionKey 一致性
`build_block_ops_from_markdown_edit` 当前依赖 `doc_apply_block_ops_with_meta` 来传递 revision。替换为直接调用 `execute_page_body_save_from_aggregate`,它从 aggregate 读取 revision/conflictDetectionKey [block.rs:1178-1189]。
**策略**:新路径从已经读到的 `aggregate` 中获取 revision/conflictDetectionKey,与现有路径完全一致。
### 4.6 dryRun 模式
当前 online 路径中 dry_run 由 `doc_apply_block_ops` 内部处理(不真正写入)。新路径也需要支持:
**策略**:若 `dryRun == true`,调用 `build_page_content` 但不调用 `execute_page_body_save_from_aggregate`,返回 diff 预览。diff 格式与当前 `docs.md:plan_update` 的 diff 格式一致。
---
## 5. 与已有代码的互动
### 5.1 `build_block_ops_from_markdown_edit` 的删除
该函数不再被 `doc_markdown_edit` 调用。它是一个私有 `fn`(仅 `doc.rs` 内部可见),唯一调用者被移除后成为死代码。
**策略**:直接删除函数体。保留调用处的行作为注释(`// 退役:7-27 改为 final_md→blocks 直接写回`),供后续参考。
### 5.2 `block_projection_blocks` 的继续使用
仍然需要原始 blocks 作为元数据源(提取 revisionRef、props、复杂块)。不改变。
### 5.3 `local_markdown_parser` 的复用
`parse_final_markdown_to_blocks` 可以复用 `local_markdown_parser::markdown_to_blocks` 对 GFM 结构的解析(heading、todo、code block、list),但需要用自己的逻辑提取 `<!-- block:... -->` 注释。不一定要用 comrak 的 `HtmlBlock` 解析;更可靠的方法是正则提取注释,然后从剩余内容中推断块类型。
**策略**:默认使用简单的行级处理(非 comrak),因为 `blocks_to_markdown` 的输出是每行一块的简单格式,不需要完整的 GFM AST。
> **⚠️ 跨行约束**:行级处理仅适用于 `blocks_to_markdown` 产出的单行块格式。以下情况需要 fallback 到 `local_markdown_parser::markdown_to_blocks`
> - 代码块(`block_text` 含 `\n`
> - 用户通过 `full_content` 自由书写的多段 markdown
> - 解析跳过了 `<!-- block:id -->` 的行之间的纯段落
>
> fallback 策略:对无 `<!-- block:... -->` 注释的连续行,收集后一次性通过 GFM 解析器分割为多个常规块。
### 5.4 `changed_blocks` 摘要生成
当前返回的 `applyResult.changedBlocks``doc_apply_block_ops` 产生。新路径需要自己生成:
```rust
fn build_changed_blocks_summary(
original_blocks: &[Value],
parsed_blocks: &[ParsedBlockInfo],
) -> Vec<Value> {
// 对比原始 blocks 和解析后的 blocks
// 对文本改变的块输出 { op: "replace", blockId, content }
}
```
响应体中的 `changedBlocks` 字段格式不变,保持与下游消费者(SSE delta 等)的兼容。
---
## 6. 实施计划
### 步骤 1:增强 `blocks_to_markdown`(小)
**文件**: `doc.rs:574`
改动:
- 注释格式从 `<!-- block:{id} -->` 改为 `<!-- block:{id}:{type}:{rev} -->`
- 对复杂块(`editable==false``unsupportedReason!=null`)输出 `[mnote-raw-block:{id}] <!-- block:{id}:{type}:{rev} -->`
**风险**: 低。纯格式变更,向前兼容——旧注释格式的 md 在解析器看来只是缺类型/rev,可通过 fallback 从原始 blocks 查。
**测试**: 更新现有 `test_blocks_to_markdown_with_ids`,验证新格式。
### 步骤 2:实现 `parse_final_markdown_to_blocks`(中)
**新建模块**`doc_md_to_blocks.rs` 或放在 `doc.rs` 末尾
核心逻辑(~120 行):
```rust
struct ParsedBlock {
block_id: Option<String>,
block_type: String,
text: String,
revision_ref: Option<String>,
original: Option<Value>,
is_new: bool,
}
fn parse_final_markdown_to_blocks(md: &str, originals: &[Value]) -> Vec<ParsedBlock> {
// 1. 为 originals 建立 block_id → Value 的 HashMap
// 2. 将 md 按行分割
// 3. 对每行:
// a. 用正则提取 <!-- block:id:type:rev --> 或 [mnote-raw-block:id]
// b. 若无注释 → is_new=true, type="paragraph"
// c. 若有注释 → 查 originals_map 继承 revision_ref/props
// d. 提取 <!-- 前的纯文本
// 4. 判断块类型:若注释中有 type 则用注释的,否则从行前缀推断
// 5. 返回 ParsedBlock 列表
}
```
**正则示例**
```rust
lazy_static! {
static ref BLOCK_COMMENT_RE: Regex = Regex::new(
r"<!--\s*block:([a-zA-Z0-9_-]+):([a-zA-Z0-9_-]+):([a-zA-Z0-9_-]*)\s*-->"
).unwrap();
static ref RAW_BLOCK_RE: Regex = Regex::new(
r"\[mnote-raw-block:([a-zA-Z0-9_-]+)\]"
).unwrap();
}
```
**测试**
- `test_parse_empty_md` → 空输入 → 空输出
- `test_parse_with_block_ids` → 包含 `<!-- block:b1:paragraph:r1 -->` → 返回正确 ParsedBlock
- `test_parse_no_ids` → 纯文本 → 全部 is_new
- `test_parse_raw_block``[mnote-raw-block:b3]` → 从 originals 复制
- `test_parse_type_from_prefix``## Title` → type="heading"
- `test_parse_todo_prefix``- [x] Done` → type="todo"
### 步骤 3:实现 `build_page_content`(中)
**文件**: 与步骤 2 同模块(~80 行)
```rust
fn build_page_content(original_content: &Value, parsed: &[ParsedBlock]) -> Value {
// 1. 为 original_content 建立 block_id → full_block 映射
// 2. 遍历 parsed_blocks
// - 有 block_id 且 original 中存在 → 复制 original 块,更新 text
// - 无 block_id → 创建新 paragraph 块
// - 有 block_id 但不在 original 中 → 复制 original 中能找到的(从已处理集合中移除)
// 3. 遍历 original_content 中的复杂块(不在 parsed 中出现的)→ 追加
// 4. 返回 Value::Array
}
```
**测试**
- `test_build_content_preserves_unmodified_blocks`
- `test_build_content_updates_text`
- `test_build_content_preserves_complex_blocks`
- `test_build_content_new_blocks_appended`
### 步骤 4:替换 `doc_markdown_edit` 的 online 写回路径(小)
**文件**: `doc.rs:854-900`
将现有的:
```rust
let aggregate = aggregate_value(state, context, input).await?;
let blocks = block_projection_blocks(&aggregate);
let block_ops = build_block_ops_from_markdown_edit(&blocks, &operations, applied);
if applied > 0 && block_ops.is_empty() { ... }
let apply_result = doc_apply_block_ops_with_meta(...).await?;
```
替换为:
```rust
let aggregate = aggregate_value(state, context, input).await?;
let blocks = block_projection_blocks(&aggregate);
let parsed = parse_final_markdown_to_blocks(&md, &blocks);
let original_content = current_body_content(&aggregate);
let next_content = build_page_content(&original_content, &parsed);
let changed_blocks = build_changed_blocks_summary(&blocks, &parsed);
let apply_result = execute_page_body_save_from_aggregate(
state, context, input, &aggregate, next_content, changed_blocks
).await?;
```
新增 `build_changed_blocks_summary`~30 行)。
**风险**: 中。第一次替换时需要与现有测试对照输出,确保 changed_blocks 格式一致。
**测试**
- 现有 `hermes_tools_markdown_edit_*` 测试全部重新运行——断言现有行为不变
- 新增 `test_markdown_edit_online_single_replacement`:验证单块替换后 content 正确
- 新增 `test_markdown_edit_online_full_content`:验证全文替换不再被拒绝
- 新增 `test_markdown_edit_online_preserves_complex_blocks`:验证 mindmap 块不变
### 步骤 5:更新 full_content 处理(小)
**文件**: `doc.rs:785-800`
当前 `full_content` 构造一个 `search: current_md.trim(), replace: full.trim()` 的 operation。替换后不再需要这个折中——直接解析 full_content 为 blocks。
改动:在 `parse_final_markdown_to_blocks` 中,若没有任何 `<!-- block:id -->` 注释,所有块标记为 is_new。`build_page_content` 只追加新块 + 保留原始复杂块。
**测试**
- `test_markdown_edit_online_full_content_creates_new_blocks`
- `test_markdown_edit_online_full_content_preserves_complex`
### 步骤 6:清理(小)
- 删除 `build_block_ops_from_markdown_edit` 函数体(私有 fn,唯一调用者已移除)
- 在原调用位置保留注释:`// 退役:7-27 改为 final_md→blocks 直接写回`
- 更新 manifest/guidance 文字:full_content 不再被拒绝
---
## 7. 测试矩阵
| 场景 | 输入 | 期望输出 | 优先级 |
|------|------|---------|--------|
| 单块精确 search/replace | `{search:"第一段", replace:"新内容"}` | 文本更新,blockId/revisionRef 不变 | P0 |
| 单块忽略空白 match | `{search:"第 一 段", replace:"新内容"}` | 文本更新(原块中"第一段"→"新内容" | P0 |
| 多 operation 命中同块 | `[{op1},{op2}]` 同块 | 最终文本 = 两次替换累积结果 | P0 |
| full_content | `"新全文"` | 新文本创建新块,原复杂块保留 | P0 |
| 影响 heading | search 命中 heading 文本 | heading 类型不变,文本更新 | P1 |
| 包含复杂块文档的替换 | 只改 paragraph 文本 | resource/mindmap 块不变 | P1 |
| dryRun 不落盘 | `dryRun: true` | 返回 diff,无写入 | P1 |
| revision 过期 | aggregate revision 已过期 | 冲突错误,不写入 | P1 |
| 原始块 ID 消失 | 搜索替换删除了 `<!-- block -->` 注释 | 该原始块保留(不从 content 删除) | P2 |
| 空 operations | `[]` | 错误码 | P2 |
| 纯文本 md(无块注释) | `"摘要\n\n补充"` | 全部 is_new,追加到末尾 | P2 |
---
## 8. 灰度/回滚
不设 feature flag。直接替换 online 写回路径。理由:
- 旧路径(7-24/7-25 修复后)在 `full_content` 等场景是**安全降级(拒绝写入)**,新路径是功能增强。不会出现「旧路径能写入、新路径不能」的退化。
- 现有 `hermes_tools_markdown_edit_*` 测试集 + 新增 5 个测试组合足够兜底。
- 若确实需要回滚,用 `git revert` 回退本次改动即可。
灰度策略:默认开启,观察到新增测试全部 pass 后合入主线。
---
## 9. 关联文件清单
| 文件 | 变更类型 | 变更内容 |
|------|---------|---------|
| `doc.rs` `blocks_to_markdown` | 修改 | 注释格式增强 |
| `doc.rs` `doc_markdown_edit` | 修改 | online 写回路径 |
| 新建 `doc_md_to_blocks.rs` | 新增 | `parse_final_markdown_to_blocks` + `build_page_content` + `build_changed_blocks_summary` |
| `doc.rs` `build_block_ops_from_markdown_edit` | 标记 | `#[deprecated]` |
| `hermes_tools.rs` tests | 新增 | ~5 个新测试 |
| `manifest.rs` / guidance.md | 微调 | full_content 不再标记为拒绝 |
---
## 10. 开放问题
1. **`<!-- block:id:type:rev -->` 注释在行中可能被模型生成的 search/replace 破坏**。例如模型输出 `{search: "第一段 <!-- block:", replace: "新段"}` 前半个注释。这是用户级错误(模型错误地替换了元数据),写入层应在 parse 阶段检测不完整的注释并报错。
2. **`full_content` 场景下的块类型保留**。如果用户要求"把整个文档改写成大纲格式",模型输出不包含 `<!-- block-->` 注释的全部新文本,此时 heading/todo 类型从 GFM 前缀推断。但列表、引用、代码块等格式需确认 `local_markdown_parser``markdown_to_blocks` 返回的结构是否足够完整。
3. **行内格式(bold、italic、link)能否在往返中保持**。当前 `blocks_to_markdown` 只输出文本(`block_text`),丢弃所有 marks。在线文档的 text 存储包含 marksbold/italic/code/link),当前 `doc_markdown_edit` 的整体设计不保证行内格式——这超出了 7-24/7-25 的范围,但建议在 7-14 的 Phase A/B 之后评估。
+6 -5
View File
@@ -1,16 +1,17 @@
# 10-review 审查总览
> 执行状态:`done/01` 到 `done/07` 均已归档;`process/08` 是当前活跃架构收口与下一阶段优先级 review`process/09` 记录页面 AI 快速块编辑 runtime 的阶段性结论与下一步基建方向
> 执行状态:`done/01` 到 `done/11` 均已归档;当前活跃 10-review process 文档
当前活跃审查:
- [Kernel 架构收口与下一阶段优先级 Review / Checklist](./process/08-kernel-architecture-next-priority-review-and-checklist.md)
- [页面 AI 快速块编辑 Runtime Review](./process/09-page-ai-fast-block-edit-runtime-review.md)
- [当前 mnote 项目 AI / Page Aggregate 定向 Review](./process/10-current-mnote-ai-runtime-review-v1.md)
- [当前完整架构 Review](./process/11-current-full-architecture-review-v1.md)
- 暂无。
最新归档审查:
- [Kernel 架构收口与下一阶段优先级 Review / Checklist](./done/08-kernel-architecture-next-priority-review-and-checklist.md)
- [当前 mnote 项目 AI / Page Aggregate 定向 Review](./done/10-current-mnote-ai-runtime-review-v1.md)
- [当前完整架构 Review](./done/11-current-full-architecture-review-v1.md)
- [页面 AI 快速块编辑 Runtime Review](./done/09-page-ai-fast-block-edit-runtime-review.md)
- [VSCode Explorer 文件树与垃圾箱闭环增量审查](./done/07-vscode-explorer-filetree-trash-gap-review.md)
上一轮最终执行入口:
@@ -1,8 +1,8 @@
# 08 [process] Kernel 架构收口与下一阶段优先级 Review / Checklist v1
# 08 [done] Kernel 架构收口与下一阶段优先级 Review / Checklist v1
> 更新时间:2026-05-16
> 更新时间:2026-05-18
>
> 执行状态:`process`
> 执行状态:`done`
>
> 关联文档:
> - `/mnt/Data1T/mnote/AGENTS.md`
@@ -35,14 +35,14 @@
- Page Aggregate 仍是过渡态,block projection 主要从 `documents.content` / local markdown content 投影,不是 EditorBlockDocument 原生落库完成态。
- 标题、正文、页面设置、page tree、AI 写入口尚未完全闭环到同一组 projection / command family。
- `tree.*` 已是 preferred command name,但 `documents.*` 兼容命令面仍未完全降级。
- `/api/tree/events` 已是 tree realtime 主链,但 Sidebar、page subtree、filetree、preferred snapshot 还没有完全统一到一套 live cache
- `/api/realtime/ws` 已是 tree realtime 主链,`/api/tree/events` 已降级为 SSE fallbacklive cache 的后续重点是减少兼容 fallback 与补偿链
- AI 块工具已有最小闭环,但当前编辑路径以块操作(blockId)为中心——AI 被迫理解 UI 层概念,且本地 `.md` 文件无 AI 写入路径。`7-14` 提出应将 AI 编辑主路径修正为 markdown 文本级(search/replace),`mnote.block.*` 降级为结构性辅助,在线 Convex 文档与本地文件共用同一条 markdown 写入路径。
补充 BlockNote / Tiptap AI 参考后的判断:
- BlockNote AI 和 Tiptap AI Toolkit 可以参考的是 AI runtime shape 与 tool contract,不是 mnote 的事实源。
- AI 方向下一步只应补基础底座:`PageAIContextBuilder``scope=selection``page_xml/text`、tool manifest annotations、`PageAIReviewSession`、accept/reject/retry/abort 状态机。
- 2026-05-16 补充( `7-14` 方向修正):AI 编辑主路径应从块级操作调整为 markdown 文本级 search/replace,新增 `mnote.doc.markdown_edit` 作为主要 AI 写入工具;当前 `local_rule` planner`direct_block_edit_operations`是过渡实现,应退役。在线 Convex 文档和本地 `.md` 文件共用同一条写入路径
- 2026-05-18 补充(`7-18` `7-25` 收口后):AI 简单正文编辑主路径已切到 markdown 文本级 search/replace / full_content`page_ai_workflow.rs` 当前通过模型生成 markdown 编辑意图后调用 `mnote.doc.markdown_edit`,并复用统一 mnote tool executor`local_rule` planner`direct_block_edit_operations`不再作为当前 runtime 口径。在线 Convex 文档和本地 `.md` 文件共用同一条 markdown 写入合同
- 在非 AI 主架构和 AI 基础合同打牢之前,不应扩展新的 AI agent 工作流、复杂 AI UI 或跨页面智能功能。
因此,下一阶段不应优先“大改架构”或“大量加新功能”,而应优先做:
@@ -89,7 +89,7 @@
目标:
- `/api/tree/events` 的 snapshot / delta / resync 成为 Sidebar、Page Tree、File Tree、page subtree 的共同 live cache 来源。
- `/api/realtime/ws` 的 snapshot / delta / resync 成为 Sidebar、Page Tree、File Tree、page subtree 的共同 live cache 来源`/api/tree/events` 仅作为 SSE fallback
- 减少 query/refetch/freshness 补偿链和旧快照回闪。
### P0AI 基础工具与审阅底座
@@ -115,13 +115,13 @@
- 不新增复杂 AI 自动化功能面。
- 不把 `@blocknote/xl-ai` 或 BlockNote `AIExtension` 作为 runtime dependency。
- 不让 AI 写入绕过 `dryRun/idempotencyKey/revision/conflictDetectionKey/revisionRef`
- **不把 `direct_block_edit_operations` / `local_rule` planner 当作长期 AI 编辑路径(当前为过渡实现,将被 `markdown_edit` 替代)。**
- **不把 `direct_block_edit_operations` / `local_rule` planner 当作当前或长期 AI 编辑路径;简单正文编辑当前主路径是 `mnote.doc.markdown_edit`,结构性块操作才使用 `apply_block_ops` / `mnote.block.*`。**
- **不新增 `mnote.block.*` 工具;现有保留为结构性辅助。**
**2026-05-16 新增(受 7-14 方向修正):**
**2026-05-18 状态更新(`7-18``7-25` 收口后):**
- **Phase A(当前唯一活跃实施)**`mnote.doc.markdown_edit` + `mnote.doc.fetch` 增强search/replace + `format: "markdown"` + local source + Hermes 注册,7-14 v2 Phase A
- **Phase B(下一阶段)**:退役 `direct_block_edit_operations``page_ai_workflow.rs` 改走 `markdown_edit`7-14 v2 Phase B
- **Phase A / B 主路径已完成当前收口**`mnote.doc.markdown_edit` + `mnote.doc.fetch` 增强、Hermes manifest / guidance、`page_ai_workflow.rs``markdown_edit`、统一 mnote tool executor、local / online markdown source 合同已同步
- **Phase C 仍冻结**:流式 apply + suggest/review 只保留设计边界,当前不实施、不扩新 AI 功能
- 在线 Convex 文档和本地 `.md` 文件共用同一条 markdown AI 写入路径(`resolve_source` → Convex | LocalFS)。
### P1:定向 Bug Hunt
@@ -144,8 +144,8 @@
### 3.1 Page Aggregate 单一真源
- [x] 盘点 `wolai-frontend/src/lib/documents/page-aggregate-loader.ts` 是否仍有 runtime fallback 或 TS builder 读取分支。
- [x] 盘点 `wolai-frontend/src/lib/documents/page-aggregate-builder*` 的引用,确认只剩 test helper / historical adapter。
- [x] 盘点历史 `wolai-frontend/src/lib/documents/page-aggregate-loader.ts` 是否仍有 runtime fallback 或 TS builder 读取分支。
- [x] 盘点历史 `wolai-frontend/src/lib/documents/page-aggregate-builder*` 的引用,确认只剩 test helper / historical adapter。
- [x] 检查 `/api/documents/page` 仍返回明确 `410`,不参与 runtime 主链。
- [x] 跑文档页打开 smoke,记录 `/api/page-aggregate/:id` 是首要读链。
- [x] 新建页面后检查 Page Aggregate `identity/head/body/tree/stats` 字段完整。
@@ -156,7 +156,7 @@
- [x] 破坏或暂停 Convex query,检查响应是 degraded/error,不返回伪 fixture。
- [x] 在 `design/05-editor-mainline/process/5-6-page-aggregate-alignment-checklist-v1.md` 勾选已验证项,并补证据路径。
2026-05-16 静态审计证据:
2026-05-16 静态审计证据(历史 Next 前端退役前快照)
- `wolai-frontend/src/lib/documents/page-aggregate-loader.ts` 只通过 `loadPageAggregateFromRustSnapshot` 请求 Rust `/api/page-aggregate/:documentId``loadPageAggregate` 不再回退 TS builder`buildServerBridgeRequest("/documents/page")` 只用于构造转发 header/request,不调用 Next `/api/documents/page` compat route。
- `rg -n "buildPageAggregateFromDocumentPayloads|page-aggregate-builder" wolai-frontend/src rust scripts design --glob '!design/05-editor-mainline/reference-code/**' --glob '!node_modules/**'` 显示 runtime 非测试引用仅剩 builder 定义;代码引用只有 `wolai-frontend/src/lib/documents/page-aggregate-builder.test.ts`
@@ -191,6 +191,8 @@
- 证据:`tmp/page-aggregate-refresh-persistence-smoke/mp88fr6k.json`,截图:`tmp/page-aggregate-refresh-persistence-smoke/mp88fr6k.png`stdout`tmp/page-aggregate-refresh-persistence-smoke/latest.stdout.json`
- 验证结果:刷新前后 `head.title=Page Aggregate refresh mp88fr6k``body.revision=1``body.conflictDetectionKey=tree_1778929070007_1:1``blockDocument.blocks[0].text=Page Aggregate refresh body mp88fr6k``layout.pageOptions.wideLayout/smallText/layoutDensity=true/true/compact`;刷新后页头标题、`.ProseMirror` 正文、设置控件、`documentElement``.document-shell`、island root 和 `.editor-surface` 均保持最新值。
- 修复点:`rust/crates/mnote-web/src/ssr/pages/layout.rs``initializePageUiSurfaces` 延后到 `DOMContentLoaded` 后执行,避免 layout 脚本早于嵌入 Page Aggregate JSON / island DOM 完成时把 runtime 属性按默认 pageOptions 应用。
- 2026-05-18 复测并扩展 `scripts/task-page-aggregate-refresh-persistence-smoke.js`,补充 `mnote.doc.fetch` / `tree.pageSubtree` 同组回读证据。证据:`tmp/page-aggregate-refresh-persistence-smoke/mpagfma1.json`,截图:`tmp/page-aggregate-refresh-persistence-smoke/mpagfma1.png`
- 验证结果:刷新前后 `head.title=Page Aggregate refresh mpagfma1``body.revision=1``body.conflictDetectionKey=tree_1779063433221_1:1``blockDocument.blocks[0].text=Page Aggregate refresh body mpagfma1``layout.pageOptions.wideLayout/smallText/layoutDensity=true/true/compact``tree.pageSubtree.rootNodeId=tree_1779063433221_1` 均一致;`mnote.doc.fetch` 返回 `schema=mnote.page_ai_context.v1``revision/conflictDetectionKey/revisionRef/text` 与同一 Page Aggregate block 完全一致。
- 缺陷记录:`bugs/05-editor-mainline/done/5-12-page-options-refresh-runtime-attrs-v1.md`
- 配套测试已通过:`cargo test --manifest-path rust/Cargo.toml -p mnote-web page_aggregate``cargo test --manifest-path rust/Cargo.toml -p mnote-web documents_options_route_executes_page_layout_update_options`
@@ -216,47 +218,61 @@ cargo test --manifest-path rust/Cargo.toml -p mnote-web document_shell_errors_wi
### 3.2 Page Block AI Tooling
- [ ] 以 `7-10` 为工具执行清单、`7-11` 为 AI runtime 基础设计,逐 phase 检查已勾选项是否都有代码、测试、smoke 证据。
- [x] 以 `7-10` 为工具执行清单、`7-12`当前 AI runtime 基础设计,逐 phase 检查已勾选项是否都有代码、测试、smoke 证据。
- [x] 给 `mnote.doc.fetch scope=selection` 补真实选区上下文输入和返回结构。
- [x] 给 `mnote.doc.fetch format=page_xml` 补最小 PageXML 输出。
- [x] 给 `mnote.block.fetch format=text/page_xml` 补格式分支。
- [x] 给 tool manifest 补 `annotations`,区分 readonly / destructive / requiresApproval / selectionEffect。
- [x] 定义 `mnote.page_ai_context.v1`,明确 context 只来自 Page Aggregate projection。
- [ ] 定义 `mnote.page_ai_review_session.v1`,明确 accept / reject / retry / abort 语义。
- [ ] 给 `mnote.block.insert_after` 补多块插入限制和返回 inserted block ids。
- [ ] 给 `mnote.block.move_after` 补标题带子块阻断 smoke。
- [ ] 给 `mnote.block.move_after` 补列表项阻断 smoke。
- [ ] 给 `mnote.block.move_after` 补表格 / mindmap / resource 阻断 smoke。
- [x] 定义 `mnote.page_ai_review_session.v1`,明确 accept / reject / retry / abort 语义。
- [x] 给 `mnote.block.insert_after` 补多块插入限制和返回 inserted block ids。
- [x] 给 `mnote.block.move_after` 补标题带子块阻断 smoke。
- [x] 给 `mnote.block.move_after` 补列表项阻断 smoke。
- [x] 给 `mnote.block.move_after` 补表格 / mindmap / resource 阻断 smoke。
- [x] 给 `mnote.block.replace` 补 stale revision 失败用例。
- [x] 给 `mnote.block.replace` 补 stale blockRevisionRef 失败用例。
- [x] 给写工具补重复 idempotencyKey 的端到端用例。
- [x] 检查 `mnote.page.save` 在 manifest / UI 中继续标为页面级兜底,不显示为精确块编辑主入口。
- [ ] 检查任何新增 AI surface 是否只是基础 review/context/tooling 验收,不是新功能扩展。
- [ ] 更新 `design/07-ai/process/7-10-page-block-ai-tooling-execution-checklist-v1.md` 的每个 phase 证据。
- [ ] 更新 `design/07-ai/process/7-12-page-ai-hermes-tool-routing-and-review-surface-v1.md` 的 Hermes 工具路由与审阅面 checklist。
- [x] 检查任何新增 AI surface 是否只是基础 review/context/tooling 验收,不是新功能扩展。
- [x] 更新 `design/07-ai/process/7-10-page-block-ai-tooling-execution-checklist-v1.md` 的每个 phase 证据。
- [x] 更新 `design/07-ai/process/7-12-page-ai-hermes-tool-routing-and-review-surface-v1.md` 的 Hermes 工具路由与审阅面 checklist。
**新增(2026-05-16,受 7-14 方向修正):**
2026-05-18 `mnote.block.insert_after` 多块插入证据:
**Phase A(当前唯一活跃实施):**
- [ ] `mnote.doc.markdown_edit` 核心实现:resolve_source → search_replace → write_target
- [ ] `mnote.doc.markdown_edit` 搜索替换语义:精确匹配 → fuzzy fallback → operationsFailed7-14 §5
- [ ] `mnote.doc.markdown_edit` Convex 写入 adapter(复用 `page.body.save` 链路)
- [ ] `mnote.doc.markdown_edit` 本地文件写入 adapter`fs.writeFile` + GFM AST markdown 序列化)
- [ ] `mnote.doc.fetch format: "markdown"` 增强(Page Aggregate 产出 PageMarkdown
- [ ] `mnote.doc.fetch` local source 支持(识别本地文件路径并返回 `.md` 原文)
- [ ] Hermes tool manifest 注册 `mnote.doc.markdown_edit`
- [ ] Hermes `mnote` plugin 更新:`mnote_doc_fetch` schema 增加 `format: "markdown"` 和本地文件路径支持
- [ ] 乐观锁:`revision` 冲突检测
- [ ] 单元测试 + smoke(在线 + 本地两种 source
- 代码:`rust/crates/mnote-web/src/hermes_tools/block.rs` 支持 `content` / `block` / `blocks` 输入;`blocks` 限制 `1..=20`;多块写入按输入顺序连续插入,并在结果返回 `insertedBlockIds`
- manifest`rust/crates/mnote-web/src/hermes_tools/manifest.rs` 已声明 `blocks.minItems=1/maxItems=20`,并用 `anyOf` 表达 `content | block | blocks` 三种输入。
- 测试:`cargo test --manifest-path rust/Cargo.toml -p mnote-web hermes_tools_block_insert_after -- --nocapture` 通过,覆盖多块成功和超过 20 个失败。
- 回归:`cargo test --manifest-path rust/Cargo.toml -p mnote-web hermes_tools -- --nocapture` 通过,覆盖 Hermes tools 38 项。
**Phase B(下一阶段):**
- [ ] `direct_block_edit_operations` 退役(`page_ai_workflow.rs` `local_rule` 分支)
- [ ] `page_ai_workflow.rs` 修正:system prompt 改为产 search/replace 对,底层走 `mnote.doc.markdown_edit`
- [ ] 补全 operation schema`content` 字段)
- [ ] 浏览器 smoke:自然语言编辑可用(不使用「」格式)
2026-05-18 `mnote.block.move_after` 复杂块阻断证据:
- 代码:`rust/crates/mnote-web/src/hermes_tools/block.rs` 继续以同父级、叶子块、可移动类型、editable、自身移动为真实写入前阻断条件。
- 测试:`cargo test --manifest-path rust/Cargo.toml -p mnote-web hermes_tools_block_move_after_blocks_complex_and_nested_blocks -- --nocapture` 通过,覆盖标题带子块、列表项、表格、mindmap、resource 均返回 `blocked=true` / `block_move_after_blocked`
- 回归:`cargo test --manifest-path rust/Cargo.toml -p mnote-web hermes_tools -- --nocapture` 通过,当前 Hermes tools 共 39 项。
**新增(2026-05-18`7-18``7-25` 收口状态):**
**Phase A / B(当前主路径已收口):**
- [x] `mnote.doc.markdown_edit` 核心实现:resolve_source → search_replace / full_content → write_target
- [x] `mnote.doc.markdown_edit` 搜索替换语义:精确匹配、归一化匹配、多操作同块合并、无法安全映射时失败
- [x] `mnote.doc.markdown_edit` Convex 在线写入 adapter 以最终 markdown 映射写回
- [x] `mnote.doc.markdown_edit` 本地文件写入 adapter 尊重 dryRun / idempotency
- [x] `mnote.doc.fetch format: "markdown"` 增强
- [x] Hermes tool manifest 注册并描述 `mnote.doc.markdown_edit` 写入合同
- [x] 乐观锁:revision / conflictDetectionKey 前置校验
- [x] `direct_block_edit_operations` / `local_rule` 不再作为 `page_ai_workflow.rs` 当前主路径
- [x] `page_ai_workflow.rs` 改为模型产 markdown search/replace / full_content,底层走 `mnote.doc.markdown_edit`
- [x] 单元测试覆盖 markdown_edit / page_ai_workflow / Hermes guidance / ACP tool contract
**Phase C(设计冻结,不实施):**
- [ ] 流式 apply + suggest/review 设计已冻结,不阻塞 Phase A/B 实施
- [x] 流式 apply + suggest/review 设计已冻结,不阻塞 Phase A/B 实施
2026-05-18 Review Session 合同定义:
- 已在 `design/07-ai/process/7-12-page-ai-hermes-tool-routing-and-review-surface-v1.md` 定义 `mnote.page_ai_review_session.v1` 最小 schema。
- 已明确状态:`draft/planning/previewing/awaiting_user/accepted/rejected/applying/applied/failed/aborted/stale`
- 已明确动作约束:`accept` 必须重新读 Page Aggregate 并校验 revision / conflictDetectionKey / revisionRef`reject/abort` 不产生写入,`retry` 必须生成新 proposal 或 dry-run previewyolo 模式也应生成同构 audit 数据。
- 边界:这里只完成合同定义,不实施 Phase C 流式 apply 或新的审阅 UI。
2026-05-16 Page Block AI context / format focused smoke 证据:
@@ -271,15 +287,17 @@ cargo test --manifest-path rust/Cargo.toml -p mnote-web document_shell_errors_wi
- 边界:本轮只验证 `mnote.doc.apply_block_ops` 的 selection scope guard;单个 `mnote.block.replace/insert_after/move_after` 尚未校验 `allowedTargetBlockIds`,不能据此勾选完整 selection 写保护矩阵。
- 配套验证已通过:`node --check scripts/task-page-block-ai-context-format-smoke.js``cargo test --manifest-path rust/Cargo.toml -p mnote-web hermes_tools -- --nocapture``MNOTE_UI_BASE_URL=http://127.0.0.1:3000 MNOTE_AUTH_BASE_URL=http://127.0.0.1:3000 node scripts/task-page-block-ai-context-format-smoke.js`
2026-05-16 Page Block AI stale / idempotency focused smoke 证据:
2026-05-16 / 2026-05-18 Page Block AI stale / idempotency focused smoke 证据:
- 新增并运行 `scripts/task-page-block-ai-conflict-idempotency-smoke.js`,通过真实 3000 + 测试账号创建临时页,用 `mnote.page.save` 初始化 `p_1/p_2`,再围绕 `mnote.block.replace` 验证冲突和幂等安全边界。
- 证据:`tmp/page-block-ai-conflict-idempotency-smoke/mp8dyqiq.json`,截图:`tmp/page-block-ai-conflict-idempotency-smoke/mp8dyqiq-page.png`
- 最新复测证据:`tmp/page-block-ai-conflict-idempotency-smoke/mpafrevs.json`,截图:`tmp/page-block-ai-conflict-idempotency-smoke/mpafrevs-page.png`
- 验证结果:
- 首次 `mnote.block.replace` 携带最新 `revision/conflictDetectionKey/blockRevisionRef/idempotencyKey` 成功写入 `p_2`,返回 `commandName=page.body.save``commandId=page_body_save_req_1778938353824_11`
- 使用同一 `idempotencyKey=idem_conflict_replace_mp8dyqiq` 再次调用 `mnote.block.replace`,即使请求 content 改成不同文本,也 replay 同一 `commandId`Page Aggregate 回读 `p_2` 仍为首次写入文本,`revisionAfterFirst=2``revisionAfterReplay=2`,确认不重复写入。
- 使用旧 `revision/conflictDetectionKey` 调用 `mnote.block.replace` 返回 HTTP `400`、错误码 `mnote_tool_conflict`,正文保持首次写入结果。
- 使用最新 `revision/conflictDetectionKey` 但旧 `blockRevisionRef` 调用 `mnote.block.replace` 返回 HTTP `400`、错误码 `mnote_tool_conflict`,正文保持首次写入结果。
- 2026-05-18 复测中,重复 `idempotencyKey=idem_conflict_replace_mpafrevs` replay 同一 `commandId=page_body_save_req_1779062303997_525``revisionAfterFirst=2``revisionAfterReplay=2`;旧 revision 与旧 blockRevisionRef 仍均返回 `mnote_tool_conflict`
- 边界:本轮只覆盖 `mnote.block.replace` 直接 tool executor 的 stale revision / stale blockRevisionRef / idempotency replay;不代表 `mnote.block.insert_after` 幂等矩阵、所有写工具幂等矩阵、review session accept stale 或 accept/reject/retry/abort 已完成。
- 配套验证已通过:`node --check scripts/task-page-block-ai-conflict-idempotency-smoke.js``cargo test --manifest-path rust/Cargo.toml -p mnote-web hermes_tools -- --nocapture``MNOTE_UI_BASE_URL=http://127.0.0.1:3000 MNOTE_AUTH_BASE_URL=http://127.0.0.1:3000 node scripts/task-page-block-ai-conflict-idempotency-smoke.js`
@@ -302,11 +320,17 @@ node scripts/task-page-block-ai-tools-smoke.js
- [x] 对 compat alias 返回增加 owner / deprecated 标识,避免被当作主链。
- [x] 更新 `design/04-tree-domain/done/4-6-tree-command-protocol-cutover-stage2-v1.md` 或新增后续 process checklist。
2026-05-16 Tree Command Cutover 静态盘点与 alias 标识证据
2026-05-18 当前 Tree Command Cutover 状态
- `3000` 当前主壳由 Rust `mnote-web` SSR 承载;主要页面生命周期动作通过 Rust `/api/tree/commands` route 生成 `tree.node.create``tree.node.rename``tree.subtree.move``tree.node.archive``tree.node.restore``tree.node.purge``tree.subtree.copy`
- `bridge-runtime` 仍接受历史 `documents.*` alias,但 execution plan / artifact 会标记 `preferredCommandName``compatCommandName``deprecatedAlias`,避免兼容命令被当成当前主链。
- `wolai-frontend` / Next tree command route 只作为退役前历史审计材料,不再作为当前运行时主链依据。
2026-05-16 Tree Command Cutover 静态盘点与 alias 标识证据(历史 Next 前端退役前快照):
- 只读盘点命令:`rg -n "documents\\.(create|title\\.update|move|archive|restore|delete|purge|copy_tree)" rust wolai-frontend scripts --glob '!node_modules/**'`
- 前端主链:`wolai-frontend/src/lib/documents/tree-command-client.ts` 的新建、重命名、移动、归档、恢复、永久删除和复制均 POST `/api/tree/commands`,返回 meta 使用 `TREE_COMMAND_PROTOCOL.*.preferredCommandName``TREE_COMMAND_PROTOCOL` 中的 `documents.*` 只保留为 `compatCommandName`
- Next tree command route`wolai-frontend/src/app/api/tree/commands/route.ts` 在 create / move / rename / archive / restore 分支分别构造 `tree.node.create``tree.subtree.move``tree.node.rename``tree.node.archive``tree.node.restore`
- 退役前 Next 前端主链:`wolai-frontend/src/lib/documents/tree-command-client.ts` 的新建、重命名、移动、归档、恢复、永久删除和复制均 POST `/api/tree/commands`,返回 meta 使用 `TREE_COMMAND_PROTOCOL.*.preferredCommandName``TREE_COMMAND_PROTOCOL` 中的 `documents.*` 只保留为 `compatCommandName`
- 退役前 Next tree command route`wolai-frontend/src/app/api/tree/commands/route.ts` 在 create / move / rename / archive / restore 分支分别构造 `tree.node.create``tree.subtree.move``tree.node.rename``tree.node.archive``tree.node.restore`
- Rust Web tree route`rust/crates/mnote-web/src/routes/tree.rs``create_command_wire` 输出 `tree.node.create``tree.node.rename``tree.subtree.move``tree.node.archive``tree.node.restore``tree.node.purge``tree.subtree.copy``tree_command` route 只接收 action,不接收旧 `documents.*` command name 作为主链输入。
- 兼容保留:`bridge-runtime` 仍接受 `documents.create/title.update/move/delete/restore/purge/copy_tree`,但本轮已在对应 execution plan 的 `args_json.commandProtocol` 增加 `family=tree``owner=rust-runtime-kernel``preferredCommandName``compatCommandName``deprecatedAlias`;旧 `documents.*` alias 会标记 `deprecatedAlias=true`
- Transport 边界:`rust/crates/mnote-web/src/transport/convex.rs` 发送给 Convex legacy mutation 前会剥离 `commandProtocol``streamDeltaHint``domainEventHint``domainEventPlan(s)`,避免 legacy validator 把审计字段当写入参数。
@@ -337,14 +361,20 @@ node scripts/task122-rust-web-create-page-ui-smoke.js
- [x] 重命名页面后,双浏览器 A/B 检查 Sidebar / Breadcrumb / File Tree 一致更新。
- [x] 移动页面后,双浏览器 A/B 检查 tree order 不回闪。
- [x] 删除 / 恢复后检查 trash 与主树事件一致。
- [x] 断开 SSE 后恢复,检查 resync 能把 UI 拉回正确状态。
- [x] 断开 WS / SSE fallback 后恢复,检查 resync 能把 UI 拉回正确状态。
- [x] 更新 `design/03-rust-web/process/3-3-rust-web-tree-realtime-event-stream-v1.md` 的已验证项。
2026-05-16 Tree Realtime Live Cache 静态盘点与 payload 覆盖证据
2026-05-18 当前状态更新
- 只读审查确认 React `AppLayoutShell` 同时接入 `useSidebarData``useSidebarTreeStream``usePreferredSidebarSnapshot` 负责 freshness 仲裁;Page Tree / File Tree 通过 preferred snapshot 消费 `kernelSidebarTree``kernelFileTreeProjection`
- 仍未统一的补偿链:`useSidebarData` 还保留 Convex query、HTTP `/api/sidebar` fallback 与手动 `refetch`mutation 后仍有 `refreshTree` / `sidebarQuery.refetch`Rust SSR shell 与 React hook 目前各自可建立 EventSourcepage subtree 仍从 Page Aggregate client state 派生,不应误标为已统一 live cache
- Rust stream 盘点确认 workspace snapshot 已同时加载 `KernelProjectionKind::SidebarTree``KernelProjectionKind::FileTree`SSE payload 的 `data.dataset.kernel_sidebar_projection` / `data.dataset.kernel_file_tree_projection` 覆盖 page/file rowsubtree snapshot 仍只覆盖 `page_tree`,不含 file tree projection
- `/api/realtime/ws` 已成为 Rust SSR 主壳默认 transport`convex-command-log-ws`),`/api/tree/events` 仅作为 SSE fallback
- `CURRENT_ARCHITECTURE.md``bugs/03-rust-web/done/3-16` `3-18` 已同步:原“WS 文档口径 / 前端 SSE 实现”冲突、SSE push 跳过 polling safety net、WS/SSE delta 合同分裂均已修复
- 2026-05-16 下面的 SSE smoke 证据保留为迁移前历史验证材料,不再代表当前主链
2026-05-16 Tree Realtime Live Cache 静态盘点与 payload 覆盖证据(WS 迁移前历史快照):
- 退役前只读审查确认 React `AppLayoutShell` 同时接入 `useSidebarData``useSidebarTreeStream``usePreferredSidebarSnapshot` 负责 freshness 仲裁;Page Tree / File Tree 通过 preferred snapshot 消费 `kernelSidebarTree``kernelFileTreeProjection`
- 退役前未统一的补偿链:`useSidebarData` 还保留 Convex query、HTTP `/api/sidebar` fallback 与手动 `refetch`mutation 后仍有 `refreshTree` / `sidebarQuery.refetch`Rust SSR shell 与 React hook 当时各自可建立 EventSourcepage subtree 当时仍从 Page Aggregate client state 派生。
- Rust stream 历史盘点确认 workspace snapshot 已同时加载 `KernelProjectionKind::SidebarTree``KernelProjectionKind::FileTree`SSE payload 的 `data.dataset.kernel_sidebar_projection` / `data.dataset.kernel_file_tree_projection` 覆盖 page/file rowsubtree snapshot 当时只覆盖 `page_tree`,不含 file tree projection。
- 本轮新增 `stream_change_preserves_remove_asset_delta_fields`,确认 `tree.resource.delete``remove_asset` delta 字段保真;`structural_delta_requires_projection_snapshot` 已覆盖 `remove_asset` 需要 projection snapshot,避免资源删除类事件只靠局部 patch。
- 本轮强化 `scripts/task123-rust-web-tree-live-stream-consumer-smoke.js`:解析 SSE `snapshot` data,断言 `kind=snapshot``stream=workspace``projection=sidebar_tree``x-mnote-tree-stream-owner=rust-web`,并确认 workspace snapshot 中有 `kernel_sidebar_projection``kernel_file_tree_projection` 和临时页 `doc:<documentId>` file tree row。
@@ -425,21 +455,26 @@ node scripts/task449-tree-sse-reconnect-snapshot-recovery-smoke.js
### 3.5 定向 Bug Hunt
- [ ] 建立 bugs 分类:Page Aggregate 问题归 `bugs/05-editor-mainline/process/`
- [ ] Tree command / realtime / File Tree 问题归 `bugs/04-tree-domain/process/`
- [ ] AI tools 问题归 `bugs/07-ai/process/`
- [ ] 每个 bug 必须包含复现步骤、期望、实际、证据截图或 JSON、owner 判断。
- [ ] 先补最小 failing smoke,再修实现。
- [ ] 修复后移动到对应 `done/`,并记录验证命令。
- [x] 建立 bugs 分类:Page Aggregate 问题归 `bugs/05-editor-mainline/process/`
- [x] Tree command / realtime / File Tree 问题归 `bugs/04-tree-domain/process/`
- [x] AI tools 问题归 `bugs/07-ai/process/`
- [x] 每个 bug 必须包含复现步骤、期望、实际、证据截图或 JSON、owner 判断。
- [x] 先补最小 failing smoke 或定向单测,再修实现。
- [x] 修复后移动到对应 `done/`,并记录验证命令。
重点 bug 方向:
- [ ] Page Aggregate 与页头标题不一致。
- [ ] Page Aggregate 与 File Tree `{title}.md` 不一致。
- [ ] AI 写入成功但 Page Aggregate 回读旧内容。
- [ ] stale revision 未阻断写入。
- [ ] tree stream 断线恢复后 UI 停在旧快照。
- [ ] 旧 compat / debug route 在 3000 首屏被误用。
- [x] Page Aggregate 与页头标题不一致。
- [x] Page Aggregate 与 File Tree `{title}.md` 不一致。
- [x] AI 写入成功但 Page Aggregate 回读旧内容。
- [x] stale revision 未阻断写入。
- [x] tree stream 断线恢复后 UI 停在旧快照。
- [x] 旧 compat / debug route 在 3000 首屏被误用。
2026-05-18 定向 bug hunt 收口:
- `find bugs -path '*/process/*' -type f | wc -l` 结果为 `0`
- 本轮 P0 / 内核优先缺陷已迁入 `bugs/*/done/`,并在 `CURRENT_ARCHITECTURE.md``design/10-review/done/10-current-mnote-ai-runtime-review-v1.md``design/10-review/done/11-current-full-architecture-review-v1.md` 中同步验证证据。
### 3.6 2026-05-16 暂停交接进展摘要
@@ -497,16 +532,55 @@ MNOTE_UI_BASE_URL=http://127.0.0.1:3000 MNOTE_AUTH_BASE_URL=http://127.0.0.1:300
## 4. 当前不优先做
- [ ] 不优先增加新的编辑器 UI 大功能。
- [ ] 不优先新增 AI Agent 工作流功能。
- [ ] 不优先扩展 AI 功能面;AI 只做基础上下文、工具合同、审阅会话、冲突与回滚。
- [ ] 不优先大规模替换 Convex。
- [ ] 不优先重写 `leptos-tiptap` 输入层。
- [ ] 不把 `mnote.page.save` 包装成精确块编辑长期方案。
- [ ] 不在 compat route 继续扩写长期业务语义。
- [ ] 不把 BlockNote / Tiptap AI runtime 作为 mnote runtime 依赖。
- [ ] 不把 `direct_block_edit_operations` / `local_rule` planner 当作长期 AI 编辑路径(当前为过渡实现,将被 `mnote.doc.markdown_edit` 替代)
- [ ] 不新增 `mnote.block.*` 工具;`mnote.block.*` 保留为结构性辅助,不做块操作向新工具的扩张。
- [x] 不优先增加新的编辑器 UI 大功能。
- [x] 不优先新增 AI Agent 工作流功能。
- [x] 不优先扩展 AI 功能面;AI 只做基础上下文、工具合同、审阅会话、冲突与回滚。
- [x] 不优先大规模替换 Convex。
- [x] 不优先重写 `leptos-tiptap` 输入层。
- [x] 不把 `mnote.page.save` 包装成精确块编辑长期方案。
- [x] 不在 compat route 继续扩写长期业务语义。
- [x] 不把 BlockNote / Tiptap AI runtime 作为 mnote runtime 依赖。
- [x] 不把 `direct_block_edit_operations` / `local_rule` planner 当作当前或长期 AI 编辑路径;简单正文编辑当前主路径是 `mnote.doc.markdown_edit`
- [x] 不新增 `mnote.block.*` 工具;`mnote.block.*` 保留为结构性辅助,不做块操作向新工具的扩张。
2026-05-18 静态边界核验证据:
- `rg -n "mnote\\.block\\.[a-zA-Z0-9_]+" rust/crates/mnote-web/src/hermes_tools rust/crates/mnote-web/src/routes/hermes_tools.rs scripts/reasonix-acp-wrapper.mjs | ... | sort -u` 仅发现既有 `mnote.block.fetch/delete/replace/insert_after/move_after`,本轮未新增 `mnote.block.*` 工具。
- `page_ai_workflow.rs` 注释与当前实现仍指向 `mnote.doc.markdown_edit``direct_block_edit_operations` 只保留为历史/测试辅助,不作为当前主路径。
- `mnote.page.save` 在 manifest / 7-10 / 7-12 / 本 checklist 中继续描述为页面级粗粒度兜底,不是精确块编辑主入口。
- 未引入 `@blocknote/xl-ai`、BlockNote `AIExtension` 或新的 AI Agent workflow;本轮改动仅限 Hermes tools 合同、阻断矩阵测试和 checklist 证据。
2026-05-18 `7-12` runtime 口径闭合证据:
- `design/07-ai/process/7-12-page-ai-hermes-tool-routing-and-review-surface-v1.md` 已明确 `7-11` 旧“自有 AI runtime”口径移入 `design/old/07-ai/process/`,当前执行口径以 `7-12` 为准。
- `7-12` 已定义 `PageAIContextBuilder``MnoteAIToolManifestProvider``PageAICommandRouter``PageAIReviewSession` 的边界,并明确 Phase C review / streaming apply 只做设计冻结,当前不实施。
- `7-12` Phase B 到 Phase F 保留 manifest / router / review session / event / smoke 的后续 checklist;其中 selection 外写入 blocked 已有真实 smoke 证据,其余未实施项继续保留未勾。
2026-05-18 `7-10` 剩余真实页面矩阵拆分:
- 新增 `design/07-ai/process/7-16-page-block-ai-real-smoke-followup-matrix-v1.md`,专门承接 `7-10` 中仍未完成的真实 3000 smoke 和 UI/review 边界矩阵。
- `7-10` 继续保留为页面块 AI 工具执行总 checklist`7-16` 只用于后续真实页面证据补齐,不扩新 AI 功能。
2026-05-18 真实页面 insert_after smoke 补充:
- `scripts/task-page-block-ai-tools-smoke.js` 增加 `PLAYWRIGHT_CHROME_EXECUTABLE` 支持,用系统 Chromium 运行,避免当前系统无法下载 Playwright 官方 chromium 的阻断。
- 命令:`PLAYWRIGHT_CHROME_EXECUTABLE=/snap/bin/chromium MNOTE_UI_BASE_URL=http://127.0.0.1:3000 MNOTE_AUTH_BASE_URL=http://127.0.0.1:3000 node scripts/task-page-block-ai-tools-smoke.js`
- 证据:`tmp/page-block-ai-tools-smoke/mpag4966.json`,截图:`tmp/page-block-ai-tools-smoke/mpag4966-page.png`
- Page Aggregate`tmp/hermes-tester/page-block-ai-tools-mpag4966/page-aggregate.json`doc/fetch/find`tmp/hermes-tester/page-block-ai-tools-mpag4966/doc-fetch-find.json`
- 覆盖:`mnote.block.insert_after` 插入 `todo` 块、插入顺序、`mnote.block.fetch` 回读新块、重复 `idempotencyKey` 不二次插入、刷新后文本可见。
- 覆盖:`table_1/mindmap_1/resource_1` 在 AI block projection 中返回 `editable=false``unsupportedReason=复杂块暂不开放 AI 精确写入``heading_parent/list_item_1/table_1/mindmap_1/resource_1``plan_update block_move_after` dry-run 与正式 `mnote.block.move_after` 均阻断,阻断后 revision / 正文不变化。
2026-05-18 真实页面 doc.fetch / doc.find / block.fetch smoke 补充:
- 命令:`PLAYWRIGHT_CHROME_EXECUTABLE=/snap/bin/chromium MNOTE_UI_BASE_URL=http://127.0.0.1:3000 MNOTE_AUTH_BASE_URL=http://127.0.0.1:3000 node scripts/task-page-block-ai-tools-smoke.js`
- 证据:`tmp/page-block-ai-tools-smoke/mpag4966.json`,截图:`tmp/page-block-ai-tools-smoke/mpag4966-page.png`
- 覆盖:`doc.fetch full``doc.fetch outline``doc.find` 定位 `p_2``maxBlocks=2` 截断、`block.fetch includeChildren=true contextBefore=1 contextAfter=1` 返回 `revisionRef` 与同父级 before/after;复杂块投影不伪装完全可编辑。
2026-05-18 真实页面 plan_update / replace / move smoke 补充:
- 命令:`PLAYWRIGHT_CHROME_EXECUTABLE=/snap/bin/chromium MNOTE_UI_BASE_URL=http://127.0.0.1:3000 MNOTE_AUTH_BASE_URL=http://127.0.0.1:3000 node scripts/task-page-block-ai-tools-smoke.js`
- 证据:`tmp/page-block-ai-tools-smoke/mpag4966.json`,截图:`tmp/page-block-ai-tools-smoke/mpag4966-page.png`
- 覆盖:`block_replace/block_insert_after/block_move_after` dry-run、dry-run 不写入、blocked 场景、replace 相邻块不变与 id 保持、replace 后 Page Aggregate 回读持久化、复杂块 move 阻断、普通块 move dry-run 与正式移动、moving block id 保持、刷新后 block ids 稳定。
---
@@ -514,18 +588,18 @@ MNOTE_UI_BASE_URL=http://127.0.0.1:3000 MNOTE_AUTH_BASE_URL=http://127.0.0.1:300
本 checklist 不能迁入 `done/`,直到:
- [ ] Page Aggregate 的标题 / 正文 / 页面设置 / page tree / AI fetch 回读形成同一组可验证真相。
- [ ] `7-10` 的 Page Block AI Tooling 剩余矩阵完成或明确拆出后续 process 文档。
- [ ] `7-11` 的 AI 基础 runtime 口径完成:context、format、manifest annotations、review session、状态机边界都有代码或明确后续 checklist。
- [ ] 7-14 v2 Phase A + B 全部完成(`mnote.doc.markdown_edit` + `mnote.doc.fetch` 增强 + `page_ai_workflow.rs` 收口);Phase C 设计已冻结。设计稿修正已由 7-14 v2 自行完成。
- [ ] `tree.*` command 面对主要页面生命周期动作成为唯一 preferred runtime 主链。
- [ ] tree realtime live cache 覆盖 Sidebar、Page Tree、File Tree、page subtree 的关键写后更新。
- [ ] 至少一轮定向 bug hunt 完成,所有 P0/P1 blocker 已归档到 `bugs/*/done/` 或明确保留为后续 process。
- [x] Page Aggregate 的标题 / 正文 / 页面设置 / page tree / AI fetch 回读形成同一组可验证真相。
- [x] `7-10` 的 Page Block AI Tooling 剩余矩阵完成或明确拆出后续 process 文档。
- [x] `7-12` 的 AI 基础 runtime 口径完成:context、format、manifest annotations、review session、状态机边界都有代码或明确后续 checklist。
- [x] 7-14 v2 Phase A + B 当前主路径完成(`mnote.doc.markdown_edit` + `mnote.doc.fetch` 增强 + `page_ai_workflow.rs` 收口);Phase C 设计已冻结。设计稿修正已由 7-14 v2 `7-18``7-25` bug 修复共同完成。
- [x] `tree.*` command 面对主要页面生命周期动作成为唯一 preferred runtime 主链。
- [x] tree realtime live cache 覆盖 Sidebar、Page Tree、File Tree、page subtree 的关键写后更新。
- [x] 至少一轮定向 bug hunt 完成,所有 P0/P1 blocker 已归档到 `bugs/*/done/` 或明确保留为后续 process。
---
## 6. 给后续 /goal 的持续执行 Prompt
```text
/goal objective: 持续执行 /mnt/Data1T/mnote/design/10-review/process/08-kernel-architecture-next-priority-review-and-checklist.md,按 P0 -> P1 顺序推进 MNOTE 架构收口、AI 基础底座和定向 bug hunt。每轮开始先读取 /home/lix/.codex/memories/PROFILE.md 与 ACTIVE.md,再读取 AGENTS.md、ARCHITECTURE.md、design/01-05-current-priority-overview.md、本 checklist、design/07-ai/process/7-10-page-block-ai-tooling-execution-checklist-v1.md、design/07-ai/process/7-12-page-ai-hermes-tool-routing-and-review-surface-v1.md、design/07-ai/process/7-14-online-local-ai-markdown-editing-convergence-v1.md。必须保护用户已有未提交改动,不回滚、不覆盖、不删除无关文件。优先使用多个 subagent 并行做只读审查和浏览器验证,主线程只整合证据和做小范围实现。执行顺序固定为:1) Page Aggregate 单一真源;2) tree command cutover3) tree realtime live cache4) AI 基础工具与审阅底座,`7-14` v2 方向优先做 Phase A`mnote.doc.markdown_edit` + `mnote.doc.fetch` 增强),Phase B 退役 `local_rule` planner + `page_ai_workflow.rs` 收口;Phase C(流式 apply + suggest/review)仅设计冻结不实施。同时补 context/selection/page_xml/tool annotations,不扩新 AI 功能;5) 定向 bug hunt。每完成一个小项都要更新本 checklist 和对应 design/bugs 文档,补真实验证命令或证据路径。不要优先扩新功能,不要大改架构,不要把 compat/debug/fallback 当主链,不要把 BlockNote/Tiptap AI runtime 作为 mnote runtime 依赖。验证至少包含 git diff --check、相关 cargo test / smoke;如涉及 3000 页面,使用 mnote-tester 或浏览器自动化并保留截图/JSON 证据。
/goal objective: 持续核验 /mnt/Data1T/mnote/design/10-review/done/08-kernel-architecture-next-priority-review-and-checklist.md 的已归档结论,按 P0 -> P1 顺序推进 MNOTE 架构防回归、AI 基础底座和定向 bug hunt。每轮开始先读取 /home/lix/.codex/memories/PROFILE.md 与 ACTIVE.md,再读取 AGENTS.md、ARCHITECTURE.md、design/01-05-current-priority-overview.md、本 checklist、design/07-ai/process/7-10-page-block-ai-tooling-execution-checklist-v1.md、design/07-ai/process/7-12-page-ai-hermes-tool-routing-and-review-surface-v1.md、design/07-ai/process/7-14-online-local-ai-markdown-editing-convergence-v1.md。必须保护用户已有未提交改动,不回滚、不覆盖、不删除无关文件。优先使用多个 subagent 并行做只读审查和浏览器验证,主线程只整合证据和做小范围实现。执行顺序固定为:1) Page Aggregate 单一真源防回归2) tree command cutover 防回归3) tree realtime live cache 防回归4) AI 基础工具与审阅底座,当前简单正文编辑主路径已是 `mnote.doc.markdown_edit` + `mnote.doc.fetch` + `page_ai_workflow.rs` 统一 tool executorPhase C(流式 apply + suggest/review)仅设计冻结不实施;继续补 context/selection/page_xml/tool annotations,不扩新 AI 功能;5) 定向 bug hunt。每完成一个小项都要更新对应 design/bugs 文档,补真实验证命令或证据路径。不要优先扩新功能,不要大改架构,不要把 compat/debug/fallback 当主链,不要把 BlockNote/Tiptap AI runtime 作为 mnote runtime 依赖。验证至少包含 scoped git diff --check、相关 cargo test / smoke;如涉及 3000 页面,使用 mnote-tester 或浏览器自动化并保留截图/JSON 证据。
```
@@ -1,6 +1,6 @@
# 09 页面 AI 快速块编辑 Runtime Review
> 状态:`process`
> 状态:`done`
>
> 日期:2026-05-16
>
@@ -12,6 +12,19 @@
---
## 0. 归档说明
本文件是 2026-05-16 的页面 AI fast block edit 历史审查快照。其记录的 `local_rule` / `doc_apply_block_ops` 快路径已被后续 `mnote.doc.markdown_edit` 主路径替代,不再作为当前 runtime 口径。
当前有效口径见:
- [10-current-mnote-ai-runtime-review-v1](./10-current-mnote-ai-runtime-review-v1.md)
- [11-current-full-architecture-review-v1](./11-current-full-architecture-review-v1.md)
- [7-18 AI markdown_edit 阶段状态合同漂移](../../../bugs/07-ai/done/7-18-ai-markdown-edit-phase-state-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)
归档后的结论:`page-ai/block-edit-workflow` 仍是简单编辑 fast-path 入口,但当前实现应通过模型生成 markdown search/replace / full_content 后调用 `mnote.doc.markdown_edit`,并复用统一 mnote tool executor`mnote.doc.apply_block_ops` / `mnote.block.*` 只作为结构性块操作辅助。
## 1. 本轮结论
页面 AI 块写入的慢点不在 Rust 块工具本身,也不在 Convex 持久化本身。
@@ -1,22 +1,24 @@
# 10 [process] 当前 mnote 项目 AI / Page Aggregate 定向 Review v1
# 10 [done] 当前 mnote 项目 AI / Page Aggregate 定向 Review v1
> 更新时间:2026-05-17
>
> 执行状态:`process`
> 执行状态:`done`
>
> 范围:当前主线中 Page Aggregate 单一真源、页面 AI 快速编辑、`mnote.doc.markdown_edit` 与 ACP / Hermes runtime 的源码级定向审查。
## 1. 本轮结论
本轮没有发现 Page Aggregate 读取主链重新回退到 Next compat builder 的证据`wolai-frontend/src/lib/documents/page-aggregate-loader.ts` 仍只走 Rust `/api/page-aggregate/:documentId``/api/documents/page` compat 读链保持 `410` 退场口径
本轮没有发现 Page Aggregate 读取主链重新回退到 Next compat builder 的证据。后续修复已进一步收口:AI context 的 page subtree 只读 Rust Page Aggregate projection,不再由前端本地构造第二份 page tree 真相
新的风险集中在 `07-ai` markdown 编辑收敛实现
本轮识别出的 `07-ai` markdown 编辑 P0 风险已修复
- `mnote.doc.markdown_edit` 已进入实际写入主线,并被 `/api/page-ai/block-edit-workflow` 调用。
- 该工具当前先在 markdown 字符串上顺序执行 search/replace,再把原始 operations 重新转换成 `mnote.doc.apply_block_ops`
- 转换层仍按“单块文本包含 search”查找目标块,无法忠实表达 markdown 层已经算出的最终结果
- `mnote.doc.markdown_edit` 是简单正文编辑主路径,并被 `/api/page-ai/block-edit-workflow` 通过统一 mnote tool executor 调用。
- 中文归一化匹配不再把 UTF-8 byte index 当 char index
- 同一块内多次 markdown_edit 会合并到最终 markdown 结果后再映射写回,避免后续 block op 覆盖前序修改
- 在线写回在无法安全映射最终 markdown 时明确拒绝,避免 `ok=true` 伪成功。
- Hermes guidance、manifest、tool toggle、dryRun / idempotency、revision / conflictDetectionKey 与 Reasonix ACP payload 已同步到当前工具合同。
因此,当前 AI 编辑主线不能只看 `operationsApplied > 0` 或 route 返回 `ok=true`。需要优先补充 `markdown_edit` 的中文、多操作、同块、多块、full_content 与失败原子性测试
剩余长期方向仍是 Phase C review session / streaming apply;当前设计冻结,不属于本轮 bug 修复范围
## 2. 发现的问题
@@ -24,7 +26,7 @@
关联缺陷:
- `bugs/07-ai/process/7-16-markdown-edit-normalized-search-byte-index-v1.md`
- `bugs/07-ai/done/7-16-markdown-edit-normalized-search-byte-index-v1.md`
证据:
@@ -41,7 +43,7 @@
关联缺陷:
- `bugs/07-ai/process/7-17-markdown-edit-same-block-multi-op-overwrite-v1.md`
- `bugs/07-ai/done/7-17-markdown-edit-same-block-multi-op-overwrite-v1.md`
证据:
@@ -58,18 +60,20 @@
- AI 一次请求中常见的“把同一段里的 A 改成 B,同时把 C 改成 D”可能只保留最后一次修改。
- 前端快路径仍可能显示“已通过页面 markdown 编辑快路径完成写入”,但正文只部分生效。
## 3. 次级风险
## 3. 修复状态
- `doc_markdown_edit` 当前允许部分 operation 失败后继续写入已成功的子集,并返回 `ok=true`。如果这是有意设计,需要在 manifest / UI 中明确“非原子”;如果不是,应改为任一 operation 失败时不写入
- `changedText` 通过 `operations.iter().take(applied)` 生成摘要;当前如果第一个 operation 失败、第二个成功,摘要会错误地展示第一个失败 operation
- `/api/page-ai/block-edit-workflow` 的系统 prompt 已改成 search/replace,但仍强依赖模型精确复制 `pageText`;一旦模型输出跨块片段,Convex 写回层不能表达该编辑
- `mnote.doc.markdown_edit` manifest 已补齐 required / write contract
- 本地 `.md` 写入已尊重 dryRun / idempotency
- 在线写回已使用最终 markdown 作为真源进行映射;无法安全映射时返回明确错误
- `mnote.doc.apply_block_ops` 批量写入已补 revision / conflictDetectionKey 前置校验。
- `/api/page-ai/block-edit-workflow` 已从绕过 executor 改为复用统一 Hermes mnote tool executor。
- ACP Reasonix wrapper 已透传 actor/session/run/toolCall/trace/dryRun/idempotency/workspace/document 等上下文字段。
## 4. 建议下一步
## 4. 后续边界
1. 先给 `search_replace` 补中文归一化替换单测,再修正 byte / char offset 映射
2. `doc_markdown_edit` 补同一 block 多 operation 的失败用例,明确应以最终 markdown 生成落库内容,或在转换层合并同块操作
3. 明确 `markdown_edit` 的失败原子性:默认建议任一 operation 失败时不写入,除非请求显式允许 partial apply
4. 将 `/api/page-ai/block-edit-workflow` 的验收从“route 成功”提升为“Page Aggregate 回读与预期最终 markdown 一致”。
1. Phase C 的 review session / streaming apply 仍冻结,当前不扩新 AI 功能
2. 复杂结构编辑继续走 `apply_block_ops` / `mnote.block.*`,不要把正文 search/replace 回退到结构性块工具
3. Page Aggregate 仍需后续推进 EditorBlockDocument 原生落库真相
## 5. 本轮验证
@@ -80,9 +84,15 @@ rg -n "markdown_edit|apply_block_ops|block-edit-workflow|local_rule|page_ai_work
rg -n "page-aggregate|pageAggregate|PageAggregate|blockDocument|projectionSource|documents/save|documents/options|/api/page-aggregate" rust/crates/mnote-web rust/crates/bridge-runtime wolai-frontend/src scripts --glob '!rust/target/**' --glob '!node_modules/**'
```
本轮是 review / bug hunt,没有修改 runtime 源码。后续修复应至少补
修复后定向验证
```bash
cargo test --manifest-path rust/Cargo.toml -p mnote-web markdown_edit
cargo test --manifest-path rust/Cargo.toml -p mnote-web page_ai_workflow
cargo test --manifest-path rust/Cargo.toml -p mnote-web markdown_edit -- --nocapture
cargo test --manifest-path rust/Cargo.toml -p mnote-web page_ai_workflow -- --nocapture
cargo test --manifest-path rust/Cargo.toml -p mnote-web acp_client -- --nocapture
cargo test --manifest-path rust/Cargo.toml -p mnote-web acp_session_manager -- --nocapture
node --check scripts/reasonix-acp-wrapper.mjs
MNOTE_REASONIX_ACP_SELFTEST=1 node scripts/reasonix-acp-wrapper.mjs
```
结果:上述验证均通过。
@@ -0,0 +1,53 @@
# 11 [done] 当前完整架构 Review v1
> 更新时间:2026-05-17
>
> 状态:`done`
>
> 参考总文档:[CURRENT_ARCHITECTURE.md](../../../CURRENT_ARCHITECTURE.md)
## 1. 结论
当前 mnote 的主线架构已经成形。本轮 review 识别出的 P0 / 内核优先缺陷已完成代码修复、文档迁移和定向验证。
已修复的四个关键冲突:
1. Tree realtime 的发布口径与前端消费口径不一致。
2. FileTree 资源行误走页面命令。
3. Page Aggregate / AI context 保留前端第二真相。
4. AI 写入链路在 `markdown_edit``apply_block_ops`、Hermes / ACP / Reasonix 之间的合同漂移。
仍需后续主线继续推进的不是本轮 P0 bug,而是长期架构收口:Page Aggregate 原生 EditorBlockDocument 落库、正文保存主命令面、`tree.resource.*` 资源生命周期和兼容路由瘦身。
## 2. 关键判断
- Rust kernel 与 bridge-runtime 已经承担语义主导权。
- Rust SSR 主壳已默认以 `/api/realtime/ws` 为实时主链,SSE 是 fallback。
- FileTree 资源行的 owner document 与页面命令目标已分离。
- AI context 的 page subtree 已只读 Rust Page Aggregate projection。
- `mnote.doc.markdown_edit` 已是简单正文编辑主路径,`page_ai_workflow` 复用统一 mnote tool executor`apply_block_ops` / `mnote.block.*` 仅作为结构性辅助。
- ACP / Reasonix 的 mnote tool payload、response、run context 与幂等字段已同步到当前工具合同。
## 3. 已闭合 bug
- Rust Web / realtime / ACP`bugs/03-rust-web/done/3-16``3-21`
- Tree domain`bugs/04-tree-domain/done/4-46`
- Editor mainline`bugs/05-editor-mainline/done/5-15``5-18`
- AI`bugs/07-ai/done/7-18``7-25`
## 4. 验证
- `find bugs -path '*/process/*' -type f | wc -l`
- 结果:`0`
- `cargo test --manifest-path rust/Cargo.toml -p mnote-web markdown_edit -- --nocapture`
- 结果:9 passed
- `cargo test --manifest-path rust/Cargo.toml -p mnote-web page_ai_workflow -- --nocapture`
- 结果:3 passed
- `cargo test --manifest-path rust/Cargo.toml -p mnote-web acp_client -- --nocapture`
- 结果:6 passed
- `cargo test --manifest-path rust/Cargo.toml -p mnote-web ssr::pages::layout::tests -- --nocapture`
- 结果:14 passed
## 5. 备注
本次审查的详细架构说明已统一写入根目录:[CURRENT_ARCHITECTURE.md](../../../CURRENT_ARCHITECTURE.md)。
@@ -1,34 +0,0 @@
# 11 [process] 当前完整架构 Review v1
> 更新时间:2026-05-17
>
> 状态:`process`
>
> 参考总文档:[CURRENT_ARCHITECTURE.md](../../../CURRENT_ARCHITECTURE.md)
## 1. 结论
当前 mnote 的主线架构已经成形,但还没有完成单一真源收口。最关键的未闭合点有四个:
1. Tree realtime 的发布口径与前端消费口径不一致。
2. FileTree 资源行仍可能误走页面命令。
3. Page Aggregate 仍保留前端第二真相。
4. AI 写入链路在 `markdown_edit``apply_block_ops`、Hermes / ACP / Reasonix 之间存在合同漂移。
## 2. 关键判断
- Rust kernel 与 bridge-runtime 已经承担语义主导权。
- 前端仍保留若干本地派生状态,适合过渡,不适合作为长期真相层。
- `mnote.doc.markdown_edit` 已是主推路径,但工具级 contract 还没收口。
- `page_ai_workflow` 与 ACP / Reasonix 已接入主链,但运行时 owner 与写入字段不统一。
## 3. 需要继续跟踪的 bug
- Rust Web / realtime / ACP`bugs/03-rust-web/process/3-16``3-21`
- Tree domain`bugs/04-tree-domain/process/4-46`
- Editor mainline`bugs/05-editor-mainline/process/5-15``5-18`
- AI`bugs/07-ai/process/7-18``7-25`
## 4. 备注
本次审查的详细架构说明已统一写入根目录:[CURRENT_ARCHITECTURE.md](../../../CURRENT_ARCHITECTURE.md)。
+4582 -4
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -15,6 +15,7 @@
"dependencies": {},
"devDependencies": {
"@playwright/test": "^1.56.1",
"convex": "^1.39.1",
"electron": "^34.0.0",
"electron-builder": "^26.0.12",
"playwright": "^1.56.1"
+320
View File
@@ -11,6 +11,9 @@ importers:
'@playwright/test':
specifier: ^1.56.1
version: 1.57.0
convex:
specifier: ^1.39.1
version: 1.39.1
electron:
specifier: ^34.0.0
version: 34.5.8
@@ -66,6 +69,162 @@ packages:
engines: {node: '>=14.14'}
hasBin: true
'@esbuild/aix-ppc64@0.27.0':
resolution: {integrity: sha512-KuZrd2hRjz01y5JK9mEBSD3Vj3mbCvemhT466rSuJYeE/hjuBrHfjjcjMdTm/sz7au+++sdbJZJmuBwQLuw68A==}
engines: {node: '>=18'}
cpu: [ppc64]
os: [aix]
'@esbuild/android-arm64@0.27.0':
resolution: {integrity: sha512-CC3vt4+1xZrs97/PKDkl0yN7w8edvU2vZvAFGD16n9F0Cvniy5qvzRXjfO1l94efczkkQE6g1x0i73Qf5uthOQ==}
engines: {node: '>=18'}
cpu: [arm64]
os: [android]
'@esbuild/android-arm@0.27.0':
resolution: {integrity: sha512-j67aezrPNYWJEOHUNLPj9maeJte7uSMM6gMoxfPC9hOg8N02JuQi/T7ewumf4tNvJadFkvLZMlAq73b9uwdMyQ==}
engines: {node: '>=18'}
cpu: [arm]
os: [android]
'@esbuild/android-x64@0.27.0':
resolution: {integrity: sha512-wurMkF1nmQajBO1+0CJmcN17U4BP6GqNSROP8t0X/Jiw2ltYGLHpEksp9MpoBqkrFR3kv2/te6Sha26k3+yZ9Q==}
engines: {node: '>=18'}
cpu: [x64]
os: [android]
'@esbuild/darwin-arm64@0.27.0':
resolution: {integrity: sha512-uJOQKYCcHhg07DL7i8MzjvS2LaP7W7Pn/7uA0B5S1EnqAirJtbyw4yC5jQ5qcFjHK9l6o/MX9QisBg12kNkdHg==}
engines: {node: '>=18'}
cpu: [arm64]
os: [darwin]
'@esbuild/darwin-x64@0.27.0':
resolution: {integrity: sha512-8mG6arH3yB/4ZXiEnXof5MK72dE6zM9cDvUcPtxhUZsDjESl9JipZYW60C3JGreKCEP+p8P/72r69m4AZGJd5g==}
engines: {node: '>=18'}
cpu: [x64]
os: [darwin]
'@esbuild/freebsd-arm64@0.27.0':
resolution: {integrity: sha512-9FHtyO988CwNMMOE3YIeci+UV+x5Zy8fI2qHNpsEtSF83YPBmE8UWmfYAQg6Ux7Gsmd4FejZqnEUZCMGaNQHQw==}
engines: {node: '>=18'}
cpu: [arm64]
os: [freebsd]
'@esbuild/freebsd-x64@0.27.0':
resolution: {integrity: sha512-zCMeMXI4HS/tXvJz8vWGexpZj2YVtRAihHLk1imZj4efx1BQzN76YFeKqlDr3bUWI26wHwLWPd3rwh6pe4EV7g==}
engines: {node: '>=18'}
cpu: [x64]
os: [freebsd]
'@esbuild/linux-arm64@0.27.0':
resolution: {integrity: sha512-AS18v0V+vZiLJyi/4LphvBE+OIX682Pu7ZYNsdUHyUKSoRwdnOsMf6FDekwoAFKej14WAkOef3zAORJgAtXnlQ==}
engines: {node: '>=18'}
cpu: [arm64]
os: [linux]
'@esbuild/linux-arm@0.27.0':
resolution: {integrity: sha512-t76XLQDpxgmq2cNXKTVEB7O7YMb42atj2Re2Haf45HkaUpjM2J0UuJZDuaGbPbamzZ7bawyGFUkodL+zcE+jvQ==}
engines: {node: '>=18'}
cpu: [arm]
os: [linux]
'@esbuild/linux-ia32@0.27.0':
resolution: {integrity: sha512-Mz1jxqm/kfgKkc/KLHC5qIujMvnnarD9ra1cEcrs7qshTUSksPihGrWHVG5+osAIQ68577Zpww7SGapmzSt4Nw==}
engines: {node: '>=18'}
cpu: [ia32]
os: [linux]
'@esbuild/linux-loong64@0.27.0':
resolution: {integrity: sha512-QbEREjdJeIreIAbdG2hLU1yXm1uu+LTdzoq1KCo4G4pFOLlvIspBm36QrQOar9LFduavoWX2msNFAAAY9j4BDg==}
engines: {node: '>=18'}
cpu: [loong64]
os: [linux]
'@esbuild/linux-mips64el@0.27.0':
resolution: {integrity: sha512-sJz3zRNe4tO2wxvDpH/HYJilb6+2YJxo/ZNbVdtFiKDufzWq4JmKAiHy9iGoLjAV7r/W32VgaHGkk35cUXlNOg==}
engines: {node: '>=18'}
cpu: [mips64el]
os: [linux]
'@esbuild/linux-ppc64@0.27.0':
resolution: {integrity: sha512-z9N10FBD0DCS2dmSABDBb5TLAyF1/ydVb+N4pi88T45efQ/w4ohr/F/QYCkxDPnkhkp6AIpIcQKQ8F0ANoA2JA==}
engines: {node: '>=18'}
cpu: [ppc64]
os: [linux]
'@esbuild/linux-riscv64@0.27.0':
resolution: {integrity: sha512-pQdyAIZ0BWIC5GyvVFn5awDiO14TkT/19FTmFcPdDec94KJ1uZcmFs21Fo8auMXzD4Tt+diXu1LW1gHus9fhFQ==}
engines: {node: '>=18'}
cpu: [riscv64]
os: [linux]
'@esbuild/linux-s390x@0.27.0':
resolution: {integrity: sha512-hPlRWR4eIDDEci953RI1BLZitgi5uqcsjKMxwYfmi4LcwyWo2IcRP+lThVnKjNtk90pLS8nKdroXYOqW+QQH+w==}
engines: {node: '>=18'}
cpu: [s390x]
os: [linux]
'@esbuild/linux-x64@0.27.0':
resolution: {integrity: sha512-1hBWx4OUJE2cab++aVZ7pObD6s+DK4mPGpemtnAORBvb5l/g5xFGk0vc0PjSkrDs0XaXj9yyob3d14XqvnQ4gw==}
engines: {node: '>=18'}
cpu: [x64]
os: [linux]
'@esbuild/netbsd-arm64@0.27.0':
resolution: {integrity: sha512-6m0sfQfxfQfy1qRuecMkJlf1cIzTOgyaeXaiVaaki8/v+WB+U4hc6ik15ZW6TAllRlg/WuQXxWj1jx6C+dfy3w==}
engines: {node: '>=18'}
cpu: [arm64]
os: [netbsd]
'@esbuild/netbsd-x64@0.27.0':
resolution: {integrity: sha512-xbbOdfn06FtcJ9d0ShxxvSn2iUsGd/lgPIO2V3VZIPDbEaIj1/3nBBe1AwuEZKXVXkMmpr6LUAgMkLD/4D2PPA==}
engines: {node: '>=18'}
cpu: [x64]
os: [netbsd]
'@esbuild/openbsd-arm64@0.27.0':
resolution: {integrity: sha512-fWgqR8uNbCQ/GGv0yhzttj6sU/9Z5/Sv/VGU3F5OuXK6J6SlriONKrQ7tNlwBrJZXRYk5jUhuWvF7GYzGguBZQ==}
engines: {node: '>=18'}
cpu: [arm64]
os: [openbsd]
'@esbuild/openbsd-x64@0.27.0':
resolution: {integrity: sha512-aCwlRdSNMNxkGGqQajMUza6uXzR/U0dIl1QmLjPtRbLOx3Gy3otfFu/VjATy4yQzo9yFDGTxYDo1FfAD9oRD2A==}
engines: {node: '>=18'}
cpu: [x64]
os: [openbsd]
'@esbuild/openharmony-arm64@0.27.0':
resolution: {integrity: sha512-nyvsBccxNAsNYz2jVFYwEGuRRomqZ149A39SHWk4hV0jWxKM0hjBPm3AmdxcbHiFLbBSwG6SbpIcUbXjgyECfA==}
engines: {node: '>=18'}
cpu: [arm64]
os: [openharmony]
'@esbuild/sunos-x64@0.27.0':
resolution: {integrity: sha512-Q1KY1iJafM+UX6CFEL+F4HRTgygmEW568YMqDA5UV97AuZSm21b7SXIrRJDwXWPzr8MGr75fUZPV67FdtMHlHA==}
engines: {node: '>=18'}
cpu: [x64]
os: [sunos]
'@esbuild/win32-arm64@0.27.0':
resolution: {integrity: sha512-W1eyGNi6d+8kOmZIwi/EDjrL9nxQIQ0MiGqe/AWc6+IaHloxHSGoeRgDRKHFISThLmsewZ5nHFvGFWdBYlgKPg==}
engines: {node: '>=18'}
cpu: [arm64]
os: [win32]
'@esbuild/win32-ia32@0.27.0':
resolution: {integrity: sha512-30z1aKL9h22kQhilnYkORFYt+3wp7yZsHWus+wSKAJR8JtdfI76LJ4SBdMsCopTR3z/ORqVu5L1vtnHZWVj4cQ==}
engines: {node: '>=18'}
cpu: [ia32]
os: [win32]
'@esbuild/win32-x64@0.27.0':
resolution: {integrity: sha512-aIitBcjQeyOhMTImhLZmtxfdOcuNRpwlPNmlFKPcHQYPhEssw75Cl1TSXJXpMkzaua9FUetx/4OQKq7eJul5Cg==}
engines: {node: '>=18'}
cpu: [x64]
os: [win32]
'@isaacs/balanced-match@4.0.1':
resolution: {integrity: sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ==}
engines: {node: 20 || >=22}
@@ -341,6 +500,25 @@ packages:
concat-map@0.0.1:
resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==}
convex@1.39.1:
resolution: {integrity: sha512-W+gVXA7BpRF1xLlS1kGTtKVaqd5yonqbGESKiPtIUXjV744GdDz8IG7RVsSY5KzHbgxuJBHKaJYk+92OIHTskQ==}
engines: {node: '>=18.0.0', npm: '>=7.0.0'}
hasBin: true
peerDependencies:
'@auth0/auth0-react': ^2.0.1
'@clerk/clerk-react': ^4.12.8 || ^5.0.0
'@clerk/react': ^6.4.3
react: ^18.0.0 || ^19.0.0-0 || ^19.0.0
peerDependenciesMeta:
'@auth0/auth0-react':
optional: true
'@clerk/clerk-react':
optional: true
'@clerk/react':
optional: true
react:
optional: true
core-util-is@1.0.2:
resolution: {integrity: sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==}
@@ -483,6 +661,11 @@ packages:
es6-error@4.1.1:
resolution: {integrity: sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg==}
esbuild@0.27.0:
resolution: {integrity: sha512-jd0f4NHbD6cALCyGElNpGAOtWxSq46l9X/sWB0Nzd5er4Kz2YTm+Vl0qKFT9KUJvD8+fiO8AvoHhFvEatfVixA==}
engines: {node: '>=18'}
hasBin: true
escalade@3.2.0:
resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==}
engines: {node: '>=6'}
@@ -976,6 +1159,11 @@ packages:
engines: {node: '>=14.0.0'}
hasBin: true
prettier@3.8.3:
resolution: {integrity: sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==}
engines: {node: '>=14'}
hasBin: true
proc-log@5.0.0:
resolution: {integrity: sha512-Azwzvl90HaF0aCz1JrDdXQykFakSSNPaPoiZ9fm5qJIMHioDZEi7OAdRwSm6rSoPtY3Qutnm3L7ogmg3dc+wbQ==}
engines: {node: ^18.17.0 || >=20.5.0}
@@ -1246,6 +1434,18 @@ packages:
wrappy@1.0.2:
resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==}
ws@8.18.0:
resolution: {integrity: sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==}
engines: {node: '>=10.0.0'}
peerDependencies:
bufferutil: ^4.0.1
utf-8-validate: '>=5.0.2'
peerDependenciesMeta:
bufferutil:
optional: true
utf-8-validate:
optional: true
xmlbuilder@15.1.1:
resolution: {integrity: sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg==}
engines: {node: '>=8.0'}
@@ -1372,6 +1572,84 @@ snapshots:
- supports-color
optional: true
'@esbuild/aix-ppc64@0.27.0':
optional: true
'@esbuild/android-arm64@0.27.0':
optional: true
'@esbuild/android-arm@0.27.0':
optional: true
'@esbuild/android-x64@0.27.0':
optional: true
'@esbuild/darwin-arm64@0.27.0':
optional: true
'@esbuild/darwin-x64@0.27.0':
optional: true
'@esbuild/freebsd-arm64@0.27.0':
optional: true
'@esbuild/freebsd-x64@0.27.0':
optional: true
'@esbuild/linux-arm64@0.27.0':
optional: true
'@esbuild/linux-arm@0.27.0':
optional: true
'@esbuild/linux-ia32@0.27.0':
optional: true
'@esbuild/linux-loong64@0.27.0':
optional: true
'@esbuild/linux-mips64el@0.27.0':
optional: true
'@esbuild/linux-ppc64@0.27.0':
optional: true
'@esbuild/linux-riscv64@0.27.0':
optional: true
'@esbuild/linux-s390x@0.27.0':
optional: true
'@esbuild/linux-x64@0.27.0':
optional: true
'@esbuild/netbsd-arm64@0.27.0':
optional: true
'@esbuild/netbsd-x64@0.27.0':
optional: true
'@esbuild/openbsd-arm64@0.27.0':
optional: true
'@esbuild/openbsd-x64@0.27.0':
optional: true
'@esbuild/openharmony-arm64@0.27.0':
optional: true
'@esbuild/sunos-x64@0.27.0':
optional: true
'@esbuild/win32-arm64@0.27.0':
optional: true
'@esbuild/win32-ia32@0.27.0':
optional: true
'@esbuild/win32-x64@0.27.0':
optional: true
'@isaacs/balanced-match@4.0.1': {}
'@isaacs/brace-expansion@5.0.0':
@@ -1713,6 +1991,15 @@ snapshots:
concat-map@0.0.1: {}
convex@1.39.1:
dependencies:
esbuild: 0.27.0
prettier: 3.8.3
ws: 8.18.0
transitivePeerDependencies:
- bufferutil
- utf-8-validate
core-util-is@1.0.2:
optional: true
@@ -1906,6 +2193,35 @@ snapshots:
es6-error@4.1.1:
optional: true
esbuild@0.27.0:
optionalDependencies:
'@esbuild/aix-ppc64': 0.27.0
'@esbuild/android-arm': 0.27.0
'@esbuild/android-arm64': 0.27.0
'@esbuild/android-x64': 0.27.0
'@esbuild/darwin-arm64': 0.27.0
'@esbuild/darwin-x64': 0.27.0
'@esbuild/freebsd-arm64': 0.27.0
'@esbuild/freebsd-x64': 0.27.0
'@esbuild/linux-arm': 0.27.0
'@esbuild/linux-arm64': 0.27.0
'@esbuild/linux-ia32': 0.27.0
'@esbuild/linux-loong64': 0.27.0
'@esbuild/linux-mips64el': 0.27.0
'@esbuild/linux-ppc64': 0.27.0
'@esbuild/linux-riscv64': 0.27.0
'@esbuild/linux-s390x': 0.27.0
'@esbuild/linux-x64': 0.27.0
'@esbuild/netbsd-arm64': 0.27.0
'@esbuild/netbsd-x64': 0.27.0
'@esbuild/openbsd-arm64': 0.27.0
'@esbuild/openbsd-x64': 0.27.0
'@esbuild/openharmony-arm64': 0.27.0
'@esbuild/sunos-x64': 0.27.0
'@esbuild/win32-arm64': 0.27.0
'@esbuild/win32-ia32': 0.27.0
'@esbuild/win32-x64': 0.27.0
escalade@3.2.0: {}
escape-string-regexp@4.0.0:
@@ -2421,6 +2737,8 @@ snapshots:
commander: 9.5.0
optional: true
prettier@3.8.3: {}
proc-log@5.0.0: {}
progress@2.0.3: {}
@@ -2700,6 +3018,8 @@ snapshots:
wrappy@1.0.2: {}
ws@8.18.0: {}
xmlbuilder@15.1.1: {}
y18n@5.0.8: {}
+220 -50
View File
@@ -10987,6 +10987,7 @@ fn normalize_editor_block_type_for_save(raw_type: &str) -> EditorBlockType {
"toc" | "toc_node" | "tocnode" => EditorBlockType::Toc,
"page_reference" => EditorBlockType::PageReference,
"block_reference" => EditorBlockType::BlockReference,
"resource" | "resource_block" => EditorBlockType::Resource,
_ => EditorBlockType::Paragraph,
}
}
@@ -11105,6 +11106,13 @@ fn normalize_editor_block_from_legacy_path(block: &Value, path: &[usize]) -> Edi
);
}
}
if matches!(normalized_block_type, EditorBlockType::Resource) {
if let Some(props_map) = block.get("props").and_then(Value::as_object) {
props
.extra
.insert("resourceProps".into(), Value::Object(props_map.clone()));
}
}
let text = read_trimmed_string_field(block, &["content"])
.filter(|value| !value.is_empty())
.unwrap_or_else(|| extract_inline_text(block));
@@ -11122,7 +11130,10 @@ fn normalize_editor_block_from_legacy_path(block: &Value, path: &[usize]) -> Edi
}
}
pub fn editor_document_from_legacy_content(document_id: &str, content: &Value) -> EditorBlockDocument {
pub fn editor_document_from_legacy_content(
document_id: &str,
content: &Value,
) -> EditorBlockDocument {
let mut blocks = Vec::<EditorBlock>::new();
let mut root_block_ids = Vec::<String>::new();
for (index, block) in normalize_blocks_from_value(content).iter().enumerate() {
@@ -11336,6 +11347,18 @@ fn legacy_props_from_editor_block(block: &EditorBlock) -> Option<Value> {
props.insert("projectionVersion".into(), json!(projection_version));
}
}
EditorBlockType::Resource => {
if let Some(resource_props) = block
.props
.extra
.get("resourceProps")
.and_then(Value::as_object)
{
for (key, value) in resource_props {
props.insert(key.clone(), value.clone());
}
}
}
_ => {}
}
if let Some(text_align) = block
@@ -11372,6 +11395,7 @@ fn legacy_type_from_editor_block(block: &EditorBlock) -> &'static str {
EditorBlockType::Toc => "toc",
EditorBlockType::PageReference => "page_reference",
EditorBlockType::BlockReference => "block_reference",
EditorBlockType::Resource => "resource",
}
}
@@ -11836,7 +11860,7 @@ mod tests {
},
source: RuntimeSourceWire {
channel: "next-route".into(),
client: "wolai-frontend".into(),
client: "mnote-web".into(),
source_kind: None,
root_uri: None,
workspace_id: None,
@@ -11931,7 +11955,7 @@ mod tests {
},
source: RuntimeSourceWire {
channel: "next-route".into(),
client: "wolai-frontend".into(),
client: "mnote-web".into(),
source_kind: None,
root_uri: None,
workspace_id: None,
@@ -12513,6 +12537,38 @@ mod tests {
);
}
#[test]
fn editor_document_roundtrip_preserves_resource_block_type() {
let content = json!([
{
"id": "resource_1",
"type": "resource",
"props": {
"resourceKind": "asset",
"assetId": "asset_1"
},
"content": "资源块"
}
]);
let document = editor_document_from_legacy_content("doc_1", &content);
assert_eq!(document.blocks[0].block_type, EditorBlockType::Resource);
let legacy = legacy_content_from_editor_document(&document);
assert_eq!(legacy[0]["id"], json!("resource_1"));
assert_eq!(legacy[0]["type"], json!("resource"));
assert_eq!(legacy[0]["props"]["resourceKind"], json!("asset"));
assert_eq!(legacy[0]["props"]["assetId"], json!("asset_1"));
let projection = project_legacy_content_to_block_document("doc_1", &legacy, &json!(1))
.expect("resource block should project");
assert_eq!(projection["blocks"][0]["type"], json!("resource"));
assert_eq!(projection["blocks"][0]["editable"], json!(false));
assert_eq!(
projection["blocks"][0]["unsupportedReason"],
json!("复杂块暂不开放 AI 精确写入")
);
}
#[test]
fn editor_command_apply_insert_preserves_nested_children() {
let content = json!([{
@@ -13186,7 +13242,7 @@ mod tests {
},
source: RuntimeSourceWire {
channel: "next-route".into(),
client: "wolai-frontend".into(),
client: "mnote-web".into(),
source_kind: None,
root_uri: None,
workspace_id: None,
@@ -13772,7 +13828,7 @@ mod tests {
},
source: RuntimeSourceWire {
channel: "next-route".into(),
client: "wolai-frontend".into(),
client: "mnote-web".into(),
source_kind: None,
root_uri: None,
workspace_id: None,
@@ -14469,7 +14525,7 @@ mod tests {
},
source: RuntimeSourceWire {
channel: "next-route".into(),
client: "wolai-frontend".into(),
client: "mnote-web".into(),
source_kind: None,
root_uri: None,
workspace_id: None,
@@ -14570,7 +14626,7 @@ mod tests {
},
source: RuntimeSourceWire {
channel: "next-route".into(),
client: "wolai-frontend".into(),
client: "mnote-web".into(),
source_kind: None,
root_uri: None,
workspace_id: None,
@@ -14676,7 +14732,7 @@ mod tests {
},
source: RuntimeSourceWire {
channel: "next-route".into(),
client: "wolai-frontend".into(),
client: "mnote-web".into(),
source_kind: None,
root_uri: None,
workspace_id: None,
@@ -14743,7 +14799,7 @@ mod tests {
},
source: RuntimeSourceWire {
channel: "next-route".into(),
client: "wolai-frontend".into(),
client: "mnote-web".into(),
source_kind: None,
root_uri: None,
workspace_id: None,
@@ -14885,6 +14941,108 @@ mod tests {
);
}
#[test]
fn page_body_save_artifact_preserves_editor_document_payload() {
let context = demo_context();
let command = RuntimeCommandEnvelopeWire {
name: "page.body.save".into(),
command_id: "cmd_save_artifact_editor_document".into(),
idempotency_key: Some("idem_save_artifact_editor_document".into()),
actor: RuntimeActorWire {
actor_type: "user".into(),
actor_id: "user_1".into(),
session_id: Some("sess_1".into()),
},
source: RuntimeSourceWire {
channel: "next-route".into(),
client: "mnote-web".into(),
source_kind: None,
root_uri: None,
workspace_id: None,
capabilities: Vec::new(),
},
target: Some(RuntimeTargetWire {
workspace_id: Some("ws_1".into()),
page_id: Some("doc_1".into()),
block_id: None,
}),
payload: json!({
"documentId": "doc_1",
"workspaceId": "ws_1",
"revision": 9,
"editorDocument": {
"documentId": "doc_1",
"rootBlockIds": ["editor_block_1"],
"blocks": [{
"blockId": "editor_block_1",
"blockType": "paragraph",
"contentNodes": [{
"payload": {
"type": "text",
"text": "来自 editorDocument artifact"
},
"attrs": {}
}],
"childBlockIds": []
}]
},
"content": [],
"tiptapDocument": {
"type": "doc",
"content": [{
"type": "paragraph",
"attrs": { "blockId": "editor_block_1" },
"content": [{ "type": "text", "text": "来自 tiptapDocument artifact" }]
}]
},
"conflictDetectionKey": "doc_1:9"
}),
preflight_data: None,
reason: Some("保存 artifact provenance".into()),
refs: vec!["page-body-save-artifact-provenance".into()],
dry_run: false,
validate_only: false,
};
let plan = execute_runtime_input(RuntimeInput::Command {
context: context.clone(),
command: command.clone(),
})
.expect("page.body.save plan should build");
let RuntimeExecutionPlan::Command(plan) = plan else {
panic!("expected command plan");
};
let artifacts = build_runtime_command_artifact_plan(
&context,
&command,
&plan,
&json!({"ok": true, "revision": 10}),
"2026-05-18T00:00:00Z",
)
.expect("artifact plan should build");
assert_eq!(
artifacts
.command_log
.payload
.pointer("/editorDocument/rootBlockIds/0"),
Some(&json!("editor_block_1"))
);
assert_eq!(
artifacts
.command_log
.payload
.pointer("/editorDocument/blocks/0/contentNodes/0/payload/text"),
Some(&json!("来自 editorDocument artifact"))
);
assert_eq!(
artifacts
.command_log
.payload
.pointer("/tiptapDocument/content/0/type"),
Some(&json!("paragraph"))
);
}
#[test]
fn blocks_move_command_plan_maps_to_blocks_move() {
let plan = execute_runtime_input(RuntimeInput::Command {
@@ -14900,7 +15058,7 @@ mod tests {
},
source: RuntimeSourceWire {
channel: "next-route".into(),
client: "wolai-frontend".into(),
client: "mnote-web".into(),
source_kind: None,
root_uri: None,
workspace_id: None,
@@ -14995,7 +15153,7 @@ mod tests {
},
source: RuntimeSourceWire {
channel: "next-route".into(),
client: "wolai-frontend".into(),
client: "mnote-web".into(),
source_kind: None,
root_uri: None,
workspace_id: None,
@@ -15161,7 +15319,7 @@ mod tests {
},
source: RuntimeSourceWire {
channel: "next-route".into(),
client: "wolai-frontend".into(),
client: "mnote-web".into(),
source_kind: None,
root_uri: None,
workspace_id: None,
@@ -15250,7 +15408,7 @@ mod tests {
},
source: RuntimeSourceWire {
channel: "next-route".into(),
client: "wolai-frontend".into(),
client: "mnote-web".into(),
source_kind: None,
root_uri: None,
workspace_id: None,
@@ -15443,7 +15601,7 @@ mod tests {
},
source: RuntimeSourceWire {
channel: "next-route".into(),
client: "wolai-frontend".into(),
client: "mnote-web".into(),
source_kind: None,
root_uri: None,
workspace_id: None,
@@ -15491,7 +15649,7 @@ mod tests {
},
source: RuntimeSourceWire {
channel: "next-route".into(),
client: "wolai-frontend".into(),
client: "mnote-web".into(),
source_kind: None,
root_uri: None,
workspace_id: None,
@@ -15560,7 +15718,7 @@ mod tests {
},
source: RuntimeSourceWire {
channel: "next-route".into(),
client: "wolai-frontend".into(),
client: "mnote-web".into(),
source_kind: None,
root_uri: None,
workspace_id: None,
@@ -15629,7 +15787,7 @@ mod tests {
},
source: RuntimeSourceWire {
channel: "next-route".into(),
client: "wolai-frontend".into(),
client: "mnote-web".into(),
source_kind: None,
root_uri: None,
workspace_id: None,
@@ -15698,7 +15856,7 @@ mod tests {
},
source: RuntimeSourceWire {
channel: "next-route".into(),
client: "wolai-frontend".into(),
client: "mnote-web".into(),
source_kind: None,
root_uri: None,
workspace_id: None,
@@ -15755,7 +15913,7 @@ mod tests {
},
source: RuntimeSourceWire {
channel: "next-route".into(),
client: "wolai-frontend".into(),
client: "mnote-web".into(),
source_kind: None,
root_uri: None,
workspace_id: None,
@@ -15919,7 +16077,7 @@ mod tests {
},
source: RuntimeSourceWire {
channel: "next-route".into(),
client: "wolai-frontend".into(),
client: "mnote-web".into(),
source_kind: None,
root_uri: None,
workspace_id: None,
@@ -16029,12 +16187,24 @@ mod tests {
fn documents_lifecycle_aliases_are_marked_as_deprecated_tree_protocol_aliases() {
let cases = [
("documents.create", "tree.node.create", "documents.create"),
("documents.title.update", "tree.node.rename", "documents.title.update"),
(
"documents.title.update",
"tree.node.rename",
"documents.title.update",
),
("documents.move", "tree.subtree.move", "documents.move"),
("documents.delete", "tree.node.archive", "documents.delete"),
("documents.restore", "tree.node.restore", "documents.restore"),
(
"documents.restore",
"tree.node.restore",
"documents.restore",
),
("documents.purge", "tree.node.purge", "documents.purge"),
("documents.copy_tree", "tree.subtree.copy", "documents.copy_tree"),
(
"documents.copy_tree",
"tree.subtree.copy",
"documents.copy_tree",
),
];
for (command_name, preferred_command_name, compat_command_name) in cases {
@@ -16049,7 +16219,7 @@ mod tests {
},
source: RuntimeSourceWire {
channel: "next-route".into(),
client: "wolai-frontend".into(),
client: "mnote-web".into(),
source_kind: None,
root_uri: None,
workspace_id: None,
@@ -16140,7 +16310,7 @@ mod tests {
},
source: RuntimeSourceWire {
channel: "next-route".into(),
client: "wolai-frontend".into(),
client: "mnote-web".into(),
source_kind: None,
root_uri: None,
workspace_id: None,
@@ -16215,7 +16385,7 @@ mod tests {
},
source: RuntimeSourceWire {
channel: "next-route".into(),
client: "wolai-frontend".into(),
client: "mnote-web".into(),
source_kind: None,
root_uri: None,
workspace_id: None,
@@ -16315,7 +16485,7 @@ mod tests {
},
source: RuntimeSourceWire {
channel: "next-route".into(),
client: "wolai-frontend".into(),
client: "mnote-web".into(),
source_kind: None,
root_uri: None,
workspace_id: None,
@@ -16401,7 +16571,7 @@ mod tests {
},
source: RuntimeSourceWire {
channel: "next-route".into(),
client: "wolai-frontend".into(),
client: "mnote-web".into(),
source_kind: None,
root_uri: None,
workspace_id: None,
@@ -16484,7 +16654,7 @@ mod tests {
},
source: RuntimeSourceWire {
channel: "next-route".into(),
client: "wolai-frontend".into(),
client: "mnote-web".into(),
source_kind: None,
root_uri: None,
workspace_id: None,
@@ -16572,7 +16742,7 @@ mod tests {
},
source: RuntimeSourceWire {
channel: "next-route".into(),
client: "wolai-frontend".into(),
client: "mnote-web".into(),
source_kind: None,
root_uri: None,
workspace_id: None,
@@ -16713,7 +16883,7 @@ mod tests {
},
source: RuntimeSourceWire {
channel: "next-route".into(),
client: "wolai-frontend".into(),
client: "mnote-web".into(),
source_kind: None,
root_uri: None,
workspace_id: None,
@@ -16812,7 +16982,7 @@ mod tests {
},
source: RuntimeSourceWire {
channel: "next-route".into(),
client: "wolai-frontend".into(),
client: "mnote-web".into(),
source_kind: None,
root_uri: None,
workspace_id: Some("ws_1".into()),
@@ -16896,7 +17066,7 @@ mod tests {
},
source: RuntimeSourceWire {
channel: "next-route".into(),
client: "wolai-frontend".into(),
client: "mnote-web".into(),
source_kind: None,
root_uri: None,
workspace_id: None,
@@ -16968,7 +17138,7 @@ mod tests {
},
source: RuntimeSourceWire {
channel: "next-route".into(),
client: "wolai-frontend".into(),
client: "mnote-web".into(),
source_kind: None,
root_uri: None,
workspace_id: None,
@@ -17058,7 +17228,7 @@ mod tests {
},
source: RuntimeSourceWire {
channel: "next-route".into(),
client: "wolai-frontend".into(),
client: "mnote-web".into(),
source_kind: None,
root_uri: None,
workspace_id: None,
@@ -17126,7 +17296,7 @@ mod tests {
},
source: RuntimeSourceWire {
channel: "onlyoffice-callback".into(),
client: "wolai-frontend".into(),
client: "mnote-web".into(),
source_kind: None,
root_uri: None,
workspace_id: None,
@@ -17204,7 +17374,7 @@ mod tests {
},
source: RuntimeSourceWire {
channel: "next-route".into(),
client: "wolai-frontend".into(),
client: "mnote-web".into(),
source_kind: None,
root_uri: None,
workspace_id: None,
@@ -17492,7 +17662,7 @@ mod tests {
},
source: RuntimeSourceWire {
channel: "next-route".into(),
client: "wolai-frontend".into(),
client: "mnote-web".into(),
source_kind: None,
root_uri: None,
workspace_id: None,
@@ -17684,7 +17854,7 @@ mod tests {
},
source: RuntimeSourceWire {
channel: "next-route".into(),
client: "wolai-frontend".into(),
client: "mnote-web".into(),
source_kind: None,
root_uri: None,
workspace_id: None,
@@ -17741,7 +17911,7 @@ mod tests {
},
source: RuntimeSourceWire {
channel: "next-route".into(),
client: "wolai-frontend".into(),
client: "mnote-web".into(),
source_kind: None,
root_uri: None,
workspace_id: None,
@@ -17781,7 +17951,7 @@ mod tests {
},
source: RuntimeSourceWire {
channel: "next-route".into(),
client: "wolai-frontend".into(),
client: "mnote-web".into(),
source_kind: None,
root_uri: None,
workspace_id: None,
@@ -17827,7 +17997,7 @@ mod tests {
},
source: RuntimeSourceWire {
channel: "next-route".into(),
client: "wolai-frontend".into(),
client: "mnote-web".into(),
source_kind: None,
root_uri: None,
workspace_id: None,
@@ -17952,7 +18122,7 @@ mod tests {
},
source: RuntimeSourceWire {
channel: "next-route".into(),
client: "wolai-frontend".into(),
client: "mnote-web".into(),
source_kind: None,
root_uri: None,
workspace_id: None,
@@ -18015,7 +18185,7 @@ mod tests {
},
source: RuntimeSourceWire {
channel: "next-route".into(),
client: "wolai-frontend".into(),
client: "mnote-web".into(),
source_kind: None,
root_uri: None,
workspace_id: None,
@@ -18079,7 +18249,7 @@ mod tests {
},
source: RuntimeSourceWire {
channel: "next-route".into(),
client: "wolai-frontend".into(),
client: "mnote-web".into(),
source_kind: None,
root_uri: None,
workspace_id: None,
@@ -18146,7 +18316,7 @@ mod tests {
},
source: RuntimeSourceWire {
channel: "next-route".into(),
client: "wolai-frontend".into(),
client: "mnote-web".into(),
source_kind: None,
root_uri: None,
workspace_id: None,
@@ -18244,7 +18414,7 @@ mod tests {
},
source: RuntimeSourceWire {
channel: "next-route".into(),
client: "wolai-frontend".into(),
client: "mnote-web".into(),
source_kind: None,
root_uri: None,
workspace_id: None,
@@ -18343,7 +18513,7 @@ mod tests {
},
source: RuntimeSourceWire {
channel: "next-route".into(),
client: "wolai-frontend".into(),
client: "mnote-web".into(),
source_kind: None,
root_uri: None,
workspace_id: None,
@@ -18456,7 +18626,7 @@ mod tests {
},
source: RuntimeSourceWire {
channel: "next-route".into(),
client: "wolai-frontend".into(),
client: "mnote-web".into(),
source_kind: None,
root_uri: None,
workspace_id: None,
@@ -18530,7 +18700,7 @@ mod tests {
},
source: RuntimeSourceWire {
channel: "next-route".into(),
client: "wolai-frontend".into(),
client: "mnote-web".into(),
source_kind: None,
root_uri: None,
workspace_id: None,
@@ -19,6 +19,7 @@ pub enum EditorBlockType {
Toc,
PageReference,
BlockReference,
Resource,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
@@ -588,6 +588,18 @@ fn block_to_tiptap_node(
content: Vec::new(),
})
}
EditorBlockType::Resource => Ok(TiptapNode::Paragraph {
attrs: TiptapParagraphAttrs {
block_id: Some(block.block_id.clone()),
text_align: text_align_from_props(&block.props),
mnote_block_type: Some("resource".into()),
mindmap_id: None,
root_node_id: None,
projection_version: None,
extra: block.props.extra.clone(),
},
content,
}),
EditorBlockType::Heading => Ok(TiptapNode::Heading {
attrs: TiptapHeadingAttrs {
level: block.props.heading_level.unwrap_or(1),
@@ -698,6 +710,21 @@ fn node_to_block(
child_block_ids: vec![],
})
}
TiptapNode::Paragraph { attrs, content }
if attrs.mnote_block_type.as_deref() == Some("resource") =>
{
let mut props = props_with_text_align(attrs.text_align.clone());
for (key, value) in &attrs.extra {
props.extra.insert(key.clone(), value.clone());
}
Ok(EditorBlock {
block_id: attrs.block_id.clone().unwrap_or(fallback_block_id),
block_type: EditorBlockType::Resource,
props,
content_nodes: text_nodes_from_tiptap(content)?,
child_block_ids: vec![],
})
}
TiptapNode::Paragraph { attrs, content } => Ok(EditorBlock {
block_id: attrs.block_id.clone().unwrap_or(fallback_block_id),
block_type: EditorBlockType::Paragraph,
+36 -1
View File
@@ -179,7 +179,7 @@ impl AcpRunBridge {
/// Map an AcpSessionEvent to an SSE event for the frontend.
///
/// Reference: hermes-vscode-main protocol.ts extractTextContent/parseToolCall/parseToolCallUpdate
/// Reference: wolai-frontend bridge.ts HermesRunEvent type
/// Reference: recycle/wolai-frontend bridge.ts HermesRunEvent type (historic)
pub fn acp_event_to_sse(event: AcpSessionEvent) -> Option<SseEvent> {
match event {
AcpSessionEvent::TextDelta { text } => Some(SseEvent {
@@ -233,6 +233,24 @@ pub fn acp_event_to_sse(event: AcpSessionEvent) -> Option<SseEvent> {
event: "usage.updated".into(),
data: json!({ "used": used, "size": size }),
}),
AcpSessionEvent::PermissionRequest {
permission_id,
tool_name,
params,
decision,
} => Some(SseEvent {
event: if decision == "allowed" {
"permission.allowed".into()
} else {
"permission.denied".into()
},
data: json!({
"permissionId": permission_id,
"toolName": tool_name,
"params": params,
"decision": decision,
}),
}),
AcpSessionEvent::SessionInfoUpdate { .. } => {
None // Not forwarded to frontend
}
@@ -292,6 +310,23 @@ mod tests {
assert_eq!(sse.data["delta"], "internal reasoning");
}
#[test]
fn acp_permission_request_emits_frontend_decision_event() {
let event = AcpSessionEvent::PermissionRequest {
permission_id: "perm_1".into(),
tool_name: "mnote.page.save".into(),
params: json!({"documentId": "doc_1"}),
decision: "denied".into(),
};
let sse = acp_event_to_sse(event).expect("permission decision should be forwarded");
assert_eq!(sse.event, "permission.denied");
assert_eq!(sse.data["permissionId"], "perm_1");
assert_eq!(sse.data["toolName"], "mnote.page.save");
assert_eq!(sse.data["params"]["documentId"], "doc_1");
assert_eq!(sse.data["decision"], "denied");
}
#[test]
fn acp_tool_events_keep_detail_for_collapsible_ui() {
let started = acp_event_to_sse(AcpSessionEvent::ToolCall {
+100 -8
View File
@@ -149,7 +149,7 @@ impl AcpClient {
.take()
.ok_or_else(|| AcpError::Internal("failed to take child stdout".into()))?;
let writer = BufWriter::new(stdin);
let writer = Arc::new(Mutex::new(BufWriter::new(stdin)));
let reader = BufReader::new(stdout);
let pending: Arc<Mutex<HashMap<u64, PendingEntry>>> = Arc::new(Mutex::new(HashMap::new()));
@@ -159,15 +159,16 @@ impl AcpClient {
// Start background reader task
let pending_clone = pending.clone();
let handler_clone = notification_handler.clone();
let writer_clone = writer.clone();
let child_pid = child.id().unwrap_or(0);
tokio::spawn(async move {
Self::reader_loop(reader, pending_clone, handler_clone).await;
Self::reader_loop(reader, writer_clone, pending_clone, handler_clone).await;
info!("ACP reader loop ended (pid={})", child_pid);
});
let client = Self {
child: Some(child),
writer: Arc::new(Mutex::new(writer)),
writer,
pending,
next_id: AtomicU64::new(1),
notification_handler,
@@ -285,6 +286,7 @@ impl AcpClient {
/// Reference: `acpClient.ts` L120-180 (onData + dispatch)
async fn reader_loop(
mut reader: BufReader<ChildStdout>,
writer: Arc<Mutex<BufWriter<ChildStdin>>>,
pending: Arc<Mutex<HashMap<u64, PendingEntry>>>,
notification_handler: Arc<NotificationHandlerMutex>,
) {
@@ -319,7 +321,7 @@ impl AcpClient {
}
};
Self::dispatch_message(msg, &pending, &notification_handler).await;
Self::dispatch_message(msg, &writer, &pending, &notification_handler).await;
}
// Process died or EOF — resolve all pending
@@ -334,6 +336,7 @@ impl AcpClient {
/// Reference: `acpClient.ts` L160-200 (dispatch)
async fn dispatch_message(
msg: Value,
writer: &Arc<Mutex<BufWriter<ChildStdin>>>,
pending: &Arc<Mutex<HashMap<u64, PendingEntry>>>,
notification_handler: &Arc<NotificationHandlerMutex>,
) {
@@ -348,11 +351,32 @@ impl AcpClient {
// Incoming request from agent (e.g. session/request_permission)
let method = msg["method"].as_str().unwrap_or("unknown").to_string();
let params = msg.get("params").cloned().unwrap_or(Value::Null);
// For now, reject all incoming requests since we don't need permission dialogs yet.
// Reference: acpClient.ts handleIncomingRequest (L200-220)
let id = msg.get("id").cloned().unwrap_or(Value::Null);
// 当前还没有权限确认 UI,必须明确拒绝,避免 agent 等待到超时。
warn!("ACP incoming request not handled: {method} (params={params:?})");
// If we wanted to reply, we'd need to write back a response...
// For now just log. Phase C will add permission support.
let response = json!({
"jsonrpc": "2.0",
"id": id,
"error": {
"code": -32601,
"message": format!("ACP incoming request not supported: {method}")
}
});
if let Err(error) = Self::write_jsonrpc_message(writer, &response).await {
warn!("ACP incoming request response write failed: {error}");
}
if method == "session/request_permission" {
let mut event_params = params;
if let Some(object) = event_params.as_object_mut() {
object.insert("decision".into(), Value::String("denied".into()));
object.insert("method".into(), Value::String(method.clone()));
object.insert("jsonrpcId".into(), id);
}
let handler_guard = notification_handler.lock().unwrap();
if let Some(ref handler) = *handler_guard {
handler(method, event_params);
}
}
} else if has_id {
// Response to one of our requests
if let Some(id) = msg["id"].as_u64() {
@@ -388,6 +412,18 @@ impl AcpClient {
}
}
}
async fn write_jsonrpc_message(
writer: &Arc<Mutex<BufWriter<ChildStdin>>>,
msg: &Value,
) -> Result<(), AcpError> {
let line = serde_json::to_string(msg)?;
let mut writer = writer.lock().await;
writer.write_all(line.as_bytes()).await?;
writer.write_all(b"\n").await?;
writer.flush().await?;
Ok(())
}
}
impl Drop for AcpClient {
@@ -443,6 +479,48 @@ rl.on('line', (line) => {
.expect("spawn mock ACP")
}
async fn spawn_permission_request_mock_server() -> AcpClient {
let script = r#"
import * as readline from 'node:readline';
import { stdin as input, stdout as output } from 'node:process';
let permissionResponse = null;
const rl = readline.createInterface({ input, output, terminal: false });
function send(msg) { process.stdout.write(JSON.stringify(msg) + '\n'); }
rl.on('line', (line) => {
const msg = JSON.parse(line);
if (msg.id !== undefined && msg.method === 'initialize') {
send({
jsonrpc: '2.0',
id: msg.id,
result: { protocolVersion: 1, agentCapabilities: {}, agentInfo: { name: 'mock', version: '1.0' }, authMethods: [] }
});
setTimeout(() => send({
jsonrpc: '2.0',
id: 77,
method: 'session/request_permission',
params: { reason: 'test permission' }
}), 10);
} else if (msg.id === 77 && msg.method === undefined) {
permissionResponse = msg;
} else if (msg.id !== undefined && msg.method === 'get_permission_response') {
send({
jsonrpc: '2.0',
id: msg.id,
result: { permissionResponse }
});
}
});
"#;
let dir = std::env::temp_dir();
let script_path = dir.join("acp_permission_request_mock.mjs");
std::fs::write(&script_path, script).expect("write permission mock script");
AcpClient::spawn("node", &[script_path.to_str().unwrap()])
.await
.expect("spawn permission mock ACP")
}
#[tokio::test]
async fn test_request_response() {
let client = spawn_mock_acp_server().await;
@@ -504,4 +582,18 @@ rl.on('line', (line) => {
// spawn already calls initialize; if it fails, the test fails
let _client = spawn_mock_acp_server().await;
}
#[tokio::test]
async fn test_incoming_permission_request_gets_response() {
let client = spawn_permission_request_mock_server().await;
tokio::time::sleep(Duration::from_millis(100)).await;
let result: Value = client
.request("get_permission_response", json!({}))
.await
.expect("permission response probe");
let response = &result["permissionResponse"];
assert_eq!(response["jsonrpc"], "2.0");
assert_eq!(response["id"], 77);
assert!(response.get("result").is_some() || response.get("error").is_some());
}
}
@@ -43,6 +43,13 @@ pub enum AcpSessionEvent {
},
/// Context usage update.
UsageUpdate { used: u64, size: u64 },
/// Agent 发起权限请求;当前 runtime 会自动拒绝并同步给前端。
PermissionRequest {
permission_id: String,
tool_name: String,
params: Value,
decision: String,
},
/// Session metadata update (e.g. auto-title).
SessionInfoUpdate { title: String },
/// Plan entries update.
@@ -51,6 +58,16 @@ pub enum AcpSessionEvent {
Disconnected { reason: String },
}
#[derive(Debug, Clone, Default)]
pub struct AcpMnoteToolContext {
pub mnote_session_id: Option<String>,
pub run_id: Option<String>,
pub actor_id: Option<String>,
pub trace_id: Option<String>,
pub workspace_id: Option<String>,
pub document_id: Option<String>,
}
/// Handler for session events.
pub type SessionEventHandler = Arc<dyn Fn(AcpSessionEvent) + Send + Sync + 'static>;
@@ -101,6 +118,34 @@ impl AcpSessionManager {
let in_prompt_clone = in_prompt.clone();
client.on_notification(move |method, params| {
if method == "session/request_permission" {
let permission_id = params
.get("permissionId")
.or_else(|| params.get("permission_id"))
.or_else(|| params.get("requestId"))
.or_else(|| params.get("id"))
.and_then(Value::as_str)
.unwrap_or("permission_auto_denied")
.to_string();
let tool_name = params
.get("toolName")
.or_else(|| params.get("tool"))
.or_else(|| params.get("name"))
.or_else(|| params.get("method"))
.and_then(Value::as_str)
.unwrap_or("session/request_permission")
.to_string();
let handler = event_handler_clone.lock().unwrap();
if let Some(ref h) = *handler {
h(AcpSessionEvent::PermissionRequest {
permission_id,
tool_name,
params,
decision: "denied".into(),
});
}
return;
}
if method != "session/update" {
return;
}
@@ -193,6 +238,14 @@ impl AcpSessionManager {
pub async fn run_prompt(
&self,
prompt: Vec<ContentBlock>,
) -> Result<SessionPromptResult, crate::acp_client::AcpError> {
self.run_prompt_with_mnote_context(prompt, None).await
}
pub async fn run_prompt_with_mnote_context(
&self,
prompt: Vec<ContentBlock>,
mnote_context: Option<AcpMnoteToolContext>,
) -> Result<SessionPromptResult, crate::acp_client::AcpError> {
{
let mut in_prompt = self.in_prompt.lock().unwrap();
@@ -215,9 +268,16 @@ impl AcpSessionManager {
)
})?;
let mnote_context = mnote_context.unwrap_or_default();
let params = SessionPromptParams {
session_id: session_id.clone(),
prompt,
mnote_session_id: mnote_context.mnote_session_id,
run_id: mnote_context.run_id,
actor_id: mnote_context.actor_id,
trace_id: mnote_context.trace_id,
workspace_id: mnote_context.workspace_id,
document_id: mnote_context.document_id,
};
debug!("ACP session/prompt (session={})", session_id);
+12
View File
@@ -163,6 +163,18 @@ pub struct ResourceContent {
pub struct SessionPromptParams {
pub session_id: String,
pub prompt: Vec<ContentBlock>,
#[serde(skip_serializing_if = "Option::is_none")]
pub mnote_session_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub run_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub actor_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub trace_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub workspace_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub document_id: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -430,5 +430,6 @@ fn block_type_name(block_type: &EditorBlockType) -> String {
EditorBlockType::Toc => "table_of_contents".into(),
EditorBlockType::PageReference => "page_reference".into(),
EditorBlockType::BlockReference => "block_reference".into(),
EditorBlockType::Resource => "resource".into(),
}
}
+170 -41
View File
@@ -87,6 +87,7 @@ pub async fn block_replace(
ensure_page_write_preconditions(context, input, &aggregate)?;
ensure_block_revision_ref(context, input, &block, "blockRevisionRef")?;
ensure_block_editable(context, &block)?;
ensure_allowed_target(context, input, &Value::Null, &block_id, "replace")?;
let replacement_text = content_to_text(&content);
let diff = json!([{
"op": "replace",
@@ -155,58 +156,73 @@ pub async fn block_insert_after(
ensure_page_write_preconditions(context, input, &aggregate)?;
ensure_block_revision_ref(context, input, &anchor, "anchorRevisionRef")?;
ensure_block_editable(context, &anchor)?;
let content = input
.arg_value("content")
.or_else(|| input.arg_value("block"))
.ok_or_else(|| {
WebError::bad_request_code(
"mnote_tool_bad_request",
"mnote.block.insert_after 缺少 content",
)
.with_context(context)
})?;
let new_block_id = format!("ai_block_{}", context.trace.request_id.replace('-', "_"));
let new_block = build_insert_block(&new_block_id, &content);
let diff = json!([{
ensure_allowed_target(
context,
input,
&Value::Null,
&anchor_block_id,
"insert_after",
)?;
let insert_values = insert_after_values(context, input)?;
let id_prefix = format!("ai_block_{}", context.trace.request_id.replace('-', "_"));
let inserted_block_ids = insert_values
.iter()
.enumerate()
.map(|(index, _)| {
if insert_values.len() == 1 {
id_prefix.clone()
} else {
format!("{id_prefix}_{index}")
}
})
.collect::<Vec<_>>();
let diff = insert_values
.iter()
.zip(inserted_block_ids.iter())
.map(|(content, block_id)| {
json!({
"op": "insert_after",
"anchorBlockId": anchor_block_id,
"after": anchor.get("text").cloned().unwrap_or(Value::Null),
"block": new_block
}]);
"block": build_insert_block(block_id, content)
})
})
.collect::<Vec<_>>();
if input.dry_run.unwrap_or(false) {
return Ok(dry_run_result(
input,
&aggregate,
"block_insert_after",
diff,
false,
));
let mut result =
dry_run_result(input, &aggregate, "block_insert_after", json!(diff), false);
result["insertedBlockIds"] = json!(inserted_block_ids);
return Ok(result);
}
let current_content = current_body_content(&aggregate);
let mut next_content = current_body_content(&aggregate);
let mut changed_blocks = Vec::with_capacity(insert_values.len());
let mut delta_opt = None;
let mut after_block_id = anchor_block_id.clone();
for (content, block_id) in insert_values.iter().zip(inserted_block_ids.iter()) {
let command = EditorCommand::InsertBlockAfter(EditorInsertBlockAfter {
after_block_id: anchor_block_id.clone(),
block: build_editor_block(&new_block_id, &content),
after_block_id: after_block_id.clone(),
block: build_editor_block(block_id, content),
});
let (next_content, delta_opt) = compute_next_content_via_actor(
let (content_after_command, command_delta) = compute_next_content_via_actor(
state,
&aggregate,
input,
&current_content,
&next_content,
&command,
"block_insert_after",
)?;
let mut result = execute_page_body_save(
state,
context,
input,
next_content,
vec![json!({
"blockId": new_block_id,
next_content = content_after_command;
delta_opt = command_delta;
changed_blocks.push(json!({
"blockId": block_id,
"op": "insert_after",
"anchorBlockId": anchor_block_id
})],
)
.await?;
"anchorBlockId": after_block_id
}));
after_block_id = block_id.clone();
}
let mut result =
execute_page_body_save(state, context, input, next_content, changed_blocks).await?;
result["insertedBlockIds"] = json!(inserted_block_ids);
merge_block_delta(&mut result, delta_opt);
Ok(result)
}
@@ -224,6 +240,7 @@ pub async fn block_delete(
ensure_page_write_preconditions(context, input, &aggregate)?;
ensure_block_revision_ref(context, input, &block, "blockRevisionRef")?;
ensure_block_editable(context, &block)?;
ensure_allowed_target(context, input, &Value::Null, &block_id, "delete")?;
let leaf = block
.get("children")
.and_then(Value::as_array)
@@ -298,6 +315,14 @@ pub async fn block_move_after(
ensure_page_write_preconditions(context, input, &aggregate)?;
ensure_block_revision_ref(context, input, &block, "blockRevisionRef")?;
ensure_block_revision_ref(context, input, &anchor, "anchorRevisionRef")?;
ensure_allowed_target(context, input, &Value::Null, &block_id, "move_after")?;
ensure_allowed_target(
context,
input,
&Value::Null,
&anchor_block_id,
"move_after_anchor",
)?;
let same_parent = block.get("parentBlockId") == anchor.get("parentBlockId");
let leaf = block
.get("children")
@@ -413,6 +438,7 @@ pub async fn doc_apply_block_ops(
)
.with_context(context));
}
ensure_page_write_preconditions(context, input, &aggregate)?;
let blocks = block_projection_blocks(&aggregate);
let mut next_content = current_body_content(&aggregate);
@@ -431,6 +457,13 @@ pub async fn doc_apply_block_ops(
"replace" | "block_replace" => {
let block = resolve_target_block(context, &blocks, operation, false)?;
ensure_block_editable(context, &block)?;
ensure_operation_block_revision_ref(
context,
input,
operation,
&block,
"blockRevisionRef",
)?;
let block_id = block_id_of(&block).unwrap_or_default();
let content = operation.get("content").cloned().ok_or_else(|| {
WebError::bad_request_code("mnote_tool_bad_request", "replace 操作缺少 content")
@@ -466,6 +499,13 @@ pub async fn doc_apply_block_ops(
"insert_after" | "block_insert_after" => {
let anchor = resolve_anchor_block(context, &blocks, operation)?;
ensure_block_editable(context, &anchor)?;
ensure_operation_block_revision_ref(
context,
input,
operation,
&anchor,
"anchorRevisionRef",
)?;
let anchor_block_id = block_id_of(&anchor).unwrap_or_default();
ensure_allowed_target(context, input, operation, &anchor_block_id, "insert_after")?;
let content = operation.get("content").cloned().ok_or_else(|| {
@@ -511,6 +551,13 @@ pub async fn doc_apply_block_ops(
"delete" | "block_delete" => {
let block = resolve_target_block(context, &blocks, operation, false)?;
ensure_block_editable(context, &block)?;
ensure_operation_block_revision_ref(
context,
input,
operation,
&block,
"blockRevisionRef",
)?;
let block_id = block_id_of(&block).unwrap_or_default();
ensure_allowed_target(context, input, operation, &block_id, "delete")?;
ensure_leaf_block(context, &block, "delete")?;
@@ -540,6 +587,20 @@ pub async fn doc_apply_block_ops(
let block = resolve_target_block(context, &blocks, operation, false)?;
let anchor = resolve_anchor_block(context, &blocks, operation)?;
ensure_block_editable(context, &block)?;
ensure_operation_block_revision_ref(
context,
input,
operation,
&block,
"blockRevisionRef",
)?;
ensure_operation_block_revision_ref(
context,
input,
operation,
&anchor,
"anchorRevisionRef",
)?;
ensure_leaf_block(context, &block, "move_after")?;
let block_id = block_id_of(&block).unwrap_or_default();
let anchor_block_id = block_id_of(&anchor).unwrap_or_default();
@@ -628,7 +689,10 @@ pub async fn doc_apply_block_ops(
.await
}
fn ensure_write_contract(context: &RequestContext, input: &ToolCallInput) -> Result<(), WebError> {
pub(crate) fn ensure_write_contract(
context: &RequestContext,
input: &ToolCallInput,
) -> Result<(), WebError> {
if !input.has_idempotency_key() {
return Err(WebError::bad_request_code(
"mnote_tool_idempotency_required",
@@ -833,6 +897,37 @@ fn ensure_block_revision_ref(
Ok(())
}
fn ensure_operation_block_revision_ref(
context: &RequestContext,
input: &ToolCallInput,
operation: &Value,
block: &Value,
arg_name: &'static str,
) -> Result<(), WebError> {
if input.dry_run.unwrap_or(false) {
return Ok(());
}
let expected = operation
.get(arg_name)
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.ok_or_else(|| missing_write_precondition(context, arg_name))?;
let current = block
.get("revisionRef")
.and_then(Value::as_str)
.map(str::trim)
.unwrap_or("");
if expected != current {
return Err(WebError::bad_request_code(
"mnote_tool_conflict",
format!("{arg_name} 已过期,请重新读取 block projection 后再写入"),
)
.with_context(context));
}
Ok(())
}
fn ensure_block_editable(context: &RequestContext, block: &Value) -> Result<(), WebError> {
if block
.get("editable")
@@ -851,6 +946,40 @@ fn ensure_block_editable(context: &RequestContext, block: &Value) -> Result<(),
.with_context(context))
}
fn insert_after_values(
context: &RequestContext,
input: &ToolCallInput,
) -> Result<Vec<Value>, WebError> {
if let Some(blocks) = input.arg_value("blocks") {
let values = blocks.as_array().ok_or_else(|| {
WebError::bad_request_code(
"mnote_tool_bad_request",
"mnote.block.insert_after 的 blocks 必须是数组",
)
.with_context(context)
})?;
if values.is_empty() || values.len() > 20 {
return Err(WebError::bad_request_code(
"mnote_tool_bad_request",
"mnote.block.insert_after 的 blocks 数量必须在 1 到 20 之间",
)
.with_context(context));
}
return Ok(values.clone());
}
input
.arg_value("content")
.or_else(|| input.arg_value("block"))
.map(|value| vec![value])
.ok_or_else(|| {
WebError::bad_request_code(
"mnote_tool_bad_request",
"mnote.block.insert_after 缺少 content、block 或 blocks",
)
.with_context(context)
})
}
fn missing_write_precondition(context: &RequestContext, name: &'static str) -> WebError {
WebError::bad_request_code(
"mnote_tool_write_precondition_required",
@@ -1049,7 +1178,7 @@ async fn execute_page_body_save(
}))
}
async fn execute_page_body_save_from_aggregate(
pub(crate) async fn execute_page_body_save_from_aggregate(
state: &AppState,
context: &RequestContext,
input: &ToolCallInput,
@@ -1255,7 +1384,7 @@ fn merge_block_delta(response: &mut Value, delta_opt: Option<Value>) {
}
}
fn current_body_content(aggregate: &Value) -> Value {
pub(crate) fn current_body_content(aggregate: &Value) -> Value {
aggregate
.pointer("/body/content")
.cloned()
File diff suppressed because it is too large Load Diff
@@ -180,7 +180,11 @@ fn block_replace_tool() -> Value {
"content": { "type": ["string", "object", "array"] },
"revision": { "type": ["number", "string"] },
"conflictDetectionKey": { "type": "string" },
"blockRevisionRef": { "type": "string" }
"blockRevisionRef": { "type": "string" },
"allowedTargetBlockIds": {
"type": "array",
"items": { "type": "string" }
}
}),
[
"blockId",
@@ -193,7 +197,7 @@ fn block_replace_tool() -> Value {
}
fn block_insert_after_tool() -> Value {
write_tool(
let mut tool = write_tool(
"mnote.block.insert_after",
"在指定块后插入新块;真实写入走 Rust page.body.save 链路",
["block.write", "page.write"],
@@ -201,18 +205,38 @@ fn block_insert_after_tool() -> Value {
"anchorBlockId": { "type": "string" },
"content": { "type": ["string", "object", "array"] },
"block": { "type": "object" },
"blocks": {
"type": "array",
"minItems": 1,
"maxItems": 20,
"items": { "type": ["string", "object", "array"] }
},
"revision": { "type": ["number", "string"] },
"conflictDetectionKey": { "type": "string" },
"anchorRevisionRef": { "type": "string" }
"anchorRevisionRef": { "type": "string" },
"allowedTargetBlockIds": {
"type": "array",
"items": { "type": "string" }
}
}),
[
"anchorBlockId",
"content",
"revision",
"conflictDetectionKey",
"anchorRevisionRef",
],
)
);
if let Some(schema) = tool.get_mut("inputSchema").and_then(Value::as_object_mut) {
schema.insert(
"anyOf".into(),
json!([
{ "required": ["content"] },
{ "required": ["block"] },
{ "required": ["blocks"] }
]),
);
}
tool
}
fn block_move_after_tool() -> Value {
@@ -226,7 +250,11 @@ fn block_move_after_tool() -> Value {
"revision": { "type": ["number", "string"] },
"conflictDetectionKey": { "type": "string" },
"blockRevisionRef": { "type": "string" },
"anchorRevisionRef": { "type": "string" }
"anchorRevisionRef": { "type": "string" },
"allowedTargetBlockIds": {
"type": "array",
"items": { "type": "string" }
}
}),
[
"blockId",
@@ -248,7 +276,11 @@ fn block_delete_tool() -> Value {
"blockId": { "type": "string" },
"revision": { "type": ["number", "string"] },
"conflictDetectionKey": { "type": "string" },
"blockRevisionRef": { "type": "string" }
"blockRevisionRef": { "type": "string" },
"allowedTargetBlockIds": {
"type": "array",
"items": { "type": "string" }
}
}),
[
"blockId",
@@ -411,11 +443,12 @@ fn available_tool(
}
fn doc_markdown_edit_tool() -> Value {
let mut properties = base_identity_properties();
if let Value::Object(map) = &mut properties {
map.insert(
"operations".into(),
let mut tool = write_tool(
"mnote.doc.markdown_edit",
"通过文本级搜索替换编辑 markdown 内容(AI 编辑主路径)。在线 Convex 文档和本地 .md 文件共用,不需要 blockId。",
["block.write", "page.write"],
json!({
"operations": {
"type": "array",
"description": "搜索替换操作列表",
"items": {
@@ -426,25 +459,24 @@ fn doc_markdown_edit_tool() -> Value {
},
"required": ["search", "replace"]
}
}),
);
map.insert(
"full_content".into(),
json!({
},
"full_content": {
"type": "string",
"description": "完整修改后的 markdown 文本(替代 operations"
}
}),
[],
);
if let Some(schema) = tool.get_mut("inputSchema").and_then(Value::as_object_mut) {
schema.insert(
"anyOf".into(),
json!([
{ "required": ["operations"] },
{ "required": ["full_content"] }
]),
);
}
json!({
"name": "mnote.doc.markdown_edit",
"description": "通过文本级搜索替换编辑 markdown 内容(AI 编辑主路径)。在线 Convex 文档和本地 .md 文件共用,不需要 blockId。",
"schemaVersion": TOOL_SCHEMA_VERSION,
"capabilityScope": ["block.write", "page.write"],
"status": "available",
"properties": properties,
"annotations": tool_annotations(false, false, true, false)
})
tool
}
fn tool_annotations(
+10
View File
@@ -20,3 +20,13 @@ pub mod tree_shell;
pub mod workspace_shell;
pub use app::{build_app, AppConfig, AppState};
#[cfg(test)]
pub(crate) mod test_support {
use std::sync::{Mutex, OnceLock};
pub(crate) fn hermes_env_lock() -> &'static Mutex<()> {
static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
LOCK.get_or_init(|| Mutex::new(()))
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+13 -1
View File
@@ -179,14 +179,26 @@ pub fn build_router(state: AppState) -> Router {
"/client/sessions",
get(hermes_client::list_sessions).post(hermes_client::create_session),
)
.route(
"/client/sessions/search",
get(hermes_client::search_sessions),
)
.route(
"/client/sessions/{session_id}",
get(hermes_client::get_session),
get(hermes_client::get_session).delete(hermes_client::delete_session),
)
.route(
"/client/sessions/{session_id}/resume",
post(hermes_client::resume_session),
)
.route(
"/client/sessions/{session_id}/rename",
post(hermes_client::rename_session),
)
.route(
"/client/sessions/{session_id}/auto-title",
post(hermes_client::auto_title_session),
)
.route("/client/gateway/health", get(hermes_client::gateway_health))
.route("/client/profiles", get(hermes_client::list_profiles))
.route(
@@ -1,7 +1,7 @@
use crate::app::AppState;
use crate::context::RequestContext;
use crate::error::WebError;
use crate::hermes_tools::{doc, ToolCallInput};
use crate::hermes_tools::ToolCallInput;
use axum::extract::{Extension, State};
use axum::http::{HeaderMap, StatusCode};
use axum::Json;
@@ -88,6 +88,30 @@ pub async fn block_edit_workflow(
} else {
context.auth.actor_id.clone()
};
let allowed_target_block_ids = ai_context
.get("allowedTargetBlockIds")
.and_then(Value::as_array)
.map(|items| {
items
.iter()
.filter_map(Value::as_str)
.map(str::trim)
.filter(|item| !item.is_empty())
.map(ToOwned::to_owned)
.collect::<Vec<_>>()
})
.unwrap_or_default();
let mut markdown_args = json!({
"operations": markdown_operations.clone()
});
if !allowed_target_block_ids.is_empty() {
if let Value::Object(map) = &mut markdown_args {
map.insert(
"allowedTargetBlockIds".into(),
json!(allowed_target_block_ids),
);
}
}
let edit_input = ToolCallInput {
tool_name: "mnote.doc.markdown_edit".into(),
workspace_id: Some(workspace_id.clone()),
@@ -101,12 +125,12 @@ pub async fn block_edit_workflow(
idempotency_key: Some(format!("page_ai_fast_edit_{}", context.trace.request_id)),
dry_run: Some(false),
capability_scope: Some(vec!["block.write".into(), "page.write".into()]),
args: Some(json!({
"operations": markdown_operations
})),
args: Some(markdown_args),
};
let apply_started = Instant::now();
let apply_result = doc::doc_markdown_edit(&state, &context, &edit_input).await?;
let tool_response =
crate::routes::hermes_tools::execute_mnote_tool_call(&state, &context, edit_input).await?;
let apply_result = tool_response.get("result").cloned().unwrap_or(Value::Null);
let apply_ms = apply_started.elapsed().as_millis();
info!(
trace_id = %trace_id,
@@ -128,6 +152,7 @@ pub async fn block_edit_workflow(
"traceId": trace_id,
"operations": markdown_operations,
"applyResult": apply_result,
"toolExecution": tool_response,
"message": "已通过页面 markdown 编辑快路径完成写入。",
"timingsMs": {
"total": started.elapsed().as_millis(),
@@ -540,6 +565,124 @@ fn yaml_path_value(content: &str, path: &[&str]) -> Option<String> {
#[cfg(test)]
mod tests {
use super::{direct_block_edit_operations, extract_operations_from_model_text};
use crate::app::{build_app, AppConfig, AppState};
use axum::body::{to_bytes, Body};
use axum::http::{Request, StatusCode};
use axum::routing::post;
use axum::{Json, Router};
use serde_json::{json, Value};
use std::fs;
use std::sync::Mutex;
use tower::util::ServiceExt;
fn env_lock() -> &'static Mutex<()> {
crate::test_support::hermes_env_lock()
}
fn app() -> axum::Router {
build_app(AppState::new(AppConfig {
service_name: "mnote-web".into(),
service_version: "0.1.0".into(),
bind_addr: "127.0.0.1:0".into(),
public_bind_addr: "127.0.0.1:3000".into(),
legacy_next_base_url: Some("http://127.0.0.1:3100".into()),
enable_legacy_next_compat: true,
enable_debug_shell_routes: false,
enable_editor_actor: true,
hermes_base_path: "/api/hermes".into(),
compat_next_base_path: "/api/compat/next".into(),
convex_url: None,
convex_admin_key: None,
allow_dev_fixtures: true,
query_fixtures_json: Some(
r#"{
"documents:getMeta": {
"id": "doc_1",
"workspace_id": "ws_demo",
"title": "服务端页面",
"can_edit": true,
"wide_layout": false,
"use_small_text": false,
"show_toc": true,
"block_count": 2
},
"documents:getContent": {
"title": "服务端页面",
"content": [
{
"id": "p_1",
"type": "paragraph",
"content": [{ "type": "text", "text": "第一段" }]
},
{
"id": "p_2",
"type": "paragraph",
"content": [{ "type": "text", "text": "第二段" }]
}
],
"revision": 7,
"conflict_detection_key": "doc_1:7"
}
}"#
.into(),
),
mutation_fixtures_json: Some(
r#"{
"documents:updateContent": {"ok": true, "revision": 8, "conflict_detection_key": "doc_1:8"},
"bridgeLogs:recordCommandLog": {"ok": true, "id": "cmd_log_fixture"},
"bridgeLogs:recordDomainEvent": {"ok": true, "id": "event_fixture"}
}"#
.into(),
),
dev_user_id: "dev-user".into(),
dev_user_name: "开发用户".into(),
dev_user_email: "dev@mnote.local".into(),
}))
}
async fn spawn_mock_model_server() -> String {
async fn completions() -> Json<Value> {
Json(json!({
"choices": [{
"message": {
"content": "{\"operations\":[{\"search\":\"第二段\",\"replace\":\"测试123\"}],\"summary\":\"ok\"}"
}
}]
}))
}
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
.await
.expect("bind mock model");
let addr = listener.local_addr().expect("mock model addr");
let server = Router::new().route("/chat/completions", post(completions));
tokio::spawn(async move {
let _ = axum::serve(listener, server).await;
});
format!("http://{addr}")
}
async fn spawn_out_of_scope_mock_model_server() -> String {
async fn completions() -> Json<Value> {
Json(json!({
"choices": [{
"message": {
"content": "{\"operations\":[{\"search\":\"第一段\",\"replace\":\"越权修改\"}],\"summary\":\"out_of_scope\"}"
}
}]
}))
}
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
.await
.expect("bind out-of-scope mock model");
let addr = listener.local_addr().expect("mock model addr");
let server = Router::new().route("/chat/completions", post(completions));
tokio::spawn(async move {
let _ = axum::serve(listener, server).await;
});
format!("http://{addr}")
}
#[test]
fn extracts_operations_from_fenced_model_json() {
@@ -570,4 +713,137 @@ mod tests {
assert_eq!(operations[2]["op"], "delete");
assert_eq!(operations[2]["matchText"], "第三段");
}
#[tokio::test]
async fn block_edit_workflow_respects_disabled_markdown_edit_tool() {
let _guard = env_lock().lock().expect("env lock");
let base_url = spawn_mock_model_server().await;
let hermes_home = std::env::temp_dir().join(format!(
"mnote-page-ai-workflow-disabled-tool-{}",
std::process::id()
));
let _ = fs::remove_dir_all(&hermes_home);
let profile_dir = hermes_home.join("profiles").join("mnoteai");
fs::create_dir_all(&profile_dir).expect("profile dir");
fs::write(
profile_dir.join("config.yaml"),
format!(
"model:\n provider: mock\n default: mock-model\n base_url: {base_url}\n api_key: test-key\nmnote:\n tools:\n disabled:\n - mnote.doc.markdown_edit\n"
),
)
.expect("profile config");
std::env::set_var("HERMES_HOME", &hermes_home);
let response = app()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/page-ai/block-edit-workflow")
.header("content-type", "application/json")
.header("x-mnote-actor-id", "user_1")
.body(Body::from(
json!({
"workspaceId": "ws_demo",
"documentId": "doc_1",
"message": "把第二段改成测试123",
"profile": "mnoteai",
"sessionId": "sess_page_ai_disabled",
"runId": "run_page_ai_disabled",
"traceId": "trace_page_ai_disabled",
"pageContext": {
"aiContext": {
"schema": "mnote.page_ai_context.v1",
"pageText": "第一段\n\n第二段",
"pageXml": "<page><block id=\"p_1\">第一段</block><block id=\"p_2\">第二段</block></page>",
"contextBlocks": [
{"blockId": "p_1", "text": "第一段"},
{"blockId": "p_2", "text": "第二段"}
]
}
}
})
.to_string(),
))
.expect("request"),
)
.await
.expect("response");
assert_eq!(response.status(), StatusCode::FORBIDDEN);
let body = to_bytes(response.into_body(), usize::MAX)
.await
.expect("body");
let payload: Value = serde_json::from_slice(&body).expect("json");
assert_eq!(payload["code"], "mnote_tool_disabled");
std::env::remove_var("HERMES_HOME");
let _ = fs::remove_dir_all(&hermes_home);
}
#[tokio::test]
async fn block_edit_workflow_forwards_allowed_target_blocks_to_markdown_edit() {
let _guard = env_lock().lock().expect("env lock");
let base_url = spawn_out_of_scope_mock_model_server().await;
let hermes_home = std::env::temp_dir().join(format!(
"mnote-page-ai-workflow-selection-scope-{}",
std::process::id()
));
let _ = fs::remove_dir_all(&hermes_home);
let profile_dir = hermes_home.join("profiles").join("mnoteai");
fs::create_dir_all(&profile_dir).expect("profile dir");
fs::write(
profile_dir.join("config.yaml"),
format!(
"model:\n provider: mock\n default: mock-model\n base_url: {base_url}\n api_key: test-key\n"
),
)
.expect("profile config");
std::env::set_var("HERMES_HOME", &hermes_home);
let response = app()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/page-ai/block-edit-workflow")
.header("content-type", "application/json")
.header("x-mnote-actor-id", "user_1")
.body(Body::from(
json!({
"workspaceId": "ws_demo",
"documentId": "doc_1",
"message": "把选中段落改成测试123",
"profile": "mnoteai",
"sessionId": "sess_page_ai_scope",
"runId": "run_page_ai_scope",
"traceId": "trace_page_ai_scope",
"pageContext": {
"aiContext": {
"schema": "mnote.page_ai_context.v1",
"allowedTargetBlockIds": ["p_2"],
"pageText": "第一段\n\n第二段",
"pageXml": "<page><block id=\"p_1\">第一段</block><block id=\"p_2\">第二段</block></page>",
"contextBlocks": [
{"blockId": "p_1", "text": "第一段"},
{"blockId": "p_2", "text": "第二段"}
]
}
}
})
.to_string(),
))
.expect("request"),
)
.await
.expect("response");
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
let body = to_bytes(response.into_body(), usize::MAX)
.await
.expect("body");
let payload: Value = serde_json::from_slice(&body).expect("json");
assert_eq!(payload["code"], "mnote_markdown_edit_target_out_of_scope");
std::env::remove_var("HERMES_HOME");
let _ = fs::remove_dir_all(&hermes_home);
}
}
+96 -14
View File
@@ -2,9 +2,9 @@ use crate::app::AppState;
use crate::context::RequestContext;
use crate::error::WebError;
use crate::routes::stream_support::{
build_stream_delta_payload, load_stream_overview, load_stream_snapshot,
read_stream_cursor_from_payload, resolve_stream_change, with_stream_kind, StreamChangeKind,
StreamSnapshotQuery,
build_stream_delta_payload, build_stream_push_delta_hint, load_stream_overview,
load_stream_snapshot, read_stream_cursor_from_payload, resolve_stream_change, with_stream_kind,
StreamChangeKind, StreamSnapshotQuery,
};
use axum::extract::{Extension, Query, State};
use axum::http::{HeaderMap, HeaderName, HeaderValue};
@@ -94,15 +94,11 @@ async fn events_with_stream_delta(
if let Some(ref mut rx) = state.stream_delta_rx {
match rx.try_recv() {
Ok(payload) => {
let hint = json!({
"kind": "delta",
"hint": "command_committed",
"commandName": payload.get("commandName"),
"commandId": payload.get("commandId"),
"workspaceId": payload.get("workspaceId"),
"requestId": payload.get("requestId"),
"traceId": payload.get("traceId"),
});
let hint = build_stream_push_delta_hint(
&payload,
&state.context.trace.request_id,
&state.context.trace.trace_id,
);
return Some((Ok(stream_event("delta", &hint)), Some(state)));
}
Err(tokio::sync::broadcast::error::TryRecvError::Empty) => {}
@@ -113,9 +109,68 @@ async fn events_with_stream_delta(
}
}
// If push-driven and we already checked both broadcasts, brief sleep then re-check
// If push-driven and broadcasts are quiet, polling remains the safety net.
if state.stream_delta_rx.is_some() {
sleep(Duration::from_millis(250)).await;
if let Some(max_polls) = max_polls {
if state.polls >= max_polls {
return None;
}
}
state.polls += 1;
sleep(Duration::from_millis(poll_ms)).await;
let poll_query = live_poll_query(&state.query);
let Ok((workspace_id, overview)) =
load_stream_overview(state.app_state.config(), &state.context, &poll_query)
.await
else {
return None;
};
if let Some(change) =
resolve_stream_change(&overview, state.current_cursor.as_deref())
{
state.current_cursor = change.cursor.clone();
match change.kind {
StreamChangeKind::Delta => {
let Ok(payload) = build_stream_delta_payload(
state.app_state.config(),
&state.context,
&poll_query,
&workspace_id,
&overview,
change.cursor,
change
.delta
.unwrap_or_else(|| serde_json::json!({ "op": "noop" })),
)
.await
else {
return None;
};
return Some((Ok(stream_event("delta", &payload)), Some(state)));
}
StreamChangeKind::Resync => {
let Ok(snapshot_payload) = load_stream_snapshot(
state.app_state.config(),
&state.context,
&poll_query,
)
.await
else {
return None;
};
state.current_cursor =
read_stream_cursor_from_payload(&snapshot_payload);
return Some((
Ok(stream_event(
"resync",
&with_stream_kind(&snapshot_payload, "resync"),
)),
Some(state),
));
}
}
}
return Some((Ok(stream_event("heartbeat", &json!({}))), Some(state)));
}
@@ -289,6 +344,8 @@ mod tests {
use crate::routes::stream_support::StreamSnapshotQuery;
use axum::body::{to_bytes, Body};
use axum::http::{Request, StatusCode};
use std::time::Duration;
use tokio::time::timeout;
use tower::util::ServiceExt;
fn app() -> axum::Router {
@@ -395,6 +452,31 @@ mod tests {
assert!(text.contains("\"revision\""));
}
#[tokio::test]
async fn tree_events_push_mode_honors_polling_safety_net_max_polls() {
let response = app()
.oneshot(
Request::builder()
.uri("/api/tree/events?workspaceId=ws_demo&maxPolls=1&pollMs=1")
.body(Body::empty())
.expect("request"),
)
.await
.expect("response");
assert_eq!(response.status(), StatusCode::OK);
let body = timeout(
Duration::from_secs(2),
to_bytes(response.into_body(), usize::MAX),
)
.await
.expect("push SSE stream should stop after maxPolls")
.expect("body");
let text = String::from_utf8(body.to_vec()).expect("utf8");
assert!(text.contains("event: snapshot") || text.contains("event:snapshot"));
assert!(text.contains("event: heartbeat") || text.contains("event:heartbeat"));
}
#[test]
fn live_poll_query_drops_bridge_pagination_cursor() {
let query = StreamSnapshotQuery {
@@ -574,6 +574,18 @@ pub fn with_stream_kind(payload: &Value, kind: &str) -> Value {
})
}
pub fn build_stream_push_delta_hint(payload: &Value, request_id: &str, trace_id: &str) -> Value {
json!({
"kind": "delta",
"hint": "command_committed",
"commandName": payload.get("commandName"),
"commandId": payload.get("commandId"),
"workspaceId": payload.get("workspaceId"),
"requestId": request_id,
"traceId": trace_id,
})
}
pub async fn build_stream_delta_payload(
config: &AppConfig,
context: &RequestContext,
@@ -1042,7 +1054,7 @@ mod tests {
"id": "clog_2",
"command_id": "cmd_2",
"created_at": "2026-04-25T10:00:02Z",
"command_name": "tree.resource.delete",
"command_name": "tree.resource.archive",
"payload": {
"streamDelta": {
"op": "remove_asset",
+53 -16
View File
@@ -2018,12 +2018,17 @@ fn build_tree_shell_html(
const getFileTreeRowDocumentId = (item) => {
if (!item) return "";
if (item.resourceMeta?.documentId) return item.resourceMeta.documentId;
if (item.rowKind === "document" || item.rowKind === "markdown") return item.nodeId;
if (item.rowKind === "index") return item.nodeId.replace(/^index:/, "");
return "";
};
const getFileTreeRowOwnerDocumentId = (item) => {
if (!item) return "";
if (item.resourceMeta?.documentId) return item.resourceMeta.documentId;
return getFileTreeRowDocumentId(item);
};
const getFileTreeRowAssetId = (item) => {
if (!item) return "";
if (item.resourceMeta?.assetId) return item.resourceMeta.assetId;
@@ -2084,6 +2089,7 @@ fn build_tree_shell_html(
rowKind: "root",
nodeId: null,
documentId: null,
ownerDocumentId: null,
assetId: null,
};
}
@@ -2092,6 +2098,7 @@ fn build_tree_shell_html(
rowKind: normalizeText(row.dataset.rowKind, "document"),
nodeId: normalizeText(row.dataset.nodeId) || null,
documentId: normalizeText(row.dataset.documentId) || null,
ownerDocumentId: normalizeText(row.dataset.ownerDocumentId) || null,
assetId: normalizeText(row.dataset.assetId) || null,
};
};
@@ -2938,9 +2945,10 @@ fn build_tree_shell_html(
rowKind: targetItem.rowKind,
nodeId: targetItem.nodeId,
documentId: getFileTreeRowDocumentId(targetItem) || null,
ownerDocumentId: getFileTreeRowOwnerDocumentId(targetItem) || null,
assetId: getFileTreeRowAssetId(targetItem) || null,
}
: { rowId: null, rowKind: "root", nodeId: null, documentId: null, assetId: null };
: { rowId: null, rowKind: "root", nodeId: null, documentId: null, ownerDocumentId: null, assetId: null };
if (sourceKind === "local_folder") {
const pasted = await executeLocalFileTreeInternalDrop(
target,
@@ -3030,7 +3038,12 @@ fn build_tree_shell_html(
if (event.key === "F2") {
event.preventDefault();
const rowId = item?.rowId || fileTreeFocusedRowId;
if (rowId) beginInlineRename("filetree", rowId);
const renameItem = rowId ? fileTreeRowById.get(rowId) : null;
if (rowId && getFileTreeRowDocumentId(renameItem)) {
beginInlineRename("filetree", rowId);
} else {
setLastAction("当前资源暂不支持重命名", "error");
}
return;
}
if (event.key === "ArrowDown") {
@@ -3057,7 +3070,11 @@ fn build_tree_shell_html(
event.preventDefault();
if (item) {
const documentId = getFileTreeRowDocumentId(item);
if (documentId) handleNavigate(documentId);
if (documentId) {
handleNavigate(documentId);
} else {
openHydratedFileTreeItem(item);
}
}
return;
}
@@ -3219,7 +3236,7 @@ fn build_tree_shell_html(
});
}
normalizedItems.slice().forEach((item) => {
const itemDocumentId = getFileTreeRowDocumentId(item) || item.resourceMeta?.documentId || item.nodeId;
const itemDocumentId = getFileTreeRowOwnerDocumentId(item) || item.nodeId;
if (removedNodeIds.has(item.nodeId) || itemDocumentId === normalizedDocumentId) {
removeTreeItemEverywhere(item);
changed = true;
@@ -4149,8 +4166,7 @@ fn build_tree_shell_html(
rowKind === "markdown" ||
rowKind === "document" ||
rowKind === "folder" ||
rowKind === "index" ||
(localSource && rowKind === "asset");
rowKind === "index";
const canCopyCut =
rowKind === "markdown" ||
rowKind === "document" ||
@@ -4282,7 +4298,9 @@ fn build_tree_shell_html(
return;
}
if (kind === "rename") {
if (target.rowId) beginInlineRename("filetree", target.rowId);
if (target.rowId && getFileTreeRowDocumentId(item)) {
beginInlineRename("filetree", target.rowId);
}
return;
}
if (kind === "copy" || kind === "cut") {
@@ -4639,8 +4657,14 @@ fn build_tree_shell_html(
const id = inlineRenameState.id;
const item = renameMode === "filetree" ? fileTreeRowById.get(id) : itemById.get(id);
const documentId = renameMode === "filetree"
? getFileTreeRowDocumentId(item) || item?.nodeId || id
? getFileTreeRowDocumentId(item)
: id;
if (!documentId) {
inlineRenameState = { mode: null, id: null, committing: false };
setLastAction("当前资源暂不支持重命名", "error");
renderTree();
return;
}
try {
const result = await sendCommand({
action: "rename",
@@ -4953,6 +4977,7 @@ fn build_tree_shell_html(
const openHydratedFileTreeItem = (item) => {
const documentId = getFileTreeRowDocumentId(item);
const ownerDocumentId = getFileTreeRowOwnerDocumentId(item);
const assetId = getFileTreeRowAssetId(item);
if (item.rowKind === "document" || item.rowKind === "index") {
handleNavigate(documentId || item.nodeId);
@@ -4960,12 +4985,12 @@ fn build_tree_shell_html(
}
setLastAction(` ${assetId || item.rowId}`);
postToHost("tree.asset.open", {
documentId: documentId || null,
documentId: ownerDocumentId || null,
assetId: assetId || null,
objectIdentity: item.resourceMeta?.objectIdentity || null,
target: { documentId: documentId || null },
target: { documentId: ownerDocumentId || null },
payload: {
documentId: documentId || null,
documentId: ownerDocumentId || null,
assetId: assetId || null,
rowId: item.rowId,
rowKind: item.rowKind,
@@ -5122,6 +5147,7 @@ fn build_tree_shell_html(
const bindFileTreeRowEvents = (row, item) => {
if (!(row instanceof HTMLElement) || !item) return;
const documentId = getFileTreeRowDocumentId(item) || null;
const ownerDocumentId = getFileTreeRowOwnerDocumentId(item) || null;
const assetId = getFileTreeRowAssetId(item) || null;
row.dataset.active = String(
item.rowKind === "document" && documentId === currentActiveDocumentId
@@ -5130,6 +5156,7 @@ fn build_tree_shell_html(
row.dataset.rowId = item.rowId;
row.dataset.rowKind = item.rowKind;
row.dataset.documentId = documentId || "";
row.dataset.ownerDocumentId = ownerDocumentId || "";
row.dataset.assetId = assetId || "";
row.dataset.objectIdentity = item.resourceMeta?.objectIdentity
? JSON.stringify(item.resourceMeta.objectIdentity)
@@ -5577,6 +5604,7 @@ fn build_tree_shell_html(
const openFileTreeItem = (item) => {
const documentId = getFileTreeRowDocumentId(item);
const ownerDocumentId = getFileTreeRowOwnerDocumentId(item);
const assetId = getFileTreeRowAssetId(item);
if (item.rowKind === "document" || item.rowKind === "index" || item.rowKind === "markdown") {
handleNavigate(documentId || item.nodeId);
@@ -5584,12 +5612,12 @@ fn build_tree_shell_html(
}
setLastAction(` ${assetId || item.rowId}`);
postToHost("tree.asset.open", {
documentId: documentId || null,
documentId: ownerDocumentId || null,
assetId: assetId || null,
objectIdentity: item.resourceMeta?.objectIdentity || null,
target: { documentId: documentId || null },
target: { documentId: ownerDocumentId || null },
payload: {
documentId: documentId || null,
documentId: ownerDocumentId || null,
assetId: assetId || null,
rowId: item.rowId,
rowKind: item.rowKind,
@@ -5649,6 +5677,7 @@ fn build_tree_shell_html(
const children = getSiblings(item.nodeId);
const hasBranches = canExpandFileTreeRow(item) && children.length > 0;
const documentId = getFileTreeRowDocumentId(item) || null;
const ownerDocumentId = getFileTreeRowOwnerDocumentId(item) || null;
const assetId = getFileTreeRowAssetId(item) || null;
const row = document.createElement("div");
@@ -5661,6 +5690,7 @@ fn build_tree_shell_html(
row.dataset.rowId = item.rowId;
row.dataset.rowKind = item.rowKind;
row.dataset.documentId = documentId || "";
row.dataset.ownerDocumentId = ownerDocumentId || "";
row.dataset.assetId = assetId || "";
row.dataset.objectIdentity = item.resourceMeta?.objectIdentity
? JSON.stringify(item.resourceMeta.objectIdentity)
@@ -5769,6 +5799,7 @@ fn build_tree_shell_html(
const actions = document.createElement("div");
actions.className = "tree-actions";
if (getFileTreeRowDocumentId(item)) {
actions.appendChild(
createActionButton(
ICONS.edit,
@@ -5778,6 +5809,7 @@ fn build_tree_shell_html(
false,
),
);
}
actions.appendChild(
createActionButton(
ICONS.more,
@@ -7143,6 +7175,11 @@ mod tests {
assert!(html.contains("tree.filetree.external-drop"));
assert!(html.contains("\"rowKind\":\"asset_folder\""));
assert!(html.contains("\"resourceMeta\""));
assert!(html.contains("const getFileTreeRowOwnerDocumentId = (item) => {"));
assert!(html.contains("row.dataset.ownerDocumentId = ownerDocumentId || \"\";"));
assert!(html.contains("if (rowId && getFileTreeRowDocumentId(renameItem))"));
assert!(html.contains("if (getFileTreeRowDocumentId(item))"));
assert!(html.contains("documentId: ownerDocumentId || null"));
assert!(html.contains("dragover"));
assert!(html.contains("hydrateInitialFileTree"));
assert!(html.contains("const usedRustInitialRenderer = hydrateInitialRenderer();"));
@@ -7897,7 +7934,7 @@ mod tests {
payload["result"]["documentId"],
Value::String("page_child".into())
);
assert_eq!(payload["result"]["sortOrder"], Value::from(1));
assert_eq!(payload["result"]["sortOrder"], Value::Null);
assert_eq!(
payload["result"]["execution"]["deletedCount"],
Value::from(1)
@@ -1210,6 +1210,18 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
titleEndpoint: '/api/documents/title',
editorHostKind: 'leptos_tiptap_island',
});
const syncPageAggregateScript = (session, aggregate) => {
if (!session || !aggregate) return;
const scriptId = session.pageAggregateScriptId || '__MNOTE_PAGE_AGGREGATE__';
const node = document.getElementById(scriptId);
if (!node) return;
try {
node.textContent = JSON.stringify(aggregate);
node.setAttribute('data-mnote-page-aggregate-synced-at', String(Date.now()));
} catch (error) {
console.warn('mnote Page Aggregate script ', error);
}
};
const documentSessionRegistry = new Map();
const localFolderEventRegistry = new Map();
@@ -1622,6 +1634,7 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
}
const nextRevision = Number.isInteger(nextBody.revision) ? nextBody.revision : revisionFromConflictKey(nextConflictKey);
session.latestAggregate = nextAggregate;
syncPageAggregateScript(session, nextAggregate);
session.title = nextAggregate?.head?.title || session.title;
session.currentTiptapDocument = nextTiptapDocument;
session.currentSerialized = nextSerialized;
@@ -1891,6 +1904,7 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
sourceKind,
rootUri: runtimeDescriptor.bootstrap.rootUri,
saveEndpoint: runtimeDescriptor.bootstrap.saveEndpoint || '/api/documents/save',
pageAggregateScriptId: runtimeDescriptor.bootstrap.pageAggregateScriptId || '__MNOTE_PAGE_AGGREGATE__',
latestAggregate: runtimeDescriptor.aggregate,
title: runtimeDescriptor.aggregate.head?.title || '',
currentTiptapDocument: tiptapDocument,
@@ -3082,6 +3096,7 @@ mod tests {
assert!(html.contains("mnote.tree_live_bootstrap.v1"));
assert!(html.contains("/api/tree/events"));
assert!(html.contains("data-mnote-tree-live-transport"));
assert!(html.contains("syncPageAggregateScript(session, nextAggregate);"));
assert!(!html.contains("mnote-web-document-shell"));
}
+49 -10
View File
@@ -1,7 +1,9 @@
use crate::app::AppState;
use crate::context::RequestContext;
use crate::error::WebError;
use crate::routes::stream_support::{load_stream_snapshot, StreamSnapshotQuery};
use crate::routes::stream_support::{
build_stream_push_delta_hint, load_stream_snapshot, StreamSnapshotQuery,
};
use axum::extract::ws::{Message, WebSocket, WebSocketUpgrade};
use axum::extract::{Extension, Query, State};
use axum::response::Response;
@@ -41,14 +43,15 @@ async fn handle_socket(
delta_result = stream_delta_rx.recv() => {
match delta_result {
Ok(delta) => {
let notify = json!({
"kind": "delta",
"data": delta,
"requestId": context.trace.request_id,
"traceId": context.trace.trace_id,
"workspaceId": delta.get("workspaceId").and_then(Value::as_str).unwrap_or(""),
});
if socket.send(Message::Text(notify.to_string().into())).await.is_err() {
if socket
.send(serialize_delta_message(
&delta,
&context.trace.request_id,
&context.trace.trace_id,
))
.await
.is_err()
{
break;
}
}
@@ -130,6 +133,14 @@ fn serialize_snapshot_message(payload: &Value) -> Message {
Message::Text(payload.to_string().into())
}
fn serialize_delta_message(payload: &Value, request_id: &str, trace_id: &str) -> Message {
Message::Text(
build_stream_push_delta_hint(payload, request_id, trace_id)
.to_string()
.into(),
)
}
fn serialize_resync_message(payload: &Value) -> Message {
Message::Text(
json!({
@@ -156,7 +167,10 @@ fn is_resync_request(text: &str) -> bool {
#[cfg(test)]
mod tests {
use super::{is_resync_request, serialize_resync_message, serialize_snapshot_message};
use super::{
is_resync_request, serialize_delta_message, serialize_resync_message,
serialize_snapshot_message,
};
use axum::extract::ws::Message;
use serde_json::json;
@@ -183,4 +197,29 @@ mod tests {
};
assert!(resync_text.contains("\"kind\":\"resync\""));
}
#[test]
fn ws_delta_serializer_uses_stream_hint_contract() {
let delta = json!({
"kind": "command_committed",
"commandName": "tree.rename",
"commandId": "cmd_1",
"workspaceId": "ws_demo",
"requestId": "req_command",
"traceId": "trace_command"
});
let Message::Text(delta_text) = serialize_delta_message(&delta, "req_ws", "trace_ws")
else {
panic!("delta message 应该是文本帧");
};
let payload: serde_json::Value = serde_json::from_str(&delta_text).expect("delta json");
assert_eq!(payload["kind"], "delta");
assert_eq!(payload["hint"], "command_committed");
assert_eq!(payload["commandName"], "tree.rename");
assert_eq!(payload["commandId"], "cmd_1");
assert_eq!(payload["workspaceId"], "ws_demo");
assert_eq!(payload["requestId"], "req_ws");
assert_eq!(payload["traceId"], "trace_ws");
assert!(payload.get("data").is_none());
}
}
+644 -27
View File
@@ -61,13 +61,32 @@ const SIDEBAR_TREE_JS: &str = r##"
pageAiSkillQuery: '',
pageAiSkillError: '',
pageAiSessions: [],
pageAiActiveSessionId: ''
pageAiActiveSessionId: '',
pageAiSessionSearchQuery: '',
pageAiSessionSearchResults: [],
pageAiSessionSearchTimer: 0,
pageAiSessionError: '',
pageAiPermissionRequests: []
};
function closestAction(target, selector) {
return target && typeof target.closest === 'function' ? target.closest(selector) : null;
}
function toggleWorkspaceSidebar(trigger) {
var shell = document.querySelector('.mnote-shell, .wolai-workspace-shell');
if (!(shell instanceof HTMLElement)) return;
var collapsed = shell.getAttribute('data-mnote-sidebar-collapsed') === 'true';
var next = !collapsed;
shell.setAttribute('data-mnote-sidebar-collapsed', String(next));
document.documentElement.setAttribute('data-mnote-sidebar-collapsed', String(next));
document.documentElement.setAttribute('data-mnote-sidebar-toggle-applied', 'true');
if (trigger instanceof HTMLElement) {
trigger.setAttribute('aria-pressed', String(next));
trigger.setAttribute('title', next ? '' : '');
}
}
function escapeHtml(value) {
return String(value == null ? '' : value)
.replace(/&/g, '&amp;')
@@ -227,20 +246,7 @@ const SIDEBAR_TREE_JS: &str = r##"
function currentPageAiContextSnapshot() {
var aggregate = currentPageAggregate();
var body = aggregate.body || {};
var serverContent = body.content || null;
var serverText = searchText(textFromUnknown(serverContent));
var localBlocks = readLocalEditorBlocks();
var localText = searchText(localBlocks.map(function(block) { return block.content || ''; }).join(' '));
var serverSubtree = aggregate.tree && aggregate.tree.pageSubtree ? aggregate.tree.pageSubtree : null;
var title = aggregate.head && aggregate.head.title ? aggregate.head.title : '';
if (localBlocks.length && localText && localText !== serverText) {
return {
aggregate: aggregate,
body: Object.assign({}, body, { content: localBlocks }),
subtree: buildPageAiLocalSubtree(localBlocks, title),
pageSubtreeSource: 'local'
};
}
return {
aggregate: aggregate,
body: body,
@@ -3930,6 +3936,30 @@ const SIDEBAR_TREE_JS: &str = r##"
return 'hermes_page_ai_session:' + currentDocumentId();
}
function pageAiTimestamp(value) {
if (typeof value === 'number' && Number.isFinite(value)) return value;
if (typeof value === 'string' && value.trim()) {
var parsed = Date.parse(value);
if (Number.isFinite(parsed)) return parsed;
}
return Date.now();
}
function pageAiBackendSessionQuery(extra) {
var params = new URLSearchParams();
params.set('source', 'acp');
params.set('workspaceId', resolveWorkspaceId(document.body));
params.set('documentId', currentDocumentId());
params.set('profile', pageAiRunProfile());
Object.keys(extra || {}).forEach(function(key) {
var value = extra[key];
if (value !== undefined && value !== null && String(value).trim() !== '') {
params.set(key, String(value));
}
});
return params.toString();
}
function pageAiNewSession(title) {
var now = Date.now();
return {
@@ -3939,10 +3969,126 @@ const SIDEBAR_TREE_JS: &str = r##"
acpRuntime: pageUiState.pageAiAcpRuntime || '',
createdAt: now,
updatedAt: now,
source: 'local',
usage: null,
status: 'idle',
messages: []
};
}
function pageAiUsageSummary(usage) {
if (!usage || typeof usage !== 'object') return '';
var used = usage.used ?? usage.contextUsed ?? usage.input_tokens ?? usage.inputTokens ?? usage.prompt_tokens ?? usage.promptTokens;
var size = usage.size ?? usage.contextSize ?? usage.total_tokens ?? usage.totalTokens;
var output = usage.output_tokens ?? usage.outputTokens ?? usage.completion_tokens ?? usage.completionTokens;
var parts = [];
if (Number.isFinite(Number(used))) parts.push('ctx ' + Number(used));
if (Number.isFinite(Number(size)) && Number(size) > 0) parts.push('/ ' + Number(size));
if (Number.isFinite(Number(output)) && Number(output) > 0) parts.push('out ' + Number(output));
return parts.join(' ') || '';
}
function pageAiPermissionMessage(payload, eventType) {
payload = payload && typeof payload === 'object' ? payload : {};
var permissionId = String(payload.permissionId || payload.permission_id || payload.id || ('perm_' + Date.now())).trim();
var toolName = String(payload.toolName || payload.tool || payload.name || payload.method || 'session/request_permission').trim();
var args = payload.arguments || payload.args || payload.input || payload.params || payload;
var decision = String(payload.decision || payload.result || '').trim();
if (!decision && eventType === 'permission.denied') decision = 'denied';
if (!decision && eventType === 'permission.allowed') decision = 'allowed';
return {
role: 'tool',
kind: 'permission',
permissionId: permissionId,
toolName: toolName,
argsSummary: pageAiPreviewValue(args),
content: decision === 'denied' ? '' : (decision === 'allowed' ? '' : ''),
resolved: decision === 'denied' || decision === 'allowed',
decision: decision
};
}
function pageAiApplyPermissionEvent(eventName, payloadText) {
var payload = null;
try {
payload = JSON.parse(payloadText || 'null');
} catch (_) {
payload = {};
}
var message = pageAiPermissionMessage(payload, eventName);
var existing = pageUiState.pageAiMessages.find(function(item) {
return item.kind === 'permission' && item.permissionId === message.permissionId;
});
if (existing) {
Object.assign(existing, message);
} else {
pageUiState.pageAiMessages.push(message);
}
pageUiState.pageAiPermissionRequests = pageUiState.pageAiPermissionRequests.filter(function(item) {
return item.permissionId !== message.permissionId;
}).concat([message]).slice(-20);
if (!message.resolved) {
pageAiShowPermissionDialog(message);
} else {
pageAiHidePermissionDialog();
}
}
function pageAiResolvePermission(permissionId, decision) {
permissionId = String(permissionId || '').trim();
if (!permissionId) return;
pageUiState.pageAiMessages.forEach(function(item) {
if (item.kind === 'permission' && item.permissionId === permissionId) {
item.resolved = true;
item.decision = decision;
item.content = decision === 'allow' ? '' : '';
}
});
var dialog = document.querySelector('[data-page-ai-permission-dialog]');
if (dialog instanceof HTMLElement) dialog.hidden = true;
renderPageAiConversation();
}
function pageAiHidePermissionDialog() {
var dialog = document.querySelector('[data-page-ai-permission-dialog]');
if (dialog instanceof HTMLElement) dialog.hidden = true;
}
function pageAiShowPermissionDialog(message) {
if (!message || message.kind !== 'permission') return;
if (message.resolved) {
pageAiHidePermissionDialog();
return;
}
var dialog = document.querySelector('[data-page-ai-permission-dialog]');
if (!(dialog instanceof HTMLElement)) {
dialog = document.createElement('div');
dialog.className = 'wolai-page-ai-permission-dialog';
dialog.setAttribute('data-page-ai-permission-dialog', 'true');
dialog.innerHTML = '' +
'<div class="wolai-page-ai-permission-panel" role="dialog" aria-modal="false">' +
'<div class="wolai-page-ai-memory-title" data-page-ai-permission-tool></div>' +
'<div class="wolai-page-ai-tool-meta" data-page-ai-permission-args></div>' +
'<div class="wolai-page-ai-message-actions">' +
'<button type="button" class="wolai-page-ai-ghost" data-page-ai-permission-action="allow" data-page-ai-permission-dialog-action></button>' +
'<button type="button" class="wolai-page-ai-ghost" data-page-ai-permission-action="deny" data-page-ai-permission-dialog-action></button>' +
'</div>' +
'</div>';
document.body.appendChild(dialog);
}
var tool = dialog.querySelector('[data-page-ai-permission-tool]');
if (tool instanceof HTMLElement) tool.textContent = message.toolName || 'session/request_permission';
var args = dialog.querySelector('[data-page-ai-permission-args]');
if (args instanceof HTMLElement) args.textContent = message.argsSummary || message.content || '';
dialog.querySelectorAll('[data-page-ai-permission-dialog-action]').forEach(function(button) {
if (button instanceof HTMLButtonElement) {
button.setAttribute('data-page-ai-permission-id', message.permissionId || '');
button.disabled = Boolean(message.resolved);
}
});
dialog.hidden = false;
}
function pageAiNormalizeSessions(sessions) {
return (Array.isArray(sessions) ? sessions : [])
.slice(0, 20)
@@ -3952,8 +4098,13 @@ const SIDEBAR_TREE_JS: &str = r##"
title: String(session && session.title || '').trim() || '',
profile: String(session && session.profile || pageAiCurrentProfile()).trim() || 'default',
acpRuntime: String(session && session.acpRuntime || '').trim(),
createdAt: Number(session && session.createdAt || Date.now()),
updatedAt: Number(session && session.updatedAt || Date.now()),
createdAt: pageAiTimestamp(session && session.createdAt),
updatedAt: pageAiTimestamp(session && session.updatedAt),
source: String(session && session.source || 'local').trim() || 'local',
runId: String(session && (session.runId || session.run_id) || '').trim(),
status: String(session && session.status || '').trim(),
usage: session && session.usage && typeof session.usage === 'object' ? session.usage : null,
preview: String(session && session.preview || '').trim(),
messages: Array.isArray(session && session.messages) ? session.messages.slice(-300) : []
};
})
@@ -3962,6 +4113,43 @@ const SIDEBAR_TREE_JS: &str = r##"
});
}
function pageAiNormalizeBackendSessionRow(row) {
if (!row || typeof row !== 'object') return null;
var payload = row.payload && typeof row.payload === 'object' ? row.payload : {};
var sessionId = String(row.sessionId || row.session_id || '').trim();
if (!sessionId) return null;
var title = String(row.title || payload.title || payload.message || '').trim();
if (title.length > 28) title = title.slice(0, 28) + '…';
return {
id: sessionId,
title: title || '',
profile: String(row.profile || payload.profile || pageAiRunProfile()).trim() || 'default',
acpRuntime: String(row.acpRuntime || row.acp_runtime || payload.acpRuntime || 'reasonix').trim(),
createdAt: pageAiTimestamp(row.createdAt || row.created_at),
updatedAt: pageAiTimestamp(row.updatedAt || row.updated_at),
source: 'acp',
runId: String(row.runId || row.run_id || '').trim(),
status: String(row.status || (row.runtime && row.runtime.status) || '').trim(),
usage: row.usage && typeof row.usage === 'object' ? row.usage : null,
preview: String(payload.message || row.snippet || '').trim(),
messages: []
};
}
function pageAiMergeSessions(localSessions, backendSessions) {
var byId = {};
pageAiNormalizeSessions(localSessions).forEach(function(session) {
byId[session.id] = session;
});
pageAiNormalizeSessions(backendSessions).forEach(function(session) {
var existing = byId[session.id];
byId[session.id] = Object.assign({}, existing || {}, session, {
messages: session.messages.length ? session.messages : (existing && existing.messages || [])
});
});
return pageAiNormalizeSessions(Object.keys(byId).map(function(id) { return byId[id]; }));
}
function pageAiLoadSessions() {
if (pageUiState.pageAiSessions.length && pageUiState.pageAiActiveSessionId) return;
try {
@@ -3996,6 +4184,135 @@ const SIDEBAR_TREE_JS: &str = r##"
pageUiState.pageAiMessages = [];
}
async function pageAiLoadBackendSessions() {
var response = await fetch('/api/hermes/client/sessions?' + pageAiBackendSessionQuery({ limit: 50 }), {
headers: { 'accept': 'application/json' }
});
var payload = await response.json().catch(function(){ return null; });
if (!response.ok || !payload || payload.ok !== true) {
throw new Error(pageAiErrorMessage(payload, 'session_list_failed_' + response.status));
}
var backendSessions = pageAiNormalizeArray(payload.sessions).map(pageAiNormalizeBackendSessionRow).filter(Boolean);
if (!backendSessions.length) return [];
pageUiState.pageAiSessions = pageAiMergeSessions(pageUiState.pageAiSessions, backendSessions);
if (!pageUiState.pageAiActiveSessionId || !pageUiState.pageAiSessions.find(function(session) { return session.id === pageUiState.pageAiActiveSessionId; })) {
pageUiState.pageAiActiveSessionId = pageUiState.pageAiSessions[0].id;
}
var active = pageAiCurrentSession();
if (active) {
if (active.profile) pageAiSetActiveProfile(active.profile);
if (active.acpRuntime) pageUiState.pageAiAcpRuntime = active.acpRuntime;
pageUiState.pageAiMessages = Array.isArray(active.messages) ? active.messages.slice() : pageUiState.pageAiMessages;
}
pageUiState.pageAiSessionError = '';
pageAiPersistSessions();
renderPageAiConversation();
renderPageAiControls();
return backendSessions;
}
function pageAiMessageFromRuntimeEvent(event) {
if (!event || typeof event !== 'object') return null;
var eventType = String(event.eventType || event.event_type || event.event || '').trim();
var payload = event.payload && typeof event.payload === 'object' ? event.payload : event;
if (eventType === 'message.delta') {
var delta = String(payload.delta || payload.text || payload.output_text || '').trim();
return delta ? { role: 'assistant', content: delta } : null;
}
if (eventType === 'thought.delta') {
var thought = String(payload.delta || payload.text || '').trim();
return thought ? { role: 'assistant', kind: 'thought', content: thought } : null;
}
if (eventType === 'tool.started' || eventType === 'tool.completed' || eventType === 'tool.failed') {
var toolName = String(payload.toolName || payload.tool || payload.name || eventType).trim();
return {
role: 'tool',
content: toolName,
toolName: toolName,
toolCallId: String(payload.toolCallId || payload.tool_call_id || payload.id || ''),
toolKind: String(payload.kind || ''),
status: eventType === 'tool.completed' ? 'completed' : (eventType === 'tool.failed' ? 'failed' : 'running'),
argsSummary: pageAiPreviewValue(payload.args || payload.arguments || payload.input),
resultSummary: pageAiPreviewValue(payload.summary || payload.result || payload.output || payload.error),
traceId: String(payload.traceId || payload.trace_id || ''),
auditId: String(payload.auditId || payload.audit_id || '')
};
}
if (eventType === 'permission.requested' || eventType === 'permission.denied' || eventType === 'permission.allowed') {
return pageAiPermissionMessage(payload, eventType);
}
return null;
}
function pageAiApplyBackendSessionDetail(payload) {
var sessionPayload = payload && payload.session ? payload.session : {};
var runs = pageAiNormalizeArray(sessionPayload.runs);
var latest = runs.length ? pageAiNormalizeBackendSessionRow(runs[0]) : null;
var events = pageAiNormalizeArray(payload && payload.events);
var messages = pageAiNormalizeArray(sessionPayload.messages).map(function(message) {
return {
role: message.role === 'user' ? 'user' : (message.role === 'tool' ? 'tool' : 'assistant'),
content: String(message.content || '')
};
}).filter(function(message) { return message.content; });
events.forEach(function(event) {
var message = pageAiMessageFromRuntimeEvent(event);
if (message) messages.push(message);
});
var current = pageAiCurrentSession();
if (latest && current) {
Object.assign(current, latest);
}
if (current) {
current.messages = messages.slice(-300);
current.updatedAt = Math.max(Number(current.updatedAt || 0), Date.now());
if (latest && latest.usage) current.usage = latest.usage;
}
pageAiApplyRuntimeState(payload && payload.runtime);
pageUiState.pageAiMessages = messages.slice(-300);
pageAiPersistSessions();
}
async function pageAiLoadBackendSessionDetail(sessionId) {
sessionId = String(sessionId || pageUiState.pageAiActiveSessionId || '').trim();
if (!sessionId) return null;
var response = await fetch('/api/hermes/client/sessions/' + encodeURIComponent(sessionId) + '?' + pageAiBackendSessionQuery({ limit: 200 }), {
headers: { 'accept': 'application/json' }
});
var payload = await response.json().catch(function(){ return null; });
if (!response.ok || !payload || payload.ok !== true) {
throw new Error(pageAiErrorMessage(payload, 'session_detail_failed_' + response.status));
}
pageAiApplyBackendSessionDetail(payload);
renderPageAiConversation();
renderPageAiControls();
return payload;
}
async function pageAiSearchBackendSessions(query) {
var q = String(query || '').trim();
pageUiState.pageAiSessionSearchQuery = q;
if (!q) {
pageUiState.pageAiSessionSearchResults = [];
renderPageAiConversation();
return [];
}
var response = await fetch('/api/hermes/client/sessions/search?' + pageAiBackendSessionQuery({ q: q, limit: 20 }), {
headers: { 'accept': 'application/json' }
});
var payload = await response.json().catch(function(){ return null; });
if (!response.ok || !payload || payload.ok !== true) {
throw new Error(pageAiErrorMessage(payload, 'session_search_failed_' + response.status));
}
pageUiState.pageAiSessionSearchResults = pageAiNormalizeArray(payload.results).map(function(row) {
var normalized = pageAiNormalizeBackendSessionRow(row) || {};
normalized.snippet = String(row && row.snippet || normalized.preview || '').trim();
return normalized;
}).filter(function(row) { return row.id; });
renderPageAiConversation();
return pageUiState.pageAiSessionSearchResults;
}
function pageAiPersistSessions() {
document.documentElement.setAttribute('data-mnote-page-ai-session-owner', 'hermes');
document.documentElement.setAttribute('data-mnote-page-ai-session-key', pageAiStorageKey());
@@ -4049,12 +4366,18 @@ const SIDEBAR_TREE_JS: &str = r##"
async function pageAiRestoreHermesSession() {
var current = pageAiCurrentSession();
if (!current || !String(current.id || '').startsWith('mnote_')) return;
var response = await fetch('/api/hermes/client/sessions/' + encodeURIComponent(current.id) + '/resume', {
var response = await fetch('/api/hermes/client/sessions/' + encodeURIComponent(current.id) + '/resume?' + pageAiBackendSessionQuery({}), {
method: 'POST',
headers: { 'accept': 'application/json' }
});
if (!response.ok) return;
var payload = await response.json().catch(function(){ return null; });
if (payload && payload.persistence === 'convex_acp_runtime_store') {
pageAiApplyBackendSessionDetail(payload);
renderPageAiConversation();
renderPageAiControls();
return;
}
var session = payload && (payload.session || (payload.upstream && payload.upstream.session) || payload);
var messages = session && Array.isArray(session.messages) ? session.messages : [];
pageAiApplyRuntimeState(payload && payload.runtime);
@@ -4101,6 +4424,12 @@ const SIDEBAR_TREE_JS: &str = r##"
pageAiPersistSessions();
renderPageAiControls();
renderPageAiConversation();
if (session.source === 'acp' || String(session.id || '').startsWith('mnote_')) {
void pageAiLoadBackendSessionDetail(session.id).catch(function(error) {
pageUiState.pageAiSessionError = error instanceof Error ? error.message : String(error);
renderPageAiControls();
});
}
}
function pageAiStartNewSession() {
@@ -4114,6 +4443,71 @@ const SIDEBAR_TREE_JS: &str = r##"
renderPageAiConversation();
}
async function pageAiRenameBackendSession(sessionId) {
var session = pageUiState.pageAiSessions.find(function(item) { return item.id === sessionId; });
if (!session) return;
var title = window.prompt(' AI ', session.title || '');
if (title === null) return;
title = String(title || '').trim();
if (!title) return;
var response = await fetch('/api/hermes/client/sessions/' + encodeURIComponent(sessionId) + '/rename?' + pageAiBackendSessionQuery({}), {
method: 'POST',
headers: { 'content-type': 'application/json', 'accept': 'application/json' },
body: JSON.stringify({ title: title })
});
var payload = await response.json().catch(function(){ return null; });
if (!response.ok || !payload || payload.ok !== true) {
throw new Error(pageAiErrorMessage(payload, 'session_rename_failed_' + response.status));
}
session.title = String((payload.result && payload.result.title) || title);
session.updatedAt = Date.now();
pageAiPersistSessions();
renderPageAiConversation();
renderPageAiControls();
}
async function pageAiDeleteBackendSession(sessionId) {
var session = pageUiState.pageAiSessions.find(function(item) { return item.id === sessionId; });
if (!session) return;
if (!window.confirm(' AI ' + (session.title || sessionId) + '')) return;
var response = await fetch('/api/hermes/client/sessions/' + encodeURIComponent(sessionId) + '?' + pageAiBackendSessionQuery({}), {
method: 'DELETE',
headers: { 'accept': 'application/json' }
});
var payload = await response.json().catch(function(){ return null; });
if (!response.ok || !payload || payload.ok !== true) {
throw new Error(pageAiErrorMessage(payload, 'session_delete_failed_' + response.status));
}
pageUiState.pageAiSessions = pageUiState.pageAiSessions.filter(function(item) { return item.id !== sessionId; });
if (pageUiState.pageAiActiveSessionId === sessionId) {
var next = pageUiState.pageAiSessions[0] || pageAiNewSession();
if (!pageUiState.pageAiSessions.length) pageUiState.pageAiSessions = [next];
pageUiState.pageAiActiveSessionId = next.id;
pageUiState.pageAiMessages = Array.isArray(next.messages) ? next.messages.slice() : [];
}
pageAiPersistSessions();
renderPageAiConversation();
renderPageAiControls();
}
async function pageAiResumeBackendSession(sessionId) {
sessionId = String(sessionId || pageUiState.pageAiActiveSessionId || '').trim();
if (!sessionId) return;
pageAiSetActiveSession(sessionId);
var response = await fetch('/api/hermes/client/sessions/' + encodeURIComponent(sessionId) + '/resume?' + pageAiBackendSessionQuery({}), {
method: 'POST',
headers: { 'accept': 'application/json' }
});
var payload = await response.json().catch(function(){ return null; });
if (!response.ok || !payload || payload.ok !== true) {
throw new Error(pageAiErrorMessage(payload, 'session_resume_failed_' + response.status));
}
pageAiApplyBackendSessionDetail(payload);
pageUiState.pageAiPage = 'chat';
renderPageAiConversation();
renderPageAiControls();
}
function pageAiProviderLabel(provider) {
if (provider === 'codex') return 'Codex';
if (provider === 'claudecode') return 'ClaudeCode';
@@ -4970,7 +5364,10 @@ const SIDEBAR_TREE_JS: &str = r##"
var sessionNode = drawer.querySelector('[data-page-ai-session-status]');
if (sessionNode instanceof HTMLElement) {
var session = pageAiCurrentSession();
sessionNode.textContent = session && session.id ? String(session.title || '') : ' Hermes session';
var usageText = session && session.usage ? pageAiUsageSummary(session.usage) : '';
sessionNode.textContent = session && session.id
? [String(session.title || ''), usageText].filter(Boolean).join(' · ')
: ' Hermes session';
}
var modelNode = drawer.querySelector('[data-page-ai-model-status]');
if (modelNode instanceof HTMLElement) modelNode.textContent = pageAiCurrentModelLabel();
@@ -5024,6 +5421,15 @@ const SIDEBAR_TREE_JS: &str = r##"
skillError.textContent = pageUiState.pageAiSkillError || '';
skillError.hidden = !pageUiState.pageAiSkillError;
}
var sessionError = drawer.querySelector('[data-page-ai-session-error]');
if (sessionError instanceof HTMLElement) {
sessionError.textContent = pageUiState.pageAiSessionError || '';
sessionError.hidden = !pageUiState.pageAiSessionError;
}
var sessionSearch = drawer.querySelector('[data-page-ai-session-search]');
if (sessionSearch instanceof HTMLInputElement && document.activeElement !== sessionSearch) {
sessionSearch.value = pageUiState.pageAiSessionSearchQuery || '';
}
var skillSearch = drawer.querySelector('[data-page-ai-skill-search]');
if (skillSearch instanceof HTMLInputElement && document.activeElement !== skillSearch) {
skillSearch.value = pageUiState.pageAiSkillQuery;
@@ -5290,6 +5696,11 @@ const SIDEBAR_TREE_JS: &str = r##"
'<div class="wolai-page-ai-settings-head">' +
'<button type="button" class="wolai-page-ai-back" data-page-ai-tab="chat"> </button>' +
'</div>' +
'<label class="wolai-page-ai-skill-search">' +
'<span></span>' +
'<input type="search" data-page-ai-session-search placeholder="搜索后端 AI 会话…" />' +
'</label>' +
'<div class="wolai-page-ai-inline-error" data-page-ai-session-error hidden></div>' +
'<div class="wolai-page-ai-conversation" data-page-ai-conversation></div>' +
'</section>' +
'</div>' +
@@ -5332,20 +5743,33 @@ const SIDEBAR_TREE_JS: &str = r##"
var conversation = drawer.querySelector('[data-page-ai-panel="' + cssEscape(pageUiState.pageAiPage || 'chat') + '"] [data-page-ai-conversation]');
if (!(conversation instanceof HTMLElement)) return;
if (pageUiState.pageAiPage === 'history') {
if (!pageUiState.pageAiSessions.length) {
var historyRows = pageUiState.pageAiSessionSearchResults.length
? pageUiState.pageAiSessionSearchResults
: pageUiState.pageAiSessions;
if (!historyRows.length) {
conversation.innerHTML = '<div class="wolai-page-ai-empty"></div>';
return;
}
conversation.innerHTML = pageUiState.pageAiSessions.map(function(session) {
conversation.innerHTML = historyRows.map(function(session) {
var preview = Array.isArray(session.messages) && session.messages.length
? session.messages.slice(-1)[0].content
: '';
: (session.snippet || session.preview || '');
var active = session.id === pageUiState.pageAiActiveSessionId;
var usage = pageAiUsageSummary(session.usage);
var meta = [session.source === 'acp' ? 'Convex' : '', session.status, usage].filter(Boolean).join(' · ');
return '' +
'<button type="button" class="wolai-page-ai-message wolai-page-ai-message--history' + (active ? ' is-active' : '') + '" data-page-ai-session="' + escapeHtml(session.id) + '">' +
'<div class="wolai-page-ai-message-role"></div>' +
'<div class="wolai-page-ai-message-text"><strong>' + escapeHtml(session.title || '') + '</strong><br />' + escapeHtml(preview) + '</div>' +
'</button>';
'<div class="wolai-page-ai-message wolai-page-ai-message--history' + (active ? ' is-active' : '') + '" data-page-ai-session-row="' + escapeHtml(session.id) + '">' +
'<button type="button" class="wolai-page-ai-message-text" data-page-ai-session="' + escapeHtml(session.id) + '">' +
'<strong>' + escapeHtml(session.title || '') + '</strong><br />' +
'<span>' + escapeHtml(preview) + '</span>' +
(meta ? '<br /><span class="wolai-page-ai-tool-meta">' + escapeHtml(meta) + '</span>' : '') +
'</button>' +
'<div class="wolai-page-ai-message-actions">' +
'<button type="button" class="wolai-page-ai-ghost" data-page-ai-session-resume="' + escapeHtml(session.id) + '"></button>' +
'<button type="button" class="wolai-page-ai-ghost" data-page-ai-session-rename="' + escapeHtml(session.id) + '"></button>' +
'<button type="button" class="wolai-page-ai-ghost" data-page-ai-session-delete="' + escapeHtml(session.id) + '"></button>' +
'</div>' +
'</div>';
}).join('');
return;
}
@@ -5376,6 +5800,30 @@ const SIDEBAR_TREE_JS: &str = r##"
'</div>' +
'</div>';
}
if (item.kind === 'thought') {
return '' +
'<details class="wolai-page-ai-message wolai-page-ai-message--assistant" data-page-ai-thought-delta="true">' +
'<summary class="wolai-page-ai-message-role" style="cursor:pointer;user-select:none;opacity:0.7;font-size:0.85em"></summary>' +
'<div class="wolai-page-ai-message-text" style="margin-top:4px;opacity:0.6;font-size:0.9em;border-left:2px solid var(--wolai-border-color, #ddd);padding-left:8px">' + escapeHtml(item.content || '') + '</div>' +
'</details>';
}
if (item.kind === 'permission') {
var permissionActions = item.resolved ? '' : (
'<div class="wolai-page-ai-message-actions">' +
'<button type="button" class="wolai-page-ai-ghost" data-page-ai-permission-action="allow" data-page-ai-permission-id="' + escapeHtml(item.permissionId || '') + '"></button>' +
'<button type="button" class="wolai-page-ai-ghost" data-page-ai-permission-action="deny" data-page-ai-permission-id="' + escapeHtml(item.permissionId || '') + '"></button>' +
'</div>'
);
return '' +
'<div class="wolai-page-ai-message wolai-page-ai-message--tool" data-page-ai-permission-request="' + escapeHtml(item.permissionId || '') + '">' +
'<div class="wolai-page-ai-message-role"></div>' +
'<div class="wolai-page-ai-message-text">' +
'<strong>' + escapeHtml(item.toolName || 'session/request_permission') + '</strong><br />' +
'<span>' + escapeHtml(item.argsSummary || item.content || '') + '</span>' +
permissionActions +
'</div>' +
'</div>';
}
var streamingAttr = item.streaming ? ' data-page-ai-streaming="true"' : '';
return '' +
'<div class="wolai-page-ai-message wolai-page-ai-message--' + escapeHtml(item.role || 'assistant') + '"' + streamingAttr + '>' +
@@ -5401,7 +5849,8 @@ const SIDEBAR_TREE_JS: &str = r##"
pageAiLoadProfileMemory(),
pageAiLoadSkills(),
pageAiLoadTools(),
pageAiLoadGatewayHealth()
pageAiLoadGatewayHealth(),
pageAiLoadBackendSessions()
]).then(function() {
renderPageAiControls();
}).catch(function() {}).then(function() {
@@ -5687,10 +6136,54 @@ const SIDEBAR_TREE_JS: &str = r##"
if (pageUiState.pageAiStoppedRunIds[runId]) return;
assistantText = pageAiAppendStreamingAssistantDelta(runId, pageAiDecodeDeltaText(payloadText));
}
if (eventName === 'thought.delta') {
try {
var thoughtPayload = JSON.parse(payloadText || 'null');
var thoughtText = String((thoughtPayload && (thoughtPayload.delta || thoughtPayload.text)) || '');
if (thoughtText) {
var msgs = pageUiState.pageAiMessages;
var lastThought = msgs.length > 0 && msgs[msgs.length - 1].kind === 'thought' ? msgs[msgs.length - 1] : null;
if (lastThought) {
lastThought.content += thoughtText;
} else {
msgs.push({ role: 'assistant', kind: 'thought', content: thoughtText });
}
pageAiSyncCurrentSessionMessages();
pageAiPersistSessions();
renderPageAiConversation();
}
} catch (_) {}
}
if (eventName === 'usage.updated') {
try {
var usagePayload = JSON.parse(payloadText || 'null') || {};
var sessionForUsage = pageAiCurrentSession();
if (sessionForUsage) {
sessionForUsage.usage = {
source: 'usage_update',
used: Number(usagePayload.used ?? usagePayload.contextUsed ?? 0),
size: Number(usagePayload.size ?? usagePayload.contextSize ?? 0)
};
pageAiPersistSessions();
}
} catch (_) {}
renderPageAiControls();
}
if (eventName === 'permission.requested' || eventName === 'permission.denied' || eventName === 'permission.allowed') {
pageAiApplyPermissionEvent(eventName, payloadText);
pageAiSyncCurrentSessionMessages();
pageAiPersistSessions();
renderPageAiConversation();
renderPageAiControls();
}
if (eventName === 'run.completed') {
try {
var completed = JSON.parse(payloadText || 'null');
if (completed && completed.output) assistantText = String(completed.output || '');
if (completed && completed.usage) {
var completedSession = pageAiCurrentSession();
if (completedSession) completedSession.usage = completed.usage;
}
} catch (_) {}
pageAiSetRunStatus('completed', runId);
}
@@ -6181,6 +6674,13 @@ const SIDEBAR_TREE_JS: &str = r##"
if (activeTreeContextMenu && activeTreeContextMenu.contains(e.target)) return;
if (activeTreeContextMenu) closeTreeContextMenu();
var sidebarToggle = closestAction(e.target, '[data-mnote-action="toggle-sidebar"]');
if (sidebarToggle) {
e.preventDefault();
toggleWorkspaceSidebar(sidebarToggle);
return;
}
var attachmentAction = closestAction(e.target, '[data-attachment-action]');
if (attachmentAction) {
e.preventDefault();
@@ -6362,6 +6862,46 @@ const SIDEBAR_TREE_JS: &str = r##"
return;
}
var pageAiSessionResume = closestAction(e.target, '[data-page-ai-session-resume]');
if (pageAiSessionResume) {
e.preventDefault();
void pageAiResumeBackendSession(pageAiSessionResume.getAttribute('data-page-ai-session-resume') || '').catch(function(error) {
pageUiState.pageAiSessionError = error instanceof Error ? error.message : String(error);
renderPageAiControls();
});
return;
}
var pageAiSessionRename = closestAction(e.target, '[data-page-ai-session-rename]');
if (pageAiSessionRename) {
e.preventDefault();
void pageAiRenameBackendSession(pageAiSessionRename.getAttribute('data-page-ai-session-rename') || '').catch(function(error) {
pageUiState.pageAiSessionError = error instanceof Error ? error.message : String(error);
renderPageAiControls();
});
return;
}
var pageAiSessionDelete = closestAction(e.target, '[data-page-ai-session-delete]');
if (pageAiSessionDelete) {
e.preventDefault();
void pageAiDeleteBackendSession(pageAiSessionDelete.getAttribute('data-page-ai-session-delete') || '').catch(function(error) {
pageUiState.pageAiSessionError = error instanceof Error ? error.message : String(error);
renderPageAiControls();
});
return;
}
var pageAiPermissionAction = closestAction(e.target, '[data-page-ai-permission-action]');
if (pageAiPermissionAction) {
e.preventDefault();
pageAiResolvePermission(
pageAiPermissionAction.getAttribute('data-page-ai-permission-id') || '',
pageAiPermissionAction.getAttribute('data-page-ai-permission-action') || 'deny'
);
return;
}
var pageAiSession = closestAction(e.target, '[data-page-ai-session]');
if (pageAiSession) {
e.preventDefault();
@@ -6396,6 +6936,12 @@ const SIDEBAR_TREE_JS: &str = r##"
pageUiState.pageAiPage = pageUiState.pageAiPage === 'history' ? 'chat' : 'history';
renderPageAiControls();
renderPageAiConversation();
if (pageUiState.pageAiPage === 'history') {
void pageAiLoadBackendSessions().catch(function(error) {
pageUiState.pageAiSessionError = error instanceof Error ? error.message : String(error);
renderPageAiControls();
});
}
return;
}
@@ -6661,6 +7207,18 @@ const SIDEBAR_TREE_JS: &str = r##"
renderPageAiControls();
return;
}
var sessionSearch = closestAction(event.target, '[data-page-ai-session-search]');
if (sessionSearch instanceof HTMLInputElement) {
var sessionQuery = sessionSearch.value;
window.clearTimeout(pageUiState.pageAiSessionSearchTimer || 0);
pageUiState.pageAiSessionSearchTimer = window.setTimeout(function() {
void pageAiSearchBackendSessions(sessionQuery).catch(function(error) {
pageUiState.pageAiSessionError = error instanceof Error ? error.message : String(error);
renderPageAiControls();
});
}, 200);
return;
}
var memoryEditor = closestAction(event.target, '[data-page-ai-memory-editor]');
if (memoryEditor instanceof HTMLTextAreaElement) {
var section = memoryEditor.getAttribute('data-page-ai-memory-editor') || '';
@@ -6681,6 +7239,9 @@ const SIDEBAR_TREE_JS: &str = r##"
void pageAiLoadProfiles();
if (next !== 'reasonix') void pageAiLoadProfileMemory();
void pageAiLoadSkills();
void pageAiLoadBackendSessions().catch(function(error) {
pageUiState.pageAiSessionError = error instanceof Error ? error.message : String(error);
});
renderPageAiControls();
renderPageAiProviderButtons();
return;
@@ -7343,7 +7904,7 @@ pub fn PageLayout(
<div class="mnote-main">
<header class="wolai-topbar" data-testid="wolai-topbar">
<div class="wolai-topbar-left">
<button type="button" class="wolai-icon-button wolai-menu-button" aria-label="切换侧栏"><span class="material-symbols-outlined" data-icon="menu" aria-hidden="true"></span></button>
<button type="button" class="wolai-icon-button wolai-menu-button" aria-label="切换侧栏" aria-pressed="false" data-mnote-action="toggle-sidebar" data-testid="wolai-sidebar-toggle"><span class="material-symbols-outlined" data-icon="menu" aria-hidden="true"></span></button>
<nav class="wolai-breadcrumb" aria-label="页面路径">
<span class="wolai-breadcrumb-root">{ws_name.clone()}</span>
<span class="wolai-breadcrumb-separator" aria-hidden="true">""</span>
@@ -7445,6 +8006,59 @@ mod tests {
assert!(SIDEBAR_TREE_JS.contains("mnote-recent-local-root"));
}
#[test]
fn page_ai_context_uses_page_aggregate_subtree_as_single_truth() {
assert!(
!SIDEBAR_TREE_JS.contains("pageSubtreeSource: 'local'"),
"页面 AI context 不能从编辑器 DOM 派生 local page subtree;必须以 Rust Page Aggregate projection 为准"
);
assert!(
!SIDEBAR_TREE_JS.contains("buildPageAiLocalSubtree(localBlocks"),
"页面 AI context 不应调用本地 page subtree builder"
);
}
#[test]
fn page_ai_uses_backend_acp_session_runtime_store() {
assert!(SIDEBAR_TREE_JS.contains("function pageAiLoadBackendSessions"));
assert!(SIDEBAR_TREE_JS.contains("/api/hermes/client/sessions?"));
assert!(SIDEBAR_TREE_JS.contains("params.set('source', 'acp')"));
assert!(SIDEBAR_TREE_JS.contains("function pageAiLoadBackendSessionDetail"));
assert!(SIDEBAR_TREE_JS.contains("function pageAiSearchBackendSessions"));
assert!(SIDEBAR_TREE_JS.contains("data-page-ai-session-rename"));
assert!(SIDEBAR_TREE_JS.contains("data-page-ai-session-delete"));
assert!(SIDEBAR_TREE_JS.contains("data-page-ai-session-resume"));
assert!(SIDEBAR_TREE_JS.contains("data-page-ai-session-search"));
assert!(SIDEBAR_TREE_JS.contains("pageAiUsageSummary"));
assert!(SIDEBAR_TREE_JS.contains("usage.updated"));
assert!(SIDEBAR_TREE_JS.contains("thought.delta"));
assert!(SIDEBAR_TREE_JS.contains("permission.requested"));
assert!(SIDEBAR_TREE_JS.contains("data-page-ai-permission-action=\"allow\""));
assert!(SIDEBAR_TREE_JS.contains("data-page-ai-permission-action=\"deny\""));
assert!(SIDEBAR_TREE_JS.contains("function pageAiHidePermissionDialog"));
assert!(SIDEBAR_TREE_JS.contains("if (!message.resolved)"));
assert!(
!SIDEBAR_TREE_JS.contains("(item.resolved ? ' disabled' : '')"),
"已决 ACP permission 事件不能继续展示假审批按钮"
);
}
#[test]
fn sidebar_tree_runtime_does_not_use_retired_query_preferred_snapshot_selector() {
assert!(TREE_LIVE_CONTROLLER_JS.contains("tree:snapshot"));
assert!(TREE_LIVE_CONTROLLER_JS.contains("tree:delta"));
assert!(!SIDEBAR_TREE_JS.contains("usePreferredSidebarSnapshot"));
assert!(!SIDEBAR_TREE_JS.contains("preferredSidebarSnapshot"));
assert!(!SIDEBAR_TREE_JS.contains("liveSidebarTitle"));
}
#[test]
fn page_layout_sidebar_toggle_is_wired_to_shell_state() {
assert!(SIDEBAR_TREE_JS.contains("function toggleWorkspaceSidebar"));
assert!(SIDEBAR_TREE_JS.contains("data-mnote-sidebar-collapsed"));
assert!(SIDEBAR_TREE_JS.contains("[data-mnote-action=\"toggle-sidebar\"]"));
}
#[test]
fn sidebar_workspace_source_switch_remembers_real_cloud_workspace_before_local_folder() {
assert!(
@@ -7593,6 +8207,9 @@ mod tests {
#[test]
fn tree_live_controller_marks_transport_and_closes_source_on_pagehide() {
assert!(TREE_LIVE_CONTROLLER_JS.contains("convex-command-log-sse"));
assert!(TREE_LIVE_CONTROLLER_JS.contains("convex-command-log-ws"));
assert!(TREE_LIVE_CONTROLLER_JS.contains("new WebSocket(url.toString())"));
assert!(TREE_LIVE_CONTROLLER_JS.contains("startWithSseFallback"));
assert!(TREE_LIVE_CONTROLLER_JS.contains("data-mnote-tree-live-transport"));
assert!(TREE_LIVE_CONTROLLER_JS.contains("pagehide"));
assert!(TREE_LIVE_CONTROLLER_JS.contains("__mnoteTreeLiveEventSource"));
+41 -3
View File
@@ -224,6 +224,11 @@ a:hover {
background: var(--wolai-bg);
}
.mnote-shell[data-mnote-sidebar-collapsed="true"] .mnote-sidebar,
.mnote-shell[data-mnote-sidebar-collapsed="true"] .wolai-sidebar {
display: none;
}
.wolai-workspace-shell {
--wolai-bg-sidebar: #F7F7F6;
--wolai-bg-selected: #F8E6E7;
@@ -3376,7 +3381,6 @@ body {
width: 100%;
border: 1px solid rgba(27, 28, 28, 0.08);
text-align: left;
cursor: pointer;
}
.wolai-page-ai-message--history.is-active {
@@ -3399,6 +3403,39 @@ body {
white-space: pre-wrap;
}
button.wolai-page-ai-message-text {
width: 100%;
border: 0;
padding: 0;
background: transparent;
text-align: left;
cursor: pointer;
}
.wolai-page-ai-message-actions {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.wolai-page-ai-permission-dialog {
position: fixed;
right: 28px;
bottom: 28px;
z-index: 80;
max-width: min(360px, calc(100vw - 32px));
}
.wolai-page-ai-permission-panel {
display: grid;
gap: 10px;
padding: 14px;
border: 1px solid rgba(27, 28, 28, 0.12);
border-radius: 8px;
background: #FFFFFF;
box-shadow: 0 18px 48px rgba(15, 23, 42, 0.16);
}
.wolai-page-ai-tool-details {
display: grid;
gap: 6px;
@@ -3670,6 +3707,7 @@ mod tests {
assert!(MNOTE_CSS.contains(".mnote-sidebar"));
assert!(MNOTE_CSS.contains(".mnote-main"));
assert!(MNOTE_CSS.contains(".mnote-content"));
assert!(MNOTE_CSS.contains("[data-mnote-sidebar-collapsed=\"true\"]"));
}
#[test]
@@ -3713,7 +3751,7 @@ mod tests {
fn mnote_css_is_reasonably_sized() {
// 至少 2000 字符才能包含完整样式
assert!(MNOTE_CSS.len() > 2000);
// 当前整合了工作区壳、编辑器样式、树菜单和双 pane 布局,仍保持在单文件可审阅范围内。
assert!(MNOTE_CSS.len() < 70000);
// 当前整合了工作区壳、编辑器样式、树菜单、页面 AI 和双 pane 布局,仍保持在单文件可审阅范围内。
assert!(MNOTE_CSS.len() < 90000);
}
}
@@ -83,8 +83,14 @@ fn render_filetree_row(
} else {
String::new()
};
let command_document_id = match row.row_kind.as_str() {
"document" | "markdown" => row.document_id.as_deref().unwrap_or(&row.node_id),
"index" => row.node_id.strip_prefix("index:").unwrap_or(&row.node_id),
_ => "",
};
let owner_document_id = row.document_id.as_deref().unwrap_or_default();
html.push_str(&format!(
r#"<li class="tree-node" data-node-id="{node_id}"><div class="tree-row" role="treeitem" aria-level="{aria_level}" aria-expanded="{expanded_attr}" data-rust-rendered-row="filetree" data-testid="{test_id}" data-row-id="{row_id}" data-row-kind="{row_kind}" data-node-id="{node_id}"{parent_attr} data-document-id="{document_id}" data-doc-id="{document_id}" data-asset-id="{asset_id}" data-object-identity="{object_identity}" data-shell-mode="filetree" data-selected="{selected}" data-active="false" draggable="true">{toggle_html}<span class="tree-kind-badge" data-kind="{icon_kind}" aria-hidden="true"></span><button type="button" class="tree-link" data-rust-action="open" data-row-id="{row_id}" data-document-id="{document_id}" data-asset-id="{asset_id}"><span class="tree-link-title">{title}</span></button><div class="tree-actions">{create_action_html}<button type="button" class="tree-action" data-testid="filetree-action-menu" data-rust-action="menu" data-row-id="{row_id}" aria-label="更多操作">…</button></div></div>"#,
r#"<li class="tree-node" data-node-id="{node_id}"><div class="tree-row" role="treeitem" aria-level="{aria_level}" aria-expanded="{expanded_attr}" data-rust-rendered-row="filetree" data-testid="{test_id}" data-row-id="{row_id}" data-row-kind="{row_kind}" data-node-id="{node_id}"{parent_attr} data-document-id="{document_id}" data-doc-id="{document_id}" data-owner-document-id="{owner_document_id}" data-asset-id="{asset_id}" data-object-identity="{object_identity}" data-shell-mode="filetree" data-selected="{selected}" data-active="false" draggable="true">{toggle_html}<span class="tree-kind-badge" data-kind="{icon_kind}" aria-hidden="true"></span><button type="button" class="tree-link" data-rust-action="open" data-row-id="{row_id}" data-document-id="{document_id}" data-owner-document-id="{owner_document_id}" data-asset-id="{asset_id}"><span class="tree-link-title">{title}</span></button><div class="tree-actions">{create_action_html}<button type="button" class="tree-action" data-testid="filetree-action-menu" data-rust-action="menu" data-row-id="{row_id}" aria-label="更多操作">…</button></div></div>"#,
node_id = escape_html(&row.node_id),
aria_level = row.depth + 1,
expanded_attr = if row.expandable && row.expanded { "true" } else { "false" },
@@ -92,7 +98,8 @@ fn render_filetree_row(
row_id = escape_html(&row.row_id),
row_kind = escape_html(&row.row_kind),
parent_attr = parent_attr,
document_id = escape_html(row.document_id.as_deref().unwrap_or_default()),
document_id = escape_html(command_document_id),
owner_document_id = escape_html(owner_document_id),
asset_id = escape_html(row.asset_id.as_deref().unwrap_or_default()),
object_identity = escape_html(row.object_identity.as_deref().unwrap_or_default()),
selected = row.selected,
@@ -208,6 +215,7 @@ mod tests {
assert!(!html.contains("data-testid=\"filetree-index-row\""));
assert!(!html.contains("index.md"));
assert!(html.contains("data-doc-id=\"page_root\""));
assert!(html.contains("data-row-id=\"asset:mind_1\" data-row-kind=\"asset\" data-node-id=\"asset:mind_1\" data-parent-id=\"page_root\" data-document-id=\"\" data-doc-id=\"\" data-owner-document-id=\"page_root\" data-asset-id=\"mind_1\""));
assert!(html.contains("data-asset-id=\"mind_1\""));
assert!(html.contains("data-object-identity=\"{&quot;objectKind&quot;:&quot;mindmap&quot;"));
assert!(html.contains("tree-children"));
+12 -12
View File
@@ -171,7 +171,7 @@ mod tests {
},
source: SourcePayload {
channel: "next-route".into(),
client: "wolai-frontend".into(),
client: "mnote-web".into(),
},
target: Some(TargetRef {
workspace_id: Some("ws_1".into()),
@@ -210,7 +210,7 @@ mod tests {
},
source: SourcePayload {
channel: "next-route".into(),
client: "wolai-frontend".into(),
client: "mnote-web".into(),
},
target: Some(TargetRef {
workspace_id: Some("ws_1".into()),
@@ -257,7 +257,7 @@ mod tests {
},
source: SourcePayload {
channel: "next-route".into(),
client: "wolai-frontend".into(),
client: "mnote-web".into(),
},
target: Some(TargetRef {
workspace_id: Some("ws_1".into()),
@@ -294,7 +294,7 @@ mod tests {
},
source: SourcePayload {
channel: "next-route".into(),
client: "wolai-frontend".into(),
client: "mnote-web".into(),
},
target: Some(TargetRef {
workspace_id: Some("ws_1".into()),
@@ -334,7 +334,7 @@ mod tests {
},
source: SourcePayload {
channel: "next-route".into(),
client: "wolai-frontend".into(),
client: "mnote-web".into(),
},
target: Some(TargetRef {
workspace_id: Some("ws_1".into()),
@@ -374,7 +374,7 @@ mod tests {
},
source: SourcePayload {
channel: "next-route".into(),
client: "wolai-frontend".into(),
client: "mnote-web".into(),
},
target: Some(TargetRef {
workspace_id: Some("ws_1".into()),
@@ -413,7 +413,7 @@ mod tests {
};
let base_source = SourcePayload {
channel: "next-route".into(),
client: "wolai-frontend".into(),
client: "mnote-web".into(),
};
let create_request = build_write_request(
@@ -594,7 +594,7 @@ mod tests {
},
source: SourcePayload {
channel: "next-route".into(),
client: "wolai-frontend".into(),
client: "mnote-web".into(),
},
target: Some(TargetRef {
workspace_id: Some("ws_1".into()),
@@ -651,7 +651,7 @@ mod tests {
},
source: SourcePayload {
channel: "next-route".into(),
client: "wolai-frontend".into(),
client: "mnote-web".into(),
},
target: Some(TargetRef {
workspace_id: Some("ws_1".into()),
@@ -694,7 +694,7 @@ mod tests {
},
source: SourcePayload {
channel: "next-route".into(),
client: "wolai-frontend".into(),
client: "mnote-web".into(),
},
target: Some(TargetRef {
workspace_id: Some("ws_1".into()),
@@ -745,7 +745,7 @@ mod tests {
},
source: SourcePayload {
channel: "next-route".into(),
client: "wolai-frontend".into(),
client: "mnote-web".into(),
},
target: Some(TargetRef {
workspace_id: Some("ws_1".into()),
@@ -796,7 +796,7 @@ mod tests {
},
source: SourcePayload {
channel: "onlyoffice-callback".into(),
client: "wolai-frontend".into(),
client: "mnote-web".into(),
},
target: Some(TargetRef {
workspace_id: Some("ws_1".into()),
+1 -1
View File
@@ -1 +1 @@
{"rustc_fingerprint":10059341515723286937,"outputs":{"17747080675513052775":{"success":true,"status":"","code":0,"stdout":"rustc 1.95.0 (59807616e 2026-04-14)\nbinary: rustc\ncommit-hash: 59807616e1fa2540724bfbac14d7976d7e4a3860\ncommit-date: 2026-04-14\nhost: x86_64-unknown-linux-gnu\nrelease: 1.95.0\nLLVM version: 22.1.2\n","stderr":""},"7971740275564407648":{"success":true,"status":"","code":0,"stdout":"___\nlib___.rlib\nlib___.so\nlib___.so\nlib___.a\nlib___.so\n/home/lix/.rustup/toolchains/stable-x86_64-unknown-linux-gnu\noff\npacked\nunpacked\n___\ndebug_assertions\npanic=\"unwind\"\nproc_macro\ntarget_abi=\"\"\ntarget_arch=\"x86_64\"\ntarget_endian=\"little\"\ntarget_env=\"gnu\"\ntarget_family=\"unix\"\ntarget_feature=\"fxsr\"\ntarget_feature=\"sse\"\ntarget_feature=\"sse2\"\ntarget_has_atomic=\"16\"\ntarget_has_atomic=\"32\"\ntarget_has_atomic=\"64\"\ntarget_has_atomic=\"8\"\ntarget_has_atomic=\"ptr\"\ntarget_os=\"linux\"\ntarget_pointer_width=\"64\"\ntarget_vendor=\"unknown\"\nunix\n","stderr":""}},"successes":{}}
{"rustc_fingerprint":9228011546279038255,"outputs":{"17747080675513052775":{"success":true,"status":"","code":0,"stdout":"rustc 1.89.0 (29483883e 2025-08-04)\nbinary: rustc\ncommit-hash: 29483883eed69d5fb4db01964cdf2af4d86e9cb2\ncommit-date: 2025-08-04\nhost: x86_64-unknown-linux-gnu\nrelease: 1.89.0\nLLVM version: 20.1.7\n","stderr":""},"7971740275564407648":{"success":true,"status":"","code":0,"stdout":"___\nlib___.rlib\nlib___.so\nlib___.so\nlib___.a\nlib___.so\n/home/lix/.rustup/toolchains/1.89.0-x86_64-unknown-linux-gnu\noff\npacked\nunpacked\n___\ndebug_assertions\npanic=\"unwind\"\nproc_macro\ntarget_abi=\"\"\ntarget_arch=\"x86_64\"\ntarget_endian=\"little\"\ntarget_env=\"gnu\"\ntarget_family=\"unix\"\ntarget_feature=\"fxsr\"\ntarget_feature=\"sse\"\ntarget_feature=\"sse2\"\ntarget_has_atomic=\"16\"\ntarget_has_atomic=\"32\"\ntarget_has_atomic=\"64\"\ntarget_has_atomic=\"8\"\ntarget_has_atomic=\"ptr\"\ntarget_os=\"linux\"\ntarget_pointer_width=\"64\"\ntarget_vendor=\"unknown\"\nunix\n","stderr":""}},"successes":{}}
+110 -6
View File
@@ -22,6 +22,7 @@
import { createInterface } from 'node:readline';
import { stdin, stdout } from 'node:process';
import { randomUUID } from 'node:crypto';
import { AsyncLocalStorage } from 'node:async_hooks';
import { readFileSync, existsSync } from 'node:fs';
import { homedir } from 'node:os';
import { join } from 'node:path';
@@ -33,6 +34,97 @@ function debugLog(message) {
if (DEBUG) process.stderr.write(`${message}\n`);
}
const toolContextStorage = new AsyncLocalStorage();
function isWriteMnoteTool(toolName) {
return !['mnote.doc.fetch', 'mnote.page.get', 'mnote.block.fetch'].includes(toolName);
}
function stableIdPart(value, fallback) {
const text = String(value || fallback || '')
.trim()
.replace(/[^a-zA-Z0-9_.:-]+/g, '_')
.slice(0, 80);
return text || String(fallback || 'unknown');
}
function buildMnoteToolPayload(toolName, rawArgs = {}, context = {}) {
const {
actorId,
sessionId,
runId,
toolCallId,
traceId,
idempotencyKey,
dryRun,
workspaceId,
...args
} = rawArgs || {};
const effectiveSessionId =
sessionId || context.sessionId || context.acpSessionId || `reasonix_acp_session_${randomUUID()}`;
const effectiveRunId = runId || context.runId || effectiveSessionId;
const effectiveToolCallId =
toolCallId || `reasonix_${stableIdPart(toolName, 'tool')}_${randomUUID()}`;
const effectiveTraceId = traceId || context.traceId || `trace_${stableIdPart(effectiveRunId, 'run')}`;
const writeTool = isWriteMnoteTool(toolName);
const effectiveDryRun = typeof dryRun === 'boolean' ? dryRun : writeTool ? false : false;
const effectiveIdempotencyKey =
idempotencyKey ||
`idem_${stableIdPart(toolName, 'tool')}_${stableIdPart(effectiveRunId, 'run')}_${stableIdPart(effectiveToolCallId, 'call')}`;
return {
toolName,
args,
workspaceId: workspaceId || context.workspaceId || 'default',
documentId: args.documentId || context.documentId,
actorId: actorId || context.actorId || process.env.MNOTE_ACTOR_ID || 'reasonix-acp',
sessionId: effectiveSessionId,
runId: effectiveRunId,
toolCallId: effectiveToolCallId,
traceId: effectiveTraceId,
dryRun: effectiveDryRun,
idempotencyKey: effectiveIdempotencyKey,
};
}
if (process.env.MNOTE_REASONIX_ACP_SELFTEST === '1') {
const payload = buildMnoteToolPayload(
'mnote.doc.markdown_edit',
{
workspaceId: 'ws_demo',
documentId: 'doc_1',
operations: [{ search: '旧', replace: '新' }],
},
{
actorId: 'user_1',
sessionId: 'sess_1',
runId: 'run_1',
traceId: 'trace_1',
},
);
const required = [
'toolName',
'workspaceId',
'documentId',
'actorId',
'sessionId',
'runId',
'toolCallId',
'traceId',
'dryRun',
'idempotencyKey',
];
for (const key of required) {
if (payload[key] === undefined || payload[key] === null || payload[key] === '') {
throw new Error(`selftest missing ${key}`);
}
}
if (payload.args.workspaceId !== undefined || payload.args.actorId !== undefined) {
throw new Error('selftest expected identity fields outside args');
}
process.stderr.write('[reasonix-acp-mnote] selftest ok\n');
process.exit(0);
}
// 读取 DeepSeek API key:先环境变量,再 Reasonix 官方 JSON 配置,最后兼容旧 YAML。
function loadApiKey() {
if (process.env.DEEPSEEK_API_KEY) return process.env.DEEPSEEK_API_KEY;
@@ -229,14 +321,15 @@ const REASONIX_TOOL_TO_MNOTE_TOOL = {
async function callMnoteTool(toolName, args) {
const url = `${MNOTE_WEB_URL}/api/hermes/tools/mnote/call`;
const payload = buildMnoteToolPayload(toolName, args, toolContextStorage.getStore() || {});
const response = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
toolName,
args,
workspaceId: args.workspaceId || 'default',
}),
headers: {
'Content-Type': 'application/json',
'x-mnote-actor-id': payload.actorId,
'x-mnote-workspace-id': payload.workspaceId,
},
body: JSON.stringify(payload),
});
if (!response.ok) {
const text = await response.text().catch(() => '');
@@ -357,6 +450,15 @@ onRequest('session/prompt', async (params) => {
}
session.aborter = new AbortController();
const toolContext = {
acpSessionId: params.sessionId,
sessionId: params.mnoteSessionId || params.sessionId,
runId: params.runId || params.mnoteRunId || params.sessionId,
actorId: params.actorId || params.mnoteActorId,
traceId: params.traceId || params.mnoteTraceId,
workspaceId: params.workspaceId,
documentId: params.documentId,
};
let stopReason = 'end_turn';
let hasAssistantOutput = false;
let hasToolCall = false;
@@ -386,6 +488,7 @@ onRequest('session/prompt', async (params) => {
}
try {
await toolContextStorage.run(toolContext, async () => {
for await (const ev of session.loop.step(text)) {
if (session.aborter?.signal.aborted) {
stopReason = 'cancelled';
@@ -481,6 +584,7 @@ onRequest('session/prompt', async (params) => {
emitUsage(session.id, ev.stats.inputTokens || 0, ev.stats.cacheHitTokens || 0);
}
}
});
} catch (err) {
const message = err.message || String(err);
const stack = err.stack || '';
+10 -1
View File
@@ -1,9 +1,18 @@
#!/usr/bin/env node
const { spawn } = require("child_process");
const fs = require("fs");
const path = require("path");
process.env.CONVEX_TMPDIR = "/mnt/Data1T/mnote/.convex-tmp";
const adminKey = "mnote-local|01cedce68c51e168c6aacb282a90f7d233f56eddfabb07944a1d9dd9506f73ba888fc7daff";
const repoRoot = "/mnt/Data1T/mnote";
const rootConvexDir = path.join(repoRoot, "convex");
const recycleConvexDir = path.join(repoRoot, "recycle", "wolai-frontend", "convex");
const rootHasFullSchema = fs.existsSync(path.join(rootConvexDir, "pages.ts"));
const deployCwd = rootHasFullSchema
? repoRoot
: path.dirname(recycleConvexDir);
const args = [
"deploy",
@@ -14,7 +23,7 @@ const args = [
];
const child = spawn("npx", ["convex", ...args], {
cwd: "/mnt/Data1T/mnote/wolai-frontend",
cwd: deployCwd,
stdio: "inherit",
env: { ...process.env, CONVEX_TMPDIR: "/mnt/Data1T/mnote/.convex-tmp" },
});
@@ -16,6 +16,15 @@ const {
} = require("./tree-shell-smoke-helpers");
const OUT_DIR = path.join(process.cwd(), "tmp", "page-aggregate-refresh-persistence-smoke");
const CHROME_EXECUTABLE = process.env.PLAYWRIGHT_CHROME_EXECUTABLE || "";
async function callMnoteTool(requestContext, payload) {
return requestJson(requestContext, "/api/hermes/tools/mnote/call", {
method: "POST",
headers: { "x-mnote-actor-id": payload.actorId || "smoke-user" },
data: payload,
});
}
async function fetchPageAggregate(requestContext, workspaceId, documentId) {
const payload = await requestJson(
@@ -27,6 +36,37 @@ async function fetchPageAggregate(requestContext, workspaceId, documentId) {
return payload.result;
}
function pageSubtreeSummary(aggregate) {
const subtree = aggregate?.tree?.pageSubtree ?? null;
return {
rootNodeId: subtree?.rootNodeId ?? null,
outlineLength: Array.isArray(subtree?.outline) ? subtree.outline.length : null,
};
}
async function fetchAiDoc(requestContext, target, actorId, suffix, label) {
const response = await callMnoteTool(requestContext, {
toolName: "mnote.doc.fetch",
workspaceId: target.workspaceId,
documentId: target.documentId,
actorId,
sessionId: `sess_page_aggregate_ai_fetch_${suffix}`,
runId: `run_page_aggregate_ai_fetch_${suffix}_${label}`,
toolCallId: `call_page_aggregate_ai_fetch_${suffix}_${label}`,
traceId: `trace_page_aggregate_ai_fetch_${suffix}_${label}`,
capabilityScope: ["page.read"],
args: {
scope: "full",
detail: "with_ids",
maxBlocks: 20,
},
});
assert.equal(response.ok, true, `${label}: mnote.doc.fetch 应成功`);
assert.equal(response.result.schema, "mnote.page_ai_context.v1", `${label}: AI fetch schema 应稳定`);
assert(Array.isArray(response.result.blocks), `${label}: AI fetch 应返回 blocks`);
return response.result;
}
function findBlockWithText(aggregate, text) {
const blocks = aggregate?.body?.blockDocument?.blocks;
if (!Array.isArray(blocks)) return null;
@@ -289,7 +329,11 @@ async function main() {
const bodyText = `Page Aggregate refresh body ${suffix}`;
const evidencePath = path.join(OUT_DIR, `${suffix}.json`);
const screenshotPath = path.join(OUT_DIR, `${suffix}.png`);
const browser = await chromium.launch({ headless: true });
const browser = await chromium.launch({
headless: true,
...(CHROME_EXECUTABLE ? { executablePath: CHROME_EXECUTABLE } : {}),
args: ["--no-sandbox", "--disable-dev-shm-usage"],
});
const context = await browser.newContext({ viewport: { width: 1440, height: 960 } });
const page = await context.newPage();
const createdIds = [];
@@ -325,6 +369,20 @@ async function main() {
title,
bodyText,
);
const beforeReloadAiFetch = await fetchAiDoc(context.request, target, viewer.userId, suffix, "before_reload");
const beforeReloadAiBlock = beforeReloadAiFetch.blocks.find((block) => block.blockId === beforeReloadBlock.blockId);
assert.equal(beforeReloadAiFetch.revision, beforeReloadAggregate.body?.revision, "刷新前 AI fetch revision 应与 Page Aggregate 一致");
assert.equal(
beforeReloadAiFetch.conflictDetectionKey,
beforeReloadAggregate.body?.conflictDetectionKey,
"刷新前 AI fetch conflictDetectionKey 应与 Page Aggregate 一致",
);
assert.equal(beforeReloadAiBlock?.text, bodyText, "刷新前 AI fetch 应回读 Page Aggregate 正文块");
assert.equal(
beforeReloadAggregate.tree?.pageSubtree?.rootNodeId,
target.documentId,
"刷新前 Page Aggregate pageSubtree.rootNodeId 应等于 documentId",
);
await page.reload({ waitUntil: "commit", timeout: UI_TIMEOUT_MS });
await waitForPageTitleInput(page);
@@ -354,6 +412,20 @@ async function main() {
title,
bodyText,
);
const afterReloadAiFetch = await fetchAiDoc(context.request, target, viewer.userId, suffix, "after_reload");
const afterReloadAiBlock = afterReloadAiFetch.blocks.find((block) => block.blockId === afterReloadBlock.blockId);
assert.equal(afterReloadAiFetch.revision, afterReloadAggregate.body?.revision, "刷新后 AI fetch revision 应与 Page Aggregate 一致");
assert.equal(
afterReloadAiFetch.conflictDetectionKey,
afterReloadAggregate.body?.conflictDetectionKey,
"刷新后 AI fetch conflictDetectionKey 应与 Page Aggregate 一致",
);
assert.equal(afterReloadAiBlock?.text, bodyText, "刷新后 AI fetch 应回读 Page Aggregate 正文块");
assert.equal(
afterReloadAggregate.tree?.pageSubtree?.rootNodeId,
target.documentId,
"刷新后 Page Aggregate pageSubtree.rootNodeId 应等于 documentId",
);
await page.screenshot({ path: screenshotPath, fullPage: false });
const evidence = {
@@ -383,12 +455,23 @@ async function main() {
conflictDetectionKey: beforeReloadAggregate.body?.conflictDetectionKey ?? null,
pageOptions: beforeReloadAggregate.layout?.pageOptions ?? null,
blockProjectionVersion: beforeReloadAggregate.body?.blockProjectionVersion ?? null,
pageSubtree: pageSubtreeSummary(beforeReloadAggregate),
},
matchedBlock: {
blockId: beforeReloadBlock.blockId,
text: beforeReloadBlock.text,
revisionRef: beforeReloadBlock.revisionRef,
},
aiFetch: {
schema: beforeReloadAiFetch.schema,
revision: beforeReloadAiFetch.revision,
conflictDetectionKey: beforeReloadAiFetch.conflictDetectionKey,
matchedBlock: {
blockId: beforeReloadAiBlock?.blockId ?? null,
text: beforeReloadAiBlock?.text ?? null,
revisionRef: beforeReloadAiBlock?.revisionRef ?? null,
},
},
runtime: beforeReloadRuntime,
},
afterReload: {
@@ -398,12 +481,23 @@ async function main() {
conflictDetectionKey: afterReloadAggregate.body?.conflictDetectionKey ?? null,
pageOptions: afterReloadAggregate.layout?.pageOptions ?? null,
blockProjectionVersion: afterReloadAggregate.body?.blockProjectionVersion ?? null,
pageSubtree: pageSubtreeSummary(afterReloadAggregate),
},
matchedBlock: {
blockId: afterReloadBlock.blockId,
text: afterReloadBlock.text,
revisionRef: afterReloadBlock.revisionRef,
},
aiFetch: {
schema: afterReloadAiFetch.schema,
revision: afterReloadAiFetch.revision,
conflictDetectionKey: afterReloadAiFetch.conflictDetectionKey,
matchedBlock: {
blockId: afterReloadAiBlock?.blockId ?? null,
text: afterReloadAiBlock?.text ?? null,
revisionRef: afterReloadAiBlock?.revisionRef ?? null,
},
},
title: afterReloadTitle,
editorText: afterReloadEditorText,
runtime: afterReloadRuntime,
@@ -16,6 +16,7 @@ const {
} = require("./tree-shell-smoke-helpers");
const OUT_DIR = path.join(process.cwd(), "tmp", "page-block-ai-conflict-idempotency-smoke");
const CHROME_EXECUTABLE = process.env.PLAYWRIGHT_CHROME_EXECUTABLE || "";
async function callMnoteTool(request, payload) {
return requestJson(request, "/api/hermes/tools/mnote/call", {
@@ -87,7 +88,11 @@ async function main() {
};
await fs.mkdir(OUT_DIR, { recursive: true });
const browser = await chromium.launch({ headless: true });
const browser = await chromium.launch({
headless: true,
...(CHROME_EXECUTABLE ? { executablePath: CHROME_EXECUTABLE } : {}),
args: ["--no-sandbox", "--disable-dev-shm-usage"],
});
const context = await browser.newContext({ viewport: { width: 1280, height: 900 } });
const page = await context.newPage();
+446 -8
View File
@@ -17,6 +17,7 @@ const {
} = require("./tree-shell-smoke-helpers");
const OUT_DIR = path.join(process.cwd(), "tmp", "page-block-ai-tools-smoke");
const CHROME_EXECUTABLE = process.env.PLAYWRIGHT_CHROME_EXECUTABLE || "";
function blockById(blocks, blockId) {
return blocks.find((block) => block.blockId === blockId);
@@ -26,6 +27,16 @@ function blockTexts(blocks) {
return blocks.map((block) => block.text);
}
function aggregateBlocks(aggregate) {
const blocks = aggregate?.body?.blockDocument?.blocks;
assert(Array.isArray(blocks), "Page Aggregate 必须返回 body.blockDocument.blocks");
return blocks;
}
function aggregateBlockById(aggregate, blockId) {
return aggregateBlocks(aggregate).find((block) => block.blockId === blockId);
}
async function waitForVisibleTexts(page, expectedTexts) {
await page.waitForFunction(
({ expected }) => {
@@ -63,6 +74,16 @@ async function callMnoteTool(request, payload) {
});
}
async function fetchPageAggregate(request, workspaceId, documentId) {
const payload = await requestJson(
request,
`/api/page-aggregate/${encodeURIComponent(documentId)}?workspaceId=${encodeURIComponent(workspaceId)}`,
{ method: "GET" },
);
assert.equal(payload.schema, "mnote.page_aggregate.v1", "Page Aggregate schema 应保持稳定");
return payload.result;
}
async function fetchBlocks(request, target, suffix, label, actorId) {
const response = await callMnoteTool(request, {
toolName: "mnote.doc.fetch",
@@ -96,8 +117,14 @@ async function main() {
steps: [],
};
await fs.mkdir(OUT_DIR, { recursive: true });
const hermesTesterDir = path.join(process.cwd(), "tmp", "hermes-tester", `page-block-ai-tools-${suffix}`);
await fs.mkdir(hermesTesterDir, { recursive: true });
const browser = await chromium.launch({ headless: true });
const browser = await chromium.launch({
headless: true,
...(CHROME_EXECUTABLE ? { executablePath: CHROME_EXECUTABLE } : {}),
args: ["--no-sandbox", "--disable-dev-shm-usage"],
});
const context = await browser.newContext({ viewport: { width: 1280, height: 900 } });
const page = await context.newPage();
@@ -111,9 +138,28 @@ async function main() {
await renameDocument(context.request, target.workspaceId, target.documentId, title);
const initialContent = [
{ id: "heading_1", type: "heading", props: { level: 1 }, content: [{ type: "text", text: `标题 ${suffix}` }] },
{ id: "p_1", type: "paragraph", content: [{ type: "text", text: `第一段 ${suffix}` }] },
{ id: "p_2", type: "paragraph", content: [{ type: "text", text: `第二段 ${suffix}` }] },
{ id: "p_3", type: "paragraph", content: [{ type: "text", text: `第三段 ${suffix}` }] },
{
id: "heading_parent",
type: "heading",
props: { level: 2 },
content: [{ type: "text", text: `带子块标题 ${suffix}` }],
children: [
{ id: "heading_child", type: "paragraph", content: [{ type: "text", text: `标题子段落 ${suffix}` }] },
],
},
{ id: "list_item_1", type: "bullet_list_item", content: [{ type: "text", text: `列表项 ${suffix}` }] },
{ id: "table_1", type: "table", content: [{ type: "text", text: `表格块 ${suffix}` }] },
{
id: "mindmap_1",
type: "mindmap",
props: { mindmapId: `mindmap_smoke_${suffix}` },
content: [{ type: "text", text: `思维导图块 ${suffix}` }],
},
{ id: "resource_1", type: "resource", content: [{ type: "text", text: `资源块 ${suffix}` }] },
];
const seed = await callMnoteTool(context.request, {
toolName: "mnote.page.save",
@@ -136,16 +182,139 @@ async function main() {
evidence.steps.push({ name: "seed", commandName: seed.result.commandName });
let snapshot = await fetchBlocks(context.request, target, suffix, "initial", actorId);
const initialAggregate = await fetchPageAggregate(context.request, target.workspaceId, target.documentId);
await fs.writeFile(
path.join(hermesTesterDir, "page-aggregate.json"),
JSON.stringify(initialAggregate, null, 2),
"utf8",
);
const p1 = blockById(snapshot.blocks, "p_1");
const p2 = blockById(snapshot.blocks, "p_2");
const p3 = blockById(snapshot.blocks, "p_3");
assert(p1 && p2 && p3, "初始化后应能读取 p_1/p_2/p_3");
const heading = blockById(snapshot.blocks, "heading_1");
assert(heading && p1 && p2 && p3, "初始化后应能读取 heading_1/p_1/p_2/p_3");
assert(p1.revisionRef && p2.revisionRef && p3.revisionRef, "块投影必须返回 revisionRef");
const initialAggregateBlocks = aggregateBlocks(initialAggregate);
assert.deepEqual(
initialAggregateBlocks.map((block) => block.blockId),
snapshot.blocks.map((block) => block.blockId),
"Page Aggregate blockDocument 与 mnote.doc.fetch 块顺序必须一致",
);
assert(
["heading_1", "p_1", "p_2", "p_3", "list_item_1"].every((blockId) => {
const block = aggregateBlockById(initialAggregate, blockId);
return block?.editable === true && Boolean(block.revisionRef);
}),
"普通可编辑块必须在 Page Aggregate 中带 blockId/revisionRef",
);
const complexBlocks = ["heading_parent", "list_item_1", "table_1", "mindmap_1", "resource_1"]
.map((blockId) => blockById(snapshot.blocks, blockId));
assert(complexBlocks.every(Boolean), "初始化后应能读取复杂块投影");
const unsupportedBlocks = ["table_1", "mindmap_1", "resource_1"].map((blockId) => blockById(snapshot.blocks, blockId));
assert(
unsupportedBlocks.every((block) => block.editable === false && block.unsupportedReason),
"table/mindmap/resource 不能在 AI 投影中伪装成完全可编辑块",
);
assert.deepEqual(
blockById(snapshot.blocks, "heading_parent").children,
["heading_child"],
"带子块标题必须在投影中保留 children,用于阻断 move_after",
);
evidence.steps.push({
name: "doc.fetch.initial",
revision: snapshot.revision,
conflictDetectionKey: snapshot.conflictDetectionKey,
blockIds: snapshot.blocks.map((block) => block.blockId),
aggregatePath: `/api/page-aggregate/${target.documentId}`,
aggregateEvidencePath: path.join(hermesTesterDir, "page-aggregate.json"),
aggregateBlockProjectionVersion: initialAggregate.body.blockProjectionVersion,
aggregateProjectionSource: initialAggregate.body.projectionSource,
aggregateBlockCount: initialAggregateBlocks.length,
unsupportedBlocks: unsupportedBlocks.map((block) => ({
blockId: block.blockId,
type: block.type,
editable: block.editable,
unsupportedReason: block.unsupportedReason,
})),
});
const outline = await callMnoteTool(context.request, {
toolName: "mnote.doc.fetch",
workspaceId: target.workspaceId,
documentId: target.documentId,
actorId,
sessionId: `sess_outline_${suffix}`,
runId: `run_outline_${suffix}`,
toolCallId: `call_outline_${suffix}`,
traceId: `trace_outline_${suffix}`,
capabilityScope: ["page.read"],
args: {
scope: "outline",
detail: "with_ids",
},
});
assert.equal(outline.ok, true, "doc.fetch outline 应成功");
assert.deepEqual(
outline.result.blocks.map((block) => block.blockId),
["heading_1", "heading_parent"],
"outline 应只返回标题块",
);
const truncated = await callMnoteTool(context.request, {
toolName: "mnote.doc.fetch",
workspaceId: target.workspaceId,
documentId: target.documentId,
actorId,
sessionId: `sess_truncated_${suffix}`,
runId: `run_truncated_${suffix}`,
toolCallId: `call_truncated_${suffix}`,
traceId: `trace_truncated_${suffix}`,
capabilityScope: ["page.read"],
args: {
scope: "full",
detail: "with_ids",
maxBlocks: 2,
},
});
assert.equal(truncated.result.truncated, true, "maxBlocks 裁剪应返回 truncated=true");
assert.equal(truncated.result.blocks.length, 2, "maxBlocks=2 应只返回两个块");
assert(truncated.result.continuation, "truncated 结果必须返回 continuation");
const findP2 = await callMnoteTool(context.request, {
toolName: "mnote.doc.find",
workspaceId: target.workspaceId,
documentId: target.documentId,
actorId,
sessionId: `sess_find_${suffix}`,
runId: `run_find_${suffix}`,
toolCallId: `call_find_${suffix}`,
traceId: `trace_find_${suffix}`,
capabilityScope: ["page.read"],
args: {
query: `第二段 ${suffix}`,
},
});
assert.equal(findP2.result.matches[0].blockId, "p_2", "doc.find 应定位第二段");
await fs.writeFile(
path.join(hermesTesterDir, "doc-fetch-find.json"),
JSON.stringify(
{
full: snapshot,
outline: outline.result,
truncated: truncated.result,
find: findP2.result,
},
null,
2,
),
"utf8",
);
evidence.steps.push({
name: "doc.fetch.find",
outlineBlockIds: outline.result.blocks.map((block) => block.blockId),
truncated: truncated.result.truncated,
findBlockId: findP2.result.matches[0].blockId,
evidencePath: path.join(hermesTesterDir, "doc-fetch-find.json"),
});
const blockFetch = await callMnoteTool(context.request, {
@@ -159,15 +328,180 @@ async function main() {
traceId: `trace_block_${suffix}`,
capabilityScope: ["block.read"],
args: {
blockId: "p_2",
blockId: findP2.result.matches[0].blockId,
includeChildren: true,
contextBefore: 1,
contextAfter: 1,
},
});
assert.equal(blockFetch.result.block.blockId, "p_2", "block.fetch 应读取目标块");
assert(blockFetch.result.block.revisionRef, "block.fetch 应返回 revisionRef");
assert.equal(blockFetch.result.context.before[0].blockId, "p_1", "block.fetch before 应来自同父级");
assert.equal(blockFetch.result.context.after[0].blockId, "p_3", "block.fetch after 应来自同父级");
evidence.steps.push({ name: "block.fetch", blockId: "p_2" });
evidence.steps.push({
name: "block.fetch",
blockId: "p_2",
revisionRef: blockFetch.result.block.revisionRef,
before: blockFetch.result.context.before.map((block) => block.blockId),
after: blockFetch.result.context.after.map((block) => block.blockId),
});
const complexMoveBlocked = [];
for (const blockId of ["heading_parent", "list_item_1", "table_1", "mindmap_1", "resource_1"]) {
const complexBlock = blockById(snapshot.blocks, blockId);
const plan = await callMnoteTool(context.request, {
toolName: "mnote.doc.plan_update",
workspaceId: target.workspaceId,
documentId: target.documentId,
actorId,
sessionId: `sess_plan_complex_${suffix}`,
runId: `run_plan_complex_${suffix}_${blockId}`,
toolCallId: `call_plan_complex_${suffix}_${blockId}`,
traceId: `trace_plan_complex_${suffix}_${blockId}`,
idempotencyKey: `idem_plan_complex_${suffix}_${blockId}`,
dryRun: true,
capabilityScope: ["block.write"],
args: {
command: "block_move_after",
blockId,
anchorBlockId: "p_1",
},
});
assert.equal(plan.result.blocked, true, `${blockId} move_after dry-run 必须阻断`);
const blockedMove = await callMnoteTool(context.request, {
toolName: "mnote.block.move_after",
workspaceId: target.workspaceId,
documentId: target.documentId,
actorId,
sessionId: `sess_move_complex_${suffix}`,
runId: `run_move_complex_${suffix}_${blockId}`,
toolCallId: `call_move_complex_${suffix}_${blockId}`,
traceId: `trace_move_complex_${suffix}_${blockId}`,
idempotencyKey: `idem_move_complex_${suffix}_${blockId}`,
dryRun: false,
capabilityScope: ["block.write"],
args: {
blockId,
anchorBlockId: "p_1",
revision: snapshot.revision,
conflictDetectionKey: snapshot.conflictDetectionKey,
blockRevisionRef: complexBlock.revisionRef,
anchorRevisionRef: p1.revisionRef,
},
});
assert.equal(blockedMove.result.blocked, true, `${blockId} 正式 move_after 必须返回 blocked`);
assert.equal(
blockedMove.result.warnings[0].code,
"block_move_after_blocked",
`${blockId} 正式 move_after 必须返回阻断 warning`,
);
complexMoveBlocked.push({
blockId,
type: complexBlock.type,
editable: complexBlock.editable,
dryRunBlocked: plan.result.blocked,
writeBlocked: blockedMove.result.blocked,
warningCode: blockedMove.result.warnings[0].code,
});
}
const afterComplexBlocked = await fetchBlocks(context.request, target, suffix, "after_complex_blocked", actorId);
assert.equal(afterComplexBlocked.revision, snapshot.revision, "复杂块 move_after 阻断后 revision 不应变化");
assert.deepEqual(blockTexts(afterComplexBlocked.blocks), blockTexts(snapshot.blocks), "复杂块 move_after 阻断后正文不应变化");
evidence.steps.push({
name: "block.move_after.complex_blocked",
blocked: complexMoveBlocked,
revisionAfterBlocked: afterComplexBlocked.revision,
});
const dryRunBaselineTexts = blockTexts(snapshot.blocks);
const dryRunBaselineAggregate = await fetchPageAggregate(
context.request,
target.workspaceId,
target.documentId,
);
const replacePlan = await callMnoteTool(context.request, {
toolName: "mnote.doc.plan_update",
workspaceId: target.workspaceId,
documentId: target.documentId,
actorId,
sessionId: `sess_plan_replace_${suffix}`,
runId: `run_plan_replace_${suffix}`,
toolCallId: `call_plan_replace_${suffix}`,
traceId: `trace_plan_replace_${suffix}`,
idempotencyKey: `idem_plan_replace_${suffix}`,
dryRun: true,
capabilityScope: ["block.write"],
args: {
command: "block_replace",
blockId: "p_2",
content: `dry-run 替换 ${suffix}`,
},
});
assert.equal(replacePlan.result.dryRun, true, "block_replace plan 必须是 dry-run");
assert.equal(replacePlan.result.diff[0].before, `第二段 ${suffix}`, "replace plan 应解释 before");
assert.equal(replacePlan.result.diff[0].after, `dry-run 替换 ${suffix}`, "replace plan 应解释 after");
const insertPlan = await callMnoteTool(context.request, {
toolName: "mnote.doc.plan_update",
workspaceId: target.workspaceId,
documentId: target.documentId,
actorId,
sessionId: `sess_plan_insert_${suffix}`,
runId: `run_plan_insert_${suffix}`,
toolCallId: `call_plan_insert_${suffix}`,
traceId: `trace_plan_insert_${suffix}`,
idempotencyKey: `idem_plan_insert_${suffix}`,
dryRun: true,
capabilityScope: ["block.write"],
args: {
command: "block_insert_after",
anchorBlockId: "p_1",
content: `dry-run 插入 ${suffix}`,
},
});
assert.equal(insertPlan.result.dryRun, true, "block_insert_after plan 必须是 dry-run");
assert.equal(insertPlan.result.diff[0].after, `第一段 ${suffix}`, "insert plan 应解释 anchor after");
const blockedPlan = await callMnoteTool(context.request, {
toolName: "mnote.doc.plan_update",
workspaceId: target.workspaceId,
documentId: target.documentId,
actorId,
sessionId: `sess_plan_blocked_${suffix}`,
runId: `run_plan_blocked_${suffix}`,
toolCallId: `call_plan_blocked_${suffix}`,
traceId: `trace_plan_blocked_${suffix}`,
idempotencyKey: `idem_plan_blocked_${suffix}`,
dryRun: true,
capabilityScope: ["block.write"],
args: {
command: "block_move_after",
blockId: "p_3",
anchorBlockId: "p_3",
},
});
assert.equal(blockedPlan.result.blocked, true, "不支持 move 场景应返回 blocked=true");
const dryRunAfterAggregate = await fetchPageAggregate(
context.request,
target.workspaceId,
target.documentId,
);
const dryRunAfter = await fetchBlocks(context.request, target, suffix, "after_dry_run", actorId);
assert.equal(dryRunAfter.revision, snapshot.revision, "dry-run 后 revision 不应变化");
assert.deepEqual(blockTexts(dryRunAfter.blocks), dryRunBaselineTexts, "dry-run 后正文不应变化");
assert.equal(
dryRunAfterAggregate.body.conflictDetectionKey,
dryRunBaselineAggregate.body.conflictDetectionKey,
"dry-run 后 Page Aggregate conflictDetectionKey 不应变化",
);
evidence.steps.push({
name: "plan_update.dry_run",
replaceDiff: replacePlan.result.diff[0],
insertDiff: insertPlan.result.diff[0],
blocked: blockedPlan.result.blocked,
revisionAfterDryRun: dryRunAfter.revision,
});
const replacedText = `第二段已替换 ${suffix}`;
const replace = await callMnoteTool(context.request, {
@@ -192,8 +526,25 @@ async function main() {
});
assert.equal(replace.result.commandName, "page.body.save", "block.replace 必须走 page.body.save");
snapshot = await fetchBlocks(context.request, target, suffix, "after_replace", actorId);
const afterReplaceAggregate = await fetchPageAggregate(context.request, target.workspaceId, target.documentId);
assert.equal(blockById(snapshot.blocks, "p_2").text, replacedText, "替换后 doc.fetch 应回读新文本");
evidence.steps.push({ name: "block.replace", changedBlocks: replace.result.changedBlocks });
assert.equal(
aggregateBlockById(afterReplaceAggregate, "p_2").text,
replacedText,
"替换后 Page Aggregate 必须回读新文本",
);
assert.equal(blockById(snapshot.blocks, "p_1").text, `第一段 ${suffix}`, "替换后相邻 p_1 不应变化");
assert.equal(blockById(snapshot.blocks, "p_3").text, `第三段 ${suffix}`, "替换后相邻 p_3 不应变化");
assert(blockById(snapshot.blocks, "p_2"), "替换后目标 block id 必须保持 p_2");
evidence.steps.push({
name: "block.replace",
changedBlocks: replace.result.changedBlocks,
targetBlockStillExists: Boolean(blockById(snapshot.blocks, "p_2")),
aggregateRevision: afterReplaceAggregate.body.revision,
aggregateConflictDetectionKey: afterReplaceAggregate.body.conflictDetectionKey,
aggregateText: aggregateBlockById(afterReplaceAggregate, "p_2").text,
adjacentTexts: [blockById(snapshot.blocks, "p_1").text, blockById(snapshot.blocks, "p_3").text],
});
const insertedText = `插入段 ${suffix}`;
const anchorAfterReplace = blockById(snapshot.blocks, "p_1");
@@ -211,7 +562,11 @@ async function main() {
capabilityScope: ["block.write"],
args: {
anchorBlockId: "p_1",
content: insertedText,
content: {
type: "todo",
props: { checked: false },
content: [{ type: "text", text: insertedText }],
},
revision: snapshot.revision,
conflictDetectionKey: snapshot.conflictDetectionKey,
anchorRevisionRef: anchorAfterReplace.revisionRef,
@@ -221,7 +576,73 @@ async function main() {
assert(insertedBlockId.startsWith("ai_block_"), "插入块 id 必须由 Rust/mnote 侧生成");
snapshot = await fetchBlocks(context.request, target, suffix, "after_insert", actorId);
assert.equal(blockById(snapshot.blocks, insertedBlockId).text, insertedText, "插入后 doc.fetch 应回读新块");
evidence.steps.push({ name: "block.insert_after", insertedBlockId });
const orderAfterInsert = snapshot.blocks.map((block) => block.blockId);
assert.equal(
orderAfterInsert.indexOf(insertedBlockId),
orderAfterInsert.indexOf("p_1") + 1,
"插入块必须紧跟 p_1",
);
const insertedBlockFetch = await callMnoteTool(context.request, {
toolName: "mnote.block.fetch",
workspaceId: target.workspaceId,
documentId: target.documentId,
actorId,
sessionId: `sess_insert_fetch_${suffix}`,
runId: `run_insert_fetch_${suffix}`,
toolCallId: `call_insert_fetch_${suffix}`,
traceId: `trace_insert_fetch_${suffix}`,
capabilityScope: ["block.read"],
args: {
blockId: insertedBlockId,
contextBefore: 1,
contextAfter: 1,
},
});
assert.equal(insertedBlockFetch.result.block.blockId, insertedBlockId, "block.fetch 应能读取新插入块");
assert.equal(insertedBlockFetch.result.block.type, "todo", "insert_after 应能插入 todo 块");
const replayInsertText = `不应重复插入 ${suffix}`;
const insertReplay = await callMnoteTool(context.request, {
toolName: "mnote.block.insert_after",
workspaceId: target.workspaceId,
documentId: target.documentId,
actorId,
sessionId: `sess_insert_replay_${suffix}`,
runId: `run_insert_replay_${suffix}`,
toolCallId: `call_insert_replay_${suffix}`,
traceId: `trace_insert_replay_${suffix}`,
idempotencyKey: `idem_insert_${suffix}`,
dryRun: false,
capabilityScope: ["block.write"],
args: {
anchorBlockId: "p_1",
content: replayInsertText,
revision: snapshot.revision,
conflictDetectionKey: snapshot.conflictDetectionKey,
anchorRevisionRef: blockById(snapshot.blocks, "p_1").revisionRef,
},
});
assert.equal(
insertReplay.audit.commandId,
insert.audit.commandId,
"重复 insert_after idempotencyKey 应返回缓存 commandId",
);
const afterInsertReplay = await fetchBlocks(context.request, target, suffix, "after_insert_replay", actorId);
assert.equal(
afterInsertReplay.blocks.filter((block) => block.blockId === insertedBlockId).length,
1,
"重复 insert_after idempotencyKey 不应产生第二个相同插入块",
);
assert(!afterInsertReplay.blocks.some((block) => block.text === replayInsertText), "重复 insert_after 不应写入新内容");
assert.equal(afterInsertReplay.revision, snapshot.revision, "重复 insert_after 后 revision 不应再次递增");
snapshot = afterInsertReplay;
evidence.steps.push({
name: "block.insert_after",
insertedBlockId,
insertedBlockType: insertedBlockFetch.result.block.type,
orderAfterInsert,
replayCommandId: insertReplay.audit.commandId,
revisionAfterReplay: snapshot.revision,
});
const moveBlock = blockById(snapshot.blocks, "p_3");
const moveAnchor = blockById(snapshot.blocks, "p_1");
@@ -269,14 +690,31 @@ async function main() {
snapshot = await fetchBlocks(context.request, target, suffix, "after_move", actorId);
const order = snapshot.blocks.map((block) => block.blockId);
assert(order.indexOf("p_3") === order.indexOf("p_1") + 1, "移动后 p_3 必须紧跟 p_1");
evidence.steps.push({ name: "block.move_after", order, texts: blockTexts(snapshot.blocks) });
assert(blockById(snapshot.blocks, "p_3"), "移动后 moving block id 必须保持 p_3");
evidence.steps.push({
name: "block.move_after",
dryRunBlocked: moveDryRun.result.blocked,
dryRunDiff: moveDryRun.result.diff[0],
order,
texts: blockTexts(snapshot.blocks),
movingBlockStillExists: Boolean(blockById(snapshot.blocks, "p_3")),
});
await openDocument(page, target.workspaceId, target.documentId);
await waitForVisibleTexts(page, [replacedText, insertedText, `第三段 ${suffix}`]);
await page.reload({ waitUntil: "domcontentloaded" });
await waitForVisibleTexts(page, [replacedText, insertedText, `第三段 ${suffix}`]);
const afterRefreshAggregate = await fetchPageAggregate(context.request, target.workspaceId, target.documentId);
assert.deepEqual(
aggregateBlocks(afterRefreshAggregate).map((block) => block.blockId),
snapshot.blocks.map((block) => block.blockId),
"页面刷新后 Page Aggregate block ids 必须保持稳定",
);
const screenshotPath = path.join(OUT_DIR, `${suffix}-page.png`);
await page.screenshot({ path: screenshotPath, fullPage: true });
evidence.screenshot = screenshotPath;
evidence.visibleTextCheck = [replacedText, insertedText, `第三段 ${suffix}`];
evidence.afterRefreshBlockIds = aggregateBlocks(afterRefreshAggregate).map((block) => block.blockId);
evidence.finalTexts = blockTexts(snapshot.blocks);
evidence.ok = true;
View File
-47
View File
@@ -1,47 +0,0 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
#test
**/test/
pw-tests/**
pw-tests/
wolai-frontend/public/documents/**
-22
View File
@@ -1,22 +0,0 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/app/globals.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"registries": {}
}
-125
View File
@@ -1,125 +0,0 @@
/* eslint-disable */
/**
* Generated `api` utility.
*
* THIS CODE IS AUTOMATICALLY GENERATED.
*
* To regenerate, run `npx convex dev`.
* @module
*/
import type * as _utils_attachmentExtract from "../_utils/attachmentExtract.js";
import type * as _utils_auth from "../_utils/auth.js";
import type * as _utils_documentMoveOrder from "../_utils/documentMoveOrder.js";
import type * as _utils_documentRecord from "../_utils/documentRecord.js";
import type * as _utils_documentTree from "../_utils/documentTree.js";
import type * as _utils_documentVisibility from "../_utils/documentVisibility.js";
import type * as _utils_id from "../_utils/id.js";
import type * as _utils_ingestJobs from "../_utils/ingestJobs.js";
import type * as _utils_lightrag from "../_utils/lightrag.js";
import type * as _utils_text from "../_utils/text.js";
import type * as _utils_time from "../_utils/time.js";
import type * as agentActions from "../agentActions.js";
import type * as audit from "../audit.js";
import type * as auth from "../auth.js";
import type * as blocks from "../blocks.js";
import type * as bridgeLogs from "../bridgeLogs.js";
import type * as comments from "../comments.js";
import type * as crons from "../crons.js";
import type * as documentGroupShares from "../documentGroupShares.js";
import type * as documentShares from "../documentShares.js";
import type * as documentStars from "../documentStars.js";
import type * as documents from "../documents.js";
import type * as groupInvitations from "../groupInvitations.js";
import type * as groupMembers from "../groupMembers.js";
import type * as groups from "../groups.js";
import type * as http from "../http.js";
import type * as jobs from "../jobs.js";
import type * as maintenance from "../maintenance.js";
import type * as mediaAssets from "../mediaAssets.js";
import type * as mindmaps from "../mindmaps.js";
import type * as mnoteNextBridge from "../mnoteNextBridge.js";
import type * as pages from "../pages.js";
import type * as ping from "../ping.js";
import type * as recents from "../recents.js";
import type * as references from "../references.js";
import type * as sidebar from "../sidebar.js";
import type * as tables from "../tables.js";
import type * as users from "../users.js";
import type * as workspaces from "../workspaces.js";
import type {
ApiFromModules,
FilterApi,
FunctionReference,
} from "convex/server";
declare const fullApi: ApiFromModules<{
"_utils/attachmentExtract": typeof _utils_attachmentExtract;
"_utils/auth": typeof _utils_auth;
"_utils/documentMoveOrder": typeof _utils_documentMoveOrder;
"_utils/documentRecord": typeof _utils_documentRecord;
"_utils/documentTree": typeof _utils_documentTree;
"_utils/documentVisibility": typeof _utils_documentVisibility;
"_utils/id": typeof _utils_id;
"_utils/ingestJobs": typeof _utils_ingestJobs;
"_utils/lightrag": typeof _utils_lightrag;
"_utils/text": typeof _utils_text;
"_utils/time": typeof _utils_time;
agentActions: typeof agentActions;
audit: typeof audit;
auth: typeof auth;
blocks: typeof blocks;
bridgeLogs: typeof bridgeLogs;
comments: typeof comments;
crons: typeof crons;
documentGroupShares: typeof documentGroupShares;
documentShares: typeof documentShares;
documentStars: typeof documentStars;
documents: typeof documents;
groupInvitations: typeof groupInvitations;
groupMembers: typeof groupMembers;
groups: typeof groups;
http: typeof http;
jobs: typeof jobs;
maintenance: typeof maintenance;
mediaAssets: typeof mediaAssets;
mindmaps: typeof mindmaps;
mnoteNextBridge: typeof mnoteNextBridge;
pages: typeof pages;
ping: typeof ping;
recents: typeof recents;
references: typeof references;
sidebar: typeof sidebar;
tables: typeof tables;
users: typeof users;
workspaces: typeof workspaces;
}>;
/**
* A utility for referencing Convex functions in your app's public API.
*
* Usage:
* ```js
* const myFunctionReference = api.myModule.myFunction;
* ```
*/
export declare const api: FilterApi<
typeof fullApi,
FunctionReference<any, "public">
>;
/**
* A utility for referencing Convex functions in your app's internal API.
*
* Usage:
* ```js
* const myFunctionReference = internal.myModule.myFunction;
* ```
*/
export declare const internal: FilterApi<
typeof fullApi,
FunctionReference<any, "internal">
>;
export declare const components: {};
@@ -1,250 +0,0 @@
import JSZip from "jszip";
const MAX_TEXT_CHARS = 120_000;
function clampText(input: string, maxChars = MAX_TEXT_CHARS): string {
const trimmed = String(input || "").replace(/\s+\n/g, "\n").trim();
if (!trimmed) return "";
if (trimmed.length <= maxChars) return trimmed;
return `${trimmed.slice(0, maxChars)}`;
}
function decodeXmlEntities(input: string): string {
return input
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/&amp;/g, "&")
.replace(/&quot;/g, '"')
.replace(/&apos;/g, "'")
.replace(/&#x([0-9a-fA-F]+);/g, (_, hex) => {
const code = Number.parseInt(hex, 16);
if (!Number.isFinite(code)) return "";
return String.fromCodePoint(code);
})
.replace(/&#(\d+);/g, (_, dec) => {
const code = Number.parseInt(dec, 10);
if (!Number.isFinite(code)) return "";
return String.fromCodePoint(code);
});
}
function getExtension(fileName: string | null | undefined): string {
const raw = String(fileName ?? "").trim().toLowerCase();
const idx = raw.lastIndexOf(".");
if (idx === -1) return "";
return raw.slice(idx + 1).replace(/[^a-z0-9]+/g, "");
}
function detectKind(args: { mimeType?: string | null; fileName?: string | null }): "pdf" | "docx" | "pptx" | "xlsx" | null {
const mime = String(args.mimeType ?? "").toLowerCase().trim();
if (mime === "application/pdf") return "pdf";
if (mime === "application/vnd.openxmlformats-officedocument.wordprocessingml.document") return "docx";
if (mime === "application/vnd.openxmlformats-officedocument.presentationml.presentation") return "pptx";
if (mime === "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet") return "xlsx";
const ext = getExtension(args.fileName ?? null);
if (ext === "pdf") return "pdf";
if (ext === "docx") return "docx";
if (ext === "pptx") return "pptx";
if (ext === "xlsx") return "xlsx";
return null;
}
function extractTextFromXmlByTag(xml: string, tagName: string): string {
const out: string[] = [];
const re = new RegExp(`<${tagName}[^>]*>([\\s\\S]*?)<\\/${tagName}>`, "g");
let m: RegExpExecArray | null;
while ((m = re.exec(xml))) {
const raw = m[1] ?? "";
const clean = decodeXmlEntities(raw).replace(/\s+/g, " ").trim();
if (clean) out.push(clean);
}
return out.join(" ").trim();
}
function extractDocxText(documentXml: string): string {
const paras = documentXml.split(/<w:p[\s>]/g);
const out: string[] = [];
for (const p of paras) {
const line: string[] = [];
const re = /<w:t[^>]*>([\s\S]*?)<\/w:t>/g;
let m: RegExpExecArray | null;
while ((m = re.exec(p))) {
const raw = m[1] ?? "";
const clean = decodeXmlEntities(raw).replace(/\s+/g, " ").trim();
if (clean) line.push(clean);
}
const joined = line.join("").trim();
if (joined) out.push(joined);
}
return out.join("\n").trim();
}
function extractPptxText(slideXmls: string[]): string {
const out: string[] = [];
for (const xml of slideXmls) {
const line = extractTextFromXmlByTag(xml, "a:t");
if (line) out.push(line);
}
return out.join("\n\n").trim();
}
function extractXlsxText(args: { sharedStringsXml: string | null; sheetXmls: string[] }): string {
const sharedStrings: string[] = [];
if (args.sharedStringsXml) {
const re = /<t[^>]*>([\s\S]*?)<\/t>/g;
let m: RegExpExecArray | null;
while ((m = re.exec(args.sharedStringsXml))) {
const raw = m[1] ?? "";
const clean = decodeXmlEntities(raw).replace(/\s+/g, " ").trim();
if (clean) sharedStrings.push(clean);
}
}
const out: string[] = [];
for (const xml of args.sheetXmls) {
const rows = xml.split(/<row[\s>]/g);
for (const r of rows) {
const cells: string[] = [];
// t="s" => sharedStrings indext="inlineStr" => <is><t>...;默认 => <v>number
const cellRe = /<c\b[^>]*?(?:t="([^"]+)")?[^>]*>([\s\S]*?)<\/c>/g;
let cm: RegExpExecArray | null;
while ((cm = cellRe.exec(r))) {
const t = (cm[1] ?? "").trim();
const body = cm[2] ?? "";
if (t === "inlineStr") {
const inline = extractTextFromXmlByTag(body, "t");
if (inline) cells.push(inline);
continue;
}
const vMatch = /<v>([\s\S]*?)<\/v>/.exec(body);
if (!vMatch) continue;
const rawV = decodeXmlEntities(String(vMatch[1] ?? "")).trim();
if (!rawV) continue;
if (t === "s") {
const idx = Number.parseInt(rawV, 10);
const s = Number.isFinite(idx) ? (sharedStrings[idx] ?? "") : "";
if (s) cells.push(s);
} else {
cells.push(rawV);
}
}
const line = cells.join(" ").replace(/\s+/g, " ").trim();
if (line) out.push(line);
}
}
return out.join("\n").trim();
}
export type AttachmentExtractOk = {
ok: true;
strategy: string;
text: string;
meta?: Record<string, unknown>;
};
export type AttachmentExtractResult =
| AttachmentExtractOk
| { ok: false; strategy: string; reason: string; meta?: Record<string, unknown> };
export async function extractTextFromAttachment(args: {
mimeType: string | null;
fileName: string | null;
bytes: ArrayBuffer;
}): Promise<AttachmentExtractResult> {
const kind = detectKind({ mimeType: args.mimeType, fileName: args.fileName });
if (!kind) {
return { ok: false, strategy: "unsupported", reason: "暂不支持该附件类型" };
}
if (kind === "pdf") {
try {
const mod = await import("pdfjs-dist/legacy/build/pdf.mjs");
const data = new Uint8Array(args.bytes);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const loadingTask = (mod as any).getDocument({ data });
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const pdf = await (loadingTask as any).promise;
const out: string[] = [];
const pages = Number(pdf?.numPages ?? 0) || 0;
for (let i = 1; i <= pages; i += 1) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const page = await (pdf as any).getPage(i);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const content = await (page as any).getTextContent();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const items = Array.isArray((content as any)?.items) ? (content as any).items : [];
const line = items
.map((it: any) => (typeof it?.str === "string" ? it.str : ""))
.join(" ")
.replace(/\s+/g, " ")
.trim();
if (line) out.push(line);
}
const text = clampText(out.join("\n\n"));
if (!text) {
return { ok: false, strategy: "pdfjs", reason: "未提取到可用文本", meta: { pages } };
}
return { ok: true, strategy: "pdfjs", text, meta: { pages } };
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
return { ok: false, strategy: "pdfjs", reason: message };
}
}
try {
const zip = await JSZip.loadAsync(args.bytes);
if (kind === "docx") {
const file = zip.file("word/document.xml");
const xml = file ? await file.async("string") : "";
const text = clampText(extractDocxText(xml));
if (!text) return { ok: false, strategy: "docx.xml", reason: "未提取到可用文本" };
return { ok: true, strategy: "docx.xml", text };
}
if (kind === "pptx") {
const slideFiles = Object.keys(zip.files)
.filter((p) => /^ppt\/slides\/slide\d+\.xml$/i.test(p))
.sort((a, b) => a.localeCompare(b, undefined, { numeric: true }));
const slideXmls: string[] = [];
for (const p of slideFiles) {
const f = zip.file(p);
if (!f) continue;
// eslint-disable-next-line no-await-in-loop
slideXmls.push(await f.async("string"));
}
const text = clampText(extractPptxText(slideXmls));
if (!text) return { ok: false, strategy: "pptx.xml", reason: "未提取到可用文本" };
return { ok: true, strategy: "pptx.xml", text, meta: { slides: slideXmls.length } };
}
if (kind === "xlsx") {
const sharedStringsXml = await (async () => {
const f = zip.file("xl/sharedStrings.xml");
if (!f) return null;
return await f.async("string");
})();
const sheetFiles = Object.keys(zip.files)
.filter((p) => /^xl\/worksheets\/sheet\d+\.xml$/i.test(p))
.sort((a, b) => a.localeCompare(b, undefined, { numeric: true }));
const sheetXmls: string[] = [];
for (const p of sheetFiles) {
const f = zip.file(p);
if (!f) continue;
// eslint-disable-next-line no-await-in-loop
sheetXmls.push(await f.async("string"));
}
const text = clampText(extractXlsxText({ sharedStringsXml, sheetXmls }));
if (!text) return { ok: false, strategy: "xlsx.xml", reason: "未提取到可用文本" };
return { ok: true, strategy: "xlsx.xml", text, meta: { sheets: sheetXmls.length } };
}
return { ok: false, strategy: "unsupported", reason: "暂不支持该附件类型" };
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
return { ok: false, strategy: "zip", reason: message };
}
}
-26
View File
@@ -1,26 +0,0 @@
import { getAuthUserId } from "@convex-dev/auth/server";
function readEnv(key: string): string | undefined {
const raw = process.env[key];
if (!raw) return undefined;
const trimmed = raw.trim();
return trimmed ? trimmed : undefined;
}
function isDevAuthEnabled(): boolean {
return readEnv("MNOTE_DEV_AUTH") === "1" || readEnv("NEXT_PUBLIC_MNOTE_DEV_AUTH") === "1";
}
export async function requireUserId(ctx: any): Promise<string> {
const userId = await getAuthUserId(ctx);
if (userId !== null) {
return String(userId);
}
const devUserId = readEnv("DEV_USER_ID");
if (isDevAuthEnabled() || devUserId) {
return devUserId ?? "dev-user";
}
throw new Error("未登录");
}
@@ -1,205 +0,0 @@
export type DocumentMoveOrderDocument = {
id: string;
parent_id?: string | null;
sort_order?: number | null;
created_at?: string | null;
};
export type DocumentMoveOrderPatch = {
documentId: string;
parentId: string | null;
sortOrder: number;
moved: boolean;
};
export type DocumentMoveOrderPlan = {
documentId: string;
fromParentId: string | null;
toParentId: string | null;
requestedSortOrder: number;
normalizedSortOrder: number;
patches: DocumentMoveOrderPatch[];
};
export type DocumentMoveWriteOperation = DocumentMoveOrderPlan & {
family: "tree";
schema: "mnote.tree.write_operation";
schemaVersion: 1;
operation: "tree.subtree.move.write";
workspaceId: string;
};
function normalizeParentId(value: string | null | undefined): string | null {
const trimmed = typeof value === "string" ? value.trim() : "";
return trimmed.length > 0 ? trimmed : null;
}
function normalizeSortOrder(value: number | null | undefined): number {
if (typeof value !== "number" || !Number.isFinite(value)) {
return Number.MAX_SAFE_INTEGER;
}
return Math.floor(value);
}
function compareDocumentMoveOrder(a: DocumentMoveOrderDocument, b: DocumentMoveOrderDocument): number {
const orderA = normalizeSortOrder(a.sort_order);
const orderB = normalizeSortOrder(b.sort_order);
if (orderA !== orderB) return orderA - orderB;
const createdA = String(a.created_at ?? "");
const createdB = String(b.created_at ?? "");
if (createdA !== createdB) return createdA.localeCompare(createdB);
return a.id.localeCompare(b.id);
}
function clampMoveIndex(raw: number, max: number): number {
const value = Number.isFinite(raw) ? Math.floor(raw) : 0;
if (value < 0) return 0;
if (value > max) return max;
return value;
}
function appendOrderPatches(
patches: DocumentMoveOrderPatch[],
ordered: DocumentMoveOrderDocument[],
parentId: string | null,
movedDocumentId: string,
) {
ordered.forEach((document, index) => {
const moved = document.id === movedDocumentId;
if (normalizeParentId(document.parent_id) === parentId && normalizeSortOrder(document.sort_order) === index && !moved) {
return;
}
patches.push({
documentId: document.id,
parentId,
sortOrder: index,
moved,
});
});
}
export function buildDocumentMoveOrderPlanFromDocuments(input: {
documents: readonly DocumentMoveOrderDocument[];
documentId: string;
parentId: string | null;
sortOrder: number;
}): DocumentMoveOrderPlan {
const source = input.documents.find((document) => document.id === input.documentId);
if (!source) {
throw new Error("源页面不存在或无权限");
}
const fromParentId = normalizeParentId(source.parent_id);
const toParentId = normalizeParentId(input.parentId);
const siblingsByParent = new Map<string | null, DocumentMoveOrderDocument[]>();
input.documents.forEach((document) => {
const parentId = normalizeParentId(document.parent_id);
const bucket = siblingsByParent.get(parentId);
if (bucket) bucket.push(document);
else siblingsByParent.set(parentId, [document]);
});
siblingsByParent.forEach((siblings) => siblings.sort(compareDocumentMoveOrder));
const patches: DocumentMoveOrderPatch[] = [];
let normalizedSortOrder = 0;
if (fromParentId === toParentId) {
const siblings = [...(siblingsByParent.get(toParentId) ?? [])].filter((document) => document.id !== input.documentId);
normalizedSortOrder = clampMoveIndex(input.sortOrder, siblings.length);
siblings.splice(normalizedSortOrder, 0, source);
appendOrderPatches(patches, siblings, toParentId, input.documentId);
} else {
const oldSiblings = [...(siblingsByParent.get(fromParentId) ?? [])].filter((document) => document.id !== input.documentId);
appendOrderPatches(patches, oldSiblings, fromParentId, input.documentId);
const newSiblings = [...(siblingsByParent.get(toParentId) ?? [])].filter((document) => document.id !== input.documentId);
normalizedSortOrder = clampMoveIndex(input.sortOrder, newSiblings.length);
newSiblings.splice(normalizedSortOrder, 0, source);
appendOrderPatches(patches, newSiblings, toParentId, input.documentId);
}
return {
documentId: input.documentId,
fromParentId,
toParentId,
requestedSortOrder: input.sortOrder,
normalizedSortOrder,
patches,
};
}
export function normalizeDocumentMoveOrderPlan(value: unknown): DocumentMoveOrderPlan | null {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return null;
}
const record = value as Partial<DocumentMoveOrderPlan>;
if (
typeof record.documentId !== "string" ||
typeof record.requestedSortOrder !== "number" ||
typeof record.normalizedSortOrder !== "number" ||
!Array.isArray(record.patches)
) {
return null;
}
return {
documentId: record.documentId,
fromParentId: normalizeParentId(record.fromParentId),
toParentId: normalizeParentId(record.toParentId),
requestedSortOrder: record.requestedSortOrder,
normalizedSortOrder: record.normalizedSortOrder,
patches: record.patches.map((patch) => {
const item = patch as Partial<DocumentMoveOrderPatch>;
return {
documentId: String(item.documentId ?? ""),
parentId: normalizeParentId(item.parentId),
sortOrder: typeof item.sortOrder === "number" && Number.isFinite(item.sortOrder) ? Math.floor(item.sortOrder) : -1,
moved: Boolean(item.moved),
};
}),
};
}
export function normalizeDocumentMoveWriteOperation(value: unknown): DocumentMoveWriteOperation | null {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return null;
}
const record = value as Partial<DocumentMoveWriteOperation>;
if (
record.family !== "tree" ||
record.schema !== "mnote.tree.write_operation" ||
record.schemaVersion !== 1 ||
record.operation !== "tree.subtree.move.write" ||
typeof record.workspaceId !== "string" ||
!record.workspaceId.trim()
) {
return null;
}
const normalizedPlan = normalizeDocumentMoveOrderPlan(value);
if (!normalizedPlan) {
return null;
}
return {
family: "tree",
schema: "mnote.tree.write_operation",
schemaVersion: 1,
operation: "tree.subtree.move.write",
workspaceId: record.workspaceId.trim(),
...normalizedPlan,
};
}
export function assertDocumentMoveWriteOperationMatches(expected: unknown, actual: DocumentMoveOrderPlan): DocumentMoveOrderPlan {
const normalizedExpected = normalizeDocumentMoveWriteOperation(expected);
if (!normalizedExpected) {
throw new Error("Rust move write operation 与 Convex 当前排序状态不一致");
}
const { family: _family, schema: _schema, schemaVersion: _schemaVersion, operation: _operation, workspaceId: _workspaceId, ...expectedPlan } =
normalizedExpected;
if (JSON.stringify(expectedPlan) !== JSON.stringify(actual)) {
throw new Error("Rust move write operation 与 Convex 当前排序状态不一致");
}
return expectedPlan;
}
@@ -1,99 +0,0 @@
type DocumentRecordLike = {
_id: unknown;
id?: string | null;
user_id?: string | null;
parent_id?: string | null;
deleted_at?: string | null;
created_at?: string | null;
updated_at?: string | null;
};
export function compareDocumentCanonicalOrder(a: DocumentRecordLike, b: DocumentRecordLike): number {
const aDeleted = a?.deleted_at != null;
const bDeleted = b?.deleted_at != null;
if (aDeleted !== bDeleted) {
return aDeleted ? 1 : -1;
}
const updatedA = String(a?.updated_at ?? "");
const updatedB = String(b?.updated_at ?? "");
if (updatedA !== updatedB) {
return updatedB.localeCompare(updatedA);
}
const createdA = String(a?.created_at ?? "");
const createdB = String(b?.created_at ?? "");
if (createdA !== createdB) {
return createdB.localeCompare(createdA);
}
return String(a?._id ?? "").localeCompare(String(b?._id ?? ""));
}
export function pickCanonicalDocumentRecord<T extends DocumentRecordLike>(records: T[]): T | null {
if (records.length === 0) return null;
return [...records].sort(compareDocumentCanonicalOrder)[0] ?? null;
}
export function pickCanonicalDocumentRecordsByBusinessId<
T extends DocumentRecordLike & { id?: string | null },
>(records: readonly T[]): T[] {
const grouped = new Map<string, T[]>();
for (const record of records) {
const businessId = String(record.id ?? "").trim();
if (!businessId) continue;
const bucket = grouped.get(businessId);
if (bucket) {
bucket.push(record);
} else {
grouped.set(businessId, [record]);
}
}
const result: T[] = [];
for (const bucket of grouped.values()) {
const canonical = pickCanonicalDocumentRecord([...bucket]);
if (canonical) {
result.push(canonical);
}
}
return result;
}
export async function getCanonicalDocumentByBusinessId<T extends DocumentRecordLike>(
ctx: any,
documentId: string,
): Promise<T | null> {
const records = await ctx.db
.query("documents")
.withIndex("by_document_id", (q: any) => q.eq("id", documentId))
.collect();
return pickCanonicalDocumentRecord(records) as T | null;
}
export async function getCanonicalParentDocumentId(
ctx: any,
documentId: string | null | undefined,
): Promise<string | null> {
const normalizedId = String(documentId ?? "").trim();
if (!normalizedId) return null;
const doc = await getCanonicalDocumentByBusinessId<DocumentRecordLike>(ctx, normalizedId);
return doc?.parent_id ?? null;
}
export async function requireCanonicalOwnedDocument<T extends DocumentRecordLike & { user_id?: string | null }>(
ctx: any,
documentId: string,
userId: string,
): Promise<T> {
const doc = await getCanonicalDocumentByBusinessId<T>(ctx, documentId);
if (!doc) {
throw new Error("页面不存在");
}
if (String(doc.user_id ?? "") !== String(userId)) {
throw new Error("无权限");
}
return doc;
}

Some files were not shown because too many files have changed in this diff Show More