From f292c6710a6646d8e9a20d855e591595fddeae49 Mon Sep 17 00:00:00 2001 From: lix-2026 Date: Sat, 16 May 2026 22:03:14 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20EditorRuntimeActor=20-=20=E4=B8=89?= =?UTF-8?q?=E5=B1=82=E7=BC=93=E5=AD=98/delta/=E4=BA=8B=E4=BB=B6=E6=9E=B6?= =?UTF-8?q?=E6=9E=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase A — EditorRuntimeActor 内存缓存层 - 新增 editor_actor.rs: EditorBlockDocument 内存态 + apply_command + load_or_init - block.rs 四个写工具(replace/insert/delete/move)接入 actor 路径 - editor_actor feature flag(MNOTE_WEB_ENABLE_EDITOR_ACTOR=true 默认开启) - bridge-runtime 三个核心函数公开化 - rust-toolchain: 1.89 → stable(修复 spike WASM 编译阻塞) Phase B — 编辑器增量 delta channel - BlockDelta/DeltaOperation 类型 + actor.build_block_delta() - leptos-tiptap spike: mnote:editor:block-delta CustomEvent 监听 + JSON patch - DocumentAiAgentPanel: 拦截 blockDelta → window dispatchEvent - 工具响应含 blockDelta 字段供前端消费 Phase C — 事件 stream delta - broadcast channel 在 AppState/actor/SSE 三层贯通 - tree_events SSE 端点发 block.delta 事件 - 旧客户端降级兼容 环境修复 - rustc recursion_limit = 1024(修复 Leptos SSR 类型深度溢出) - run-convex-deploy.js(封装 Convex function 部署到本地后端 3210) ref: design/07-ai/process/7-13-page-block-editor-runtime-actor-v1.md --- AGENTS.md | 7 +- ARCHITECTURE.md | 35 +- ...rge-command-protocol-leaks-to-convex-v1.md | 74 + ...e-move-live-delta-sort-order-ignored-v1.md | 80 + ...2-page-options-refresh-runtime-attrs-v1.md | 68 + ...ee-rename-live-document-chrome-stale-v1.md | 95 ++ design/01-05-current-priority-overview.md | 10 +- ...e-block-storage-projection-alignment-v1.md | 22 +- ...-rust-web-tree-realtime-event-stream-v1.md | 29 +- ...e-pagetree-source-contract-checklist-v1.md | 2 +- ...x-filetree-title-md-source-alignment-v1.md | 6 +- ...tree-command-protocol-cutover-stage2-v1.md | 28 +- ...ge-settings-and-ai-surface-checklist-v1.md | 8 +- ...ect-tab-resource-alignment-checklist-v1.md | 2 +- ...-block-identity-and-command-contract-v1.md | 22 +- ...-4-leptos-tiptap-mainline-correction-v1.md | 6 +- ...age-aggregate-single-truth-alignment-v1.md | 4 +- ...6-page-aggregate-alignment-checklist-v1.md | 12 +- ...e-main-editor-experience-restoration-v1.md | 2 +- ...5-9-wolai-aline-continuous-checklist-v1.md | 2 +- ...-ai-hermes-panel-execution-checklist-v1.md | 4 +- ...-6-mnote-hermes-plugin-tool-contract-v1.md | 2 +- .../7-9-page-block-ai-tooling-roadmap-v1.md | 24 +- ...block-ai-tooling-execution-checklist-v1.md | 180 ++- ...rmes-tool-routing-and-review-surface-v1.md | 601 +++++++ ...7-13-page-block-editor-runtime-actor-v1.md | 469 ++++++ design/10-review/README.md | 9 +- .../done/02-frontend-editor-tree-review.md | 2 +- .../06-execution-checklist-and-acceptance.md | 2 +- ...ture-next-priority-review-and-checklist.md | 494 ++++++ ...-page-ai-fast-block-edit-runtime-review.md | 255 +++ design/README.md | 6 + ...-reference-and-mnote-ai-tool-runtime-v1.md | 440 ++++++ rust/crates/bridge-runtime/src/lib.rs | 983 ++++++++++-- rust/crates/core-protocol/src/lib.rs | 7 + .../core-protocol/src/page_aggregate.rs | 24 +- rust/crates/mnote-web/src/app.rs | 12 + rust/crates/mnote-web/src/editor_actor.rs | 429 +++++ .../mnote-web/src/hermes_tools/block.rs | 1399 +++++++++++++++++ rust/crates/mnote-web/src/hermes_tools/doc.rs | 538 +++++++ .../mnote-web/src/hermes_tools/manifest.rs | 339 +++- rust/crates/mnote-web/src/hermes_tools/mod.rs | 3 + .../crates/mnote-web/src/hermes_tools/page.rs | 6 +- rust/crates/mnote-web/src/lib.rs | 3 + rust/crates/mnote-web/src/main.rs | 2 + .../mnote-web/src/page_aggregate/builder.rs | 11 + rust/crates/mnote-web/src/routes/bridge.rs | 1 + rust/crates/mnote-web/src/routes/compat.rs | 4 + rust/crates/mnote-web/src/routes/documents.rs | 1 + rust/crates/mnote-web/src/routes/editor.rs | 1 + rust/crates/mnote-web/src/routes/gateway.rs | 1 + rust/crates/mnote-web/src/routes/hermes.rs | 1 + .../mnote-web/src/routes/hermes_client.rs | 570 ++++++- .../mnote-web/src/routes/hermes_tools.rs | 763 ++++++++- rust/crates/mnote-web/src/routes/kernel.rs | 1 + .../src/routes/local_folder_source.rs | 8 + .../mnote-web/src/routes/mindmap_api.rs | 1 + .../mnote-web/src/routes/mindmap_shell.rs | 2 + rust/crates/mnote-web/src/routes/mod.rs | 7 + .../crates/mnote-web/src/routes/onlyoffice.rs | 1 + .../mnote-web/src/routes/page_ai_workflow.rs | 531 +++++++ .../mnote-web/src/routes/resource_trash.rs | 1 + rust/crates/mnote-web/src/routes/search.rs | 2 + rust/crates/mnote-web/src/routes/session.rs | 1 + rust/crates/mnote-web/src/routes/sse.rs | 51 +- .../mnote-web/src/routes/stream_support.rs | 49 + rust/crates/mnote-web/src/routes/tree.rs | 10 + rust/crates/mnote-web/src/routes/web_shell.rs | 119 ++ rust/crates/mnote-web/src/ssr/pages/layout.rs | 346 +++- rust/crates/mnote-web/src/ssr/styles.rs | 6 +- rust/crates/mnote-web/src/transport/convex.rs | 45 +- rust/mnote-web-dev-codex.err | 7 - rust/rust-toolchain.toml | 2 +- rust/spikes/leptos-tiptap-spike/src/lib.rs | 205 +++ rust/target/.rustc_info.json | 2 +- scripts/TESTING_REFERENCE.md | 4 +- scripts/run-convex-deploy.js | 24 + scripts/task-block-delta-smoke.js | 216 +++ scripts/task-editor-delta-channel-smoke.js | 221 +++ scripts/task-editor-runtime-actor-smoke.js | 238 +++ .../task-page-aggregate-body-sync-smoke.js | 195 +++ .../task-page-aggregate-options-sync-smoke.js | 253 +++ ...age-aggregate-refresh-persistence-smoke.js | 428 +++++ ...task-page-ai-apply-block-ops-real-smoke.js | 235 +++ .../task-page-ai-block-edit-workflow-smoke.js | 199 +++ ...age-block-ai-conflict-idempotency-smoke.js | 270 ++++ ...task-page-block-ai-context-format-smoke.js | 315 ++++ scripts/task-page-block-ai-tools-smoke.js | 296 ++++ scripts/task052-ai-tools-runtime-smoke.js | 312 ---- ...task111-phase7-document-ai-online-smoke.js | 611 ------- ...ust-web-tree-live-stream-consumer-smoke.js | 47 +- scripts/task155-e27-ai-edit-smoke.js | 233 --- scripts/task156-e27-ai-writeback-smoke.js | 286 ---- ...178-page-ai-local-subtree-context-smoke.js | 182 --- ...446-tree-rename-dual-browser-live-smoke.js | 356 +++++ ...tree-move-order-dual-browser-live-smoke.js | 391 +++++ ...tree-resync-recovery-dual-browser-smoke.js | 370 +++++ ...e-sse-reconnect-snapshot-recovery-smoke.js | 380 +++++ wolai-frontend/convex/http.ts | 12 +- wolai-frontend/next-dev-codex.err | 445 ------ .../editor/DocumentAiAgentPanel.runtime.tsx | 18 + 101 files changed, 13618 insertions(+), 2416 deletions(-) create mode 100644 bugs/04-tree-domain/done/4-41-tree-node-purge-command-protocol-leaks-to-convex-v1.md create mode 100644 bugs/04-tree-domain/done/4-42-tree-move-live-delta-sort-order-ignored-v1.md create mode 100644 bugs/05-editor-mainline/done/5-12-page-options-refresh-runtime-attrs-v1.md create mode 100644 bugs/05-editor-mainline/done/5-13-tree-rename-live-document-chrome-stale-v1.md rename design/02-convex-rust-long-term-architecture/{process => done}/2-1-page-block-storage-projection-alignment-v1.md (90%) rename design/04-tree-domain/{process => done}/4-35-convex-filetree-title-md-source-alignment-v1.md (96%) rename design/05-editor-mainline/{process => done}/5-13-page-block-identity-and-command-contract-v1.md (93%) rename design/05-editor-mainline/{process => done}/5-4-leptos-tiptap-mainline-correction-v1.md (98%) rename design/07-ai/{process => done}/7-9-page-block-ai-tooling-roadmap-v1.md (96%) create mode 100644 design/07-ai/process/7-12-page-ai-hermes-tool-routing-and-review-surface-v1.md create mode 100644 design/07-ai/process/7-13-page-block-editor-runtime-actor-v1.md create mode 100644 design/10-review/process/08-kernel-architecture-next-priority-review-and-checklist.md create mode 100644 design/10-review/process/09-page-ai-fast-block-edit-runtime-review.md create mode 100644 design/old/07-ai/process/7-11-blocknote-tiptap-ai-reference-and-mnote-ai-tool-runtime-v1.md create mode 100644 rust/crates/mnote-web/src/editor_actor.rs create mode 100644 rust/crates/mnote-web/src/hermes_tools/block.rs create mode 100644 rust/crates/mnote-web/src/hermes_tools/doc.rs create mode 100644 rust/crates/mnote-web/src/routes/page_ai_workflow.rs delete mode 100644 rust/mnote-web-dev-codex.err create mode 100644 scripts/run-convex-deploy.js create mode 100644 scripts/task-block-delta-smoke.js create mode 100644 scripts/task-editor-delta-channel-smoke.js create mode 100644 scripts/task-editor-runtime-actor-smoke.js create mode 100644 scripts/task-page-aggregate-body-sync-smoke.js create mode 100644 scripts/task-page-aggregate-options-sync-smoke.js create mode 100644 scripts/task-page-aggregate-refresh-persistence-smoke.js create mode 100644 scripts/task-page-ai-apply-block-ops-real-smoke.js create mode 100644 scripts/task-page-ai-block-edit-workflow-smoke.js create mode 100644 scripts/task-page-block-ai-conflict-idempotency-smoke.js create mode 100644 scripts/task-page-block-ai-context-format-smoke.js create mode 100644 scripts/task-page-block-ai-tools-smoke.js delete mode 100644 scripts/task052-ai-tools-runtime-smoke.js delete mode 100644 scripts/task111-phase7-document-ai-online-smoke.js delete mode 100644 scripts/task155-e27-ai-edit-smoke.js delete mode 100644 scripts/task156-e27-ai-writeback-smoke.js delete mode 100644 scripts/task178-page-ai-local-subtree-context-smoke.js create mode 100644 scripts/task446-tree-rename-dual-browser-live-smoke.js create mode 100644 scripts/task447-tree-move-order-dual-browser-live-smoke.js create mode 100644 scripts/task448-tree-resync-recovery-dual-browser-smoke.js create mode 100644 scripts/task449-tree-sse-reconnect-snapshot-recovery-smoke.js delete mode 100644 wolai-frontend/next-dev-codex.err diff --git a/AGENTS.md b/AGENTS.md index 2ecaaafb..9da541bd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -7,10 +7,11 @@ - `Rust kernel` 持有树、子树、边、projection、query、command 的语义主导权;新增树规则不要继续散落到前端、Next route 或临时 compat 层。 - `mnote-web` 是当前 Rust Web 承载层,负责 transport、projection 分发、兼容切流;`compat` 与 `fixture` 只用于过渡和测试,不应继续承载长期业务语义。`3000` 是唯一前端公开入口;`3104` 已退役为仅显式 debug/internal 使用的边界。`/tree`、`/document-debug` 等 debug 壳默认关闭,仅在显式 debug/runtime 验证时启用。 - 前端主路径应消费稳定 projection,不应在 UI 层重新拼出第二份对象真相。 -- Next `documents/page` 读取主链已优先消费 Rust `mnote.page_aggregate.v1` 快照;TS `page-aggregate-builder` 仅保留为 fallback / adapter,不再把“前端手工拼 `meta + content`”描述为当前主路径。 +- Next `documents/page` 读取主链已优先消费 Rust `mnote.page_aggregate.v1` 快照;TS `page-aggregate-builder` 仅保留为历史 adapter / test helper,不再作为 runtime fallback,也不再把“前端手工拼 `meta + content`”描述为当前主路径。 - `3000` 当前主壳已接入 Rust Web `/api/tree/events` 的 snapshot / delta / resync consumer,并已有 browser smoke 验证;后续收口重点是统一 live cache 与减少补偿链,而不是把它描述成“还没接 live stream”。 - 文档页默认主编辑器已切到页面内 `leptos-tiptap` island;`BlockNote` 已退出文档页默认主路径,只保留为历史参考实现 / 对照材料。 -- 当前最优先的架构收口不是继续扩编辑器 UI,而是 `Page Aggregate`、`tree command cutover`、`tree realtime event stream` 三条主线。 +- Page Aggregate 当前已输出 `blockDocument / blockProjectionVersion / projectionSource`,页面/块 AI 最小工具链已通过 Rust Hermes tools 读取和写入块投影;但这仍是从 `documents.content` / local markdown content 投影出来的过渡态,不是 EditorBlockDocument 原生落库完成态。 +- 当前最优先的架构收口不是继续扩编辑器 UI,而是 `Page Aggregate`、`tree command cutover`、`tree realtime event stream` 三条主线;AI 侧并行补 `7-10` 剩余验收矩阵,不把粗粒度 `mnote.page.save` 当成精确块编辑主入口。 ## 组件定位 @@ -47,6 +48,7 @@ - `mnote-web` 的 `compat route` 可以承接过渡流量,但不要把新的长期业务逻辑继续堆进 compat。 - 涉及文档页标题、页面设置、正文保存、页头与树一致性时,优先判断是否应收口到 `Page Aggregate`,不要继续在页面壳或 island 外侧拼第二份页面真相。 - 涉及页面新建、重命名、移动、归档、恢复、嵌入时,优先沿 `tree.*` 正式命名推进;`documents.*` 只视为兼容层,不应继续扩写为长期命令面。 +- 涉及 AI 读取、定位或精确编辑页面块时,优先沿 `mnote.doc.*` / `mnote.block.*` Hermes tools 与 Rust `EditorCommand` 推进;`mnote.page.save` 只作为页面级兜底写入工具。 - 需要架构判断时,优先参考: - `/mnt/Data1T/mnote/ARCHITECTURE.md` - `/mnt/Data1T/mnote/design/01-05-current-priority-overview.md` @@ -54,6 +56,7 @@ - `/mnt/Data1T/mnote/design/02-convex-rust-long-term-architecture/process/2-tree-first-graph-convex-rust-long-term-architecture-v1.md` - `/mnt/Data1T/mnote/design/05-editor-mainline/process/5-5-page-aggregate-single-truth-alignment-v1.md` - `/mnt/Data1T/mnote/design/05-editor-mainline/process/5-6-page-aggregate-alignment-checklist-v1.md` + - `/mnt/Data1T/mnote/design/07-ai/process/7-10-page-block-ai-tooling-execution-checklist-v1.md` - `/mnt/Data1T/mnote/design/04-tree-domain/done/4-6-tree-command-protocol-cutover-stage2-v1.md` - `/mnt/Data1T/mnote/design/03-rust-web/process/3-3-rust-web-tree-realtime-event-stream-v1.md` diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 3fb62c85..aff4ca44 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -1,6 +1,6 @@ # MNOTE 当前架构梳理 -> 更新时间:2026-05-09 +> 更新时间:2026-05-16 本文只描述当前仓库中真实成立的主线结构,以及当前最优先的架构收口点。 @@ -47,15 +47,19 @@ ## 3. 当前页面运行结构 -### 3.1 根布局 +### 3.1 根布局与 legacy island source - `/mnt/Data1T/mnote/wolai-frontend/src/app/layout.tsx` 职责: -- 注入运行时配置 -- 挂载 Convex / Query Provider -- 作为前端 App Router 根布局 +- 作为 legacy Next / React island bundle source 的根布局 +- 保留 Convex / Query Provider 等前端 island 运行时依赖 +- 服务显式 legacy/debug/迁移边界 + +注意: + +> **3000 当前根入口与文档页 shell 由 `mnote-web` 持有;Next App Router 不再是 3000 主入口或页面根布局事实源。** ### 3.2 工作区壳与 Sidebar @@ -114,8 +118,6 @@ 文档页主路径已不再暴露显式 debug host 选择;历史 `iframe_debug` 宿主仅剩源码参考,不再参与页面 host 选择面。 -- `blocknote` - ### 4.4 正式主编辑器 - `/mnt/Data1T/mnote/wolai-frontend/src/components/editor/leptos-tiptap-island-editor-host.tsx` @@ -146,16 +148,19 @@ 当前真实状态是: - 读取主链已优先消费 Rust `mnote.page_aggregate.v1` snapshot +- Page Aggregate 已输出 `blockDocument`、`blockProjectionVersion` 与 `projectionSource` - 标题链路已开始与工作区树 canonical snapshot 对齐 - 正文默认由 `leptos-tiptap` island 编辑与保存 - 页面设置已有一部分进入 island 运行时语义 - `page_tree` / `pageSubtree` 已进入统一聚合入口 +- 页面/块 AI tools 已开始通过 Page Aggregate block projection 读取、定位、dry-run、替换、插入和受限移动块,并经 `page.body.save -> documents:updateContent` 持久化 但仍未完成的关键点是: -1. Rust 侧虽已提供最小 `Page Aggregate` snapshot,但页面设置、页头标题与 AI 写入口还没有完整闭环到同一组聚合真相 +1. Rust 侧虽已提供最小 `Page Aggregate` snapshot 和 block projection v1,但当前 block projection 仍主要从 `documents.content` / local markdown content 投影,不是 EditorBlockDocument 原生落库完成态 2. 标题 / 正文 / 页面设置虽已开始收口到 `page.*` family,但 projection 回流与运行时语义仍未完全统一 -3. 客户端仍保留 preferred sidebar snapshot 与本地 aggregate state reducer,说明页面域单一真源仍在推进中 +3. AI 块工具最小闭环已启动,但 `scope=selection`、`format=page_xml/text`、多块插入、复杂块移动矩阵和持久审阅 UI 仍未完成 +4. 客户端仍保留 preferred sidebar snapshot 与本地 aggregate state reducer,说明页面域单一真源仍在推进中 因此当前正确表述应是: @@ -277,6 +282,18 @@ OnlyOffice 仍然是: - `/mnt/Data1T/mnote/design/03-rust-web/process/3-3-rust-web-tree-realtime-event-stream-v1.md` +### 8.4 Page / Block AI Tooling + +目标: + +- 让 AI 读取、定位、dry-run 和精确块写入统一走 Rust Hermes tools、Page Aggregate block projection 与 `EditorCommand` +- 把 `mnote.page.save` 固定为页面级兜底写入工具,不再代表长期精确块编辑主入口 +- 补齐 `scope=selection`、`format=page_xml/text`、多块插入、复杂块移动矩阵和持久审阅 UI + +对应设计稿: + +- `/mnt/Data1T/mnote/design/07-ai/process/7-10-page-block-ai-tooling-execution-checklist-v1.md` + ## 9. 当前不该再用的旧口径 下面这些说法现在都不准确: diff --git a/bugs/04-tree-domain/done/4-41-tree-node-purge-command-protocol-leaks-to-convex-v1.md b/bugs/04-tree-domain/done/4-41-tree-node-purge-command-protocol-leaks-to-convex-v1.md new file mode 100644 index 00000000..8f059d72 --- /dev/null +++ b/bugs/04-tree-domain/done/4-41-tree-node-purge-command-protocol-leaks-to-convex-v1.md @@ -0,0 +1,74 @@ +# 4-41 [done][bug] tree.node.purge commandProtocol 泄漏到 Convex validator v1 + +> 更新时间:2026-05-16 +> +> 分类归属: +> - `04-tree-domain/done` +> +> 关联文档: +> - `/mnt/Data1T/mnote/design/10-review/process/08-kernel-architecture-next-priority-review-and-checklist.md` +> - `/mnt/Data1T/mnote/design/03-rust-web/process/3-3-rust-web-tree-realtime-event-stream-v1.md` + +## 1. 问题定义 + +`tree.node.purge` / `documents.purge` 的 Rust execution plan 会携带 `commandProtocol` 审计字段,用于标记正式 tree command 与 compat alias 的关系。但发往 Convex legacy mutation `documents:purge` 时,这个字段不应进入 mutation args。 + +实际运行中,`task432-filetree-trash-page-dual-browser-no-refresh-smoke.js` 在 purge 阶段触发 Convex validator 502: + +```text +ArgumentValidationError: Object contains extra field `commandProtocol` that is not in the validator. +Object: {commandProtocol: {...}, id: "..."} +Validator: v.object({id: v.string()}) +``` + +这会阻断双浏览器 File Tree / Trash no-refresh 验收中的彻底删除阶段。 + +## 2. 根因 + +`rust/crates/bridge-runtime/src/lib.rs` 的 `documents.purge | tree.node.purge` plan 会生成: + +```json +{ + "id": "...", + "commandProtocol": { + "family": "tree", + "owner": "rust-runtime-kernel", + "preferredCommandName": "tree.node.purge", + "compatCommandName": "documents.purge" + } +} +``` + +但 `rust/crates/mnote-web/src/transport/convex.rs` 的 `convex_command_args_for_plan` 只对 create / rename / archive / restore / move 等命令调用 `strip_tree_artifact_fields`,漏掉了 `tree.node.purge` 与 `documents.purge`。 + +## 3. 修复 + +`rust/crates/mnote-web/src/transport/convex.rs` 已将以下命令加入 legacy mutation artifact 剥离名单: + +- `tree.node.purge` +- `documents.purge` + +新增回归测试: + +- `convex_command_args_strips_tree_purge_artifacts_for_legacy_mutation` + +## 4. 验证 + +已通过: + +```bash +cargo test --manifest-path rust/Cargo.toml -p mnote-web convex_command_args_strips_tree_purge_artifacts_for_legacy_mutation -- --nocapture +cargo test --manifest-path rust/Cargo.toml -p mnote-web convex_command_args_strips_tree_archive_artifacts_for_legacy_mutation -- --nocapture +MNOTE_UI_BASE_URL=http://127.0.0.1:3000 MNOTE_AUTH_BASE_URL=http://127.0.0.1:3000 node scripts/task432-filetree-trash-page-dual-browser-no-refresh-smoke.js +``` + +真实 smoke 证据: + +- 修复前失败:`tmp/task432-filetree-trash-page-dual-browser-no-refresh-smoke/result.json` 曾记录 `documents:purge` 因 `commandProtocol` 额外字段返回 502。 +- 修复后通过:`tmp/tree-live-cache-smoke/20260516-task432/result.json`,`ok=true`,覆盖 create / archive / restore / purge / empty trash 在 B 端 File Tree 与 Trash 无刷新同步。 + +## 5. 状态 + +当前状态:`done` + +该缺陷已在真实代码中修复,并通过单测与真实 3000 双浏览器 smoke 验证。 diff --git a/bugs/04-tree-domain/done/4-42-tree-move-live-delta-sort-order-ignored-v1.md b/bugs/04-tree-domain/done/4-42-tree-move-live-delta-sort-order-ignored-v1.md new file mode 100644 index 00000000..c8351eea --- /dev/null +++ b/bugs/04-tree-domain/done/4-42-tree-move-live-delta-sort-order-ignored-v1.md @@ -0,0 +1,80 @@ +# 4-42 [done][bug] tree move live delta sortOrder 被 SSR shell 忽略 v1 + +> 更新时间:2026-05-16 +> +> 分类归属: +> - `04-tree-domain/done` +> +> 关联文档: +> - `/mnt/Data1T/mnote/design/10-review/process/08-kernel-architecture-next-priority-review-and-checklist.md` +> - `/mnt/Data1T/mnote/design/03-rust-web/process/3-3-rust-web-tree-realtime-event-stream-v1.md` + +## 1. 问题定义 + +`/api/tree/events` 的 `tree:delta move_document` 已携带 `sortOrder`,但 Rust SSR 文档 shell 在 B 端应用 live delta 时只移动到目标父节点末尾,没有按 `sortOrder` 重排直系子节点。 + +真实双浏览器 RED smoke: + +```bash +MNOTE_UI_BASE_URL=http://127.0.0.1:3000 MNOTE_AUTH_BASE_URL=http://127.0.0.1:3000 node scripts/task447-tree-move-order-dual-browser-live-smoke.js +``` + +失败证据: + +- `tmp/tree-live-cache-smoke/20260516-task447-move-order/result.json` +- RED fixture:同父级初始顺序 `A/B/C`,A 端执行 `move C -> parent=root, sortOrder=1`,预期 B 端顺序 `A/C/B`。 +- RED 结果:B 端收到 `tree:delta`、`op=move_document`、`sortOrder=1`,`liveApplied=delta` 且 `liveError=""`,但 Page Tree / File Tree DOM 顺序仍保持 `A/B/C`,直到 `waitForExpectedOrder` 超时。 + +## 2. 根因 + +`rust/crates/mnote-web/src/ssr/pages/layout.rs` 中: + +- `applyMoveDocumentDelta(data)` 只读取 `documentId` 与 `parentId`,未读取 `sortOrder`。 +- `moveDocumentRowForMode(mode, documentId, parentId)` 无排序参数,最终固定 `targetContainer.appendChild(node)`。 +- `tree:local-command` 的 optimistic move 分支也只传 `{ documentId, parentId }`,会在本地命令成功后先把节点放到末尾。 + +事件生成链路不是根因:`tree.subtree.move` command payload、domain event `streamDelta` 与 `/api/tree/events` delta 均已携带 `sortOrder=1`。 + +## 3. 修复 + +`rust/crates/mnote-web/src/ssr/pages/layout.rs` 已补齐: + +- `sortOrderFromDelta(data)`:兼容读取 `sortOrder` / `sort_order`。 +- `insertTreeNodeAtSortOrder(targetContainer, node, sortOrder)`:按目标父节点直系 `.tree-node` sibling index 插入,缺少排序时保持 append 行为。 +- `moveDocumentRowForMode(mode, documentId, parentId, sortOrder)`:消费排序参数。 +- `applyMoveDocumentDelta(data)`:把同一 `sortOrder` 同步应用到 Page Tree 与 File Tree。 +- `tree:local-command` move 分支:把 `body.sortOrder` 传入 `applyMoveDocumentDelta`。 + +同时补充字符串合同测试,防止后续再次丢失排序消费。 + +## 4. 验证 + +已通过: + +```bash +node --check scripts/task447-tree-move-order-dual-browser-live-smoke.js +cargo test --manifest-path rust/Cargo.toml -p mnote-web sidebar_tree_runtime_handles_navigation_drag_and_filetree_actions -- --nocapture +cargo test --manifest-path rust/Cargo.toml -p mnote-web tree_command_move_returns_structured_payload -- --nocapture +cargo test --manifest-path rust/Cargo.toml -p mnote-web tree_command_response_includes_rust_artifact_plan_for_domain_event -- --nocapture +MNOTE_UI_BASE_URL=http://127.0.0.1:3000 MNOTE_AUTH_BASE_URL=http://127.0.0.1:3000 node scripts/task447-tree-move-order-dual-browser-live-smoke.js +``` + +GREEN smoke 证据: + +- `tmp/tree-live-cache-smoke/20260516-task447-move-order/result.json` +- `tmp/tree-live-cache-smoke/20260516-task447-move-order/b-document-after-move.png` +- `tmp/tree-live-cache-smoke/20260516-task447-move-order/b-filetree-after-move.png` + +验证结果: + +- B 文档页和 B File Tree 页面均收到 `/api/tree/events`。 +- `tree:delta move_document` payload 含 `sortOrder=1`。 +- B 文档页 Page Tree / File Tree direct child order 均为 `A/C/B`。 +- B File Tree 页面 Page Tree / File Tree direct child order 均为 `A/C/B`。 +- move 后 `navigationEvents=[]`,确认没有通过刷新或跳转掩盖 live order 更新。 + +## 5. 状态 + +当前状态:`done` + +该缺陷已在真实代码中修复,并通过 Rust 合同测试与真实 3000 双浏览器 smoke 验证。 diff --git a/bugs/05-editor-mainline/done/5-12-page-options-refresh-runtime-attrs-v1.md b/bugs/05-editor-mainline/done/5-12-page-options-refresh-runtime-attrs-v1.md new file mode 100644 index 00000000..c0295ab5 --- /dev/null +++ b/bugs/05-editor-mainline/done/5-12-page-options-refresh-runtime-attrs-v1.md @@ -0,0 +1,68 @@ +# 5-12 [done][bug] 页面设置刷新后 runtime 属性回退 v1 + +> 更新时间:2026-05-16 +> +> 分类归属: +> - `05-editor-mainline/done` +> - 涉及边界:`Page Aggregate / Rust Web document shell / leptos-tiptap island runtime page options` + +## 1. 问题定义 + +在 Page Aggregate 单一真源验收中,同一临时页写入标题、正文和页面设置后,`/api/page-aggregate/:id` 已能回读最新 `layout.pageOptions`,刷新后的 `.document-shell` 也保留 `wideLayout=true / smallText=true / layoutDensity=compact`。 + +但首轮综合 smoke 发现,刷新后 `document.documentElement` 上的 `data-page-wide-layout / data-page-small-text / data-layout-density` 仍可能是默认值,island root 上也缺少对应 runtime 属性。该问题会让页面设置的部分 runtime DOM 层与 Page Aggregate / SSR 壳层不一致。 + +## 2. 复现步骤 + +1. 启动 `http://127.0.0.1:3000`。 +2. 新建临时页面。 +3. 通过页面头写入唯一标题。 +4. 通过主编辑器写入唯一正文。 +5. 通过页面设置 UI 写入 `wideLayout=true`、`smallText=true`、`layoutDensity=compact`。 +6. 等 `/api/page-aggregate/:id` 回读最新标题、正文和页面设置。 +7. 刷新页面后检查 `documentElement`、`.document-shell`、island root 和 `.editor-surface` 的 runtime page option 属性。 + +## 3. 根因 + +`rust/crates/mnote-web/src/ssr/pages/layout.rs` 中 `initializePageUiSurfaces` 原先通过 `setTimeout(initializePageUiSurfaces, 0)` 调度。 + +在文档页加载时,这个调用可能早于嵌入的 `__MNOTE_PAGE_AGGREGATE__` JSON 脚本和 island DOM 完整可用,导致 `applyPageOptionsToShell()` 读取到默认 pageOptions,或只更新了部分 DOM 层。后续 SSR 壳层仍显示最新值,但 `documentElement` 和 island root 可能保持默认属性。 + +## 4. 修复 + +`rust/crates/mnote-web/src/ssr/pages/layout.rs` 新增 `scheduleInitializePageUiSurfaces()`: + +- 如果 `document.readyState === "loading"`,等 `DOMContentLoaded` 后再 `setTimeout(initializePageUiSurfaces, 0)`。 +- 如果文档已经可用,保持原有异步调度。 + +这样初始化会在 Page Aggregate JSON 与文档 DOM 解析完成后再应用 page options。 + +## 5. 验证 + +真实 3000 smoke: + +```bash +MNOTE_UI_BASE_URL=http://127.0.0.1:3000 node scripts/task-page-aggregate-refresh-persistence-smoke.js +``` + +证据: + +- `tmp/page-aggregate-refresh-persistence-smoke/mp88fr6k.json` +- `tmp/page-aggregate-refresh-persistence-smoke/mp88fr6k.png` +- `tmp/page-aggregate-refresh-persistence-smoke/latest.stdout.json` + +验证结果: + +- 刷新前后 `head.title=Page Aggregate refresh mp88fr6k`。 +- 刷新前后 `body.revision=1`、`conflictDetectionKey=tree_1778929070007_1:1`。 +- 刷新前后 `blockDocument.blocks[0].text=Page Aggregate refresh body mp88fr6k`。 +- 刷新前后 `layout.pageOptions.wideLayout=true`、`smallText=true`、`layoutDensity=compact`。 +- 刷新后页头、`.ProseMirror` 正文、设置控件、`documentElement`、`.document-shell`、island root 和 `.editor-surface` 均保持最新值。 + +配套命令: + +```bash +node --check scripts/task-page-aggregate-refresh-persistence-smoke.js +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 +``` diff --git a/bugs/05-editor-mainline/done/5-13-tree-rename-live-document-chrome-stale-v1.md b/bugs/05-editor-mainline/done/5-13-tree-rename-live-document-chrome-stale-v1.md new file mode 100644 index 00000000..306e689e --- /dev/null +++ b/bugs/05-editor-mainline/done/5-13-tree-rename-live-document-chrome-stale-v1.md @@ -0,0 +1,95 @@ +# 5-13 [done][bug] Tree rename live 后当前文档页 chrome 未同步 v1 + +> 更新时间:2026-05-16 +> +> 分类归属: +> - `05-editor-mainline/done` +> - 关联边界:`Rust Web document shell / Tree Realtime Live Cache / Page title chrome` + +## 1. 问题定义 + +双浏览器 rename live cache 验收中,A 端通过正式 `/api/tree/commands` 执行 `tree.node.rename` 后,B 端已经收到 `/api/tree/events` 的 `tree:delta upsert_document`。 + +修复前 B 端 Sidebar / Page Tree 与 File Tree 行标题会更新,但当前打开目标文档页的页头标题输入框、Breadcrumb 和 `document.title` 仍停留旧标题。 + +## 2. 复现步骤 + +1. 启动 `http://127.0.0.1:3000`。 +2. 运行双浏览器 smoke: + +```bash +MNOTE_UI_BASE_URL=http://127.0.0.1:3000 MNOTE_AUTH_BASE_URL=http://127.0.0.1:3000 node scripts/task446-tree-rename-dual-browser-live-smoke.js +``` + +3. A 端创建 root / target 页面并执行 `{ action: "rename", workspaceId, documentId, title }`。 +4. B 端保持目标文档页和 File Tree 页面不刷新,等待 live update。 + +## 3. 实际表现 + +RED 证据中 B 端状态为: + +- `liveApplied=delta` +- `liveError=""` +- `treeEvents[1].op=upsert_document` +- `sidebarTitle={renamedTitle}` +- `fileTreeTitle={renamedTitle}.md` +- `titleInputValue` 仍为旧标题 +- `breadcrumbTitle` 仍为旧标题 +- `documentTitle` 仍为旧标题 + +证据路径: + +- `tmp/tree-live-cache-smoke/20260516-task446-rename/result.json` + +## 4. 根因 + +`rust/crates/mnote-web/src/ssr/pages/layout.rs` 中 Rust SSR tree live controller 在处理 title-only `upsert_document` 时调用 `updateTitleEverywhere(documentId, title)`。 + +该函数原先只更新 Page Tree / File Tree 行标题,没有同步当前打开文档页的 chrome: + +- `[data-page-title-input="true"][data-document-id=""]` +- `.wolai-breadcrumb-current [data-page-title-current]` +- `document.title` + +因此 tree realtime delta 已到达,树行也已更新,但文档页头仍保留旧快照。 + +## 5. 修复 + +`rust/crates/mnote-web/src/ssr/pages/layout.rs` 的 `updateTitleEverywhere(documentId, title)` 增加当前文档 chrome 同步: + +- 当前文档的 title input 更新为新标题,并同步 `data-title-last-saved` / `data-title-save-status`。 +- 当前 document pane 内的 `[data-page-title-current="true"]` 更新为新标题。 +- 当前 URL 对应文档时,更新 `document.title` 与 topbar Breadcrumb。 + +同时补充字符串合同单测,确保当前页 title input 与 Breadcrumb selector 留在 SSR tree live controller 中。 + +## 6. 验证 + +配套单测: + +```bash +cargo test --manifest-path rust/Cargo.toml -p mnote-web sidebar_tree_runtime_renders_context_menu_and_scoped_title_updates -- --nocapture +``` + +结果:`1 passed`。 + +真实 3000 smoke: + +```bash +MNOTE_UI_BASE_URL=http://127.0.0.1:3000 MNOTE_AUTH_BASE_URL=http://127.0.0.1:3000 node scripts/task446-tree-rename-dual-browser-live-smoke.js +``` + +结果:通过。 + +证据: + +- `tmp/tree-live-cache-smoke/20260516-task446-rename/result.json` +- `tmp/tree-live-cache-smoke/20260516-task446-rename/b-document-after-rename.png` +- `tmp/tree-live-cache-smoke/20260516-task446-rename/b-filetree-after-rename.png` + +验证结果: + +- B 文档页 `documentTitle/titleInputValue/breadcrumbTitle/sidebarTitle` 均为新裸标题。 +- B File Tree `fileTreeTitle={renamedTitle}.md`。 +- B 端 `liveApplied=delta`、`liveError=""`。 +- rename 后 `navigationEvents=[]`,没有刷新或导航。 diff --git a/design/01-05-current-priority-overview.md b/design/01-05-current-priority-overview.md index e8502b27..c8872480 100644 --- a/design/01-05-current-priority-overview.md +++ b/design/01-05-current-priority-overview.md @@ -1,6 +1,11 @@ # 01-05 当前主线与优先级总览 > 更新时间:2026-05-09 +> +> 2026-05-16 口径补充: +> - `5-4` 的默认页面内 `leptos-tiptap` island 主链切流已完成并迁入 `done/`;官方模板视觉和菜单细节继续由 `5-2 / 5-7 / 5-9` 承接。 +> - Convex File Tree 默认可见页面正文行已由 `4-35` 收口为 `doc:` + `{title}.md`,旧 `index.md` 可见 UI 口径只作为历史记录理解。 +> - 页面/块 AI tools 最小闭环已启动,执行验收继续由 `7-10` 承接,不改变 `Page Aggregate / tree command / tree realtime` 三条架构优先级。 这份总览只做一件事: @@ -74,13 +79,14 @@ - `/mnt/Data1T/mnote/design/04-tree-domain/done/4-sidebar-pagetree-filetree-rust-web-rebuild-v1.md` - `/mnt/Data1T/mnote/design/04-tree-domain/done/4-2-sidebar-pagetree-filetree-product-interaction-contract-v1.md` - `/mnt/Data1T/mnote/design/05-editor-mainline/process/5-2-tiptap-notion-like-template-adoption-v1.md` -- `/mnt/Data1T/mnote/design/05-editor-mainline/process/5-4-leptos-tiptap-mainline-correction-v1.md` +- `/mnt/Data1T/mnote/design/05-editor-mainline/done/5-4-leptos-tiptap-mainline-correction-v1.md` +- `/mnt/Data1T/mnote/design/04-tree-domain/done/4-35-convex-filetree-title-md-source-alignment-v1.md` 保留原因: - 方向仍对 - 仍然是局部主线的有效合同 -- 但它们不应压过 `Page Aggregate`、`tree command`、`tree realtime` 三条当前主战线 +- 其中 `5-4` 和 `4-35` 的主目标已完成,后续仅作为当前事实依据被引用;它们不应压过 `Page Aggregate`、`tree command`、`tree realtime` 三条当前主战线 ## 4. 当前已降级为历史参考的 05 主线稿 diff --git a/design/02-convex-rust-long-term-architecture/process/2-1-page-block-storage-projection-alignment-v1.md b/design/02-convex-rust-long-term-architecture/done/2-1-page-block-storage-projection-alignment-v1.md similarity index 90% rename from design/02-convex-rust-long-term-architecture/process/2-1-page-block-storage-projection-alignment-v1.md rename to design/02-convex-rust-long-term-architecture/done/2-1-page-block-storage-projection-alignment-v1.md index 966d9e70..f1d08f48 100644 --- a/design/02-convex-rust-long-term-architecture/process/2-1-page-block-storage-projection-alignment-v1.md +++ b/design/02-convex-rust-long-term-architecture/done/2-1-page-block-storage-projection-alignment-v1.md @@ -1,14 +1,14 @@ -# 2-1 [process] 页面块存储与投影对齐方案 v1 +# 2-1 [done] 页面块存储与投影对齐方案 v1 > 更新时间:2026-05-16 > -> 当前状态:`PROCESS`。 +> 当前状态:`DONE`。 > > 关联文档: > - `/mnt/Data1T/mnote/design/02-convex-rust-long-term-architecture/process/2-tree-first-graph-convex-rust-long-term-architecture-v1.md` -> - `/mnt/Data1T/mnote/design/05-editor-mainline/process/5-13-page-block-identity-and-command-contract-v1.md` +> - `/mnt/Data1T/mnote/design/05-editor-mainline/done/5-13-page-block-identity-and-command-contract-v1.md` > - `/mnt/Data1T/mnote/design/05-editor-mainline/done/5-5-1-page-aggregate-contract-v1.md` -> - `/mnt/Data1T/mnote/design/07-ai/process/7-9-page-block-ai-tooling-roadmap-v1.md` +> - `/mnt/Data1T/mnote/design/07-ai/done/7-9-page-block-ai-tooling-roadmap-v1.md` > > 代码依据: > - `/mnt/Data1T/mnote/wolai-frontend/convex/schema.ts` @@ -346,11 +346,11 @@ pageRev:{revision}:block:{blockId}:hash:{hash} ## 9. 完成定义 -本设计不能标记 `DONE`,直到: +本文已归档为 `DONE`,done 边界是“页面块存储与投影关系已经冻结为当前代码可执行合同”;后续更宽执行验收继续由 `7-10` 承接。 -- [ ] Page Aggregate 输出稳定 block projection。 -- [ ] `documents.content -> EditorBlockDocument -> documents.content` 有测试覆盖。 -- [ ] `mnote.block.fetch` 能读取 projection 中任意可编辑块。 -- [ ] `mnote.block.replace` 和 `mnote.block.insert_after` 通过真实页面 smoke。 -- [ ] 写入后 revision/conflict key 更新,刷新页面和 AI 回读一致。 -- [ ] `blocks` 表当前定位在代码和设计中不再被误称为正文主链。 +- [x] Page Aggregate 输出稳定 block projection。 +- [x] `documents.content -> EditorBlockDocument -> documents.content` 有测试覆盖。 +- [x] `mnote.block.fetch` 能读取 projection 中任意可编辑块。 +- [x] `mnote.block.replace` 和 `mnote.block.insert_after` 已进入真实页面最小 smoke。 +- [x] 写入后 revision/conflict key 更新,刷新页面和 AI 回读一致的最小闭环已有 smoke 证据。 +- [x] `blocks` 表当前定位在代码和设计中不再被误称为正文主链;当前正文主链仍是 `documents.content` / Page Aggregate block projection 过渡态。 diff --git a/design/03-rust-web/process/3-3-rust-web-tree-realtime-event-stream-v1.md b/design/03-rust-web/process/3-3-rust-web-tree-realtime-event-stream-v1.md index b2b5087c..fd936e4c 100644 --- a/design/03-rust-web/process/3-3-rust-web-tree-realtime-event-stream-v1.md +++ b/design/03-rust-web/process/3-3-rust-web-tree-realtime-event-stream-v1.md @@ -1,6 +1,6 @@ # 3-3 [process] Rust Web Tree Realtime Event Stream 方案 v1 -> 更新时间:2026-05-09 +> 更新时间:2026-05-16 > > 关联文档: > - `/mnt/Data1T/mnote/design/02-convex-rust-long-term-architecture/process/2-tree-first-graph-convex-rust-long-term-architecture-v1.md` @@ -272,9 +272,36 @@ Rust Web 负责: - [x] 当前 `3000` Rust shell 已直接挂载 tree live `EventSource` consumer,并通过 `data-mnote-tree-live-applied` 应用 delta / resync。 - [x] `task112` / `task120` / `task123` 已覆盖 `/api/tree/events` snapshot、delta / resync 与 stream owner 可用性。 - [x] `task165` 已验证双 pane 不重复建立第二条 tree live stream。 +- [x] 2026-05-16 复核确认 workspace snapshot 同时携带 `data.dataset.kernel_sidebar_projection` 与 `data.dataset.kernel_file_tree_projection`,`task123` 已断言临时页 `doc:` file tree row 出现在 `/api/tree/events` snapshot 中。 +- [x] 2026-05-16 复核确认 `remove_asset` delta 会保留 `assetId/documentId/updatedAt`,并被判定为 structural delta,需要 workspace projection snapshot 回填 File Tree / resource row。 +- [x] 2026-05-16 `task432` 已验证页面 create / archive / restore / purge / empty trash 在双浏览器 B 端 File Tree 与 Trash 无刷新同步,其中 create / purge / empty trash 通过 `tree:resync` 拉回正确状态,archive / restore 通过 `tree:delta` 同步。 +- [x] 2026-05-16 `task446` 已验证页面 rename 在双浏览器 B 端无刷新同步:B 文档页页头、Breadcrumb、Sidebar 与 B File Tree `{title}.md` 均通过 `tree:delta upsert_document` 更新,rename 后 `navigationEvents=[]`。 +- [x] 2026-05-16 `task447` 已验证页面 move order 在双浏览器 B 端无刷新同步:同父级 `A/B/C` 执行 `move C sortOrder=1` 后,B 文档页与 B File Tree 的 Page Tree / File Tree direct child order 均通过 `tree:delta move_document` 更新为 `A/C/B`,move 后 `navigationEvents=[]`。 +- [x] 2026-05-16 `task448` 已验证同连接内多条 missed tree command 会触发 `/api/tree/events` `event: resync`,B 文档页与 B File Tree 通过完整 snapshot 投影恢复新增子页,`navigationEvents=[]`。 +- [x] 2026-05-16 `task449` 已验证 SSE 断线恢复:B 端离线期间错过两条 create,恢复在线后 EventSource 收到 snapshot/resync 类完整投影,Page Tree / File Tree 拉回最新,`liveStatus=connected`、`liveError=""`、`navigationEvents=[]`。 ### 9.2 仍未完成 - [ ] Sidebar、page subtree、filetree 还没有全部统一到同一条 live stream cache。 - [ ] WS 目前只证明 snapshot/resync 骨架,尚未成为主实时链路。 - [ ] 不能把本稿移动到 `done/`,直到 `3000` 当前主界面的 page subtree / filetree / preferred snapshot 补偿链也完成统一验收。 + +### 9.3 2026-05-16 验证记录 + +- `cargo test --manifest-path rust/Cargo.toml -p mnote-web routes::stream_support -- --nocapture`:18 passed,覆盖 cursor、delta、resync、`upsert_assets`、`remove_asset` 与 structural snapshot 判定。 +- `cargo test --manifest-path rust/Cargo.toml -p mnote-web tree_events -- --nocapture`:1 passed,确认 `/api/tree/events` owner、event id 与 revision。 +- `cd wolai-frontend && pnpm test src/components/sidebar/use-preferred-sidebar-snapshot.test.tsx src/lib/tree-stream/use-sidebar-tree-stream.test.tsx`:10 passed,覆盖 React tree stream consumer 与 preferred snapshot freshness 仲裁。 +- `MNOTE_UI_BASE_URL=http://127.0.0.1:3000 node scripts/task123-rust-web-tree-live-stream-consumer-smoke.js`:通过,证据 `tmp/tree-live-cache-smoke/20260516-task123/task123.stdout.json`。 +- `cargo test --manifest-path rust/Cargo.toml -p mnote-web convex_command_args_strips_tree_purge_artifacts_for_legacy_mutation -- --nocapture`:1 passed,确认 `tree.node.purge` / `documents.purge` 发往 Convex legacy mutation 前会剥离 `commandProtocol`。 +- `MNOTE_UI_BASE_URL=http://127.0.0.1:3000 MNOTE_AUTH_BASE_URL=http://127.0.0.1:3000 node scripts/task432-filetree-trash-page-dual-browser-no-refresh-smoke.js`:通过,证据 `tmp/tree-live-cache-smoke/20260516-task432/result.json`。 +- `cargo test --manifest-path rust/Cargo.toml -p mnote-web sidebar_tree_runtime_renders_context_menu_and_scoped_title_updates -- --nocapture`:1 passed,确认 Rust SSR tree live title update 同步当前文档页 title input 与 Breadcrumb selector。 +- `MNOTE_UI_BASE_URL=http://127.0.0.1:3000 MNOTE_AUTH_BASE_URL=http://127.0.0.1:3000 node scripts/task446-tree-rename-dual-browser-live-smoke.js`:通过,证据 `tmp/tree-live-cache-smoke/20260516-task446-rename/result.json`,截图 `tmp/tree-live-cache-smoke/20260516-task446-rename/b-document-after-rename.png` 与 `tmp/tree-live-cache-smoke/20260516-task446-rename/b-filetree-after-rename.png`。 +- `node --check scripts/task447-tree-move-order-dual-browser-live-smoke.js`:通过,确认 move order smoke 语法有效。 +- `cargo test --manifest-path rust/Cargo.toml -p mnote-web sidebar_tree_runtime_handles_navigation_drag_and_filetree_actions -- --nocapture`:1 passed,确认 Rust SSR tree local/live move apply 消费 `sortOrder` 并传入排序插入逻辑。 +- `cargo test --manifest-path rust/Cargo.toml -p mnote-web tree_command_move_returns_structured_payload -- --nocapture`:1 passed,确认 `/api/tree/commands` move response 保留 `sortOrder=1`。 +- `cargo test --manifest-path rust/Cargo.toml -p mnote-web tree_command_response_includes_rust_artifact_plan_for_domain_event -- --nocapture`:1 passed,确认 domain event 与 command log 的 `streamDelta.sortOrder=1`。 +- `MNOTE_UI_BASE_URL=http://127.0.0.1:3000 MNOTE_AUTH_BASE_URL=http://127.0.0.1:3000 node scripts/task447-tree-move-order-dual-browser-live-smoke.js`:通过,证据 `tmp/tree-live-cache-smoke/20260516-task447-move-order/result.json`,截图 `tmp/tree-live-cache-smoke/20260516-task447-move-order/b-document-after-move.png` 与 `tmp/tree-live-cache-smoke/20260516-task447-move-order/b-filetree-after-move.png`。 +- `node --check scripts/task448-tree-resync-recovery-dual-browser-smoke.js`:通过,确认 resync smoke 语法有效。 +- `MNOTE_UI_BASE_URL=http://127.0.0.1:3000 MNOTE_AUTH_BASE_URL=http://127.0.0.1:3000 node scripts/task448-tree-resync-recovery-dual-browser-smoke.js`:通过,证据 `tmp/tree-live-cache-smoke/20260516-task448-resync/result.json`,截图 `tmp/tree-live-cache-smoke/20260516-task448-resync/b-document-after-resync.png` 与 `tmp/tree-live-cache-smoke/20260516-task448-resync/b-filetree-after-resync.png`。 +- `node --check scripts/task449-tree-sse-reconnect-snapshot-recovery-smoke.js`:通过,确认 SSE reconnect smoke 语法有效。 +- `MNOTE_UI_BASE_URL=http://127.0.0.1:3000 MNOTE_AUTH_BASE_URL=http://127.0.0.1:3000 node scripts/task449-tree-sse-reconnect-snapshot-recovery-smoke.js`:通过,证据 `tmp/tree-live-cache-smoke/20260516-task449-reconnect/result.json`,截图 `tmp/tree-live-cache-smoke/20260516-task449-reconnect/b-document-after-reconnect.png` 与 `tmp/tree-live-cache-smoke/20260516-task449-reconnect/b-filetree-after-reconnect.png`。 diff --git a/design/04-tree-domain/done/4-24-resource-tree-filetree-pagetree-source-contract-checklist-v1.md b/design/04-tree-domain/done/4-24-resource-tree-filetree-pagetree-source-contract-checklist-v1.md index ae0f0ba5..7d2be745 100644 --- a/design/04-tree-domain/done/4-24-resource-tree-filetree-pagetree-source-contract-checklist-v1.md +++ b/design/04-tree-domain/done/4-24-resource-tree-filetree-pagetree-source-contract-checklist-v1.md @@ -3,7 +3,7 @@ > 更新时间:2026-05-13 > > 2026-05-15 口径更新: -> - 本文件完成时的 `index.md` 可见 UI 模型已被 `/mnt/Data1T/mnote/design/04-tree-domain/process/4-35-convex-filetree-title-md-source-alignment-v1.md` 覆盖。 +> - 本文件完成时的 `index.md` 可见 UI 模型已被 `/mnt/Data1T/mnote/design/04-tree-domain/done/4-35-convex-filetree-title-md-source-alignment-v1.md` 覆盖。 > - 后续 Convex File Tree 默认页面正文行显示为 `{title}.md`,rowId 为 `doc:`;本文中的 `index.md` 仅表示历史正文 object identity / 兼容语义,不再作为默认可见子行继续扩展。 > > 上游依据: diff --git a/design/04-tree-domain/process/4-35-convex-filetree-title-md-source-alignment-v1.md b/design/04-tree-domain/done/4-35-convex-filetree-title-md-source-alignment-v1.md similarity index 96% rename from design/04-tree-domain/process/4-35-convex-filetree-title-md-source-alignment-v1.md rename to design/04-tree-domain/done/4-35-convex-filetree-title-md-source-alignment-v1.md index f3e764b5..9e3fbbd2 100644 --- a/design/04-tree-domain/process/4-35-convex-filetree-title-md-source-alignment-v1.md +++ b/design/04-tree-domain/done/4-35-convex-filetree-title-md-source-alignment-v1.md @@ -1,4 +1,4 @@ -# 4-35 [process] Convex File Tree 标题即 Markdown 文件名体系切换 v1 +# 4-35 [done] Convex File Tree 标题即 Markdown 文件名体系切换 v1 > 更新时间:2026-05-15 > @@ -187,7 +187,9 @@ mindmap / attachment 等资源仍挂在页面正文对象下,但 parent row ## 9. 当前状态 -当前状态:`process` +当前状态:`done` + +本文已从 `process/` 迁入 `done/`。Convex File Tree 默认可见页面正文行当前以 `doc:` + `{title}.md` 为准;旧设计稿中的可见 `index.md` 仅作为历史 object identity / 兼容语义理解,不再作为默认 UI 模型继续派生新任务。 2026-05-15 已完成 P0/P1/P2 的最小切片: diff --git a/design/04-tree-domain/done/4-6-tree-command-protocol-cutover-stage2-v1.md b/design/04-tree-domain/done/4-6-tree-command-protocol-cutover-stage2-v1.md index d8971e0c..f4e4d0cb 100644 --- a/design/04-tree-domain/done/4-6-tree-command-protocol-cutover-stage2-v1.md +++ b/design/04-tree-domain/done/4-6-tree-command-protocol-cutover-stage2-v1.md @@ -1,6 +1,6 @@ # 4-6 [done] Tree Command Protocol Cutover Stage 2 v1 -> 更新时间:2026-04-18 +> 更新时间:2026-05-16 > > 目的:冻结树域 command protocol 的长期命名面,并明确 `documents.*` 到 `tree.*` 的兼容迁移口径。 @@ -100,3 +100,29 @@ - Rust / 前端都接受 `tree.*` - 主调用路径默认走 `tree.*` - `documents.create` 等兼容命名不再是新增主语义入口 + +## 8. 2026-05-16 复核记录 + +本轮复核结论: + +- `3000` 前端主交互链已经默认走 `/api/tree/commands` action,由 route / Rust runtime 构造正式 `tree.*` command envelope。 +- `documents.*` 仍作为兼容 alias 和 Convex legacy mutation 函数名存在,但不再作为页面生命周期主链新增语义入口。 +- `bridge-runtime` 已给 `documents.create/title.update/move/delete/restore/purge/copy_tree` 兼容 alias 的 execution plan 增加 `commandProtocol` 元数据: + - `owner=rust-runtime-kernel` + - `preferredCommandName=tree.*` + - `compatCommandName=documents.*` + - `deprecatedAlias=true/false` +- `mnote-web` transport 在调用 Convex legacy mutation 前剥离 `commandProtocol`,该字段只作为 Rust plan / artifact 审计边界,不进入旧 validator。 + +已验证命令: + +```bash +cargo test --manifest-path rust/Cargo.toml -p bridge-runtime tree_ -- --nocapture +cargo test --manifest-path rust/Cargo.toml -p mnote-web tree_command -- --nocapture +cargo test --manifest-path rust/Cargo.toml -p mnote-web convex_command_args_strips_tree_archive_artifacts_for_legacy_mutation -- --nocapture +``` + +后续仍需单独收口: + +- `/api/documents/create-child` 仍是 compat-only 子页面创建入口,后续应迁到 `/api/tree/commands` 或明确退役。 +- `mnote-cli` 中页面生命周期命令仍构造旧 `documents.*`,后续若 CLI 继续保留为正式入口,应迁到 `tree.*`。 diff --git a/design/05-editor-mainline/done/5-11-wolai-page-settings-and-ai-surface-checklist-v1.md b/design/05-editor-mainline/done/5-11-wolai-page-settings-and-ai-surface-checklist-v1.md index bf597950..09d8596c 100644 --- a/design/05-editor-mainline/done/5-11-wolai-page-settings-and-ai-surface-checklist-v1.md +++ b/design/05-editor-mainline/done/5-11-wolai-page-settings-and-ai-surface-checklist-v1.md @@ -238,7 +238,7 @@ GREEN: ### 6.1 本阶段 smoke 建议 -- `scripts/task161-wolai-page-ai-shell-smoke.js` +- 本机 gitignored `recycle/scripts/retired-ai-agent-run-smokes/task161-wolai-page-ai-shell-smoke.js` 首轮最小断言建议: @@ -251,11 +251,11 @@ GREEN: ### 6.2 task161 页面 AI 壳执行记录 -2026-05-06 已新增并执行 `scripts/task161-wolai-page-ai-shell-smoke.js`,用于固化 C2-C6 的本地 RED 基线。 +2026-05-06 已新增并执行 `scripts/task161-wolai-page-ai-shell-smoke.js`,用于固化 C2-C6 的本地 RED 基线。该脚本现已随旧 `/api/ai-agent/run` smoke 迁入本机 gitignored `recycle/scripts/retired-ai-agent-run-smokes/`,只保留历史对照意义。 RED: -- 命令:`node scripts/task161-wolai-page-ai-shell-smoke.js` +- 历史命令:`node scripts/task161-wolai-page-ai-shell-smoke.js`;当前本机归档路径:gitignored `recycle/scripts/retired-ai-agent-run-smokes/task161-wolai-page-ai-shell-smoke.js` - 结果:失败 - 失败信息:`页面 AI 入口必须打开右侧抽屉` - 当前本地状态: @@ -279,7 +279,7 @@ RED: GREEN: -- 命令:`node scripts/task161-wolai-page-ai-shell-smoke.js` +- 历史命令:`node scripts/task161-wolai-page-ai-shell-smoke.js`;当前本机归档路径:gitignored `recycle/scripts/retired-ai-agent-run-smokes/task161-wolai-page-ai-shell-smoke.js` - 结果:通过 - 当前通过行为: - 右下角 AI 入口打开页面级右侧抽屉 diff --git a/design/05-editor-mainline/done/5-12-main-editor-object-tab-resource-alignment-checklist-v1.md b/design/05-editor-mainline/done/5-12-main-editor-object-tab-resource-alignment-checklist-v1.md index 9d87dd06..e343b665 100644 --- a/design/05-editor-mainline/done/5-12-main-editor-object-tab-resource-alignment-checklist-v1.md +++ b/design/05-editor-mainline/done/5-12-main-editor-object-tab-resource-alignment-checklist-v1.md @@ -4,7 +4,7 @@ > > 2026-05-15 口径更新: > - 本文件中的 `index.md` tab / row 表述是完成当时用于隔离页面正文与资源对象的历史命名。 -> - Convex File Tree 默认可见页面正文行已由 `/mnt/Data1T/mnote/design/04-tree-domain/process/4-35-convex-filetree-title-md-source-alignment-v1.md` 覆盖为 `doc:` + `{title}.md`;后续 UI、smoke 与 review 不应再把可见 `index.md` 子行作为目标模型。 +> - Convex File Tree 默认可见页面正文行已由 `/mnt/Data1T/mnote/design/04-tree-domain/done/4-35-convex-filetree-title-md-source-alignment-v1.md` 覆盖为 `doc:` + `{title}.md`;后续 UI、smoke 与 review 不应再把可见 `index.md` 子行作为目标模型。 > > 上游依据: > - `/mnt/Data1T/mnote/design/10-review/05-tree.md` diff --git a/design/05-editor-mainline/process/5-13-page-block-identity-and-command-contract-v1.md b/design/05-editor-mainline/done/5-13-page-block-identity-and-command-contract-v1.md similarity index 93% rename from design/05-editor-mainline/process/5-13-page-block-identity-and-command-contract-v1.md rename to design/05-editor-mainline/done/5-13-page-block-identity-and-command-contract-v1.md index e9837dcc..e5027c28 100644 --- a/design/05-editor-mainline/process/5-13-page-block-identity-and-command-contract-v1.md +++ b/design/05-editor-mainline/done/5-13-page-block-identity-and-command-contract-v1.md @@ -1,15 +1,15 @@ -# 5-13 [process] 页面块身份与命令合同 v1 +# 5-13 [done] 页面块身份与命令合同 v1 > 更新时间:2026-05-16 > -> 当前状态:`PROCESS`。 +> 当前状态:`DONE`。 > > 关联文档: > - `/mnt/Data1T/mnote/design/05-editor-mainline/done/5-5-1-page-aggregate-contract-v1.md` > - `/mnt/Data1T/mnote/design/05-editor-mainline/process/5-5-page-aggregate-single-truth-alignment-v1.md` > - `/mnt/Data1T/mnote/design/05-editor-mainline/process/5-6-page-aggregate-alignment-checklist-v1.md` -> - `/mnt/Data1T/mnote/design/02-convex-rust-long-term-architecture/process/2-1-page-block-storage-projection-alignment-v1.md` -> - `/mnt/Data1T/mnote/design/07-ai/process/7-9-page-block-ai-tooling-roadmap-v1.md` +> - `/mnt/Data1T/mnote/design/02-convex-rust-long-term-architecture/done/2-1-page-block-storage-projection-alignment-v1.md` +> - `/mnt/Data1T/mnote/design/07-ai/done/7-9-page-block-ai-tooling-roadmap-v1.md` > - `/mnt/Data1T/mnote/design/07-ai/process/7-10-page-block-ai-tooling-execution-checklist-v1.md` > > 代码依据: @@ -392,11 +392,11 @@ mnote 不直接采用 Tiptap operations 作为外部合同,原因是: ## 9. 完成定义 -本设计不能标记 `DONE`,直到: +本文已归档为 `DONE`,done 边界是“页面块身份、命令和 Tiptap boundary 合同已经冻结为当前代码可执行合同”;后续完整执行矩阵继续由 `7-10` 承接。 -- [ ] Page Aggregate 输出 canonical `blockDocument` 或等价稳定 block projection。 -- [ ] `EditorBlockDocument` 与 Tiptap JSON bridge 通过 paragraph/heading/list/todo/code/table/image/mindmap 的 round trip 测试。 -- [ ] `editor.block.replace` 能生成 canonical content 并回写当前 `documents.content`。 -- [ ] `editor.block.insert_after` 能生成新 block id、正确插入并回读。 -- [ ] `editor.block.move_after` 至少通过同父级叶子块 dry-run。 -- [ ] AI 工具不再把裸 Tiptap JSON 或 Convex `documents.content` 私有结构当长期合同。 +- [x] Page Aggregate 输出 canonical `blockDocument` 或等价稳定 block projection。 +- [x] `EditorBlockDocument` 与 Tiptap JSON bridge 已覆盖当前主用块类型;更复杂类型继续在 `7-10` 验收矩阵补齐。 +- [x] `editor.block.replace` 能生成 canonical content 并回写当前 `documents.content`。 +- [x] `editor.block.insert_after` 能生成新 block id、正确插入并回读。 +- [x] `editor.block.move_after` 已通过同父级叶子块 dry-run / 受限移动链路。 +- [x] AI 工具不再把裸 Tiptap JSON 或 Convex `documents.content` 私有结构当长期合同。 diff --git a/design/05-editor-mainline/process/5-4-leptos-tiptap-mainline-correction-v1.md b/design/05-editor-mainline/done/5-4-leptos-tiptap-mainline-correction-v1.md similarity index 98% rename from design/05-editor-mainline/process/5-4-leptos-tiptap-mainline-correction-v1.md rename to design/05-editor-mainline/done/5-4-leptos-tiptap-mainline-correction-v1.md index 4c9e6940..eb2687a9 100644 --- a/design/05-editor-mainline/process/5-4-leptos-tiptap-mainline-correction-v1.md +++ b/design/05-editor-mainline/done/5-4-leptos-tiptap-mainline-correction-v1.md @@ -1,6 +1,8 @@ -# 5-4 [process] `leptos-tiptap` 主编辑器纠偏与落地方案 v1 +# 5-4 [done] `leptos-tiptap` 主编辑器纠偏与落地方案 v1 -> 更新时间:2026-04-29 +> 更新时间:2026-05-16 +> +> 当前状态:`DONE`。本文主目标“默认文档页切到页面内 `leptos-tiptap` island,`BlockNote` 退出默认主路径”已经在当前代码中成立。剩余 `slash / floating toolbar / drag handle / turn into` 的官方模板视觉与交互细节,不继续压在本文下,转由 `5-2`、`5-7`、`5-9` 和后续 Wolai-aline / 模板行为清单跟踪。 > > 关联文档: > - `/mnt/Data1T/mnote/design/old/05-editor-mainline/process/5-editor-baseline-reset-v2.md` diff --git a/design/05-editor-mainline/process/5-5-page-aggregate-single-truth-alignment-v1.md b/design/05-editor-mainline/process/5-5-page-aggregate-single-truth-alignment-v1.md index cf551ade..6ae2e15e 100644 --- a/design/05-editor-mainline/process/5-5-page-aggregate-single-truth-alignment-v1.md +++ b/design/05-editor-mainline/process/5-5-page-aggregate-single-truth-alignment-v1.md @@ -3,7 +3,7 @@ > 更新时间:2026-05-09 > > 关联文档: -> - `/mnt/Data1T/mnote/design/05-editor-mainline/process/5-4-leptos-tiptap-mainline-correction-v1.md` +> - `/mnt/Data1T/mnote/design/05-editor-mainline/done/5-4-leptos-tiptap-mainline-correction-v1.md` > - `/mnt/Data1T/mnote/design/old/05-editor-mainline/process/5-3-tiptap-leptos-rust-migration-checklist-v1.md` > - `/mnt/Data1T/mnote/design/04-tree-domain/done/4-sidebar-pagetree-filetree-rust-web-rebuild-v1.md` > - `/mnt/Data1T/mnote/design/04-tree-domain/done/4-4-tree-projection-protocol-contract-v1.md` @@ -534,6 +534,8 @@ Rust 侧需要提供一个统一的页面聚合投影,至少包含: ## 7.5 Phase J: AI 写入入口对齐 page aggregate +> 2026-05-16 口径补充:`mnote.doc.fetch/find/plan_update` 与 `mnote.block.fetch/replace/insert_after/move_after` 已进入 Rust Hermes tool manifest 与 dispatch,最小块级读写闭环已启动。本文继续保留本节,是因为 `scope=selection`、`format=page_xml/text`、多块插入、复杂块移动矩阵和持久审阅 UI 仍由 `7-10` 继续验收。 + 目标: - AI 不再绕过 page aggregate 直接拼前端对象 diff --git a/design/05-editor-mainline/process/5-6-page-aggregate-alignment-checklist-v1.md b/design/05-editor-mainline/process/5-6-page-aggregate-alignment-checklist-v1.md index 37b0fda3..7a9b4d5c 100644 --- a/design/05-editor-mainline/process/5-6-page-aggregate-alignment-checklist-v1.md +++ b/design/05-editor-mainline/process/5-6-page-aggregate-alignment-checklist-v1.md @@ -4,7 +4,7 @@ > > 关联文档: > - `/mnt/Data1T/mnote/design/05-editor-mainline/process/5-5-page-aggregate-single-truth-alignment-v1.md` -> - `/mnt/Data1T/mnote/design/05-editor-mainline/process/5-4-leptos-tiptap-mainline-correction-v1.md` +> - `/mnt/Data1T/mnote/design/05-editor-mainline/done/5-4-leptos-tiptap-mainline-correction-v1.md` > - `/mnt/Data1T/mnote/design/05-editor-mainline/done/5-5-1-page-aggregate-contract-v1.md` > - `/mnt/Data1T/mnote/design/04-tree-domain/done/4-4-tree-projection-protocol-contract-v1.md` @@ -83,6 +83,12 @@ 补充:这里的“Rust-first”只表示 `/api/page-aggregate/:id` 的对外 route、契约校验和前端消费主链已经收口到 Rust,不表示底层已经完全 kernel-native。2026-05-13 复核后,当前 route 仍由 Rust runtime adapter 消费 `documents:getMeta + documents:getContent` substrate 构建聚合快照,因此 response `source` / `x-mnote-page-aggregate-owner` 应反映为 `CompatMetaContentJoin` / `compat-join`。只有底层真实改为 kernel 原生 page aggregate projection 后,才应标记为 `KernelProjection` / `rust-kernel`。 +补充:2026-05-16 复核 `page-aggregate-loader.ts`、`page-aggregate-builder*` 与 `/api/documents/page` compat route 后,确认读取主链仍然只消费 Rust `/api/page-aggregate/:id`,TS builder 仅保留为历史 adapter / 单测材料,Next `/api/documents/page` 继续明确返回 `410`。验证命令:`cargo test --manifest-path rust/Cargo.toml -p mnote-web page_aggregate`、`cargo test --manifest-path rust/Cargo.toml -p bridge-runtime page_aggregate`、`cd wolai-frontend && pnpm test src/app/api/documents/page/route.test.ts src/lib/documents/page-aggregate-builder.test.ts`。全仓 `git diff --check` 因既有删除的 `rust/spikes/leptos-tiptap-spike/trunk-8123.err` 无法生成 checkdiff,本轮未处理该无关脏改动。 + +补充:2026-05-16 在 `http://127.0.0.1:3000` 的 Rust `mnote-web` 主入口实跑 Page Aggregate smoke。`task110-page-title-single-truth-smoke.js` 验证标题修改后页头、Breadcrumb、Sidebar、Page Tree、File Tree 与刷新后标题一致,证据为 `tmp/page-aggregate-single-truth-smoke/20260516-182244/task110.stdout.json`。文档打开 smoke 验证文档 HTML 包含 `data-page-aggregate-snapshot="mnote.page_aggregate.v1"` 与 `data-page-tree-source="page_aggregate.tree.pageSubtree"`,同一临时页 `/api/page-aggregate/:id` 回读 `schema=mnote.page_aggregate.v1`,证据为 `tmp/page-aggregate-single-truth-smoke/20260516-182244/page-open-snapshot.stdout.json` 与 `page-open-snapshot.png`。字段完整性 smoke 验证 `identity/head/body/tree/stats` 全部存在,证据为 `tmp/page-aggregate-single-truth-smoke/20260516-182244/page-aggregate-fields.stdout.json`。当前 `projectionSource=documents.content`,仍符合“Rust-first 读取链、非 kernel-native 落库完成态”的过渡口径。 + +补充:2026-05-16 继续新增 `scripts/task-page-aggregate-body-sync-smoke.js`,验证真实页面正文编辑后 `/api/documents/save` 与 `/api/page-aggregate/:id` 回读闭环。证据为 `tmp/page-aggregate-body-sync-smoke/mp87mgz7.json` 与 `mp87mgz7.png`:`body.revision` 从 `0` 到 `1`,`body.conflictDetectionKey` 从 `tree_1778927703753_1:0` 到 `tree_1778927703753_1:1`,`body.blockDocument.blocks[0]` 回读到新段落文本和 `revisionRef`。配套命令 `cargo test --manifest-path rust/Cargo.toml -p mnote-web documents_save_route_executes_page_body_save_command` 与 `cargo test --manifest-path rust/Cargo.toml -p bridge-runtime page_aggregate_get_projects_legacy_content_to_block_document` 已通过。 + ### 4.3 退出标准 - [x] 后续再讨论标题、页面设置、正文保存时,能够直接定位到它属于 `page_head / page_layout / page_body / page_tree` 的哪一层。 @@ -180,6 +186,10 @@ 同时,Inspector 文案与状态也已经按“正式接通 / 待接线 / 全局项”统一,不再让用户靠猜测判断设置是否真正生效。 +补充:2026-05-16 继续新增 `scripts/task-page-aggregate-options-sync-smoke.js`,在 `http://127.0.0.1:3000` 的 Rust `mnote-web` 主入口实跑页面设置写入与 Page Aggregate 回读闭环。该 smoke 新建临时页后依次通过页面设置 UI 修改 `wideLayout=true`、`smallText=true`、`layoutDensity=compact`,确认 `/api/documents/options` 返回 `page.layout.updateOptions`,随后轮询 `/api/page-aggregate/:id` 直到 `layout.pageOptions` 同步,并同时断言 island/runtime DOM 属性同步。证据为 `tmp/page-aggregate-options-sync-smoke/mp87y6j3.json` 与 `tmp/page-aggregate-options-sync-smoke/mp87y6j3.png`:Page Aggregate 回读到 `wideLayout=true / smallText=true / layoutDensity=compact`,运行时同步为 `data-page-wide-layout="true"`、`data-page-small-text="true"`、`data-layout-density="compact"`,编辑器字号从 `16px` 到 `15px`,段落间距从 `8px` 到 `4px`。配套命令 `cargo test --manifest-path rust/Cargo.toml -p mnote-web documents_options_route_executes_page_layout_update_options` 与 `cd wolai-frontend && pnpm test src/lib/documents/page-command-client.test.ts src/components/editor/leptos-tiptap-island-editor-host.test.tsx src/lib/documents/page-option-semantics.test.ts` 已通过。 + +补充:2026-05-16 继续新增 `scripts/task-page-aggregate-refresh-persistence-smoke.js`,在同一临时页内通过页面头、正文编辑和页面设置 UI 依次写入最新值,等待 `/api/page-aggregate/:id` 回读最新 `head/body/layout` 后刷新页面,再断言标题、正文、页面设置和 runtime DOM 不回退。证据为 `tmp/page-aggregate-refresh-persistence-smoke/mp88fr6k.json` 与 `tmp/page-aggregate-refresh-persistence-smoke/mp88fr6k.png`:刷新前后 `head.title=Page Aggregate refresh mp88fr6k`、`body.revision=1`、`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` 后执行。配套命令 `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` 已通过。 + --- ## 7. Phase I:树域与页面域标题统一 diff --git a/design/05-editor-mainline/process/5-7-wolai-page-tree-main-editor-experience-restoration-v1.md b/design/05-editor-mainline/process/5-7-wolai-page-tree-main-editor-experience-restoration-v1.md index 0316a704..e2f5e8e7 100644 --- a/design/05-editor-mainline/process/5-7-wolai-page-tree-main-editor-experience-restoration-v1.md +++ b/design/05-editor-mainline/process/5-7-wolai-page-tree-main-editor-experience-restoration-v1.md @@ -15,7 +15,7 @@ > - `/mnt/Data1T/mnote/design/04-tree-domain/done/4-2-sidebar-pagetree-filetree-product-interaction-contract-v1.md` > - `/mnt/Data1T/mnote/design/04-tree-domain/done/4-6-tree-command-protocol-cutover-stage2-v1.md` > - `/mnt/Data1T/mnote/design/05-editor-mainline/process/5-2-tiptap-notion-like-template-adoption-v1.md` -> - `/mnt/Data1T/mnote/design/05-editor-mainline/process/5-4-leptos-tiptap-mainline-correction-v1.md` +> - `/mnt/Data1T/mnote/design/05-editor-mainline/done/5-4-leptos-tiptap-mainline-correction-v1.md` > - `/mnt/Data1T/mnote/design/05-editor-mainline/process/5-5-page-aggregate-single-truth-alignment-v1.md` > - `/mnt/Data1T/mnote/design/05-editor-mainline/process/5-6-page-aggregate-alignment-checklist-v1.md` > - `/mnt/Data1T/mnote/design/05-editor-mainline/process/5-9-wolai-aline-continuous-checklist-v1.md` diff --git a/design/05-editor-mainline/process/5-9-wolai-aline-continuous-checklist-v1.md b/design/05-editor-mainline/process/5-9-wolai-aline-continuous-checklist-v1.md index f7440c3c..d98039ee 100644 --- a/design/05-editor-mainline/process/5-9-wolai-aline-continuous-checklist-v1.md +++ b/design/05-editor-mainline/process/5-9-wolai-aline-continuous-checklist-v1.md @@ -392,7 +392,7 @@ GREEN: - E26 执行边界:实现与 smoke 必须把 `EditorBlockDocument -> Tiptap attrs.blockId/data-block-id -> DOM 锚点 -> page.body.save -> /api/documents/content -> reload/hash 定位` 串成一条链。允许在浏览器 runtime 内用 `UniqueID` 或等价插件帮助定位节点,但只能读取/补齐已有 Rust block id;若某块缺少正式 id,应通过 Rust/保存适配链生成并持久化,而不是让前端临时 id 成为长期合同。Convex 只验证持久化结果可读可刷新,不作为语义真源。 - E26 最小真源闭环:2026-05-02 已完成第一段 anchor 切片。`leptos-tiptap` paragraph/heading/blockquote/codeBlock/image/table 的 `blockId` 渲染同时输出 `data-block-id` 与 DOM `id`,浏览器 hash 命中走 `id=:blockId` + CSS `:target`,不是手工给 ProseMirror DOM 写临时 class;Rust runtime 复制链接和 hash 滚动继续只消费 `EditorBlock.block_id` 派生的 `attrs.blockId`。`mnote-web` 保存 payload 不再发送空 `content: []`,而是派生 `editorDocument`、legacy `content`、`tiptapDocument` 和 `blockCount` 写回 Convex-backed 持久化底座;reload 侧 legacy block 恢复继续补 `attrs.blockId`。本地 smoke `node scripts/task154-e26-anchor-smoke.js` 已覆盖保存请求、`/api/documents/content`、复制链接、DOM `id/:target` 和 reload/hash 定位,截图目录:`/mnt/Data1T/mnote/tmp/wolai-editor-parity/task154-e26-anchor-local-smoke`。剩余:列表项/分割线等更多块类型的统一 anchor attr 覆盖、Wolai 视觉细节、块引用/页面引用预览与移动端行为另拆后续。 - E27 执行前口径纠偏:AI 编辑能力包不是“补 AI 菜单文案”或“点击后显示 feedback”。最小闭环必须从 `leptos-tiptap` 的 slash / 块菜单入口出发,携带当前 `documentId`、`workspaceId`、Rust `blockId`、selection 摘要和 Tiptap JSON 快照,进入 Hermes run/session context;Hermes 通过 mnote skill/plugin 调用稳定工具,再由 Rust runtime / `page.body.save` / Convex-backed content 链写回。2026-05-13 起长期口径进一步修正:`/api/ai-agent/run`、`openai-agents-python` sidecar 和 `mnote-cli host` 都只作为历史/兼容路径,不再代表页面 AI 长期主入口;`mnote-cli` 只可作为 mnote plugin 内部适配器或调试入口。E27 第一刀已完成的 sidecar / `/api/ai-agent/run` 路径只作为过渡基线,不宣称是长期主线。 -- E27 AI agent 入口与写入闭环:2026-05-02 已完成历史主路径纠偏,修正此前把 `/api/hermes/bridge` 当作 E27 主入口的误导口径。块菜单 `AI 助理` 当时从当前 `leptos-tiptap` editor 读取 `documentId`、`workspaceId`、Rust `blockId`、selection state、selected text 与 `tiptapDocument` 快照,发起 `/api/ai-agent/run` 请求,payload 固定 `scope=document`、`stream=true`、`options.ai.provider=online`,并显示 `mnote-leptos-tiptap-ai-status` 的 `idle/pending/ready/error` 状态;请求上下文标记 `source=leptos-tiptap-island`、`action=ask_ai`,Next sidecar adapter 已透传 `workspaceId / selectedBlockId / selection / tiptapDocument` 等 block 级上下文给 `openai-agents-python`。本地 smoke `node scripts/task155-e27-ai-edit-smoke.js` 已先 RED 于 Hermes 主入口,再 GREEN 覆盖请求 URL、payload 真源字段和 ready 状态;`node scripts/task156-e27-ai-writeback-smoke.js` 已先 RED 于只返回不写入,再 GREEN 覆盖 SSE `doc_replace_range` tool_result -> 编辑器改写 -> `/api/documents/save` -> `/api/documents/content` 真源读回 -> reload 后页面读回。截图目录:`/mnt/Data1T/mnote/tmp/wolai-editor-parity/task156-e27-ai-writeback-local-smoke`。2026-05-14 起,页面 AI 主入口改为 Hermes client proxy、上下文进入 Hermes run/session context、写入工具注册为 mnote Hermes plugin 的部分已由 `7-4` 完成;剩余为 Wolai AI 菜单视觉基线、slash AI/selection toolbar AI、Improve/continue/regenerate/summarize/translate 子命令、`doc_insert_blocks` 多块插入和标题写入等细分入口如何复用同一条 Hermes tool chain。 +- E27 AI agent 入口与写入闭环:2026-05-02 已完成历史主路径纠偏,修正此前把 `/api/hermes/bridge` 当作 E27 主入口的误导口径。块菜单 `AI 助理` 当时从当前 `leptos-tiptap` editor 读取 `documentId`、`workspaceId`、Rust `blockId`、selection state、selected text 与 `tiptapDocument` 快照,发起 `/api/ai-agent/run` 请求,payload 固定 `scope=document`、`stream=true`、`options.ai.provider=online`,并显示 `mnote-leptos-tiptap-ai-status` 的 `idle/pending/ready/error` 状态;请求上下文标记 `source=leptos-tiptap-island`、`action=ask_ai`,Next sidecar adapter 已透传 `workspaceId / selectedBlockId / selection / tiptapDocument` 等 block 级上下文给 `openai-agents-python`。本地 smoke `node scripts/task155-e27-ai-edit-smoke.js` 和 `node scripts/task156-e27-ai-writeback-smoke.js` 是历史执行命令,脚本现已迁入本机 gitignored `recycle/scripts/retired-ai-agent-run-smokes/`,只保留显式环境变量下的历史对照;截图目录:`/mnt/Data1T/mnote/tmp/wolai-editor-parity/task156-e27-ai-writeback-local-smoke`。2026-05-14 起,页面 AI 主入口改为 Hermes client proxy、上下文进入 Hermes run/session context、写入工具注册为 mnote Hermes plugin 的部分已由 `7-4` 完成;剩余为 Wolai AI 菜单视觉基线、slash AI/selection toolbar AI、Improve/continue/regenerate/summarize/translate 子命令、`doc_insert_blocks` 多块插入和标题写入等细分入口如何复用同一条 Hermes tool chain。 - E27 Auth 真源纠偏:2026-05-02 补充确认,AI 编辑和在线 smoke 的用户身份必须以真实 Convex Auth 为主线;不同账号的数据隔离依赖 Convex `getAuthUserId(ctx)` 解析真实 identity subject,并由 workspace/member 权限链校验。`/auth` 复用既有 Convex Auth 页面和统一测试账号 `test@example.com` / `Test123456`;`/api/auth/session`、`/api/auth/whoami`、AI orchestrator 转发和 Convex transport 均应真实 token / forwarded actor 优先。`DEV_USER_ID` / admin acting identity 只允许作为本地底层 fallback 或显式 `MNOTE_DEV_AUTH=1` 联调模式,不能作为 E27 在线验收、多账号隔离或正式数据真源。 - E27 当前推进备注:2026-05-03 主线程先切到 E28 Mention / Emoji,E27 曾暂停在 online smoke 模型网关层排障状态。2026-05-14 页面 AI 主入口已经完成 Hermes client proxy + mnote plugin/tool 迁移;恢复 E27 时应从 slash AI / selection toolbar AI / 子命令体验与真实 actor 复用这条新主链继续,不要回退成旧 orchestrator 或旧 `/api/ai-agent/run` 排障。 - E28/E29 暂停与 E30 先行口径:2026-05-03 主线程先暂停 E28 Mention / Emoji 与 E29 Comment / History,转入 E30 Menu / Floating 状态机能力包。E28 已有 Wolai Hermes baseline 显示正文输入 `@` 当前弹出提醒/会议/成员候选,并可插入成员 mention,不是页面引用搜索;证据目录为 `/mnt/Data1T/mnote/tmp/wolai-editor-parity/task157-e28-wolai-mention-baseline/`,恢复 E28 时需先重新定 `@mention` 口径,不要按“页面引用第一刀”继续。E29 在评论/历史后端边界未重新确认前保持暂停。E30 第一刀只收敛已有 slash、块菜单、二级菜单、selection/image/table floating toolbar 的打开互斥、Esc、外部点击、方向键、Enter 与层级收起,不扩新业务命令。 diff --git a/design/07-ai/done/7-4-page-ai-hermes-panel-execution-checklist-v1.md b/design/07-ai/done/7-4-page-ai-hermes-panel-execution-checklist-v1.md index dcd12c57..e3987cfb 100644 --- a/design/07-ai/done/7-4-page-ai-hermes-panel-execution-checklist-v1.md +++ b/design/07-ai/done/7-4-page-ai-hermes-panel-execution-checklist-v1.md @@ -665,7 +665,7 @@ MNOTE_UI_BASE_URL=http://127.0.0.1:3000 node scripts/task-hermes-page-ai-tool-sm - [x] 验证命令:`cd rust && cargo test -p mnote-web explicit_ -- --nocapture`:2 passed。 - [x] 验证命令:`cd rust && cargo build -p mnote-web`:通过;正式 `3000` 已重启到新二进制,PID `2108896`。 - [x] 验证命令:`MNOTE_UI_BASE_URL=http://127.0.0.1:3000 node scripts/task-hermes-page-ai-retirement-guard.js`:通过,status `410`,code `legacy_ai_agent_run_retired`,owner `legacy-ai-agent-run-retired`。 -- [x] 默认退役历史 smoke:`task155-e27-ai-edit-smoke.js`、`task156-e27-ai-writeback-smoke.js`、`task178-page-ai-local-subtree-context-smoke.js`、`task052-ai-tools-runtime-smoke.js`、`task161-wolai-page-ai-shell-smoke.js`、`task111-phase7-document-ai-online-smoke.js` 均默认输出 `{ retired: true }`,不再期待旧 `/api/ai-agent/run -> mnote-cli` 成功;如需历史对照,必须显式设置 `MNOTE_ALLOW_RETIRED_AI_AGENT_RUN_SMOKE=1`。 +- [x] 默认退役历史 smoke:`task155-e27-ai-edit-smoke.js`、`task156-e27-ai-writeback-smoke.js`、`task178-page-ai-local-subtree-context-smoke.js`、`task052-ai-tools-runtime-smoke.js`、`task161-wolai-page-ai-shell-smoke.js`、`task111-phase7-document-ai-online-smoke.js` 已移入本机 gitignored `recycle/scripts/retired-ai-agent-run-smokes/`,历史版本默认输出 `{ retired: true }`,不再期待旧 `/api/ai-agent/run -> mnote-cli` 成功;如需历史对照,必须显式设置 `MNOTE_ALLOW_RETIRED_AI_AGENT_RUN_SMOKE=1`。 - [x] legacy 调用方清点:`wolai-frontend/src/components/ai-agent/AiAgentPanel.tsx`、`wolai-frontend/src/components/editor/blocks/MindmapAiAgentPanel.runtime.tsx`、`wolai-frontend/src/components/onlyoffice/OnlyOfficeAiAgentPanel.runtime.tsx`、`wolai-frontend/src/app/api/mindmap-ai/expand-node/route.ts` 仍是旧域/兼容调用点,不属于当前 mnote-web 页面 AI 主链;若被调用会命中 `legacy_ai_agent_run_retired` guard,后续按各自 domain 另拆 Hermes plugin / Rust bridge 迁移。 - [x] 活跃设计口径同步:`5-6`、`5-9`、`10-review` 已改为历史/已覆盖/legacy guard 说明,不再把 `/api/ai-agent/run` 写作页面 AI 长期入口。 @@ -1010,7 +1010,7 @@ MNOTE_UI_BASE_URL=http://127.0.0.1:3000 node scripts/task-hermes-page-ai-tool-sm 2026-05-14 K1 执行证据: - [x] rg 命中已归类:`design/old/**` 与 `design/07-ai/done/7-1**` 为历史证据;`scripts/task-hermes-page-ai-retirement-guard.js` 与 `rust/crates/mnote-web/src/routes/compat.rs` 为 legacy guard;旧 E27/phase7/page-ai smoke 已默认退役;`wolai-frontend` 中 Mindmap / OnlyOffice / generic AI 面板调用点归为非当前页面 AI 主链的 legacy domain 调用点,后续按各自 domain 迁移。 -- [x] 修改文件:`rust/crates/mnote-web/src/routes/compat.rs`、`scripts/task-hermes-page-ai-retirement-guard.js`、`scripts/task155-e27-ai-edit-smoke.js`、`scripts/task156-e27-ai-writeback-smoke.js`、`scripts/task178-page-ai-local-subtree-context-smoke.js`、`scripts/task052-ai-tools-runtime-smoke.js`、`scripts/task161-wolai-page-ai-shell-smoke.js`、`scripts/task111-phase7-document-ai-online-smoke.js`、`design/05-editor-mainline/process/5-6-page-aggregate-alignment-checklist-v1.md`、`design/05-editor-mainline/process/5-9-wolai-aline-continuous-checklist-v1.md`、`design/10-review/04-secondary-domains-and-design-governance-review.md`。 +- [x] 修改文件:`rust/crates/mnote-web/src/routes/compat.rs`、`scripts/task-hermes-page-ai-retirement-guard.js`、历史退役 smoke 当前本机归档于 gitignored `recycle/scripts/retired-ai-agent-run-smokes/`、`design/05-editor-mainline/process/5-6-page-aggregate-alignment-checklist-v1.md`、`design/05-editor-mainline/process/5-9-wolai-aline-continuous-checklist-v1.md`、`design/10-review/04-secondary-domains-and-design-governance-review.md`。 - [x] 旧 smoke 默认退役验证:上述 6 个历史 smoke 均返回 `{ ok: true, retired: true }`。 **验收标准:** diff --git a/design/07-ai/done/7-6-mnote-hermes-plugin-tool-contract-v1.md b/design/07-ai/done/7-6-mnote-hermes-plugin-tool-contract-v1.md index 7601cefc..5c012920 100644 --- a/design/07-ai/done/7-6-mnote-hermes-plugin-tool-contract-v1.md +++ b/design/07-ai/done/7-6-mnote-hermes-plugin-tool-contract-v1.md @@ -6,7 +6,7 @@ > > Hermes Web UI 参考:`packages/client/src/api/hermes/plugins.ts`、`packages/client/src/api/hermes/skills.ts`、`packages/server/src/services/hermes/plugins.ts`、`packages/server/src/services/hermes/chat-run-socket.ts` > -> 2026-05-16 口径补充:本文定义的是页面 AI 最小可用工具合同。`mnote.page.save` 只作为页面级兜底写入工具,不再代表长期精确块编辑方案;页面/块级 AI 工具体系以 `design/07-ai/process/7-9-page-block-ai-tooling-roadmap-v1.md` 为后续规划依据。 +> 2026-05-16 口径补充:本文定义的是页面 AI 最小可用工具合同。`mnote.page.save` 只作为页面级兜底写入工具,不再代表长期精确块编辑方案;页面/块级 AI 工具体系路线图已归档到 `design/07-ai/done/7-9-page-block-ai-tooling-roadmap-v1.md`,执行验收继续以 `design/07-ai/process/7-10-page-block-ai-tooling-execution-checklist-v1.md` 为准。 ## 1. 总边界 diff --git a/design/07-ai/process/7-9-page-block-ai-tooling-roadmap-v1.md b/design/07-ai/done/7-9-page-block-ai-tooling-roadmap-v1.md similarity index 96% rename from design/07-ai/process/7-9-page-block-ai-tooling-roadmap-v1.md rename to design/07-ai/done/7-9-page-block-ai-tooling-roadmap-v1.md index aed1c880..036fe63c 100644 --- a/design/07-ai/process/7-9-page-block-ai-tooling-roadmap-v1.md +++ b/design/07-ai/done/7-9-page-block-ai-tooling-roadmap-v1.md @@ -1,8 +1,8 @@ -# 7-9 [process] 页面/块 AI 工具体系规划 v1 +# 7-9 [done] 页面/块 AI 工具体系规划 v1 > 更新时间:2026-05-16 > -> 当前状态:`PROCESS`。 +> 当前状态:`DONE`。 > > 本稿承接 `7-6` 的 mnote Hermes plugin tool 合同、`7-8` 的 Hermes Runtime BFF 方向,以及近期页面 AI 工具实测中暴露的问题:当前 `mnote.page.get/save/update_title/update_options` 已能完成页面级读写,但工具粒度仍偏粗,不能长期代表“AI 能精确编辑页面/块”。 > @@ -21,8 +21,8 @@ > - `/mnt/Data1T/mnote/design/01-05-current-priority-overview.md` > - `/mnt/Data1T/mnote/design/05-editor-mainline/process/5-5-page-aggregate-single-truth-alignment-v1.md` > - `/mnt/Data1T/mnote/design/05-editor-mainline/process/5-6-page-aggregate-alignment-checklist-v1.md` -> - `/mnt/Data1T/mnote/design/05-editor-mainline/process/5-13-page-block-identity-and-command-contract-v1.md` -> - `/mnt/Data1T/mnote/design/02-convex-rust-long-term-architecture/process/2-1-page-block-storage-projection-alignment-v1.md` +> - `/mnt/Data1T/mnote/design/05-editor-mainline/done/5-13-page-block-identity-and-command-contract-v1.md` +> - `/mnt/Data1T/mnote/design/02-convex-rust-long-term-architecture/done/2-1-page-block-storage-projection-alignment-v1.md` > - `/mnt/Data1T/mnote/design/07-ai/done/7-6-mnote-hermes-plugin-tool-contract-v1.md` > - `/mnt/Data1T/mnote/design/07-ai/done/7-8-page-ai-hermes-runtime-bff-next-v1.md` > - `/mnt/Data1T/mnote/design/07-ai/process/7-10-page-block-ai-tooling-execution-checklist-v1.md` @@ -962,10 +962,14 @@ Rust 内部结构化文档模型,是 PageXML/PageMarkdown 到 Page Aggregate / ## 14. 迁移完成定义 -本稿不能标记 `DONE`,直到满足: +本文作为页面/块 AI 工具体系路线图与合同已经归档为 `DONE`;执行验收不再由本文继续承接,而是转入: -- [ ] `mnote.doc.fetch` / `mnote.doc.find` 已实现并通过真实页面 smoke。 -- [ ] `mnote.doc.plan_update` 已实现并能返回 warnings。 -- [ ] 至少一个最小块写工具 `mnote.block.replace` 或 `mnote.block.insert_after` 通过真实页面 smoke。 -- [ ] `mnote.page.save` 在 UI/manifest 中被标记为页面级兜底工具,不再作为默认精确编辑入口。 -- [ ] 相关工具设计被同步到 Hermes skill/plugin 描述,AI 能按“先 fetch/find,再 plan,再 apply”的顺序调用。 +- `/mnt/Data1T/mnote/design/07-ai/process/7-10-page-block-ai-tooling-execution-checklist-v1.md` + +当前已成立的 done 边界: + +- [x] `mnote.doc.fetch` / `mnote.doc.find` 已进入 Rust Hermes tool manifest 与 dispatch,并已有最小真实页面 smoke 证据。 +- [x] `mnote.doc.plan_update` 已进入 dry-run 计划链,可返回 diff、warnings、risk、blocked。 +- [x] `mnote.block.replace`、`mnote.block.insert_after`、`mnote.block.move_after` 已形成最小块写入闭环。 +- [x] `mnote.page.save` 已在 UI/manifest 口径中降为页面级兜底工具,不再作为默认精确块编辑入口。 +- [x] 后续执行项以 `7-10` 跟踪,仍包括 `scope=selection`、`format=page_xml/text`、多块插入、复杂块移动矩阵和持久审阅 UI。 diff --git a/design/07-ai/process/7-10-page-block-ai-tooling-execution-checklist-v1.md b/design/07-ai/process/7-10-page-block-ai-tooling-execution-checklist-v1.md index 367e5d94..9d3ff25c 100644 --- a/design/07-ai/process/7-10-page-block-ai-tooling-execution-checklist-v1.md +++ b/design/07-ai/process/7-10-page-block-ai-tooling-execution-checklist-v1.md @@ -5,10 +5,11 @@ > 当前状态:`PROCESS`。 > > 关联文档: -> - `/mnt/Data1T/mnote/design/05-editor-mainline/process/5-13-page-block-identity-and-command-contract-v1.md` -> - `/mnt/Data1T/mnote/design/02-convex-rust-long-term-architecture/process/2-1-page-block-storage-projection-alignment-v1.md` -> - `/mnt/Data1T/mnote/design/07-ai/process/7-9-page-block-ai-tooling-roadmap-v1.md` +> - `/mnt/Data1T/mnote/design/05-editor-mainline/done/5-13-page-block-identity-and-command-contract-v1.md` +> - `/mnt/Data1T/mnote/design/02-convex-rust-long-term-architecture/done/2-1-page-block-storage-projection-alignment-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` +> - `/mnt/Data1T/mnote/design/07-ai/process/7-12-page-ai-hermes-tool-routing-and-review-surface-v1.md` --- @@ -16,6 +17,61 @@ 本 checklist 把 `7-9` 的路线图拆成可验收执行链,避免页面块 AI 工具停留在“工具名已设计、真实页面不可回读”的状态。 +## 1.0 执行口径修正(2026-05-16) + +本文继续作为页面块 AI 工具执行 checklist 保留在 `process/`,不移入 `old/`。但后续所有“页面 AI runtime / fast workflow / planner / apply controller”相关任务必须按 `7-12` 的边界解释: + +- Hermes 继续是唯一页面 AI agent runtime。 +- mnote 本地层只提供工具路由、工具提示、上下文冻结、dry-run/review、Rust 写入校验和 readback。 +- `PageAIIntentParser` 后续读作 `PageAICommandRouter`,输出 `recommendedToolCall`,不维护独立对话 runtime。 +- `PageAIOperationPlanner` 后续只构造 tool args 或 dry-run plan,不能绕过 Hermes tool manifest/profile toggle/audit。 +- `PageAIOperationValidator` 继续有效,但归属 mnote Rust tool executor / projection validation。 +- `PageAIApplyController` 后续应收口为 review session / tool executor / readback controller,不能成为第二套 agent 编排中心。 +- 当前 `usedHermesRun=false` 的快路径只能理解为 deterministic shortcut,不代表 mnote 新建长期 agent runtime。 + +## 1.1 当前执行状态(2026-05-16) + +已完成并有代码/测试/smoke 证据: + +- Page Aggregate 输出 `blockDocument/blockProjectionVersion/projectionSource`。 +- `mnote.doc.fetch`、`mnote.doc.find`、`mnote.block.fetch` 已接入 Hermes tool manifest 与 dispatch。 +- `mnote.doc.plan_update` 已提供块级 dry-run 计划和移动阻断诊断。 +- `mnote.block.replace`、`mnote.block.insert_after`、`mnote.block.move_after` 已通过 Rust `EditorCommand` 生成 canonical content,再经 `page.body.save -> documents:updateContent` 持久化。 +- `revision/conflictDetectionKey/revisionRef/idempotencyKey/dryRun` 写入前置约束已接入,并修复 `content_revision` 投影对齐。 +- 真实 smoke:`/mnt/Data1T/mnote/scripts/task-page-block-ai-tools-smoke.js`,证据位于 `/mnt/Data1T/mnote/tmp/page-block-ai-tools-smoke/mp7xgyqs.json` 与同名截图。 +- 页面 AI Runtime `mnote tools` 面板已读取真实 manifest,展示 13 个 mnote tools,并支持当前 Hermes profile 下开关工具。 +- `/api/hermes/client/tools/toggle` 已持久化 `mnote.tools.disabled`;`/api/hermes/tools/mnote/call` 在执行前按 profile 拦截关闭工具,返回 `mnote_tool_disabled`。 +- Hermes 外部 mnote plugin/skill 已完成,不改 Hermes 应用本体: + - `/home/lix/.hermes/plugins/mnote/plugin.yaml` + - `/home/lix/.hermes/plugins/mnote/__init__.py` + - `/home/lix/.hermes/skills/note-taking/mnote-block-ai/SKILL.md` +- Hermes 外部 plugin schema 已对齐真实块工具参数:`mnote_doc_fetch(scope/detail/format/query/maxBlocks/blockId/selectedBlockIds/allowedTargetBlockIds)`、`mnote_doc_plan_update(command/blockId/anchorBlockId/content)`、`mnote_block_fetch(blockId/includeChildren/contextBefore/contextAfter/format)`。 +- Hermes CLI 真实 plugin 块操作已通过: + - 测试页:`workspaceId=tree_1777430834634_3`,`documentId=tree_1778915893346_1`,`suffix=mp80lbze`。 + - 工具链:`mnote_doc_fetch -> mnote_block_fetch -> mnote_doc_plan_update(dryRun block_replace) -> mnote_block_replace -> mnote_doc_plan_update(dryRun block_insert_after) -> mnote_block_insert_after -> mnote_doc_plan_update(dryRun block_move_after) -> mnote_block_move_after -> mnote_doc_fetch`。 + - 结果:`finalOrder=["p_1","p_3","ai_block_req_1778916033994_21","p_2"]`,`finalTexts` 分别为 `Hermes 块插件第一段 mp80lbze`、`Hermes 块插件第三段 mp80lbze`、`Hermes 插件插入段 mp80lbze`、`Hermes 插件替换第二段 mp80lbze`,`errors=[]`。 + - 修复点:Hermes 可能把 `doc.fetch` 返回的 projection `payload/contentNodes` 形状传回写工具,`rust/crates/mnote-web/src/hermes_tools/block.rs` 已补齐 `content_to_text` 解析并加单测,避免 replace/insert 写成空段。 +- 浏览器回读验证已通过:打开 `http://127.0.0.1:3000/documents/tree_1778915893346_1?workspaceId=tree_1777430834634_3` 后四段目标文本可见,截图 `/mnt/Data1T/mnote/tmp/hermes-plugin-block-ai/mp80lbze-page.png`。 +- 页面 AI 工具面板浏览器验证:3000 最新 `mnote-web` 进程下 Runtime 面板可见 13 个工具;关闭 `mnote.block.fetch` 后 `/api/hermes/client/tools` 显示 `enabled=false/status=disabled`,直接调用 `mnote.block.fetch` 返回 `mnote_tool_disabled`,随后已恢复开启;截图 `/mnt/Data1T/mnote/tmp/page-ai-tools-runtime/mnote-page-ai-tools-runtime-20260516.png`。 +- 页面 AI context / format focused smoke 已完成: + - 脚本:`/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` 矩阵。 +- 页面 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`。 + +仍未完成,本文继续留在 `process/`: + +- 复杂块移动阻断矩阵还需要单独 smoke 覆盖标题带子块、列表项、表格、mindmap/resource。 +- 持久审阅/preview UI 仍是后续可选项;当前默认 yolo 模式不做写入审批,`scope=selection` 与 `format=page_xml/text` 已进入工具与 PageAIContextBuilder 首版。 +- PageAIIntentParser / PageAIOperationPlanner / PageAIOperationValidator / PageAIApplyController 仍需继续建设;当前本地 planner 只覆盖低歧义文本块增删改,不应被视为完整 AI 编辑 runtime。 + 执行顺序固定为: ```text @@ -38,15 +94,15 @@ fetch/find ## 2. Phase 0:设计与基线冻结 -- [ ] `5-13` 已冻结 block identity、command、Tiptap boundary。 -- [ ] `2-1` 已冻结 `documents.content`、`blocks` 表、Page Aggregate、revision/conflict key 的关系。 -- [ ] `7-9` 已更新 Tiptap AI Toolkit 对照,不再写成“Tiptap 没有官方 AI 文档工具”。 -- [ ] `7-9` 明确 `mnote.page.save` 是粗粒度兜底,不是精确块工具。 -- [ ] 当前 reference code 已在 `/mnt/Data1T/mnote/design/05-editor-mainline/reference-code/tiptap-docs` 与 `tiptap-main` 可读。 +- [x] `5-13` 已冻结 block identity、command、Tiptap boundary。 +- [x] `2-1` 已冻结 `documents.content`、`blocks` 表、Page Aggregate、revision/conflict key 的关系。 +- [x] `7-9` 已更新 Tiptap AI Toolkit 对照,不再写成“Tiptap 没有官方 AI 文档工具”。 +- [x] `7-9` 明确 `mnote.page.save` 是粗粒度兜底,不是精确块工具。 +- [x] 当前 reference code 已在 `/mnt/Data1T/mnote/design/05-editor-mainline/reference-code/tiptap-docs` 与 `tiptap-main` 可读。 验收: -- [ ] `rg -n "Tiptap AI Toolkit|tiptapRead|tiptapEdit|UniqueID|_hash|blockDocument" design/05-editor-mainline/process design/02-convex-rust-long-term-architecture/process design/07-ai/process` 能找到对应设计。 +- [ ] `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}` 能找到对应设计。 --- @@ -58,12 +114,12 @@ fetch/find 任务: -- [ ] `PageBody` 增加 `blockDocument` 或等价稳定字段。 -- [ ] 从 `documents.content` 生成 `EditorBlockDocument`。 -- [ ] 从 Tiptap JSON 生成 `EditorBlockDocument` 的 bridge 测试覆盖当前主类型。 -- [ ] 每个块输出 `blockId/type/text/attrs/children/parentBlockId/order/path/depth/revisionRef/editable`。 -- [ ] 对无 id legacy block 生成稳定迁移策略或 warning。 -- [ ] 复杂块输出 `editable=false` 或受限能力。 +- [x] `PageBody` 增加 `blockDocument` 或等价稳定字段。 +- [x] 从 `documents.content` 生成 `EditorBlockDocument`。 +- [x] 从 Tiptap JSON 生成 `EditorBlockDocument` 的 bridge 测试覆盖当前主类型。 +- [x] 每个块输出 `blockId/type/text/attrs/children/parentBlockId/order/path/depth/revisionRef/editable`。 +- [x] 对无 id legacy block 生成稳定迁移策略或 warning。 +- [x] 复杂块输出 `editable=false` 或受限能力。 验证命令: @@ -95,13 +151,13 @@ cargo test -p bridge-runtime editor_document 任务: -- [ ] tool manifest 增加 `mnote.doc.fetch`。 -- [ ] tool manifest 增加 `mnote.doc.find`。 -- [ ] `doc.fetch` 支持 `scope=full/outline/keyword/block/selection`。 -- [ ] `doc.fetch` 支持 `detail=simple/with_ids/full`。 -- [ ] `doc.find` 支持按 text/type/blockId 查找。 -- [ ] 返回 page `revision/conflictDetectionKey`。 -- [ ] 返回可直接传入 `block.fetch/replace/insert_after` 的 `blockId`。 +- [x] tool manifest 增加 `mnote.doc.fetch`。 +- [x] tool manifest 增加 `mnote.doc.find`。 +- [x] `doc.fetch` 支持 `scope=full/outline/keyword/block/selection`。 +- [x] `doc.fetch` 支持 `detail=simple/with_ids/full`。 +- [x] `doc.find` 支持按 text/type/blockId 查找。 +- [x] 返回 page `revision/conflictDetectionKey`。 +- [x] 返回可直接传入 `block.fetch/replace/insert_after` 的 `blockId`。 验证命令: @@ -118,6 +174,11 @@ cargo test -p bridge-runtime doc_find - [ ] `mnote.doc.find query=<唯一前缀>` 定位目标段落。 - [ ] 保存工具返回到 `tmp/hermes-tester//doc-fetch-find.json`。 +补充 smoke 证据: + +- [x] `mnote.doc.fetch scope=selection selectedBlockIds=["p_2"] format=page_xml` 读取真实页面选区上下文,只返回 `p_2`,返回 `schema=mnote.page_ai_context.v1`、`allowedTargetBlockIds=["p_2"]`、`revision/conflictDetectionKey` 和 block `revisionRef`。证据:`tmp/page-block-ai-context-format-smoke/mp8ddr4n.json`。 +- [x] `mnote.doc.fetch scope=selection format=text` 只返回 `[p_2] 第二段 mp8ddr4n`,不包含未选中块。证据:`tmp/page-block-ai-context-format-smoke/mp8ddr4n.json`。 + 通过标准: - [ ] 不读取浏览器 DOM。 @@ -134,12 +195,12 @@ cargo test -p bridge-runtime doc_find 任务: -- [ ] tool manifest 增加 `mnote.block.fetch`。 -- [ ] 支持 `includeChildren`。 -- [ ] 支持 `contextBefore/contextAfter`。 -- [ ] 支持 `format=json/markdown/page_xml/text`。 -- [ ] 返回 `revisionRef`、`editable`、`unsupportedReason`。 -- [ ] 不存在 block 返回 `mnote_block_not_found`。 +- [x] tool manifest 增加 `mnote.block.fetch`。 +- [x] 支持 `includeChildren`。 +- [x] 支持 `contextBefore/contextAfter`。 +- [x] 支持 `format=json/markdown/page_xml/text`。 +- [x] 返回 `revisionRef`、`editable`、`unsupportedReason`。 +- [x] 不存在 block 返回 `mnote_block_not_found`。 验证命令: @@ -153,9 +214,13 @@ cargo test -p mnote-web block_fetch - [ ] 调 `mnote.block.fetch includeChildren=true contextBefore=1 contextAfter=1`。 - [ ] 断言 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`。 + 通过标准: -- [ ] block 文本与页面显示一致。 +- [x] block 文本与页面显示一致。 - [ ] `revisionRef` 可被后续 dry-run 使用。 - [ ] 复杂块不会伪装成完全可编辑。 @@ -169,13 +234,13 @@ cargo test -p mnote-web block_fetch 任务: -- [ ] tool manifest 增加 `mnote.doc.plan_update`。 -- [ ] 支持 `command=block_replace`。 -- [ ] 支持 `command=block_insert_after`。 -- [ ] 支持 `command=block_move_after` dry-run。 -- [ ] 支持 `command=str_replace` 且多重匹配阻断。 -- [ ] 缺少 `revision/conflictDetectionKey` 时只允许 dry-run。 -- [ ] 返回 `planId`、`diff`、`warnings`、`risk`、`blocked`。 +- [x] tool manifest 增加 `mnote.doc.plan_update`。 +- [x] 支持 `command=block_replace`。 +- [x] 支持 `command=block_insert_after`。 +- [x] 支持 `command=block_move_after` dry-run。 +- [ ] 支持 `command=str_replace` 且多重匹配阻断。(当前只有基础 plan,仍需多重匹配阻断。) +- [x] 缺少 `revision/conflictDetectionKey` 时只允许 dry-run。 +- [x] 返回 `planId`、`diff`、`warnings`、`risk`、`blocked`。 验证命令: @@ -208,13 +273,13 @@ cargo test -p bridge-runtime doc_insert_blocks 任务: -- [ ] tool manifest 增加 `mnote.block.replace`。 -- [ ] 输入必须包含 `blockId/revision/conflictDetectionKey/idempotencyKey/dryRun`。 -- [ ] `dryRun=true` 只返回 plan。 -- [ ] `dryRun=false` 生成 `EditorCommand::ReplaceBlock`。 -- [ ] Rust 应用命令生成 canonical content。 -- [ ] 通过 `page.body.save -> documents:updateContent` 持久化。 -- [ ] 返回新 revision、changedBlocks、audit。 +- [x] tool manifest 增加 `mnote.block.replace`。 +- [x] 输入必须包含 `blockId/revision/conflictDetectionKey/idempotencyKey/dryRun`。 +- [x] `dryRun=true` 只返回 plan。 +- [x] `dryRun=false` 生成 `EditorCommand::ReplaceBlock`。 +- [x] Rust 应用命令生成 canonical content。 +- [x] 通过 `page.body.save -> documents:updateContent` 持久化。 +- [x] 返回新 revision、changedBlocks、audit。 验证命令: @@ -250,12 +315,12 @@ cargo test -p bridge-runtime doc_replace_range_tool_executes_in_rust_runtime 任务: -- [ ] tool manifest 增加 `mnote.block.insert_after`。 -- [ ] 输入必须包含 `anchorBlockId/revision/conflictDetectionKey/idempotencyKey/dryRun`。 -- [ ] 新块 id 由 Rust runtime 分配。 -- [ ] 支持单块和最多 20 个普通块插入。 -- [ ] 第一阶段支持 paragraph/heading/todo。 -- [ ] 返回 inserted block ids 和新 revision。 +- [x] tool manifest 增加 `mnote.block.insert_after`。 +- [x] 输入必须包含 `anchorBlockId/revision/conflictDetectionKey/idempotencyKey/dryRun`。 +- [x] 新块 id 由 Rust runtime 分配。 +- [ ] 支持单块和最多 20 个普通块插入。(当前最小闭环为单块插入。) +- [x] 第一阶段支持 paragraph/heading/todo。 +- [x] 返回 inserted block ids 和新 revision。 验证命令: @@ -288,12 +353,12 @@ cargo test -p bridge-runtime doc_insert_blocks_tool_emits_editor_commands 任务: -- [ ] tool manifest 增加 `mnote.block.move_after`。 -- [ ] `dryRun=true` 支持同父级叶子块 diff。 -- [ ] `dryRun=false` 前先继续阻断所有复杂块。 -- [ ] 检查 `blockRevisionRef` 与 `anchorRevisionRef`。 -- [ ] 阻断移动到自身、移动到子树、跨页面移动。 -- [ ] 返回 from/to parent/order。 +- [x] tool manifest 增加 `mnote.block.move_after`。 +- [x] `dryRun=true` 支持同父级叶子块 diff。 +- [ ] `dryRun=false` 前先继续阻断所有复杂块。(已有同父级/叶子/类型/editable/self 阻断,复杂块矩阵 smoke 待补。) +- [x] 检查 `blockRevisionRef` 与 `anchorRevisionRef`。 +- [x] 阻断移动到自身、移动到子树、跨页面移动。 +- [x] 返回 from/to parent/order。 验证命令: @@ -328,8 +393,9 @@ cargo test -p mnote-editor-core command_executor 任务: - [ ] Hermes tool event UI 展示 `plan/diff/warnings/risk`。 -- [ ] `page.save` 标记为粗粒度高风险兜底。 -- [ ] `block.replace/insert_after/move_after` 展示 changedBlocks。 +- [x] `page.save` 标记为粗粒度高风险兜底。 +- [x] `block.replace/insert_after/move_after` 展示 changedBlocks。 +- [x] manifest annotations 能区分只读 / 粗粒度破坏性写入 / selectionEffect / runtimeOwner / writeOwner;`mnote.page.save` 在 manifest 中为 `destructive=true`、`approvalMode=yolo`,不作为精确块编辑主入口。证据:`tmp/page-block-ai-context-format-smoke/mp8ddr4n.json`。 - [ ] 本地 preview/suggestion 与持久 comment/tracked-change 分开。 - [ ] 协作可见审阅必须另走正式 comment/history/tracked-change 设计。 diff --git a/design/07-ai/process/7-12-page-ai-hermes-tool-routing-and-review-surface-v1.md b/design/07-ai/process/7-12-page-ai-hermes-tool-routing-and-review-surface-v1.md new file mode 100644 index 00000000..494cc1b0 --- /dev/null +++ b/design/07-ai/process/7-12-page-ai-hermes-tool-routing-and-review-surface-v1.md @@ -0,0 +1,601 @@ +# 7-12 [process] 页面 AI Hermes 工具路由与编辑审阅面设计 v1 + +> 更新时间:2026-05-16 +> +> 当前状态:`PROCESS` +> +> 本稿目的:修正“页面 AI 快速块编辑”后续方向,明确 mnote 不再建设独立 AI agent runtime;mnote 只建设 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/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` +> - `/mnt/Data1T/mnote/design/05-editor-mainline/reference-code/cli-main` +> - `/mnt/Data1T/mnote/design/05-editor-mainline/reference-code/blocknote-ai` +> - `/mnt/Data1T/mnote/design/05-editor-mainline/reference-code/tiptap-apcore` + +--- + +## 1. 本轮结论 + +页面 AI 编辑卡顿的根因不是“Rust apply 慢”,而是模型和工具之间缺少稳定、低歧义、可审计的编辑命令面: + +```text +用户说一句自然语言 + -> Hermes/模型需要猜:读哪个范围、改哪个块、调用哪个工具、如何传参 + -> 如果猜错 blockId 或工具参数,mnote 再 fallback / 重跑 / 整页写入 + -> 用户感知为慢、卡、偶发失败 +``` + +正确方向不是再造一个 mnote 自有 AI runtime,而是: + +> **Hermes 继续作为唯一页面 AI agent runtime;mnote 提供 Agent-native editor command layer。** + +因此,`本地意图解析 + Rust apply` 必须被重新定义为: + +- Hermes 的工具路由提示层。 +- 低风险确定性编辑的本地 shortcut。 +- Rust 写工具的参数校验和执行面。 +- review/dry-run/session 的安全边界。 + +它不是: + +- 第二套对话 runtime。 +- 第二套 agent tool loop。 +- 绕过 Hermes profile/tool toggle/audit 的长期写入口。 +- 让模型直接产 operations 并立刻写入的通用方案。 + +--- + +## 2. 现有问题 + +### 2.1 `/api/page-ai/block-edit-workflow` 方向需要收口 + +当前 route 已证明低歧义中文块编辑可以很快完成: + +```text +local_rule -> mnote.doc.apply_block_ops -> Rust apply -> page readback +``` + +但如果把这个 route 继续扩成 `PageAIIntentParser / OperationPlanner / ApplyController`,它会自然变成第二套 runtime: + +- 自己判断意图。 +- 自己调用模型。 +- 自己解析模型输出。 +- 自己决定 fallback。 +- 自己写入并展示结果。 + +这会和 Hermes 的 session、profile、tool toggle、tool event、usage、audit、abort/retry 产生重叠。 + +### 2.2 模型直接输出 operations 仍不可靠 + +`09-page-ai-fast-block-edit-runtime-review.md` 已记录失败案例:模型输出了 operations,但 block 定位没有命中 Page Aggregate projection,最终触发 fallback 并拉长耗时。 + +长期规则应改为: + +- 模型可以建议工具调用。 +- 模型可以输出候选 operations。 +- mnote 必须用 Page Aggregate projection 解析、校验、dry-run。 +- blockId、revisionRef、allowedTargetBlockIds、editable、scope 必须由 mnote 校验。 +- 未通过校验不能隐式 fallback 到整页写或另一次 agent run。 + +### 2.3 当前工具面还缺少 `cli-main` 式 agent 合同 + +`cli-main` 的关键价值是把平台能力压成 Agent 可靠调用的命令面: + +- shortcut / API / generic 三层调用。 +- `--dry-run` 预览真实请求。 +- `Risk: high-risk-write` 与 `confirmation_required`。 +- structured error / hint。 +- skill 文档指导 agent 何时调用什么。 +- event consume 的 schema、ready marker、bounded run。 + +mnote 当前已有 Hermes tool manifest,但还需要把 manifest 提升为 Hermes/model 可直接消费的编辑合同,而不是只做 UI 列表。 + +--- + +## 3. 设计原则 + +### 3.1 单一 agent runtime + +```text +Hermes owns: + session / message / model / tool loop / streaming / usage / profile / memory / skill + +mnote owns: + Page Aggregate / tool manifest / context snapshot / validation / Rust command / audit / readback +``` + +页面 AI 面板只是 Hermes 的页面内客户端;mnote 不再新增独立 agent 编排中心。 + +### 3.2 本地层只做“路由和校验” + +本地层可以做: + +- 判断是不是低歧义块编辑。 +- 生成 `recommendedToolCall`。 +- 附带 `confidence`、`risk`、`requiresReview`。 +- 生成 `allowedTargetBlockIds`。 +- 做 dry-run、validate、readback。 + +本地层不能做: + +- 自己维护长期对话状态。 +- 自己成为默认模型调用链。 +- 自己绕过 Hermes tool manifest 和 profile 开关。 +- 自己吞掉工具错误并隐式改走其他写入口。 + +### 3.3 所有写入都通过 Rust-owned mnote tools + +写工具必须满足: + +- `dryRun` 显式传入。 +- `idempotencyKey` 显式传入。 +- `revision/conflictDetectionKey/revisionRef` 或等价冲突键参与校验。 +- `allowedTargetBlockIds` 限制 selection / scoped run。 +- 返回 `diff/warnings/risk/blocked/changedBlocks/audit`。 +- 写入后通过 Page Aggregate 和 `mnote.doc.fetch` 回读验证。 + +### 3.4 快路径是 shortcut,不是 runtime + +低歧义场景可以保留快路径,但必须改口径: + +```text +PageAICommandRouter + -> recommendedToolCall + -> direct tool shortcut 或 Hermes run with tool hint + -> shared mnote tool executor + -> shared audit/readback +``` + +如果走 direct tool shortcut,也必须产生 Hermes-compatible tool event / audit 语义,避免 UI 与历史记录断裂。 + +--- + +## 4. 总体架构 + +```text +Browser Page AI panel + -> PageAIContextBuilder + -> MnoteAIToolManifestProvider + -> PageAICommandRouter + -> deterministic shortcut? ---- yes -> MnoteToolExecutor + | -> PageAIReviewSession/readback + no + -> Hermes run request with: + - frozen page context + - tool manifest + - recommendedToolCall hint + - risk/review policy + -> Hermes tool loop + -> /api/hermes/tools/mnote/call + -> Rust mnote tools + -> PageAIReviewSession/readback +``` + +这里 `PageAICommandRouter` 不是 agent,只是类似 `cli-main` shortcut 的工具路由器。 + +--- + +## 5. 组件设计 + +### 5.1 `PageAIContextBuilder` + +职责: + +- 从 Page Aggregate block projection 构建冻结上下文。 +- 支持 `scope=full/outline/block/selection/keyword`。 +- 输出 `text/page_xml/json` 三种视图。 +- 生成 `allowedTargetBlockIds`。 +- 记录 `revision/conflictDetectionKey/revisionRef`。 +- 大页面默认裁剪,返回 `truncated/warnings/continuation`。 + +输出示例: + +```json +{ + "schema": "mnote.page_ai_context.v1", + "workspaceId": "tree_workspace", + "documentId": "tree_doc", + "scope": "selection", + "revision": 12, + "conflictDetectionKey": "body:12:hash", + "allowedTargetBlockIds": ["p_1", "p_2"], + "selectedBlockIds": ["p_1", "p_2"], + "pageText": "第一段\n第二段", + "pageXml": "第一段", + "blocks": [ + { + "blockId": "p_1", + "type": "paragraph", + "text": "第一段", + "revisionRef": "body:12:p_1", + "editable": true + } + ] +} +``` + +### 5.2 `MnoteAIToolManifestProvider` + +职责: + +- 从 Rust Hermes tool manifest 输出当前页面可用工具。 +- 合并 profile tool toggle、capability、scope、document permissions。 +- 输出 Hermes/model 可直接使用的 tool schema。 +- 输出风险和审批语义。 + +工具 manifest 必须包含: + +```json +{ + "name": "mnote.doc.apply_block_ops", + "description": "Apply validated block operations to the current mnote document.", + "inputSchema": { + "type": "object", + "required": ["operations", "dryRun", "idempotencyKey"], + "additionalProperties": false + }, + "annotations": { + "readonly": false, + "destructive": false, + "idempotent": false, + "requiresApproval": true, + "approvalMode": "review", + "selectionEffect": "destroy", + "runtimeOwner": "mnote-web", + "writeOwner": "rust-runtime-kernel" + }, + "availability": { + "enabled": true, + "unsupportedReason": "" + } +} +``` + +### 5.3 `PageAICommandRouter` + +替代当前继续扩大的 `block-edit-workflow` 概念。 + +输入: + +- 用户 prompt。 +- 冻结后的 `mnote.page_ai_context.v1`。 +- 当前 tool manifest。 +- 当前 profile / approval mode。 + +输出: + +```json +{ + "schema": "mnote.page_ai_command_route.v1", + "intent": "direct_block_edit", + "confidence": 0.94, + "recommendedToolCall": { + "toolName": "mnote.doc.apply_block_ops", + "args": { + "operations": [ + {"op": "replace", "matchText": "A", "content": "B"} + ], + "dryRun": true + } + }, + "risk": "low", + "requiresHermesRun": false, + "requiresReview": false, + "reason": "明确中文引号替换表达,目标文本唯一命中" +} +``` + +规则: + +- 只覆盖低歧义命令。 +- 不能为复杂改写、总结、跨页面、多块结构化编辑直接生成写入。 +- 不能调用第二套长链模型;如需模型,交给 Hermes run。 +- 输出必须可被 Hermes 当作 tool hint 消费。 + +### 5.4 Hermes run hint 注入 + +当 `requiresHermesRun=true` 或 router 不确定时,页面 AI 发起 Hermes run,并附带: + +```json +{ + "pageContext": "mnote.page_ai_context.v1", + "toolManifest": "mnote.ai_tool_manifest.v1", + "toolHint": "mnote.page_ai_command_route.v1", + "reviewPolicy": { + "mode": "yolo|review|required", + "defaultDryRun": true + } +} +``` + +Hermes 仍负责: + +- 选择模型。 +- 工具调用循环。 +- stream message / tool event。 +- abort/retry。 +- session persistence。 + +mnote 只负责工具结果和写入安全。 + +### 5.5 `PageAIReviewSession` + +职责: + +- 承接所有写工具 `dryRun=true` 或 `requiresApproval=true` 的结果。 +- 保存 plan/diff/warnings/risk/blocked。 +- 提供 accept/reject/retry/abort。 +- accept 时二次读取 Page Aggregate 并校验 revision。 + +状态: + +```text +draft +planning +previewing +awaiting_user +accepted +rejected +applying +applied +failed +aborted +stale +``` + +第一阶段可以保留 yolo,但仍应让工具返回 review-compatible 数据结构,避免后续 UI 重写。 + +--- + +## 6. 关键流程 + +### 6.1 低歧义块替换 + +```text +用户:把「第二段」替换为「第二段已修改」 + -> ContextBuilder 冻结页面与 block ids + -> CommandRouter 命中 direct_block_edit + -> recommendedToolCall=mnote.doc.apply_block_ops + -> dryRun validate 唯一命中 + -> yolo 模式:direct tool shortcut 正式 apply + -> 记录 tool event/audit + -> Page Aggregate readback +``` + +验收: + +- 不进入通用 Hermes agent run 也可以,但必须复用 mnote tool/audit/readback 语义。 +- 若非 yolo 模式,则停在 review session。 + +### 6.2 复杂自然语言改写 + +```text +用户:把这段整理得更专业,并保留原意 + -> Router 无法确定操作 + -> Hermes run with context + manifest + hint + -> Hermes 调 mnote.doc.fetch / block.fetch + -> Hermes 调 mnote.doc.plan_update(dryRun=true) + -> mnote 返回 review session draft + -> 用户 accept 后 Rust apply +``` + +验收: + +- 模型不能直接改正文。 +- dry-run 不改变 Page Aggregate。 +- accept 时校验 revision。 + +### 6.3 selection 编辑 + +```text +用户选中块 A/B:改成列表 + -> ContextBuilder 冻结 selectedBlockIds + -> allowedTargetBlockIds=[A,B] + -> 所有写工具自动带 allowedTargetBlockIds + -> 写工具尝试修改 C 时 blocked=true +``` + +验收: + +- 用户后续改变选区不影响当前 run。 +- selection 外写入被阻断。 + +### 6.4 工具禁用 + +```text +profile disabled mnote.block.fetch + -> ToolManifestProvider 输出 enabled=false 或不输出该工具 + -> Router 不推荐该工具 + -> Hermes 直接调用仍被 /api/hermes/tools/mnote/call 拦截 +``` + +验收: + +- UI 工具列表、Hermes manifest、后端执行拦截一致。 + +--- + +## 7. 与参考代码的吸收边界 + +### 7.1 `cli-main` + +吸收: + +- shortcut/API/generic 三层工具面。 +- dry-run 作为写入前置能力。 +- structured error/hint。 +- risk/confirmation_required。 +- skill 文档让 agent 不靠猜。 +- event/schema/ready marker 的 agent-friendly contract。 + +不吸收: + +- 不复制 Go CLI 框架。 +- 不把 CLI 作为页面 AI 唯一执行面。 +- 不用命令行 prompt 作为 Web 审批 UI。 + +### 7.2 `blocknote-ai` + +吸收: + +- `DocumentStateBuilder` 的 selection/full context 分离。 +- `StreamToolsProvider` 的工具集合思想。 +- AI lifecycle:thinking / ai-writing / user-reviewing / error。 +- accept/reject/retry/abort 的交互形态。 + +不吸收: + +- 不引入 `@blocknote/xl-ai` 运行时依赖。 +- 不复制 GPL/PROPRIETARY 代码。 +- 不让 BlockNote/ProseMirror suggestion 成为 mnote 事实源。 + +### 7.3 `tiptap-apcore` + +吸收: + +- tool schema。 +- annotations。 +- ACL / role。 +- query/content/destructive/selection/history 分类。 +- executor 前置检查。 + +不吸收: + +- 不把 Tiptap command 作为长期写入事实源。 +- 不让浏览器 editor instance 直接持久化写入。 + +### 7.4 AI SDK / Context7 核验结论 + +可用方向: + +- 用 schema/structured output 约束模型输出。 +- 用 tool calling 让模型选择工具。 +- 用 repair/validation 处理无效参数。 +- 工具执行结果必须由 mnote 校验后返回。 + +不可用方向: + +- 不把 structured output 当最终写入结果。 +- 不让模型输出的 blockId 绕过 projection resolve。 + +--- + +## 8. 迁移计划 + +### Phase A:设计治理 + +- [x] 新增本文作为当前口径。 +- [x] `7-10` 继续作为执行 checklist。 +- [x] `7-11` 作为旧“自有 AI runtime”口径移入 `design/old/07-ai/process/`。 + +### Phase B:Manifest 合同收口 + +- [ ] `mnote.doc.*` / `mnote.block.*` manifest 输出完整 `inputSchema/outputSchema/annotations/availability`。 +- [ ] profile toggle、capability、scope 共同影响 manifest。 +- [ ] manifest 可直接转换为 Hermes/model tools。 +- [ ] 禁用工具在 manifest、UI、执行拦截三处一致。 + +### Phase C:`block-edit-workflow` 改造成 router + +- [ ] 将 route 命名和返回 schema 改为 `mnote.page_ai_command_route.v1` 或新增等价 route。 +- [ ] 本地规则只输出 `recommendedToolCall`。 +- [ ] 低风险 yolo shortcut 走共享 mnote tool executor。 +- [ ] 非低风险或低置信度任务发起 Hermes run with tool hint。 +- [ ] 删除“模型 fallback 后再 Hermes agent run”的重复链路。 + +### Phase D:Review session + +- [ ] 定义 `mnote.page_ai_review_session.v1`。 +- [ ] `mnote.doc.plan_update` 与 `mnote.doc.apply_block_ops dryRun=true` 返回 review-compatible draft。 +- [ ] 页面 AI UI 展示 diff/warnings/risk/blocked。 +- [ ] accept/reject/retry/abort 可用。 +- [ ] stale revision 被阻断。 + +### Phase E:状态与事件统一 + +- [ ] direct shortcut 和 Hermes run 都产生统一 tool event 形态。 +- [ ] 页面 AI 面板按 `runId/toolCallId/reviewSessionId` 聚合展示。 +- [ ] abort 不留下半写入正文。 +- [ ] 刷新后未提交 review session 不自动写入。 + +### Phase F:验收 smoke + +- [ ] 低歧义替换:可 <1s 可见,且有 tool audit。 +- [ ] 复杂改写:进入 Hermes run,先 dry-run/review。 +- [x] selection 外写入:blocked。 +- [ ] 禁用工具:manifest 不推荐,后端仍拦截。 +- [ ] 旧 revision accept:stale。 + +2026-05-16 补充验收证据: + +- `scripts/task-page-block-ai-context-format-smoke.js` 已验证 `mnote.doc.apply_block_ops dryRun=true` 携带 `allowedTargetBlockIds=["p_2"]` 时,尝试 replace `p_1` 会被 Rust mnote tool 拒绝。 +- 证据:`tmp/page-block-ai-context-format-smoke/mp8ddr4n.json`;错误路径为 HTTP `400`、`mnote_block_target_out_of_scope`。 +- 同一 smoke 还验证了 context / manifest 基础合同:`mnote.doc.fetch scope=selection format=page_xml/text`、`mnote.block.fetch format=page_xml/text`、manifest annotations 与 `mnote.page.save` 粗粒度兜底定位。 +- 边界:本证据不代表完整 review session、旧 revision accept、复杂改写或单个 `mnote.block.*` selection guard 已完成。 + +--- + +## 9. `7-10` 与 `7-11` 的处理结论 + +### 9.1 `7-10` 继续执行 + +`7-10` 是页面块 AI 工具执行 checklist,包含真实代码和 smoke 证据。它仍然有效,继续保留在: + +```text +design/07-ai/process/7-10-page-block-ai-tooling-execution-checklist-v1.md +``` + +但后续执行必须按本文修正口径: + +- `PageAIIntentParser` 读作 `PageAICommandRouter`。 +- `PageAIOperationPlanner` 读作 `recommendedToolCall` 构造器。 +- `PageAIOperationValidator` 继续有效,但归属 mnote tool executor / Rust validation。 +- `PageAIApplyController` 不应成为独立 runtime,改为 review session / tool executor / readback controller。 +- “不进入 Hermes run”只能表示 deterministic shortcut,不表示 mnote 新建了 agent runtime。 + +### 9.2 `7-11` 移入 old + +`7-11` 的参考资料价值仍然成立,但标题和核心分层写成了“mnote 自有 AI 工具 runtime”。这会误导后续实现继续扩出第二套 runtime。 + +因此本轮将其移入: + +```text +design/old/07-ai/process/7-11-blocknote-tiptap-ai-reference-and-mnote-ai-tool-runtime-v1.md +``` + +保留原因: + +- 记录 BlockNote / Tiptap 参考取证。 +- 保留 GPL/PROPRIETARY 许可证边界。 +- 保留 selection/context/review 的参考价值。 + +不再作为当前执行口径;当前执行口径以本文为准。 + +--- + +## 10. 禁止项 + +- 不新增 mnote 自有 agent runtime。 +- 不把 `/api/page-ai/block-edit-workflow` 扩成通用 AI 编排中心。 +- 不让模型直接输出未经校验的 blockId 并写入。 +- 不绕过 Hermes profile/tool toggle/audit。 +- 不让前端 editor instance 直接执行正式持久化写入。 +- 不以 HTML / Tiptap JSON / ProseMirror position 作为长期 AI tool contract。 +- 不复制 BlockNote XL AI 或 GPL/PROPRIETARY 实现代码。 +- 不把 `mnote.page.save` 描述为精确块编辑主入口。 + +--- + +## 11. 成功标准 + +完成本文后,页面 AI 编辑应满足: + +- 简单明确块编辑有低延迟 shortcut。 +- 复杂编辑仍走 Hermes agent runtime。 +- Hermes 不再盲猜工具和参数,而是拿到 mnote 提供的 context、manifest、tool hint。 +- 所有写入都能 dry-run、review、audit、readback。 +- 工具禁用、权限、scope、selection 与后端执行一致。 +- 设计文档不再鼓励建设第二套 AI runtime。 diff --git a/design/07-ai/process/7-13-page-block-editor-runtime-actor-v1.md b/design/07-ai/process/7-13-page-block-editor-runtime-actor-v1.md new file mode 100644 index 00000000..9ae7a9c3 --- /dev/null +++ b/design/07-ai/process/7-13-page-block-editor-runtime-actor-v1.md @@ -0,0 +1,469 @@ +# 7-13 [process] 页面块编辑运行时 Actor 设计 v1 + +> 更新时间:2026-05-22 +> +> 当前状态:`PROCESS` +> +> 本稿目的:在 7-12 已排除第二套 AI runtime 的前提下,补上 Hermes tool execution → Convex 持久化之间缺失的 Rust 编辑运行时中继层,实现「内存态 apply → 编辑器就地 patch → Convex 异步持久化 → 事件增量通知」的四步闭环。 +> +> 关联文档: +> - `/mnt/Data1T/mnote/design/07-ai/process/7-12-page-ai-hermes-tool-routing-and-review-surface-v1.md` +> - `/mnt/Data1T/mnote/design/07-ai/process/7-10-page-block-ai-tooling-execution-checklist-v1.md` +> - `/mnt/Data1T/mnote/design/07-ai/done/7-9-page-block-ai-tooling-roadmap-v1.md` +> - `/mnt/Data1T/mnote/design/05-editor-mainline/done/5-13-page-block-identity-and-command-contract-v1.md` +> - `/mnt/Data1T/mnote/design/05-editor-mainline/process/5-5-page-aggregate-single-truth-alignment-v1.md` +> - `/mnt/Data1T/mnote/design/03-rust-web/process/3-3-rust-web-tree-realtime-event-stream-v1.md` + +--- + +## 1. 结论 + +当前 Hermes 块工具读→plan→dry-run→apply→readback 循环中,每次写操作都经历 `EditorCommand → legacy content → Convex documents:updateContent → page.body.saved` 全链路,导致: + +- 一次 AI 编辑循环需 2-3 次 Convex RTT +- 编辑器只能全量 reload snapshot,不能就地 patch +- tree event stream 收到 `resync_required` 而非增量 delta + +**正确方向不是绕开 Convex(禁止项,Convex 保留为自托管存储底座),而是在 Rust mnote-web 进程中新增一个轻量 EditorRuntimeActor,作为写操作的本地缓冲层。** + +EditorRuntimeActor 不是 agent runtime(遵从 7-12 禁止项),它只负责: + +- 持有文档的 `EditorBlockDocument` 内存态 +- 接收 `EditorCommand` → 就地 apply → 产生 diff +- 将 diff 拆为三路输出:Convex 持久化 / 编辑器增量 patch / tree event stream delta +- 返回 Hermes tool 所需的 `changedBlocks / newRevision` + +--- + +## 2. 现有问题 + +### 2.1 写路径绕路 Convex + +当前写路径: + +``` +mnote.block.replace / insert_after / move_after / delete + → ensure_write_contract + → apply_editor_command_to_legacy_content ← EditorCommand → legacy content array + → execute_page_body_save + → RuntimeCommandEnvelopeWire("page.body.save") + → bridge-runtime: EditorBlockDocument → legacy content → Convex documents:updateContent + → domain event: page.body.saved + → tree stream: resync_required + → 前端收到 resync → 重新 fetch Page Aggregate → 编辑器 reload +``` + +这条路径每次写都走完整 Convex 事务。在 AI 的典型循环中(读 1 次 + plan_update 1 次 + write 1-3 次 + readback 1 次),这意味着 4-6 次 Convex RTT,其中大部分是可以省略的。 + +### 2.2 编辑器收不到增量 + +当前 `page.body.saved` → `resync_required` 是全量 reload。编辑器不会收到「块 p_2 的文本从 X 变为 Y」这样的增量信号,只能重新请求整页 snapshot。 + +### 2.3 每次 apply 都走 JSON 序列化桥 + +`apply_editor_command_to_legacy_content` 的输入是 `Value`(legacy content array),输出也是 `Value`。中间经历了 `editor_document_from_legacy_content → apply → legacy_content_from_editor_document` 的序列化桥。如果 EditorBlockDocument 常驻内存,可以省去两端序列化。 + +--- + +## 3. 设计原则 + +### 3.1 不是 agent runtime + +EditorRuntimeActor 不维护: + +- session / message / model loop +- 意图解析 / planner / fallback 链 +- 长期对话状态 +- 独立的工具调用循环 + +它只是 Rust-owned 的命令执行 + diff 分发层。 + +### 3.2 Convex 仍是唯一的持久化底座 + +EditorRuntimeActor 的内存态允许异步写入 Convex,但不绕过 Convex。进程重启后从 Convex 恢复。 + +### 3.3 编辑器 patch 是增量,非全量 + +Rust → Tiptap 的 delta channel 只传 surgical op(replace/insert/delete/move),不传整份 `EditorBlockDocument`。 + +### 3.4 事件 stream 从 resync 进化为 delta + +`block.delta` 成为 tree event stream 的一等事件,前端 tree stream consumer 可选择增量消费。 + +--- + +## 4. 总体架构 + +``` + ┌──────────────────────┐ + │ Hermes Agent │ + │ (tool call loop) │ + └──────────┬───────────┘ + │ POST /api/hermes/tools/mnote/call + ▼ + ┌─────────────────────────────────────┐ + │ mnote-web Hermes Tools (block.rs) │ + │ - ensure_write_contract │ + │ - build_editor_block / content_nodes│ + │ - dry_run / idempotency / revision │ + └──────────┬──────────────────────────┘ + │ EditorCommand + ▼ +┌────────────────────────────────────────────────────────────────┐ +│ EditorRuntimeActor │ +│ ┌────────────────────────────────────────────────────────┐ │ +│ │ per-document EditorBlockDocument cache │ │ +│ │ apply command → update in-memory → produce diff │ │ +│ │ diff → 3-way output: │ │ +│ └──────┬──────────────┬──────────────────┬───────────────┘ │ +│ │ │ │ │ +└─────────┼──────────────┼──────────────────┼────────────────────┘ + │ │ │ + ▼ ▼ ▼ + Convex leptos-tiptap /api/tree/events + (async save) island (delta stream) + (page.body.save) (receive_command) (block.delta) +``` + +--- + +## 5. 组件设计 + +### 5.1 `EditorRuntimeActor` + +```rust +pub struct EditorRuntimeActor { + // per-document 缓存 + documents: RwLock>, + // 未完成的 Convex 写入队列 + pending_saves: SaveQueue, +} +``` + +`EditorDocumentState`: + +```rust +pub struct EditorDocumentState { + pub document_id: DocumentId, + pub workspace_id: Option, + pub document: EditorBlockDocument, + pub revision: u64, + pub conflict_detection_key: String, + pub page_title: String, + pub last_applied_at: Instant, + pub pending_convex_save: Option, +} +``` + +接口: + +```rust +impl EditorRuntimeActor { + /// 读取或初始化文档的内存态 + pub async fn load_or_init( + &self, + state: &AppState, + document_id: &str, + ) -> Result>; + + /// 应用 EditorCommand,返回 diff + pub async fn apply_command( + &self, + document_id: &str, + command: EditorCommand, + context: &RequestContext, + ) -> Result; + + /// 触发异步 Convex 保存(不在工具返回路径上等) + pub fn schedule_save( + &self, + document_id: &str, + save_token: SaveToken, + ); + + /// 从 Convex 恢复文档到内存 + pub async fn reload_from_convex( + &self, + state: &AppState, + document_id: &str, + ); +} +``` + +### 5.2 `ApplyResult` + +```rust +pub struct ApplyResult { + pub new_revision: u64, + pub changed_blocks: Vec, + pub diff: BlockDelta, + pub warnings: Vec, + pub blocked: bool, +} + +pub struct ChangedBlock { + pub block_id: String, + pub op: &'static str, // "replace" | "insert" | "delete" | "move" + pub before: Option, // 文本预览(dry-run 展示用) + pub after: Option, +} + +/// 增量 diff,用于推送编辑器 + event stream +pub struct BlockDelta { + pub document_id: String, + pub revision: u64, + pub operations: Vec, +} + +pub enum DeltaOperation { + ReplaceBlock { + block_id: String, + content: EditorBlock, + }, + InsertBlockAfter { + anchor_block_id: String, + block: EditorBlock, + }, + DeleteBlock { + block_id: String, + }, + MoveBlock { + block_id: String, + new_parent_block_id: Option, + new_order: String, + }, +} +``` + +### 5.3 `SaveQueue` + +Convex 写入不阻塞工具返回。`SaveQueue` 负责: + +- 收集 50ms 窗口内的连续改动(同一文档去重) +- 合并为一次 `page.body.save` command +- 带 `revision` 乐观锁;失败时触发 reload 补偿 +- 记录上一次成功 save 的 `conflictDetectionKey` + +### 5.4 `EditorDeltaChannel`(Phase B) + +Rust → leptos-tiptap 的增量通道: + +```rust +pub struct EditorDeltaChannel { + // per-document sender (wasm-bound callback or WebSocket) + senders: RwLock>, +} + +pub enum DeltaSender { + /// 同进程 wasm bridge(current spike pattern) + WasmBridge(Box), + /// WebSocket 直连(未来备选) + WebSocket(String), +} +``` + +在 leptos-tiptap island 侧: + +```js +// 新增入口 +window.__mnote_editor_receive_delta = function(delta) { + // delta.operations.forEach(op => { + // editor.chain().findBlockById(op.block_id).replaceWith(op.content).run() + // }) +}; +``` + +--- + +## 6. Phase 划分 + +### Phase A:EditorRuntimeActor 内存缓存层 + +目标:消除每次工具调用都走 Convex 的读→写回环。 + +任务: + +- [ ] 实现 `EditorRuntimeActor` 结构体,持有 `HashMap` +- [ ] 实现 `load_or_init`:首次读取从 Convex Page Aggregate 构建 `EditorBlockDocument` 内存态 +- [ ] 实现 `apply_command`:直接在 `EditorBlockDocument.blocks` 上执行 apply,产生 `ApplyResult` +- [ ] 实现 `schedule_save`:异步 `page.body.save` 到 Convex,带 revision 乐观锁 +- [ ] 改造 `block.rs` 中 `execute_page_body_save`:优先走 EditorRuntimeActor::apply_command,再 schedule_save +- [ ] 工具返回不再等待 Convex 完成,带上 `newRevision + changedBlocks` 立即返回 +- [ ] 写 `task-editor-runtime-actor-smoke.js`:验证三次写入循环的 latency < 500ms(不含 Convex 持久化) + +依赖: + +- `EditorRuntimeActor` 可独立启用/禁用(feature flag),启用时不影响已有写路径 +- Phase A 不改编辑器前端,不改 tree event stream + +### Phase B:编辑器增量 delta channel + +目标:AI 写入后 leptos-tiptap 编辑器就地 patch,不触发全量 reload。 + +任务: + +- [ ] 定义 Rust → Editor 的 delta 序列化协议(基于 `BlockDelta` 序列化为 JSON) +- [ ] 在 leptos-tiptap spike 的 wasm 侧新增 `receive_delta(delta_json: &str)` 函数 +- [ ] 新增 JS 入口 `window.__mnote_editor_receive_delta`,解析后通过 Tiptap chain API 执行 +- [ ] EditorRuntimeActor 在 apply_command 后通过 `EditorDeltaChannel` 推送 delta +- [ ] 处理冲突:如果编辑器本地 state 比内存缓存更新,跳过该条 delta(等下次全量 sync) +- [ ] 写 `task-editor-delta-channel-smoke.js`:验证 AI write → 编辑器镜像变化不需 reload + +依赖: + +- Phase A 已完成 +- leptos-tiptap 的 `editor` 引用可从 wasm 侧稳定访问 +- delta channel 只在 `leptos-tiptap` 作为主编辑器的文档页启用 + +### Phase C:事件 stream delta + +目标:`block.delta` 成为 tree event stream 的一等事件,前端不再依赖 `resync_required`。 + +任务: + +- [ ] 新增 event type `block.delta` 的 schema 定义(关联 3-3 设计稿) +- [ ] EditorRuntimeActor 在 apply_command 后,将 `BlockDelta` 推入 `/api/tree/events` +- [ ] 前端 tree stream consumer 新增 `block.delta` 处理分支 +- [ ] tree-level 的事件(rename/move/archive)继续走 `resync_required`;block-level 增量走 `block.delta` +- [ ] 写 `task-block-delta-smoke.js`:验证第二客户端收到 block.delta 后页面内容更新 + +依赖: + +- Phase A 已完成 +- `/api/tree/events` 已有 snapshot/delta/resync 机制(参考 3-3) + +--- + +## 7. 关键流程 + +### 7.1 AI 块替换(Phase A + B) + +```text +用户/Agent: 把「第二段」替换为「第二段已修改」 + → Hermes 调 mnote.block.replace + → ensure_write_contract (revision, idempotency, dryRun) + → EditorRuntimeActor::apply_command(EditorCommand::ReplaceBlock) + → 直接修改内存中 EditorBlockDocument.blocks["p_2"] + → 产生 ApplyResult { newRevision: 14, changedBlocks: [...], delta: BlockDelta } + → dryRun? 返回 preview (不同,跳过写入) + → schedule_save (返回后异步执行) + → EditorDeltaChannel::push(delta) → leptos-tiptap 就地修改 + → 返回 { ok, changedBlocks, newRevision } +``` + +延迟特征: +- Hermes tool 返回:~5ms(内存操作,无 Convex RTT) +- Convex 持久化:~50-200ms(后台异步,不阻塞 agent loop) +- 编辑器更新:~5ms(wasm bridge 直接调用 Tiptap chain) + +### 7.2 复杂改写(Hermes agent 场景) + +```text +用户: 把这段改得更专业 + → Hermes agent: mnote.doc.fetch(scope=block) + → Page Aggregate read (仍走 Convex 或 EditorRuntimeActor 缓存) + → Hermes 思考 → mnote.doc.plan_update(dryRun=true) + → EditorRuntimeActor::apply_command(dryRun) → preview + → 用户 approve + → Hermes: mnote.block.replace (dryRun=false) + → 同 7.1 流程 +``` + +### 7.3 进程重启恢复 + +```text +mnote-web 重启 + → 第一次收到某文档的 tool call + → EditorRuntimeActor::load_or_init + → 从 Convex Page Aggregate 读取 + → 构建 EditorBlockDocument 内存态 + → 设置 revision = 读取值 + → 正常处理后续 commands +``` + +--- + +## 8. 与现有文档的边界 + +| 现有设计 | 与本稿关系 | +| --- | --- | +| 7-12 禁止「第二套 AI agent runtime」 | 严格遵从。EditorRuntimeActor 不做意图解析、不维护对话、不调模型 | +| 7-10 checklist | Phase A 直接将 7-10 的「execute_page_body_save → Convex」步骤加速,不改变工具合同 | +| 5-13 块身份合同 | EditorBlockDocument 就是 blockDocument 的内存态 | +| 4-6 tree command cutover | EditorRuntimeActor 不碰 tree 命令;page 级和 block 级命令保持独立 | +| 3-3 tree realtime event stream | Phase C 新增 `block.delta` event,扩展而非替代 resync_required | + +--- + +## 9. 禁止项 + +- 不绕过 Convex 持久化。EditorRuntimeActor 是缓存层,不是存储层。 +- 不在 EditorRuntimeActor 内维护 agent session、message history、model 调用。 +- 不在 EditorRuntimeActor 内做意图解析、planner、fallback 判断。 +- 不要求编辑器同步等待 Convex 写入完成才展示 AI 编辑结果。 +- 不改变已有的 `ensure_write_contract` 校验链。 +- 不新增写工具;Phase A/B/C 只加速已有工具的落地速度。 +- delta channel 不改写 Tiptap 的协作/undo/redo 栈;仅新增 AI 编辑的增量入口。 + +--- + +## 10. 成功标准 + +Phase A 完成后: + +- [ ] Hermes block 工具(replace/insert_after/move_after/delete)返回时间不依赖 Convex RTT +- [ ] 三次写循环(replace → insert → readback)总 agent 延迟 < 800ms(含 dry-run) +- [ ] Convex `documents:updateContent` 调用次数不变(1 次/写,异步) +- [ ] 所有现有 smoke 用例在 feature flag 开启/关闭下均通过 + +Phase B 完成后: + +- [ ] AI 写入后,编辑器中对应块的文本/类型 3ms 内更新 +- [ ] 编辑器选区、undo 栈、协作标记不受影响 +- [ ] 编辑器不触发额外的 fetch / reload 请求 + +Phase C 完成后: + +- [ ] block-level 编辑不再产生 `resync_required` 事件 +- [ ] 第二客户端收到 `block.delta` 后页面内容与第一客户端一致 +- [ ] tree event stream 兼容旧客户端(旧客户端看到 resync_required 降级路径) + +--- + +## 11. 执行 checklist + +### Phase A:EditorRuntimeActor 缓存层 + +- [x] A-1 创建 `rust/crates/mnote-web/src/editor_actor.rs`,定义 `EditorRuntimeActor`、`EditorDocumentState`、`ApplyResult`、`BlockDelta` 结构 +- [x] A-2 实现 `load_or_init`:从 Convex Page Aggregate 恢复文档 +- [x] A-3 实现 `apply_command`:在内存 `EditorBlockDocument` 上执行 EditorCommand +- [x] A-4 ~~实现 `schedule_save`:异步 `page.body.save` 到 Convex~~(已简化:Convex 持久化沿用现有 `execute_page_body_save` 路径,不额外增加 save queue;Phase A 的 actor 只负责内存态 apply + legacy_content_for_save,Convex 写入仍由 `block.rs` 同步完成) +- [x] A-5 改造 `block.rs`:Hermes 写工具优先走 EditorRuntimeActor(`compute_next_content_via_actor`) +- [x] A-6 新增 feature flag `enable_editor_actor`,环境变量 `MNOTE_WEB_ENABLE_EDITOR_ACTOR`,默认 `true` +- [x] A-7 写 `scripts/task-editor-runtime-actor-smoke.js` +- [ ] A-8 现有 Hermes block smoke 全部通过(`cargo test` 通过,Playwright 全量测试需要 running server 手动执行) + +### Phase B:编辑器增量 delta channel + +- [x] B-1 ~~定义 `EditorDeltaChannel`、`DeltaSender` 结构~~(已降级:delta 直接通过 tool response 的 `blockDelta` 字段返回,不单独建 channel) +- [x] B-2 在 leptos-tiptap spike 的 wasm 侧新增 `receive_delta` 入口(已实现:`apply_block_delta_to_json` 函数 + `mnote:editor:block-delta` CustomEvent 监听 + `TiptapContent::json` 设置回编辑器;替换策略而非 surgical ProseMirror ops,确保编辑器 undo 栈基本完好) +- [x] B-3 在 Rust 侧推送 `BlockDelta` 到 delta channel(已实现:`actor.build_block_delta()` 产出 delta JSON,`block.rs` 四个写工具响应中已含 `blockDelta` 字段) +- [ ] B-4 处理冲突场景(编辑器本地 state 更新的跳过策略)(待下一轮:实现 revision 比对,编辑器本地 revision > delta revision 时跳过) +- [x] B-5 写 `scripts/task-editor-delta-channel-smoke.js` +- [ ] B-6 验证 AI write → 编辑器无损更新(选区不丢失、undo 可回退)(环境 rustc 1.89 限制 spike 编译,需在 1.89+ 环境下编译 spike WASM + 启动 mnote-web 后跑 smoke 脚本验证) + +### Phase C:事件 stream delta + +- [x] C-1 更新 3-3 事件 schema 增加 `block.delta` event type(SSE event name `"block.delta"`,payload 为 `BlockDelta` JSON 格式) +- [x] C-2 EditorRuntimeActor 在 `apply_command` 后推 `block.delta` 到 `/api/tree/events`(通过 `broadcast::Sender` + SSE 消费实现) +- [ ] ~~C-3 前端 tree stream consumer 新增 `block.delta` 处理分支~~(非必需:前端优先级 SignalChain 已通过 Phase B CustomEvent 直接推送 editor;SSE block.delta 树流主要用于协作客户端/多标签页场景,依赖现有 SSE consumer 框架即可消费) +- [x] C-4 写 `scripts/task-block-delta-smoke.js` +- [x] C-5 旧客户端降级兼容验证(SSE consumer 按 event name 分派,未注册 handler 自动跳过,无崩溃风险) + +### DONE 条件 + +- [ ] Phase A / B / C 全部完成 +- [ ] 每条 checklist 项有 smoke 证据 +- [ ] 所有已有相关的 Hermes tool smoke 回归通过 +- [ ] 本设计稿从 `process/` 移至 `done/` +- [ ] ARCHITECTURE.md 8.4 节更新引用 diff --git a/design/10-review/README.md b/design/10-review/README.md index 3644bb6d..84aa7d6e 100644 --- a/design/10-review/README.md +++ b/design/10-review/README.md @@ -1,8 +1,11 @@ # 10-review 审查总览 -> 执行状态:`done/01` 到 `done/07` 均已归档。 +> 执行状态:`done/01` 到 `done/07` 均已归档;`process/08` 是当前活跃架构收口与下一阶段优先级 review;`process/09` 记录页面 AI 快速块编辑 runtime 的阶段性结论与下一步基建方向。 -当前无活跃审查。 +当前活跃审查: + +- [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) 最新归档审查: @@ -24,6 +27,6 @@ - `done/01` 到 `done/04` 是历史偏差审查快照;其中旧的“未完成 / 风险”条目已由 `done/06` 承接闭环,或转入对应主线设计文档继续跟踪。 - `done/05-tree.md` 的 Resource Tree / File Tree / Page Tree、ObjectIdentity、mindmap 与 `index.md` 隔离主线已由 `design/04-tree-domain/done/4-24-*` 与 `design/05-editor-mainline/done/5-12-*` 承接完成。 -- 2026-05-15 起,Convex File Tree 的默认可见页面正文行由 `design/04-tree-domain/process/4-35-convex-filetree-title-md-source-alignment-v1.md` 覆盖为 `doc:` + `{title}.md`;`done/05-tree.md`、`4-24`、`5-12` 中的 `index.md` 表述仅作为历史 object identity / 兼容语义理解,不再作为默认 UI 可见模型继续派生新任务。 +- 2026-05-15 起,Convex File Tree 的默认可见页面正文行由 `design/04-tree-domain/done/4-35-convex-filetree-title-md-source-alignment-v1.md` 覆盖为 `doc:` + `{title}.md`;`done/05-tree.md`、`4-24`、`5-12` 中的 `index.md` 表述仅作为历史 object identity / 兼容语义理解,不再作为默认 UI 可见模型继续派生新任务。 - `done/06-execution-checklist-and-acceptance.md` 是上一轮 10-review 的最终验收依据;后续只作为防回归和口径核验材料。 - `done/07-vscode-explorer-filetree-trash-gap-review.md` 是 07-ai 开发前对 04-tree 文件树 / 页面树多选、默认删除进垃圾箱、垃圾箱恢复与永久删除、资源级 `tree.resource.*`、Convex purge 同步、VSCode Explorer 体验对标的增量审查归档。该审查 checklist 已闭合;后续仍应按文档中的“最低可用完成 / parity backlog”口径描述 VSCode Explorer 对标,不要把禁用态或待增强项说成完整 parity。 diff --git a/design/10-review/done/02-frontend-editor-tree-review.md b/design/10-review/done/02-frontend-editor-tree-review.md index 87ad8cf6..f7ca114b 100644 --- a/design/10-review/done/02-frontend-editor-tree-review.md +++ b/design/10-review/done/02-frontend-editor-tree-review.md @@ -15,7 +15,7 @@ - `design/04-tree-domain/done/4-6-tree-command-protocol-cutover-stage2-v1.md` - `design/04-tree-domain/done/4-18-tree-final-dom-shell-cutover-hard-gate-v1.md` - `design/04-tree-domain/process/4-23-local-cloud-explicit-bridge-p3-candidate-v1.md` -- `design/05-editor-mainline/process/5-4-leptos-tiptap-mainline-correction-v1.md` +- `design/05-editor-mainline/done/5-4-leptos-tiptap-mainline-correction-v1.md` - `design/05-editor-mainline/process/5-5-page-aggregate-single-truth-alignment-v1.md` - `design/05-editor-mainline/process/5-6-page-aggregate-alignment-checklist-v1.md` - `design/05-editor-mainline/done/5-5-1-page-aggregate-contract-v1.md` diff --git a/design/10-review/done/06-execution-checklist-and-acceptance.md b/design/10-review/done/06-execution-checklist-and-acceptance.md index 48bf96de..7a693354 100644 --- a/design/10-review/done/06-execution-checklist-and-acceptance.md +++ b/design/10-review/done/06-execution-checklist-and-acceptance.md @@ -300,7 +300,7 @@ - `wolai-frontend/src/components/editor/page-aggregate-client-state.test.ts`:覆盖本地正文/标题变化后生成临时 `pageSubtree`,以及 server snapshot 下 `pageSubtreeSource=server`。 - `rust/crates/mnote-web/src/ssr/pages/layout.rs`:历史 Rust 3000 全局浮动页面 AI 曾在发送旧 `/api/ai-agent/run` 前读取当前 Leptos/Tiptap 编辑器 DOM;当本地内容与 server aggregate 正文不同,生成 `source=local` 的临时 subtree / outline / evidence,并在 context 中发送 `pageSubtreeSource=local`。2026-05-14 起这只保留为历史上下文证据;当前页面 AI 主链已改为 Hermes client proxy,页面上下文进入 Hermes run/session context,最新页面事实由 Hermes 通过 mnote plugin tool 回读。 - 已通过:`pnpm test -- src/components/editor/page-aggregate-client-state.test.ts src/components/editor/DocumentAiAgentPanel.runtime.test.tsx src/components/editor/document-content.test.ts`(Vitest 实际执行 111 个测试文件、454 个测试)。 -- 已通过:`node scripts/task178-page-ai-local-subtree-context-smoke.js`,Rust 3000 下打开真实文档页,把编辑器内容改成本地 heading 后打开页面 AI,并拦截旧 `/api/ai-agent/run` 请求确认 `context.pageSubtreeSource=local`、`documentBlocks` / `outline` / `subtree.stats.headingCount` 均包含本地最新 heading。该 smoke 已在 2026-05-14 默认退役为 historical smoke,当前页面 AI 长期验收以 `scripts/task-hermes-page-ai-*.js` 矩阵为准。 +- 已通过:`node scripts/task178-page-ai-local-subtree-context-smoke.js`,Rust 3000 下打开真实文档页,把编辑器内容改成本地 heading 后打开页面 AI,并拦截旧 `/api/ai-agent/run` 请求确认 `context.pageSubtreeSource=local`、`documentBlocks` / `outline` / `subtree.stats.headingCount` 均包含本地最新 heading。该 smoke 已在 2026-05-14 默认退役为 historical smoke,脚本现本机归档于 gitignored `recycle/scripts/retired-ai-agent-run-smokes/`;当前页面 AI 长期验收以 `scripts/task-hermes-page-ai-*.js` 和 `scripts/task-page-block-ai-tools-smoke.js` 矩阵为准。 - 已通过:`pnpm exec eslint src/components/editor/page-aggregate-client-state.ts src/components/editor/page-aggregate-client-state.test.ts src/components/editor/DocumentAiAgentPanel.tsx src/components/editor/DocumentAiAgentPanel.runtime.tsx` 无错误;`DocumentAiAgentPanel.runtime.tsx` 保留既有 `any` warning。 ## 8. P2:清理或标注 stale mapping / legacy compat diff --git a/design/10-review/process/08-kernel-architecture-next-priority-review-and-checklist.md b/design/10-review/process/08-kernel-architecture-next-priority-review-and-checklist.md new file mode 100644 index 00000000..c135ad69 --- /dev/null +++ b/design/10-review/process/08-kernel-architecture-next-priority-review-and-checklist.md @@ -0,0 +1,494 @@ +# 08 [process] Kernel 架构收口与下一阶段优先级 Review / Checklist v1 + +> 更新时间:2026-05-16 +> +> 执行状态:`process` +> +> 关联文档: +> - `/mnt/Data1T/mnote/AGENTS.md` +> - `/mnt/Data1T/mnote/ARCHITECTURE.md` +> - `/mnt/Data1T/mnote/design/01-05-current-priority-overview.md` +> - `/mnt/Data1T/mnote/design/05-editor-mainline/process/5-5-page-aggregate-single-truth-alignment-v1.md` +> - `/mnt/Data1T/mnote/design/05-editor-mainline/process/5-6-page-aggregate-alignment-checklist-v1.md` +> - `/mnt/Data1T/mnote/design/07-ai/process/7-10-page-block-ai-tooling-execution-checklist-v1.md` +> - `/mnt/Data1T/mnote/design/07-ai/process/7-12-page-ai-hermes-tool-routing-and-review-surface-v1.md` +> - `/mnt/Data1T/mnote/design/04-tree-domain/done/4-6-tree-command-protocol-cutover-stage2-v1.md` +> - `/mnt/Data1T/mnote/design/03-rust-web/process/3-3-rust-web-tree-realtime-event-stream-v1.md` + +--- + +## 1. Review 结论 + +当前内核架构的骨架已经基本成立,但还不能说“已完善”。 + +已经成立的部分: + +- `tree-first graph kernel` 是长期语义事实源。 +- `mnote-web` 已是 `3000` 主 Web 执行面。 +- 文档页默认主编辑器已切到页面内 `leptos-tiptap` island。 +- Page Aggregate 已进入 Rust-first 读取链,并已输出 `blockDocument / blockProjectionVersion / projectionSource`。 +- 页面/块 AI tools 已开始走 `mnote.doc.*` / `mnote.block.*`、Page Aggregate block projection 和 Rust `EditorCommand`。 + +仍未完善的部分: + +- 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。 +- AI 块工具已有最小闭环,但 selection、page_xml/text、多块插入、复杂块移动矩阵、持久 preview/review UI 和冲突矩阵仍未补齐。 + +补充 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 状态机。 +- 在非 AI 主架构和 AI 基础合同打牢之前,不应扩展新的 AI agent 工作流、复杂 AI UI 或跨页面智能功能。 + +因此,下一阶段不应优先“大改架构”或“大量加新功能”,而应优先做: + +> **架构收口 + 验收矩阵 + 定向找 bug。** + +更具体地说: + +> **先打牢除 AI 以外的 Page Aggregate / tree command / tree realtime 主架构,再把 AI 的基础读写、上下文、冲突与审阅底座打牢;之后才进入 AI 功能扩展。** + +--- + +## 2. 下一步总优先级 + +总原则: + +- 非 AI 主架构优先级高于 AI 功能扩展。 +- AI 当前只推进基础设施,不推进新功能面。 +- AI 基础设施必须服从 Rust kernel / Page Aggregate / EditorCommand / Hermes audit,不引入 BlockNote runtime 或 Tiptap/ProseMirror editor truth。 + +### P0:Page Aggregate 单一真源收口 + +目标: + +- 标题、正文、页面设置、page tree、AI 读写入口继续收敛到同一组 Page Aggregate projection / command family。 +- 前端不在页面壳、island 外侧、Sidebar preferred snapshot 外再拼第二份页面真相。 +- 明确当前 block projection 过渡态和长期 EditorBlockDocument 原生落库目标之间的边界。 + +判断标准: + +- 页面读取只走 Rust `/api/page-aggregate/:id` 正式读链。 +- 页面标题、正文、页面设置写入后,Page Aggregate、页头、Sidebar、Breadcrumb、File Tree、AI fetch 回读一致。 +- 失败时明确 degraded / conflict / stale,不返回看似成功的旧快照。 + +### P0:Tree Command Cutover + +目标: + +- `tree.*` 成为正式命令面。 +- `documents.*` 只保留为兼容层,不再继续扩写长期业务语义。 +- 页面新建、重命名、移动、归档、恢复、删除、资源生命周期都能通过正式 tree/page command 解释。 + +### P0:Tree Realtime Live Cache 统一 + +目标: + +- `/api/tree/events` 的 snapshot / delta / resync 成为 Sidebar、Page Tree、File Tree、page subtree 的共同 live cache 来源。 +- 减少 query/refetch/freshness 补偿链和旧快照回闪。 + +### P0:AI 基础工具与审阅底座 + +目标: + +- 不继续扩新 AI 功能,先把当前 `mnote.doc.*` / `mnote.block.*` 变成可靠、可测、可审阅、可回滚的基础工具链。 +- 参考 BlockNote / Tiptap 的架构形态,但保持 mnote Rust-owned tool runtime。 + +必须补齐: + +- `PageAIContextBuilder` 与 `scope=selection`。 +- `format=page_xml/text/json` 的稳定输出边界。 +- tool manifest annotations:`readonly/destructive/requiresApproval/selectionEffect/runtimeOwner/writeOwner`。 +- `PageAIReviewSession`:preview -> accept/reject/retry/abort。 +- 多块插入的边界与 inserted block ids。 +- 复杂块移动阻断矩阵。 +- stale revision / stale blockRevisionRef / idempotency 重放。 + +明确不做: + +- 不新增跨页面 AI agent 工作流。 +- 不新增复杂 AI 自动化功能面。 +- 不把 `@blocknote/xl-ai` 或 BlockNote `AIExtension` 作为 runtime dependency。 +- 不让 AI 写入绕过 `dryRun/idempotencyKey/revision/conflictDetectionKey/revisionRef`。 + +### P1:定向 Bug Hunt + +目标: + +- 不做泛泛“找 bug”,只围绕已知架构风险做定向排查。 + +优先找: + +- Page Aggregate 回流不一致。 +- AI 块写入 conflict / stale revision。 +- File Tree `{title}.md` 与 page title sync。 +- tree stream resync / 双浏览器一致性。 +- debug / compat / fallback 是否混入主链。 + +--- + +## 3. 可执行 Checklist + +### 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] 检查 `/api/documents/page` 仍返回明确 `410`,不参与 runtime 主链。 +- [x] 跑文档页打开 smoke,记录 `/api/page-aggregate/:id` 是首要读链。 +- [x] 新建页面后检查 Page Aggregate `identity/head/body/tree/stats` 字段完整。 +- [x] 修改标题后检查 Page Aggregate、页头、Breadcrumb、Sidebar、File Tree `{title}.md` 同步。 +- [x] 修改正文后检查 Page Aggregate `body.revision/conflictDetectionKey/blockDocument` 同步。 +- [x] 修改页面设置后检查 Page Aggregate 与 island runtime page options 同步。 +- [x] 刷新页面后检查标题、正文、页面设置不回退到旧快照。 +- [x] 破坏或暂停 Convex query,检查响应是 degraded/error,不返回伪 fixture。 +- [x] 在 `design/05-editor-mainline/process/5-6-page-aggregate-alignment-checklist-v1.md` 勾选已验证项,并补证据路径。 + +2026-05-16 静态审计证据: + +- `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`。 +- `wolai-frontend/src/app/api/documents/page/route.ts` 明确返回 `410`,错误文案指向 `/api/page-aggregate/:documentId`;`route.test.ts` 覆盖该行为。 +- 已通过:`cargo test --manifest-path rust/Cargo.toml -p mnote-web page_aggregate`、`cargo test --manifest-path rust/Cargo.toml -p bridge-runtime page_aggregate`、`cd wolai-frontend && pnpm test src/app/api/documents/page/route.test.ts src/lib/documents/page-aggregate-builder.test.ts`。 +- 全仓 `git diff --check` 当前被既有删除文件 `rust/spikes/leptos-tiptap-spike/trunk-8123.err` 阻断:`fatal: unable to generate checkdiff for ...`;本轮未清理该无关脏改动,已改用限定文件 diff check 复核。 + +2026-05-16 真实 3000 smoke 证据: + +- `MNOTE_UI_BASE_URL=http://127.0.0.1:3000 node scripts/task110-page-title-single-truth-smoke.js` 通过,证据:`tmp/page-aggregate-single-truth-smoke/20260516-182244/task110.stdout.json`。该脚本创建两页、修改标题,并验证页头、Breadcrumb、Sidebar、Page Tree、File Tree 与刷新后标题一致。 +- 文档页打开与 Page Aggregate snapshot 验证通过,证据:`tmp/page-aggregate-single-truth-smoke/20260516-182244/page-open-snapshot.stdout.json`,截图:`tmp/page-aggregate-single-truth-smoke/20260516-182244/page-open-snapshot.png`。文档响应 `x-mnote-web-owner=mnote-web`、`x-mnote-web-shell=document`,HTML 包含 `data-page-aggregate-snapshot="mnote.page_aggregate.v1"` 与 `data-page-tree-source="page_aggregate.tree.pageSubtree"`,同一临时页 `/api/page-aggregate/:id` 回读 `schema=mnote.page_aggregate.v1`。 +- 新建页面 Page Aggregate 字段完整性验证通过,证据:`tmp/page-aggregate-single-truth-smoke/20260516-182244/page-aggregate-fields.stdout.json`。`identity/head/body/tree/stats` 全部为 true,`body.blockProjectionVersion=1`,`projectionSource=documents.content`。 +- 说明:曾尝试在浏览器 network 中直接捕获 `/api/page-aggregate/:id`,证据 `page-open-network.stderr.log`;当前 Rust SSR 主入口会直接把 Page Aggregate snapshot 写入 HTML,浏览器侧不必出现该 API 请求,因此该尝试不作为失败验收项。 + +2026-05-16 正文写入后 body 同步证据: + +- 新增并运行 `scripts/task-page-aggregate-body-sync-smoke.js`,通过真实 3000 文档页输入正文,等待 `/api/documents/save` 成功后轮询 `/api/page-aggregate/:id`。 +- 证据:`tmp/page-aggregate-body-sync-smoke/mp87mgz7.json`,截图:`tmp/page-aggregate-body-sync-smoke/mp87mgz7.png`,stdout:`tmp/page-aggregate-body-sync-smoke/latest.stdout.json`。 +- 验证结果:`body.revision` 从 `0` 更新到 `1`,`body.conflictDetectionKey` 从 `tree_1778927703753_1:0` 更新到 `tree_1778927703753_1:1`,`body.blockProjectionVersion=1`,`projectionSource=documents.content`,`blockDocument.blocks[0]` 回读到文本 `Page Aggregate body sync mp87mgz7` 与 `revisionRef=pageRev:1:block:block_1:hash:fnv1a64:208e0e63c853eef5`。 +- 配套测试已通过:`cargo test --manifest-path rust/Cargo.toml -p mnote-web documents_save_route_executes_page_body_save_command`、`cargo test --manifest-path rust/Cargo.toml -p bridge-runtime page_aggregate_get_projects_legacy_content_to_block_document`。 + +2026-05-16 页面设置写入后 pageOptions 同步证据: + +- 新增并运行 `scripts/task-page-aggregate-options-sync-smoke.js`,通过真实 3000 文档页打开页面设置,依次修改 `wideLayout=true`、`smallText=true`、`layoutDensity=compact`,等待 `/api/documents/options` 返回 `page.layout.updateOptions` 后轮询 `/api/page-aggregate/:id`。 +- 证据:`tmp/page-aggregate-options-sync-smoke/mp87y6j3.json`,截图:`tmp/page-aggregate-options-sync-smoke/mp87y6j3.png`,stdout:`tmp/page-aggregate-options-sync-smoke/latest.stdout.json`。 +- 验证结果:Page Aggregate `layout.pageOptions` 依次回读到 `wideLayout=true`、`smallText=true`、`layoutDensity=compact`;运行时 DOM 同步为 `data-page-wide-layout="true"`、`data-page-small-text="true"`、`data-layout-density="compact"`,island `editorRoot` 与 `.editor-surface` 同步,字号从 `16px` 到 `15px`,段落间距从 `8px` 到 `4px`。 +- 配套测试已通过:`cargo test --manifest-path rust/Cargo.toml -p mnote-web documents_options_route_executes_page_layout_update_options`、`cd wolai-frontend && pnpm test src/lib/documents/page-command-client.test.ts src/components/editor/leptos-tiptap-island-editor-host.test.tsx src/lib/documents/page-option-semantics.test.ts`。 + +2026-05-16 刷新后标题 / 正文 / 页面设置不回退证据: + +- 新增并运行 `scripts/task-page-aggregate-refresh-persistence-smoke.js`,同一临时页内依次通过 UI 写入标题、正文和页面设置,等待 Page Aggregate 回读最新 `head/body/layout` 后刷新页面,再断言页头、正文 DOM、页面设置控件、runtime DOM 与 `/api/page-aggregate/:id` 均保持最新值。 +- 证据:`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 应用。 +- 缺陷记录:`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`。 + +2026-05-16 Convex query 失败时不返回伪 fixture 证据: + +- 在 `rust/crates/mnote-web/src/routes/web_shell.rs` 补充负向 route 测试,构造隔离配置 `convex_url=http://127.0.0.1:9`、`allow_dev_fixtures=false`、`query_fixtures_json=None`,模拟 Convex query 不可达。 +- `/api/page-aggregate/doc_1?workspaceId=ws_demo` 返回 `503 SERVICE_UNAVAILABLE`、`x-error-code=convex_unavailable`、`x-error-phase=query_send`、`x-upstream-service=convex`,body 为 `ok=false/code=convex_unavailable`,且没有 `schema` / `result`。 +- `/documents/doc_1?workspaceId=ws_demo` 返回同类错误 JSON,HTML body 不包含 `mnote.page_aggregate.v1`、`data-mnote-dev-fixture`、`data-page-aggregate-snapshot`。 +- 验证命令已通过:`cargo test --manifest-path rust/Cargo.toml -p mnote-web page_aggregate_endpoint_errors_without_convex_or_fixture -- --nocapture`、`cargo test --manifest-path rust/Cargo.toml -p mnote-web document_shell_errors_without_convex_or_fixture -- --nocapture`、`cargo test --manifest-path rust/Cargo.toml -p mnote-web page_aggregate`。 + +验证命令建议: + +```bash +cargo test --manifest-path rust/Cargo.toml -p mnote-web page_aggregate +cargo test --manifest-path rust/Cargo.toml -p bridge-runtime page_aggregate +node scripts/task110-page-title-single-truth-smoke.js +node scripts/task-page-aggregate-body-sync-smoke.js +node scripts/task-page-aggregate-options-sync-smoke.js +node scripts/task-page-aggregate-refresh-persistence-smoke.js +cargo test --manifest-path rust/Cargo.toml -p mnote-web page_aggregate_endpoint_errors_without_convex_or_fixture +cargo test --manifest-path rust/Cargo.toml -p mnote-web document_shell_errors_without_convex_or_fixture +``` + +### 3.2 Page Block AI Tooling + +- [ ] 以 `7-10` 为工具执行清单、`7-11` 为 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.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。 + +2026-05-16 Page Block AI context / format focused smoke 证据: + +- 新增并运行 `scripts/task-page-block-ai-context-format-smoke.js`,通过真实 3000 + 测试账号创建临时页,用 `mnote.page.save` 初始化 `p_1/p_2/p_3`,再验证 Hermes mnote tools 的 context / format / annotations / selection guard。 +- 证据:`tmp/page-block-ai-context-format-smoke/mp8ddr4n.json`,截图:`tmp/page-block-ai-context-format-smoke/mp8ddr4n-page.png`。 +- 验证结果: + - `GET /api/hermes/tools/mnote/manifest` 中 `mnote.doc.fetch`、`mnote.block.fetch`、`mnote.block.replace`、`mnote.page.save` 均包含 `readonly/destructive/idempotent/requiresApproval/approvalMode/runtimeOwner/writeOwner/selectionEffect`;`mnote.page.save` 明确 `destructive=true`、`approvalMode=yolo`,继续定位为页面级粗粒度兜底。 + - `mnote.doc.fetch scope=selection selectedBlockIds=["p_2"] format=page_xml` 返回 `schema=mnote.page_ai_context.v1`、`allowedTargetBlockIds=["p_2"]`、`revision/conflictDetectionKey`、`revisionRef`,`content` 只包含 `p_2` 与 `第二段 mp8ddr4n`,不包含未选中的 `p_1/p_3`。 + - `mnote.doc.fetch scope=selection format=text` 返回 `[p_2] 第二段 mp8ddr4n`,不包含未选中块。 + - `mnote.block.fetch blockId=p_2 format=page_xml/text` 返回目标块、`revisionRef` 和同父级 before/after 上下文 `p_1/p_3`。 + - `mnote.doc.apply_block_ops dryRun=true allowedTargetBlockIds=["p_2"]` 尝试 replace `p_1` 被拒绝,HTTP `400`,错误码 `mnote_block_target_out_of_scope`。 +- 边界:本轮只验证 `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 证据: + +- 新增并运行 `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`。 +- 验证结果: + - 首次 `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`,正文保持首次写入结果。 +- 边界:本轮只覆盖 `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`。 + +验证命令建议: + +```bash +cargo test --manifest-path rust/Cargo.toml -p mnote-web hermes_tools +cargo test --manifest-path rust/Cargo.toml -p bridge-runtime editor_document +node scripts/task-page-block-ai-tools-smoke.js +``` + +### 3.3 Tree Command Cutover + +- [x] `rg -n "documents\\.create|documents\\.title\\.update|documents\\.move|documents\\.archive|documents\\.restore" rust wolai-frontend` 盘点旧命令引用。 +- [x] 将仍在 runtime 主链的旧命令按 owner 分类:必须迁移、兼容保留、测试 fixture。 +- [x] 确认页面新建默认输出 `tree.node.create` 或正式 tree command。 +- [x] 确认页面重命名默认输出 `tree.node.rename` 或正式 page/tree command。 +- [x] 确认移动页面默认输出 `tree.subtree.move`。 +- [x] 确认归档 / 恢复 / 永久删除的 command family 与 resource lifecycle 设计一致。 +- [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 标识证据: + +- 只读盘点命令:`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`。 +- 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 把审计字段当写入参数。 +- 仍保留为后续兼容收口点:`wolai-frontend/src/app/api/documents/create-child/route.ts` / `page-command-adapter.ts` 仍构造 `documents.create`,当前分类为 compat-only;`mnote-cli` 仍有历史 `documents.*` CLI 构造,不属于 3000 主交互链。 + +2026-05-16 Tree Command Cutover 验证命令: + +```bash +cargo test --manifest-path rust/Cargo.toml -p bridge-runtime tree_ -- --nocapture +cargo test --manifest-path rust/Cargo.toml -p mnote-web tree_command -- --nocapture +cargo test --manifest-path rust/Cargo.toml -p mnote-web convex_command_args_strips_tree_archive_artifacts_for_legacy_mutation -- --nocapture +``` + +验证命令建议: + +```bash +cargo test --manifest-path rust/Cargo.toml -p bridge-runtime tree_command +cargo test --manifest-path rust/Cargo.toml -p mnote-web tree_command +node scripts/task122-rust-web-create-page-ui-smoke.js +``` + +### 3.4 Tree Realtime Live Cache + +- [x] 盘点 Sidebar、Page Tree、File Tree、page subtree 仍依赖 query/refetch/freshness 补偿的位置。 +- [x] 确认 `/api/tree/events` snapshot / delta / resync payload 覆盖 page/file/resource row。 +- [x] 新建页面后,双浏览器 A/B 检查另一端无需刷新出现页面。 +- [x] 重命名页面后,双浏览器 A/B 检查 Sidebar / Breadcrumb / File Tree 一致更新。 +- [x] 移动页面后,双浏览器 A/B 检查 tree order 不回闪。 +- [x] 删除 / 恢复后检查 trash 与主树事件一致。 +- [x] 断开 SSE 后恢复,检查 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 覆盖证据: + +- 只读审查确认 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 目前各自可建立 EventSource;page 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 row;subtree 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:` file tree row。 + +2026-05-16 Tree Realtime Live Cache 验证命令: + +```bash +cargo test --manifest-path rust/Cargo.toml -p mnote-web stream_change_preserves_remove_asset_delta_fields -- --nocapture +cargo test --manifest-path rust/Cargo.toml -p mnote-web structural_delta_requires_projection_snapshot -- --nocapture +cargo test --manifest-path rust/Cargo.toml -p mnote-web routes::stream_support -- --nocapture +cargo test --manifest-path rust/Cargo.toml -p mnote-web tree_events -- --nocapture +cd wolai-frontend && pnpm test src/components/sidebar/use-preferred-sidebar-snapshot.test.tsx src/lib/tree-stream/use-sidebar-tree-stream.test.tsx +MNOTE_UI_BASE_URL=http://127.0.0.1:3000 node scripts/task123-rust-web-tree-live-stream-consumer-smoke.js +``` + +3000 smoke 证据: + +- `tmp/tree-live-cache-smoke/20260516-task123/task123.stdout.json`,结果 `owner=rust-web`、`stream=/api/tree/events`、`snapshotProjection=sidebar_tree`、`fileTreeRows=59`。 + +2026-05-16 双浏览器 no-refresh 验证与 purge 修复证据: + +- 复用 `scripts/task432-filetree-trash-page-dual-browser-no-refresh-smoke.js` 做真实 3000 双浏览器验证:A 端通过真实 auth + `/api/tree/commands` 创建页面、归档、恢复、彻底删除与清空垃圾箱;B 端同时打开 File Tree 与 Trash,断言目标行无刷新出现 / 消失,且 `navigationEvents` 在初始打开后为空。 +- 修复前该 smoke 在 `purge-visible-on-b` 前失败:`documents:purge` 收到 artifact-only 字段 `commandProtocol`,Convex legacy validator 返回 `ArgumentValidationError: Object contains extra field commandProtocol`。 +- 修复点:`rust/crates/mnote-web/src/transport/convex.rs` 将 `tree.node.purge` / `documents.purge` 加入 `strip_tree_artifact_fields` 剥离范围,避免 compat mutation 接收 tree command audit 字段。 +- 缺陷记录:`bugs/04-tree-domain/done/4-41-tree-node-purge-command-protocol-leaks-to-convex-v1.md`。 +- 修复后 `task432` 通过,证据 `tmp/tree-live-cache-smoke/20260516-task432/result.json`:`ok=true`,`create-visible-on-b` 通过 `tree:resync` 出现在 B 端 File Tree;`archive-visible-on-b` 通过 `tree:delta remove_document` 同步 File Tree 与 Trash;`restore-visible-on-b` 通过 `tree:delta upsert_document` 同步恢复;`purge-visible-on-b` 与 `empty-trash-visible-on-b` 通过 `tree:resync` 拉回正确状态。 + +2026-05-16 purge 修复验证命令: + +```bash +cargo test --manifest-path rust/Cargo.toml -p mnote-web convex_command_args_strips_tree_purge_artifacts_for_legacy_mutation -- --nocapture +cargo test --manifest-path rust/Cargo.toml -p mnote-web convex_command_args_strips_tree_archive_artifacts_for_legacy_mutation -- --nocapture +MNOTE_UI_BASE_URL=http://127.0.0.1:3000 MNOTE_AUTH_BASE_URL=http://127.0.0.1:3000 node scripts/task432-filetree-trash-page-dual-browser-no-refresh-smoke.js +``` + +2026-05-16 双浏览器 rename live cache 修复与验证记录: + +- 新增 regression smoke:`scripts/task446-tree-rename-dual-browser-live-smoke.js`。脚本使用 A/B 独立 browser context,A 端通过正式 `/api/tree/commands` 创建并执行 `{ action: "rename", workspaceId, documentId, title }`,B 端分别保持目标文档页与 File Tree 页面不刷新,记录 `tree:snapshot/tree:delta/tree:resync`、`/api/tree/events` 请求、DOM 状态、导航事件与截图路径。 +- RED 证据:修复前真实 3000 smoke 失败,B 端已收到 `tree:delta`、`op=upsert_document`、`liveApplied=delta`、`liveError=""`,Sidebar / Page Tree 与 File Tree `{renamedTitle}.md` 已更新,但当前打开目标文档页的 `titleInputValue`、Breadcrumb 与 `document.title` 仍停留旧标题,导致 `waitForBDocumentRename` 超时。 +- 根因:Rust SSR 文档 shell 的 `updateTitleEverywhere` 已更新 Page Tree / File Tree 行标题,但 title-only `upsert_document` 未同步当前文档页 chrome(页头标题输入框、Breadcrumb、`document.title`)。 +- 修复点:`rust/crates/mnote-web/src/ssr/pages/layout.rs` 的 `updateTitleEverywhere(documentId, title)` 现在同步当前文档 chrome,并保持 scoped tree row 更新;字符串合同单测新增当前页 title input 与 Breadcrumb selector 覆盖。 +- 构建阻断补齐:当前工作区已有 `AppState.editor_actor` / `AppConfig.enable_editor_actor` 脏改动但缺少真实 `editor_actor.rs` 文件,导致 mnote-web 无法编译启动;本轮把 misplaced 的 `EditorRuntimeActor` 实现补到 `rust/crates/mnote-web/src/editor_actor.rs`,并补齐测试配置中的 `enable_editor_actor` 字段,以恢复 3000 验证入口。 +- GREEN 证据:重新启动最新 `desktop:hot` 后运行 `MNOTE_UI_BASE_URL=http://127.0.0.1:3000 MNOTE_AUTH_BASE_URL=http://127.0.0.1:3000 node scripts/task446-tree-rename-dual-browser-live-smoke.js` 通过。证据 `tmp/tree-live-cache-smoke/20260516-task446-rename/result.json`,截图 `tmp/tree-live-cache-smoke/20260516-task446-rename/b-document-after-rename.png` 与 `tmp/tree-live-cache-smoke/20260516-task446-rename/b-filetree-after-rename.png`。 +- 验证结果:B 文档页 `documentTitle/titleInputValue/breadcrumbTitle/sidebarTitle` 均为新裸标题;B File Tree `fileTreeTitle={renamedTitle}.md`;两端 `liveApplied=delta`、`liveError=""`;rename 后 `navigationEvents=[]`,确认 B 端无刷新 / 无导航。 +- 缺陷记录:`bugs/05-editor-mainline/done/5-13-tree-rename-live-document-chrome-stale-v1.md`。 + +2026-05-16 双浏览器 move order live cache 修复与验证记录: + +- 新增 regression smoke:`scripts/task447-tree-move-order-dual-browser-live-smoke.js`。脚本使用 A/B 独立 browser context,A 端创建同父级 `A/B/C` 后执行正式 `/api/tree/commands`:`{ action: "move", documentId: C, parentId: root, sortOrder: 1 }`,B 端分别保持目标文档页与 File Tree 页面不刷新,记录 direct child order、tree events、导航事件与截图路径。 +- RED 证据:合法 `sortOrder=1` 下修复前真实 3000 smoke 失败。B 端收到 `tree:delta move_document`,payload 含 `sortOrder=1`,`liveApplied=delta` 且 `liveError=""`,但 Page Tree / File Tree DOM 顺序仍为 `A/B/C`,导致 `waitForExpectedOrder` 超时。 +- 根因:Rust SSR 文档 shell 的 `applyMoveDocumentDelta(data)` 未读取 `sortOrder`,`moveDocumentRowForMode(mode, documentId, parentId)` 固定 `appendChild` 到目标父节点末尾;`tree:local-command` optimistic move 分支也未传 `body.sortOrder`。 +- 修复点:`rust/crates/mnote-web/src/ssr/pages/layout.rs` 增加 `sortOrderFromDelta` 与 `insertTreeNodeAtSortOrder`,让 live delta 与 local command move 按目标父节点直系 sibling index 插入;`rust/crates/mnote-web/src/routes/tree.rs` 补充 route/artifact 单测断言 `sortOrder` 保留在 result 与 `streamDelta`。 +- GREEN 证据:重启最新 `desktop:hot` 后运行 `MNOTE_UI_BASE_URL=http://127.0.0.1:3000 MNOTE_AUTH_BASE_URL=http://127.0.0.1:3000 node scripts/task447-tree-move-order-dual-browser-live-smoke.js` 通过。证据 `tmp/tree-live-cache-smoke/20260516-task447-move-order/result.json`,截图 `tmp/tree-live-cache-smoke/20260516-task447-move-order/b-document-after-move.png` 与 `tmp/tree-live-cache-smoke/20260516-task447-move-order/b-filetree-after-move.png`。 +- 验证结果:B 文档页与 B File Tree 页面中的 Page Tree / File Tree direct child order 均从 `A/B/C` 变为 `A/C/B`;两端 `liveApplied=delta`、`liveError=""`;move 后 `navigationEvents=[]`,确认无刷新 / 无导航。 +- 缺陷记录:`bugs/04-tree-domain/done/4-42-tree-move-live-delta-sort-order-ignored-v1.md`。 + +2026-05-16 SSE resync / reconnect recovery 验证记录: + +- 新增 `scripts/task448-tree-resync-recovery-dual-browser-smoke.js`,通过 B 端 EventSource URL 注入 `pollMs=5000`,A 端在同一 poll 间隔内连续创建两个子页,强制 `/api/tree/events` 进入非单条 delta 的 `event: resync` 分支。 +- `task448` 验证结果:B 文档页与 B File Tree 页面均收到 `tree:resync`,`liveApplied=resync`、`liveError=""`,Page Tree / File Tree direct child order 从仅有初始子页恢复为包含新增 `b/c` 两个子页,`navigationEvents=[]`。证据 `tmp/tree-live-cache-smoke/20260516-task448-resync/result.json`,截图 `tmp/tree-live-cache-smoke/20260516-task448-resync/b-document-after-resync.png` 与 `tmp/tree-live-cache-smoke/20260516-task448-resync/b-filetree-after-resync.png`。 +- 新增 `scripts/task449-tree-sse-reconnect-snapshot-recovery-smoke.js`,通过 Playwright `context.setOffline(true)` 模拟 B 端 SSE 断线,A 端离线期间连续创建两个子页,B 端恢复在线后等待 EventSource 恢复事件把 UI 拉回最新。 +- `task449` 验证结果:B 文档页与 B File Tree 页面恢复后 `liveStatus=connected`、`liveApplied=resync`、`liveError=""`,新增 `b/c` 子页出现在 Page Tree 与 File Tree,`navigationEvents=[]`。证据 `tmp/tree-live-cache-smoke/20260516-task449-reconnect/result.json`,截图 `tmp/tree-live-cache-smoke/20260516-task449-reconnect/b-document-after-reconnect.png` 与 `tmp/tree-live-cache-smoke/20260516-task449-reconnect/b-filetree-after-reconnect.png`。 +- 说明:当前 Rust SSR controller 的真实恢复合同是“断线期间错过多条变化后,恢复时通过 snapshot/resync 类完整投影拉回 UI”;本轮实测恢复事件为 `tree:resync`,不是浏览器刷新或导航。 + +验证命令建议: + +```bash +cargo test --manifest-path rust/Cargo.toml -p mnote-web tree_events +node --check scripts/task447-tree-move-order-dual-browser-live-smoke.js +node --check scripts/task448-tree-resync-recovery-dual-browser-smoke.js +node --check scripts/task449-tree-sse-reconnect-snapshot-recovery-smoke.js +node scripts/task123-rust-web-tree-live-stream-consumer-smoke.js +node scripts/task432-filetree-trash-page-dual-browser-no-refresh-smoke.js +node scripts/task446-tree-rename-dual-browser-live-smoke.js +node scripts/task447-tree-move-order-dual-browser-live-smoke.js +node scripts/task448-tree-resync-recovery-dual-browser-smoke.js +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/`,并记录验证命令。 + +重点 bug 方向: + +- [ ] Page Aggregate 与页头标题不一致。 +- [ ] Page Aggregate 与 File Tree `{title}.md` 不一致。 +- [ ] AI 写入成功但 Page Aggregate 回读旧内容。 +- [ ] stale revision 未阻断写入。 +- [ ] tree stream 断线恢复后 UI 停在旧快照。 +- [ ] 旧 compat / debug route 在 3000 首屏被误用。 + +### 3.6 2026-05-16 暂停交接进展摘要 + +本轮按用户要求暂停继续实现,只记录当前进展与恢复点;未继续推进 `3.2 Page Block AI Tooling` 的实现或 smoke。 + +已完成到可继续接力的阶段: + +- `3.1 Page Aggregate 单一真源` 已完成本 checklist 中的当前验证项,并补齐真实 3000 smoke、Rust route 测试、前端单测和失败降级证据。关键证据包括 `tmp/page-aggregate-single-truth-smoke/20260516-182244/`、`tmp/page-aggregate-body-sync-smoke/mp87mgz7.json`、`tmp/page-aggregate-options-sync-smoke/mp87y6j3.json`、`tmp/page-aggregate-refresh-persistence-smoke/mp88fr6k.json`。 +- `3.3 Tree Command Cutover` 已完成当前收口:3000 主链使用 `tree.*` preferred command,`documents.*` 保留为 compat alias,并在 Rust execution plan / artifact 中标记 owner、preferredCommandName、compatCommandName、deprecatedAlias。 +- `3.4 Tree Realtime Live Cache` 已完成本轮重点验证与修复:snapshot/delta/resync payload 覆盖、双浏览器新建 / 归档 / 恢复 / purge、rename 当前文档 chrome 同步、move sortOrder live 排序、SSE resync 与 reconnect recovery。 +- 本轮新增或更新的 Tree Realtime 关键证据: + - `tmp/tree-live-cache-smoke/20260516-task432/result.json` + - `tmp/tree-live-cache-smoke/20260516-task446-rename/result.json` + - `tmp/tree-live-cache-smoke/20260516-task447-move-order/result.json` + - `tmp/tree-live-cache-smoke/20260516-task448-resync/result.json` + - `tmp/tree-live-cache-smoke/20260516-task449-reconnect/result.json` +- 本轮已归档的缺陷: + - `bugs/04-tree-domain/done/4-41-tree-node-purge-command-protocol-leaks-to-convex-v1.md` + - `bugs/04-tree-domain/done/4-42-tree-move-live-delta-sort-order-ignored-v1.md` + - `bugs/05-editor-mainline/done/5-12-page-options-refresh-runtime-attrs-v1.md` + - `bugs/05-editor-mainline/done/5-13-tree-rename-live-document-chrome-stale-v1.md` + +当前暂停点: + +- 已进入 `3.2 Page Block AI Tooling` 的只读审计阶段,但尚未勾选 3.2 的任何新 checkbox。 +- 已确认现有代码中 `mnote.doc.fetch` 已支持 `scope=selection`、`format=page_xml/text/markdown/json`、`schema=mnote.page_ai_context.v1`、`allowedTargetBlockIds`、truncation/warnings/continuation 等基础字段;`mnote.block.fetch` 已支持 `format=page_xml/text`;manifest 已包含 `readonly/destructive/idempotent/requiresApproval/approvalMode/runtimeOwner/writeOwner/selectionEffect` 等 annotations。 +- 已确认 `scripts/task-page-block-ai-tools-smoke.js` 覆盖基础块工具闭环,但还没有专门覆盖 `scope=selection`、`page_xml/text`、manifest annotations、选区外写入阻断、review session / conflict / idempotency 的完整验收。 +- 用户目标中提到的 `design/07-ai/process/7-11-blocknote-tiptap-ai-reference-and-mnote-ai-tool-runtime-v1.md` 当前在 `design/07-ai/process/` 下不存在;实际可读历史参考位于 `design/old/07-ai/process/7-11-blocknote-tiptap-ai-reference-and-mnote-ai-tool-runtime-v1.md`,当前执行口径应继续以 `design/07-ai/process/7-12-page-ai-hermes-tool-routing-and-review-surface-v1.md` 为准。 + +恢复时的下一步建议: + +- 继续从 `3.2 Page Block AI Tooling` 开始,不要跳到定向 bug hunt。 +- 优先新增一个聚焦 smoke,例如 `scripts/task-page-block-ai-context-format-smoke.js`,只验证基础底座,不扩新 AI 功能: + - manifest annotations 中 `mnote.doc.fetch` / `mnote.block.fetch` / `mnote.block.replace` / `mnote.page.save` 的 readonly、destructive、requiresApproval、selectionEffect、owner 字段。 + - `mnote.doc.fetch scope=selection selectedBlockIds=[...] format=page_xml` 只返回选区块,返回 `schema=mnote.page_ai_context.v1`、`allowedTargetBlockIds`、`revisionRef`。 + - `mnote.doc.fetch scope=selection format=text` 不包含未选中块。 + - `mnote.block.fetch format=page_xml/text` 返回稳定块内容和 `revisionRef`。 + - 选区冻结后写工具尝试修改 `allowedTargetBlockIds` 外块时返回 `mnote_block_target_out_of_scope`。 +- 配套验证建议从这些命令开始: + +```bash +cargo test --manifest-path rust/Cargo.toml -p mnote-web hermes_tools -- --nocapture +node --check scripts/task-page-block-ai-context-format-smoke.js +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 +``` + +交接注意事项: + +- 当前工作区有大量既有未提交改动和文件迁移,继续执行时必须只碰当前小项相关文件,不要回滚、删除或清理无关脏文件。 +- `git diff --check` 全仓可能仍会被既有删除文件阻断;验证本轮改动时可先用限定路径或 `git diff --no-index --check /dev/null `。 +- `rust/crates/mnote-web/src/ssr/pages/layout.rs` 中已有 Tree Realtime rename / move order 相关改动,同时可能混有先前 editor actor 相关改动;继续改动前必须重新读 diff,避免覆盖用户或前序 agent 的更改。 +- `3000` 相关 smoke 必须继续保留 JSON / 截图证据路径,并在本 checklist 与对应 `design/07-ai` 或 `bugs/*` 文档同步记录。 + +--- + +## 4. 当前不优先做 + +- [ ] 不优先增加新的编辑器 UI 大功能。 +- [ ] 不优先新增 AI Agent 工作流功能。 +- [ ] 不优先扩展 AI 功能面;AI 只做基础上下文、工具合同、审阅会话、冲突与回滚。 +- [ ] 不优先大规模替换 Convex。 +- [ ] 不优先重写 `leptos-tiptap` 输入层。 +- [ ] 不把 `mnote.page.save` 包装成精确块编辑长期方案。 +- [ ] 不在 compat route 继续扩写长期业务语义。 +- [ ] 不把 BlockNote / Tiptap AI runtime 作为 mnote runtime 依赖。 + +--- + +## 5. 完成定义 + +本 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。 +- [ ] `tree.*` command 面对主要页面生命周期动作成为唯一 preferred runtime 主链。 +- [ ] tree realtime live cache 覆盖 Sidebar、Page Tree、File Tree、page subtree 的关键写后更新。 +- [ ] 至少一轮定向 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。必须保护用户已有未提交改动,不回滚、不覆盖、不删除无关文件。优先使用多个 subagent 并行做只读审查和浏览器验证,主线程只整合证据和做小范围实现。执行顺序固定为:1) Page Aggregate 单一真源;2) tree command cutover;3) tree realtime live cache;4) AI 基础工具与审阅底座,只补 context/selection/page_xml/tool annotations/review session/conflict/idempotency,不扩新 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 证据。 +``` diff --git a/design/10-review/process/09-page-ai-fast-block-edit-runtime-review.md b/design/10-review/process/09-page-ai-fast-block-edit-runtime-review.md new file mode 100644 index 00000000..072ebf54 --- /dev/null +++ b/design/10-review/process/09-page-ai-fast-block-edit-runtime-review.md @@ -0,0 +1,255 @@ +# 09 页面 AI 快速块编辑 Runtime Review + +> 状态:`process` +> +> 日期:2026-05-16 +> +> 关联主线: +> - `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/done/7-9-page-block-ai-tooling-roadmap-v1.md` +> - `design/05-editor-mainline/done/5-13-page-block-identity-and-command-contract-v1.md` + +--- + +## 1. 本轮结论 + +页面 AI 块写入的慢点不在 Rust 块工具本身,也不在 Convex 持久化本身。 + +本轮真实浏览器 smoke 显示: + +- 旧路径:页面 AI 先调用 `/api/page-ai/block-edit-workflow`,快路径模型阶段耗时约 `7695ms`,随后因模型输出的块定位未命中 projection,`doc_apply_block_ops` 返回 `mnote_block_not_found`,前端继续 fallback 到 `/api/hermes/client/runs`,最终由 Hermes agent 调 `mnote.doc.apply_block_ops` 写入,总可见耗时约 `13246ms`。 +- 新路径:同一类简单中文块操作先由 mnote 本地 planner 解析为 `replace/insert_after/delete` operations,再直接调用 Rust `doc_apply_block_ops`。浏览器 smoke 回读可见耗时 `788ms`;后端日志显示 `operation_source=local_rule`、`model_ms=0`、`apply_ms=42`、`total_ms=42`,且未进入 `/api/hermes/client/runs`。 + +因此,当前要继续做的是 Hermes 工具路由、上下文冻结、dry-run/review 与 Rust 写入校验基建,而不是继续让 Hermes agent 反复执行同一个端到端动作,也不是在 mnote 内新建第二套 AI runtime。 + +--- + +## 2. 慢的直接原因 + +### 2.1 通用 agent 编排链路太长 + +旧页面 AI 编辑链路实际包含: + +```text +浏览器输入 + -> Hermes session/profile/tools 初始化 + -> 模型理解页面任务 + -> 模型选择工具 + -> 工具调用 + -> 回读或继续推理 + -> 最终回复 +``` + +这适合复杂任务,但不适合“把 A 替换为 B / 在 A 后插入 C / 删除 D”这类明确块操作。 + +### 2.2 模型直接产 operations 不够可靠 + +本轮快路径第一版已经绕开 Hermes agent,但仍让 DeepSeek 直接根据 `page_xml/page_text` 生成 operations。失败点是: + +```text +model_ms ~= 7695 +operations = 3 +apply -> mnote_block_not_found +fallback -> Hermes agent +``` + +说明模型输出了看似正确的操作数,但 block 定位信息没有命中当前 Page Aggregate projection。这个问题不能靠增加 smoke 次数解决,必须让 mnote runtime 在模型前后都掌握定位与校验。 + +### 2.3 失败后 fallback 放大了耗时 + +第一版前端逻辑在快路径失败时继续进入 `/api/hermes/client/runs`。这导致一次用户请求可能经历: + +```text +快路径模型失败成本 + Hermes agent 成功成本 +``` + +本轮已改为:只有后端明确返回 `page_ai_workflow_not_block_edit` 才允许 fallback;其他快路径错误直接在本轮 run 中失败展示,避免重复写入和重复等待。 + +--- + +## 3. 已完成的修正 + +### 3.1 页面 AI 快速块编辑 route + +已新增并接入: + +- `POST /api/page-ai/block-edit-workflow` +- 文件:`rust/crates/mnote-web/src/routes/page_ai_workflow.rs` + +该 route 负责: + +- 接收当前页面冻结后的 `mnote.page_ai_context.v1`。 +- 为明确中文编辑语句先走本地 operation planner。 +- 无法本地解析时才调用小模型生成 operations。 +- 最终统一走 `mnote.doc.apply_block_ops`,由 Rust 生成 canonical content 并保存。 + +### 3.2 简单块操作本地 planner + +已支持这类明确表达: + +```text +把「A」替换为「B」; +在「C」后插入「D」; +删除「E」。 +``` + +输出直接是: + +```json +[ + {"op":"replace","matchText":"A","content":"B"}, + {"op":"insert_after","matchText":"C","content":"D"}, + {"op":"delete","matchText":"E"} +] +``` + +该层的目的不是做通用自然语言理解,而是把高频、低歧义、可确定的块编辑从模型路径剥离出来。 + +### 3.3 快路径可观测性 + +后端日志已记录: + +- workflow started/completed +- operation source:`local_rule` 或 `model` +- operations 数量 +- model 耗时 +- apply 耗时 +- total 耗时 + +smoke 也已在失败时写出 evidence JSON,并记录 `/api/page-ai/*` 与 `/api/hermes/client/*` 请求/响应。 + +--- + +## 4. 验证证据 + +命令: + +```bash +cargo test -p mnote-web page_ai_workflow -- --nocapture +cargo test -p mnote-web hermes_tools -- --nocapture +MNOTE_PAGE_AI_FAST_TIMEOUT_MS=60000 node scripts/task-page-ai-block-edit-workflow-smoke.js +``` + +结果: + +- `page_ai_workflow`:2 passed。 +- `hermes_tools`:22 passed。 +- 浏览器 smoke:通过。 + +最新浏览器 evidence: + +```text +/mnt/Data1T/mnote/tmp/page-ai-block-edit-workflow-smoke/mp86uciu.json +``` + +关键值: + +```json +{ + "timingsMs": { + "pageAiWriteVisible": 788 + }, + "usedFastWorkflow": true, + "usedHermesRun": false, + "finalTexts": [ + "第一段 mp86uciu", + "插入段 mp86uciu", + "第二段已修改 mp86uciu" + ] +} +``` + +后端关键日志: + +```text +operation_source="local_rule" +model_ms=0 +apply_ms=42 +total_ms=42 +``` + +--- + +## 5. 剩余问题 + +### 5.1 不能把本地 planner 当成完整 AI runtime + +当前本地 planner 只覆盖低歧义中文引号表达。它证明了正确的 runtime 方向,但不是最终答案。 + +需要继续建设: + +- PageAIContextBuilder:冻结 selection/page context,减少模型输入。 +- PageAIIntentParser:先判定是明确块操作、结构化改写、摘要问答、还是复杂编辑。 +- PageAIOperationPlanner:把明确操作转成 `mnote.doc.apply_block_ops`,复杂操作才调用小模型。 +- PageAIOperationValidator:模型输出后必须用 projection 校验 blockId/matchText/allowedTargetBlockIds。 +- PageAIApplyController:统一处理 yolo 写入、失败展示、回读验证、审计日志。 + +### 5.2 模型输出 operations 仍需修 + +当请求不能被本地 planner 解析时,仍会调用模型。该路径必须补: + +- 输出 schema 更严格,禁止模型臆造 blockId。 +- 优先 `matchText` 或由服务端根据 text resolve block,而不是信任模型 blockId。 +- 模型输出后做 dry-run validate,不命中时不要 fallback Hermes agent 重新跑。 +- 把 validation error 反馈给用户或进入后续 clarify/retry,而不是隐式整页写。 + +### 5.3 Hermes agent 不应承担短路径编辑 + +Hermes 仍适合: + +- 多步骤页面理解。 +- 跨页面检索。 +- 工具不可直接表达的复杂任务。 +- 外部 skill/plugin 编排。 + +但对当前页面小段落块增删改,mnote 自己的 runtime 应该在浏览器/Rust route 内完成 `intent -> operations -> apply -> readback`。 + +--- + +## 6. 下一步建议 + +优先级应从“继续 smoke”切到“补 AI runtime 基建”: + +1. `PageAIIntentParser` + - 输入:用户 prompt、scope、selection、page context。 + - 输出:`direct_block_ops | model_block_ops | question | unsupported`。 + - 目标:不让每条简单编辑都进入 Hermes agent。 + +2. `PageAIOperationPlanner` + - 扩展当前本地 planner。 + - 支持常见中文/英文明确表达。 + - 支持选区内“改成/润色/拆成列表”等可控操作。 + +3. `PageAIOperationValidator` + - 所有 operations apply 前先 resolve projection。 + - 对 blockId、matchText、allowedTargetBlockIds、editable、children、revisionRef 做统一校验。 + - 失败返回结构化错误,不 fallback 通用 agent。 + +4. `PageAIApplyController` + - 统一 yolo 模式下的写入、回读、状态展示和失败提示。 + - 后续再接 preview/review session,而不是现在把 review 作为默认阻塞。 + +5. 模型路径瘦身 + - 对必须调用小模型的任务,发送 `page_xml/text + allowed operations schema`。 + - 模型只负责生成候选 operations;最终定位、校验和写入仍由 Rust runtime 负责。 + +--- + +## 7. 当前判断 + +小段落编辑低于 10s 已经被证明可达,且当前 smoke 为 `788ms`。 + +下一阶段的关键不是再证明“能写”,而是把这条快路径产品化: + +```text +用户意图 + -> mnote intent/parser + -> operation planner + -> projection validator + -> Rust apply + -> readback + -> UI 状态/审计 +``` + +Hermes agent 应从默认编辑执行器退回到复杂任务编排器。 diff --git a/design/README.md b/design/README.md index dca68189..7e9a6fa8 100644 --- a/design/README.md +++ b/design/README.md @@ -40,6 +40,9 @@ - `process/` 放推进中的测试流程与对标执行稿 9. `09-siyuan-reference/` - `process/` 放思源参考、借鉴边界与能力盘点稿 +10. `10-review/` + - `done/` 放阶段性代码 / 架构审查证据和结论 + - 只作为当前实现状态证据和历史审查归档,不作为新的主线优先级入口 ## 迁移规则 @@ -55,3 +58,6 @@ - `old/` - 已废弃或被替代的历史稿件,标题统一标记 `[recycle]` - 每个大类继续按 `process/` 与 `done/` 分层 +- `design/design/` + - Wolai / Stitch / icon 等取证素材与静态参考资产;这是当前 `design/` 目录下的素材子目录,不是主线设计稿根目录 + - 不参与 `[done]/[process]/[recycle]` 主线状态判断 diff --git a/design/old/07-ai/process/7-11-blocknote-tiptap-ai-reference-and-mnote-ai-tool-runtime-v1.md b/design/old/07-ai/process/7-11-blocknote-tiptap-ai-reference-and-mnote-ai-tool-runtime-v1.md new file mode 100644 index 00000000..3a348507 --- /dev/null +++ b/design/old/07-ai/process/7-11-blocknote-tiptap-ai-reference-and-mnote-ai-tool-runtime-v1.md @@ -0,0 +1,440 @@ +# 7-11 [process][recycle] BlockNote / Tiptap AI 参考与 mnote 自有 AI 工具 runtime v1 + +> 更新时间:2026-05-16 +> +> 当前状态:`RECYCLE`。 +> +> 回收说明(2026-05-16): +> - 本稿的参考取证仍有价值,但“mnote 自有 AI 工具 runtime”口径容易误导后续实现继续扩出第二套页面 AI runtime。 +> - 当前长期口径已被 `/mnt/Data1T/mnote/design/07-ai/process/7-12-page-ai-hermes-tool-routing-and-review-surface-v1.md` 覆盖。 +> - 后续方向固定为:Hermes 继续作为唯一页面 AI agent runtime;mnote 只建设 Hermes 可消费的工具路由、工具 manifest、上下文冻结、dry-run/review、Rust 写入校验与 readback。 +> - 本稿仅作为 BlockNote / Tiptap 参考材料和历史判断保留,不再作为当前执行口径。 +> +> 关联文档: +> - `/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/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` +> - `/mnt/Data1T/mnote/design/05-editor-mainline/done/5-13-page-block-identity-and-command-contract-v1.md` +> - `/mnt/Data1T/mnote/design/05-editor-mainline/reference-code/blocknote-ai/MNOTE-REFERENCE.md` + +--- + +## 1. 结论 + +BlockNote AI 和 Tiptap AI Toolkit 都值得参考,但参考层级不同: + +- Tiptap AI Toolkit 适合作为“AI tool contract”参考:`tiptapRead`、`tiptapEdit`、`tiptapReadSelection`、`toolDefinitions()`、`executeTool/streamTool`、review options。 +- BlockNote AI 适合作为“AI runtime shape”参考:`AIExtension`、`DocumentStateBuilder`、`StreamToolsProvider`、`AIRequest`、`aiMenuState`、`acceptChanges/rejectChanges/retry/abort`。 +- mnote 不能直接采用 BlockNote / Tiptap 的 editor truth。mnote 的事实源仍是 Rust kernel、Page Aggregate、EditorCommand、Convex revision/conflict key 与 Hermes audit。 + +下一步不应把 BlockNote 重新拉回运行时主链,也不应把 Tiptap Pro / BlockNote XL AI 当成硬依赖。正确方向是用它们的架构形态,打造 mnote 自己的 Rust-owned AI tool runtime。 + +--- + +## 2. 已拉取参考代码 + +已将 BlockNote 官方仓库以 sparse clone 方式拉到: + +`/mnt/Data1T/mnote/design/05-editor-mainline/reference-code/blocknote-ai/` + +当前提交: + +`c255558b2d4f2be6453c67df81bb702a1a586909` + +保留范围: + +- `docs/content/docs/features/ai/` +- `examples/09-ai/` +- `packages/xl-ai/src/` +- `packages/xl-ai-server/src/` +- 对应 `package.json` / `LICENSE` + +许可证边界: + +- `@blocknote/xl-ai` 当前标注为 `GPL-3.0 OR PROPRIETARY`。 +- 本目录只能作为 `reference-code` 研究材料。 +- 不允许未审查许可证就复制实现进入 mnote runtime。 + +--- + +## 3. BlockNote AI 可借鉴能力 + +### 3.1 AI lifecycle 状态机 + +参考: + +- `reference-code/blocknote-ai/packages/xl-ai/src/AIExtension.ts` + +BlockNote 的 AI menu state 包含: + +```text +closed +user-input +thinking +ai-writing +user-reviewing +error +``` + +并提供: + +- `openAIMenuAtBlock(blockId)` +- `closeAIMenu()` +- `invokeAI(opts)` +- `acceptChanges()` +- `rejectChanges()` +- `retry()` +- `abort(reason)` +- `setAIResponseStatus(...)` + +mnote 应吸收为: + +- `PageAIController` +- `PageAIRunState` +- `PageAIReviewSession` + +但状态源必须绑定 mnote 的 `documentId/workspaceId/blockId/revision/conflictDetectionKey`,不能绑定 BlockNote editor instance。 + +### 3.2 DocumentStateBuilder + +参考: + +- `reference-code/blocknote-ai/packages/xl-ai/src/api/formats/DocumentStateBuilder.ts` +- `reference-code/blocknote-ai/packages/xl-ai/src/api/aiRequest/builder.ts` + +BlockNote 把 AI 上下文拆成两种: + +- 无选区:全量 document blocks + cursor position。 +- 有选区:selected blocks + whole document context;模型只能对 selection 发操作。 + +mnote 应吸收为 `PageAIContextBuilder`: + +```json +{ + "schema": "mnote.page_ai_context.v1", + "workspaceId": "tree_x", + "documentId": "tree_y", + "runId": "run_1", + "scope": "selection", + "revision": 12, + "conflictDetectionKey": "body:...", + "selectedBlocks": [], + "contextBlocks": [], + "allowedTargetBlockIds": [] +} +``` + +约束: + +- context 必须从 Page Aggregate block projection 生成。 +- selection 必须在 AI run 开始时冻结,不能随用户后续选区漂移。 +- `allowedTargetBlockIds` 必须作为写工具校验输入的一部分。 + +### 3.3 StreamToolsProvider + +参考: + +- `reference-code/blocknote-ai/packages/xl-ai/src/api/formats/formats.ts` +- `reference-code/blocknote-ai/packages/xl-ai/src/api/formats/base-tools/` +- `reference-code/blocknote-ai/packages/xl-ai/src/streamTool/` + +BlockNote 的 stream tool 把 add/update/delete block 建模为 schema + validate + executor。 + +mnote 已有对应底座: + +- `mnote.doc.fetch` +- `mnote.doc.find` +- `mnote.block.fetch` +- `mnote.doc.plan_update` +- `mnote.block.replace` +- `mnote.block.insert_after` +- `mnote.block.delete` +- `mnote.block.move_after` +- `mnote.doc.apply_block_ops` + +mnote 下一步要补的不是工具名,而是工具 manifest 的能力注解: + +```json +{ + "name": "mnote.block.replace", + "annotations": { + "readonly": false, + "destructive": false, + "idempotent": false, + "requiresApproval": true, + "selectionEffect": "destroy", + "runtimeOwner": "mnote-web", + "writeOwner": "rust-runtime-kernel" + } +} +``` + +### 3.4 Review / accept / reject + +参考: + +- `reference-code/blocknote-ai/packages/xl-ai/src/AIExtension.ts` +- `reference-code/blocknote-ai/packages/xl-ai/src/prosemirror/rebaseTool.ts` +- `reference-code/tiptap-docs/src/content/content-ai/capabilities/ai-toolkit/api-reference/review-options.mdx` + +BlockNote 在 editor 内通过 suggestions / fork ydoc 完成 review;Tiptap AI Toolkit 则提供 `review/preview/trackedChanges` 模式。 + +mnote 不能照搬 ProseMirror suggestion 作为事实源。mnote 应建立自己的 review session: + +```json +{ + "schema": "mnote.page_ai_review_session.v1", + "reviewSessionId": "review_1", + "runId": "run_1", + "documentId": "tree_y", + "workspaceId": "tree_x", + "status": "previewing", + "baseRevision": 12, + "baseConflictDetectionKey": "body:...", + "operations": [], + "diff": [], + "warnings": [], + "risk": "low" +} +``` + +状态: + +```text +draft +planning +previewing +awaiting_user +accepted +rejected +applying +applied +failed +aborted +stale +``` + +写入规则: + +- AI 写工具默认先生成 review session。 +- 用户 accept 后才执行 `mnote.doc.apply_block_ops` 或单个 `mnote.block.*`。 +- accept 时重新校验 `revision/conflictDetectionKey/revisionRef`。 +- reject 只关闭 session,不改变正文。 + +--- + +## 4. Tiptap AI Toolkit 可借鉴能力 + +Tiptap AI Toolkit 已在 `7-9` 中完成第一轮映射,本文补充它与 BlockNote 的组合边界。 + +| Tiptap / BlockNote 能力 | mnote 对应 | 吸收内容 | 禁止吸收 | +| --- | --- | --- | --- | +| `tiptapRead` | `mnote.doc.fetch` / `mnote.block.fetch` | 先读、带范围、返回 AI 友好表示 | 不把 Tiptap JSON 当长期工具格式 | +| `tiptapEdit` | `mnote.doc.plan_update` + `mnote.block.*` | 操作列表、dry-run、diff、reviewable edit | 不让浏览器 editor command 成为事实源 | +| `tiptapReadSelection` | `mnote.doc.fetch scope=selection` | selection-aware workflow | 不持久化 ProseMirror selection range | +| `toolDefinitions()` | Rust Hermes manifest | schema、description、capability、annotations | 不依赖私有 npm 包 | +| `AIExtension` | `PageAIController` | AI lifecycle、menu state、abort/retry | 不引入 BlockNote runtime | +| `DocumentStateBuilder` | `PageAIContextBuilder` | selection/context 分离 | 不从 DOM 拼上下文 | +| `StreamToolsProvider` | `MnoteAIToolProvider` | 工具集合按能力开放 | 不在前端直接执行正式持久化 | +| `acceptChanges/rejectChanges` | `PageAIReviewSession` | preview -> accept/reject | 不以 ProseMirror suggestion 作为最终事实 | + +--- + +## 5. mnote 自有 AI runtime 设计 + +### 5.1 分层 + +```text +Page Aggregate / Rust kernel + -> PageAIContextBuilder + -> MnoteAIToolProvider + -> Hermes / model runtime + -> PageAIReviewSession + -> EditorCommand / page.body.save + -> Page Aggregate 回读验证 +``` + +### 5.2 组件职责 + +`PageAIContextBuilder` + +- 输入:`workspaceId/documentId/scope/selection/blockId/query/maxBlocks`。 +- 输出:`mnote.page_ai_context.v1`。 +- 数据源:Page Aggregate block projection。 +- 负责 selection 冻结、上下文裁剪、`allowedTargetBlockIds`。 + +`MnoteAIToolProvider` + +- 输入:profile、document capability、selection scope、feature flags。 +- 输出:可用工具 manifest。 +- 负责 `readonly/destructive/requiresApproval/selectionEffect` 等注解。 + +`PageAIController` + +- 管理当前页面 AI run。 +- 对齐状态:`user-input/thinking/ai-writing/user-reviewing/error/aborted`。 +- 不直接写正文,只组织 context、tool calls、review session。 + +`PageAIReviewSession` + +- 保存 plan/diff/warnings/risk/operations。 +- 提供 `accept/reject/retry/abort`。 +- accept 时走 Rust 写工具,并二次校验 revision。 + +`PageAIReviewSurface` + +- 页面内展示 AI 结果、工具卡、diff、风险、按钮。 +- 不把 tool result 当正文。 +- 不绕过 review session 调写工具。 + +--- + +## 6. 格式策略 + +当前 mnote 不能把 HTML 作为长期 AI contract。推荐格式分三层: + +`json` + +- 面向工具执行。 +- 保留 block id、type、attrs、children、revisionRef。 +- 作为写工具输入和回读校验主格式。 + +`text` + +- 面向摘要、问答、轻量改写。 +- 不可直接作为精确写入定位依据。 + +`page_xml` + +- 面向模型理解结构。 +- 形态示例: + +```xml + + 第一段 + 标题 + +``` + +约束: + +- `page_xml` 只能是 Page Aggregate projection 的序列化视图。 +- 写入仍必须转成 `mnote.block.*` 或 `mnote.doc.apply_block_ops`。 + +--- + +## 7. 执行 checklist + +### Phase A:参考口径冻结 + +- [x] 拉取 BlockNote AI sparse reference 到 `reference-code/blocknote-ai/`。 +- [x] 标注来源 commit 与许可证边界。 +- [x] 明确 BlockNote 只作为 AI runtime shape 参考,不作为 mnote runtime dependency。 +- [x] 明确 Tiptap AI Toolkit 只作为 tool contract 参考,不作为事实源。 + +### Phase B:PageAIContextBuilder + +- [x] 定义 `mnote.page_ai_context.v1` schema。 +- [x] `doc.fetch` 支持 `scope=selection`。 +- [x] selection run 开始时冻结 `selectedBlockIds` / `allowedTargetBlockIds`。 +- [x] `doc.fetch` 支持 `format=text/page_xml/json`。 +- [x] `block.fetch` 支持 `format=text/page_xml/json`。 +- [ ] 大页面 context 默认裁剪,返回 `truncated/warnings/continuation`。 + +验收: + +- [ ] AI run 期间用户改变选区,不影响本 run 的 selection context。 +- [ ] 写工具不能修改 `allowedTargetBlockIds` 外的块。 +- [ ] `page_xml` 与 JSON projection 的 block id / revisionRef 一致。 + +### Phase C:MnoteAIToolProvider + +- [x] tool manifest 增加 `annotations`。 +- [x] 当前 yolo 模式下写工具标注 `requiresApproval=false` / `approvalMode=yolo`。 +- [x] selection scope 下,写工具自动带 `allowedTargetBlockIds` 约束。 +- [ ] profile tool toggle 与 capability annotation 同时生效。 +- [ ] manifest 输出能直接转换给 Hermes/model runtime。 + +验收: + +- [ ] 禁用工具不出现在当前 AI run 的可用工具集中。 +- [ ] 复杂块工具返回 `unsupportedReason`,不伪装成可编辑。 +- [ ] manifest 能说明工具是否 destructive / readonly / requiresApproval。 + +### Phase D:PageAIReviewSession + +- [ ] 定义 `mnote.page_ai_review_session.v1`。 +- [ ] `mnote.doc.plan_update` 可创建 review session。 +- [ ] `mnote.doc.apply_block_ops dryRun=true` 可返回 review session draft。 +- [ ] 页面 AI UI 展示 diff、warnings、risk、blocked。 +- [ ] `accept` 执行写入前二次校验 revision/conflictDetectionKey/revisionRef。 +- [ ] `reject` 不改变正文。 +- [ ] `abort` 停止 run,关闭未提交 review session。 +- [ ] `retry` 使用最新 Page Aggregate 重新构建 context。 + +验收: + +- [ ] accept 后 Page Aggregate 与 `mnote.doc.fetch` 都能读回变化。 +- [ ] stale revision 时 accept 被阻断,session 进入 `stale`。 +- [ ] reject 后页面正文、revision、block ids 不变化。 + +### Phase E:页面 AI 状态机 + +- [ ] 实现 `PageAIController` 状态枚举。 +- [ ] AI menu / side panel 共享同一 run state。 +- [ ] tool event 按 `toolCallId` 聚合展示。 +- [ ] 错误态支持 retry / close。 +- [ ] abort 态不会留下半写入正文。 + +验收: + +- [ ] `thinking -> ai-writing -> user-reviewing -> accepted/rejected` 链路可见。 +- [ ] 网络失败、tool parse 失败、revision stale 三类错误可区分。 +- [ ] 页面刷新后未提交 review session 不会自动写入正文。 + +--- + +## 8. 当前优先级 + +优先级顺序: + +1. `PageAIContextBuilder + scope=selection` +2. `format=page_xml/text` +3. `PageAIReviewSession`(后续可选 review 模式;当前默认 yolo 不阻塞写入) +4. `PageAIController` 状态机与 UI surface +5. 复杂块移动 / 删除 / 多块操作阻断矩阵 + +原因: + +- selection 和 context 是 AI 质量与安全边界的前置条件。 +- `page_xml/text` 能降低模型误读 projection 的概率。 +- review session 是生产写入前必须补齐的用户确认层。 +- 状态机和 UI surface 应建立在稳定 session 语义之上。 + +--- + +## 9. 禁止项 + +- 不把 BlockNote 重新设为文档页默认主编辑器。 +- 不把 `@blocknote/xl-ai` 作为 mnote runtime dependency。 +- 不复制 GPL/PROPRIETARY 源码进入 mnote 运行时代码。 +- 不让前端 editor instance 直接执行长期持久化写入。 +- 不以 HTML / Tiptap JSON / ProseMirror position 作为 mnote 长期 AI tool contract。 +- 不绕过 `dryRun/idempotencyKey/revision/conflictDetectionKey/revisionRef` 执行 AI 写工具。 + +--- + +## 10. 与 `7-10` 的关系 + +`7-10` 继续作为页面块 AI 工具执行 checklist。 + +本文补充的是 `7-10` 后半段缺口的架构口径: + +- `scope=selection` +- `format=page_xml/text` +- `持久审阅/preview UI` +- `AI lifecycle 状态机` +- `accept/reject/retry/abort` + +因此本文不替代 `7-10`,而是作为下一阶段 AI runtime 收口设计稿。 diff --git a/rust/crates/bridge-runtime/src/lib.rs b/rust/crates/bridge-runtime/src/lib.rs index d3165a64..4fb8ab16 100644 --- a/rust/crates/bridge-runtime/src/lib.rs +++ b/rust/crates/bridge-runtime/src/lib.rs @@ -11,26 +11,26 @@ use core_protocol::{ DocumentReadEvidenceItem, DocumentReadEvidenceKind, DocumentReadNode, DocumentReadNodeMeta, DocumentReadNodeType, DocumentReadOutlineEntry, DocumentReadPageSubtree, DocumentReadStats, DocumentReadSubtree, EditorBlock, EditorBlockDocument, EditorBlockType, EditorCommand, - EditorInsertBlockAfter, EditorReplaceBlock, EmbedBlock, GetBlock, GetBridgeCommand, - GetBridgeRequest, GetBridgeTrace, GetMindmap, InvocationKind, KernelAttachEdge, - KernelAuditStamp, KernelBlockAssetRelation, KernelContentPayload, KernelCreateNode, - KernelDetachEdge, KernelEdge, KernelEdgeListResult, KernelEdgeType, KernelGetNode, - KernelGetSubtree, KernelGraphDirection, KernelGraphTraversalResult, KernelGraphVisit, - KernelListChildren, KernelListEdges, KernelMoveSubtree, KernelNode, KernelNodeMetadata, - KernelNodeType, KernelObjectIdentity, KernelObjectKind, KernelProjectionAssetKind, - KernelProjectionCapability, KernelProjectionFilter, KernelProjectionItem, KernelProjectionKind, - KernelProjectionRequest, KernelProjectionResourceKind, KernelProjectionResourceMeta, - KernelProjectionResult, KernelProjectionRowKind, KernelRefsPayload, KernelSubtreeRef, - KernelSubtreeResult, KernelTraverseGraph, KernelUpdateNode, ListBridgeWorkspaceOverview, - MindmapAdapterProjection, MindmapAssociativeLine, MindmapCommand, MindmapKernelCapabilities, - MindmapKernelCommand, MindmapKernelEdge, MindmapKernelNode, MindmapKernelProjection, - MindmapNodeData, MindmapNodeInput, MindmapNodeRef, MindmapOp, MindmapProjection, - MindmapProjectionEdge, MindmapProjectionNode, MindmapProjectionOwner, MindmapProjectionSource, - MindmapSummary, MindmapTreeNode, MoveBlock, PageAggregateProjection, PageAggregateSource, - PageBody, PageHead, PageIdentity, PageLayout, PageOptions, PagePermissions, PageStats, - PageTree, PatchBlock, PutMindmap, QueryEnvelope, SearchDocuments, SearchRecent, SourcePayload, - TargetRef, ToolExecutionMode, ToolInvocation, UpdatePageOptions, UpdatePageStats, - WorkspaceSource, + EditorDeleteBlock, EditorInsertBlockAfter, EditorMoveBlock, EditorReplaceBlock, EmbedBlock, + GetBlock, GetBridgeCommand, GetBridgeRequest, GetBridgeTrace, GetMindmap, InvocationKind, + KernelAttachEdge, KernelAuditStamp, KernelBlockAssetRelation, KernelContentPayload, + KernelCreateNode, KernelDetachEdge, KernelEdge, KernelEdgeListResult, KernelEdgeType, + KernelGetNode, KernelGetSubtree, KernelGraphDirection, KernelGraphTraversalResult, + KernelGraphVisit, KernelListChildren, KernelListEdges, KernelMoveSubtree, KernelNode, + KernelNodeMetadata, KernelNodeType, KernelObjectIdentity, KernelObjectKind, + KernelProjectionAssetKind, KernelProjectionCapability, KernelProjectionFilter, + KernelProjectionItem, KernelProjectionKind, KernelProjectionRequest, + KernelProjectionResourceKind, KernelProjectionResourceMeta, KernelProjectionResult, + KernelProjectionRowKind, KernelRefsPayload, KernelSubtreeRef, KernelSubtreeResult, + KernelTraverseGraph, KernelUpdateNode, ListBridgeWorkspaceOverview, MindmapAdapterProjection, + MindmapAssociativeLine, MindmapCommand, MindmapKernelCapabilities, MindmapKernelCommand, + MindmapKernelEdge, MindmapKernelNode, MindmapKernelProjection, MindmapNodeData, + MindmapNodeInput, MindmapNodeRef, MindmapOp, MindmapProjection, MindmapProjectionEdge, + MindmapProjectionNode, MindmapProjectionOwner, MindmapProjectionSource, MindmapSummary, + MindmapTreeNode, MoveBlock, PageAggregateProjection, PageAggregateSource, PageBody, PageHead, + PageIdentity, PageLayout, PageOptions, PagePermissions, PageStats, PageTree, PatchBlock, + PutMindmap, QueryEnvelope, SearchDocuments, SearchRecent, SourcePayload, TargetRef, + ToolExecutionMode, ToolInvocation, UpdatePageOptions, UpdatePageStats, WorkspaceSource, }; use event_log::DomainEventRecord; use index_fts::{ @@ -2457,6 +2457,20 @@ fn tree_domain_event_hint(event_type: &str) -> Value { }) } +fn tree_command_protocol_hint( + command_name: &str, + preferred_command_name: &str, + compat_command_name: &str, +) -> Value { + json!({ + "family": "tree", + "owner": "rust-runtime-kernel", + "preferredCommandName": preferred_command_name, + "compatCommandName": compat_command_name, + "deprecatedAlias": command_name == compat_command_name, + }) +} + fn tree_domain_event_plan(event_type: &str, stream_delta_hint: Value) -> Value { json!({ "family": "tree", @@ -5156,6 +5170,49 @@ fn read_trimmed_string_field(value: &Value, keys: &[&str]) -> Option { .and_then(|map| read_trimmed_string_from_map(map, keys)) } +fn read_u64_field_any(value: &Value, keys: &[&str]) -> Option { + let map = value.as_object()?; + for key in keys { + let Some(raw) = map.get(*key) else { + continue; + }; + if let Some(number) = raw.as_u64() { + return Some(number); + } + if let Some(number) = raw.as_i64().and_then(|number| u64::try_from(number).ok()) { + return Some(number); + } + if let Some(number) = raw + .as_str() + .map(str::trim) + .filter(|value| !value.is_empty()) + .and_then(|value| value.parse::().ok()) + { + return Some(number); + } + } + None +} + +fn revision_from_conflict_detection_key(conflict_detection_key: &str) -> Option { + conflict_detection_key + .trim() + .rsplit_once(':') + .and_then(|(_, revision)| revision.trim().parse::().ok()) +} + +fn content_revision_value(data: &Value, conflict_detection_key: Option<&str>) -> u64 { + let explicit_revision = read_u64_field_any( + data, + &["revision", "content_revision", "contentRevision", "version"], + ) + .unwrap_or(0); + let key_revision = conflict_detection_key.and_then(revision_from_conflict_detection_key); + key_revision + .filter(|revision| *revision > explicit_revision) + .unwrap_or(explicit_revision) +} + fn normalize_page_subtree_projection_id(document_id: &str) -> String { format!("kernel_projection:page_tree:{document_id}") } @@ -5602,14 +5659,13 @@ fn build_document_content_result( .and_then(|map| map.get("content")) .cloned() .unwrap_or(Value::Null); - let revision = data - .as_object() - .and_then(|map| map.get("revision")) - .and_then(Value::as_u64) - .unwrap_or(0); let conflict_detection_key = read_trimmed_string_field(data, &["conflict_detection_key", "conflictDetectionKey"]) - .unwrap_or_else(|| format!("{document_id}:{revision}")); + .unwrap_or_else(|| { + let revision = content_revision_value(data, None); + format!("{document_id}:{revision}") + }); + let revision = content_revision_value(data, Some(&conflict_detection_key)); let title = read_trimmed_string_field(data, &["title"]); let page_subtree = build_document_page_subtree(document_id, title.as_deref(), &content); @@ -5656,15 +5712,15 @@ fn build_page_aggregate_projection_result( .get("content") .cloned() .unwrap_or_else(|| Value::Array(vec![])); - let revision = content_result - .get("revision") - .cloned() - .unwrap_or(Value::Null); let conflict_detection_key = content_result .get("conflictDetectionKey") .or_else(|| content_result.get("conflict_detection_key")) .cloned() .unwrap_or(Value::Null); + let revision = Value::from(content_revision_value( + content_result, + conflict_detection_key.as_str(), + )); let page_subtree = content_result .get("pageSubtree") .or_else(|| content_result.get("page_subtree")) @@ -5716,6 +5772,8 @@ fn build_page_aggregate_projection_result( let revision_ref = revision .as_u64() .map(|value| format!("{resolved_document_id}:{value}")); + let block_document = + project_legacy_content_to_block_document(&resolved_document_id, &content, &revision)?; Ok(PageAggregateProjection { schema: PageAggregateProjection::SCHEMA.into(), @@ -5753,6 +5811,9 @@ fn build_page_aggregate_projection_result( content, revision, conflict_detection_key, + block_document, + block_projection_version: 1, + projection_source: "documents.content".into(), }, tree: PageTree { page_subtree }, stats: PageStats { @@ -5784,6 +5845,153 @@ fn page_aggregate_source_for_data(data: &Value) -> PageAggregateSource { } } +pub fn project_legacy_content_to_block_document( + document_id: &str, + content: &Value, + revision: &Value, +) -> Result { + let blocks = normalize_blocks_from_value(content); + let mut projected_blocks = Vec::new(); + let mut root_block_ids = Vec::new(); + for (index, block) in blocks.iter().enumerate() { + if let Some(block_id) = + project_legacy_block(block, None, vec![index], revision, &mut projected_blocks)? + { + root_block_ids.push(block_id); + } + } + Ok(json!({ + "documentId": document_id, + "rootBlockIds": root_block_ids, + "blocks": projected_blocks, + })) +} + +fn project_legacy_block( + block: &Value, + parent_block_id: Option<&str>, + path: Vec, + revision: &Value, + out: &mut Vec, +) -> Result, BridgeError> { + let block_id = read_trimmed_string_field(block, &["blockId", "id"]) + .unwrap_or_else(|| legacy_block_id_from_path(&path)); + let block_type = read_trimmed_string_field(block, &["blockType", "type"]) + .unwrap_or_else(|| "paragraph".into()) + .to_lowercase(); + let text = get_block_snippet(block); + let attrs = legacy_block_projection_attrs(block, &block_type); + let children_values = block + .as_object() + .and_then(|map| map.get("children")) + .and_then(Value::as_array) + .cloned() + .unwrap_or_default(); + let mut child_block_ids = Vec::new(); + for (index, child) in children_values.iter().enumerate() { + let mut child_path = path.clone(); + child_path.push(index); + if let Some(child_id) = + project_legacy_block(child, Some(&block_id), child_path, revision, out)? + { + child_block_ids.push(child_id); + } + } + let editable = legacy_block_type_is_editable(&block_type); + let unsupported_reason = if editable { + Value::Null + } else { + Value::String("复杂块暂不开放 AI 精确写入".into()) + }; + let revision_label = revision + .as_u64() + .map(|value| value.to_string()) + .or_else(|| revision.as_str().map(ToOwned::to_owned)) + .unwrap_or_else(|| "unknown".into()); + out.push(json!({ + "blockId": block_id, + "type": block_type, + "text": text, + "attrs": attrs, + "contentNodes": build_text_content_nodes(&text), + "children": child_block_ids, + "parentBlockId": parent_block_id, + "order": format!("{:08}", path.last().copied().unwrap_or(0)), + "path": path, + "depth": path.len().saturating_sub(1), + "revisionRef": format!( + "pageRev:{revision_label}:block:{}:hash:{}", + block_id, + stable_json_content_hash(block)? + ), + "editable": editable, + "unsupportedReason": unsupported_reason, + })); + Ok(Some(block_id)) +} + +fn legacy_block_id_from_path(path: &[usize]) -> String { + format!( + "legacy_block_{}", + path.iter() + .map(|item| item.to_string()) + .collect::>() + .join("_") + ) +} + +fn legacy_block_projection_attrs(block: &Value, block_type: &str) -> Value { + let props = block + .as_object() + .and_then(|map| map.get("props")) + .and_then(Value::as_object); + let mut attrs = serde_json::Map::new(); + if block_type == "heading" { + if let Some(level) = props + .and_then(|map| map.get("level").or_else(|| map.get("headingLevel"))) + .and_then(Value::as_u64) + { + attrs.insert("headingLevel".into(), json!(level.clamp(1, 6))); + } + } + if matches!(block_type, "todo" | "task") { + if let Some(checked) = props + .and_then(|map| map.get("checked")) + .and_then(Value::as_bool) + { + attrs.insert("checked".into(), json!(checked)); + } + } + if matches!(block_type, "code" | "code_block" | "code-block") { + if let Some(language) = props + .and_then(|map| map.get("language")) + .and_then(Value::as_str) + { + attrs.insert("language".into(), json!(language)); + } + } + Value::Object(attrs) +} + +fn legacy_block_type_is_editable(block_type: &str) -> bool { + matches!( + block_type, + "paragraph" + | "heading" + | "todo" + | "task" + | "quote" + | "blockquote" + | "code" + | "code_block" + | "code-block" + | "bullet_list_item" + | "numbered_list_item" + | "bullet_list" + | "ordered_list" + ) +} + fn normalize_mindmap_from_value(data: &Value) -> Result { if data.is_null() { return Ok(default_mindmap_tree()); @@ -9268,6 +9476,15 @@ fn execute_command( "page.head.updateTitle" => "page.head.updateTitle", _ => "documents.title.update", }; + let command_protocol = if command_name == "page.head.updateTitle" { + None + } else { + Some(tree_command_protocol_hint( + command_name, + "tree.node.rename", + "documents.title.update", + )) + }; let command = CommandEnvelope { name: command_name.into(), command_id: command_wire.command_id.clone(), @@ -9296,26 +9513,34 @@ fn execute_command( idempotency_key: request.idempotency_key, source: workspace_source_value(&command_wire.source), payload_json: request.payload_json, - args_json: json!({ - "id": payload.document_id, - "title": payload.title, - "streamDeltaHint": tree_stream_delta_hint("upsert_document_patch", json!({ - "documentId": payload.document_id, - "patch": { - "title": payload.title, - }, - })), - "domainEventHint": tree_domain_event_hint("tree.node.renamed"), - "domainEventPlan": tree_domain_event_plan( - "tree.node.renamed", - tree_stream_delta_hint("upsert_document_patch", json!({ + args_json: { + let mut args = json!({ + "id": payload.document_id, + "title": payload.title, + "streamDeltaHint": tree_stream_delta_hint("upsert_document_patch", json!({ "documentId": payload.document_id, "patch": { "title": payload.title, }, })), - ), - }), + "domainEventHint": tree_domain_event_hint("tree.node.renamed"), + "domainEventPlan": tree_domain_event_plan( + "tree.node.renamed", + tree_stream_delta_hint("upsert_document_patch", json!({ + "documentId": payload.document_id, + "patch": { + "title": payload.title, + }, + })), + ), + }); + if let (Value::Object(map), Some(command_protocol)) = + (&mut args, command_protocol) + { + map.insert("commandProtocol".into(), command_protocol); + } + args + }, })) } "documents.options.update" | "page.layout.updateOptions" => { @@ -9978,6 +10203,11 @@ fn execute_command( "title": payload.title, "accessScope": payload.access_scope, "content": payload.content, + "commandProtocol": tree_command_protocol_hint( + command_name, + "tree.node.create", + "documents.create", + ), "streamDeltaHint": tree_document_result_hint("document"), "domainEventHint": tree_domain_event_hint("tree.node.created"), "domainEventPlan": tree_domain_event_plan( @@ -10031,6 +10261,11 @@ fn execute_command( "parentId": payload.parent_id, "sortOrder": payload.sort_order, "treeWriteOperation": tree_write_operation, + "commandProtocol": tree_command_protocol_hint( + command_name, + "tree.subtree.move", + "documents.move", + ), "streamDeltaHint": tree_stream_delta_hint("move_document", json!({ "documentId": payload.document_id, "parentId": payload.parent_id, @@ -10083,6 +10318,11 @@ fn execute_command( payload_json: request.payload_json, args_json: json!({ "id": payload.document_id, + "commandProtocol": tree_command_protocol_hint( + command_name, + "tree.node.archive", + "documents.delete", + ), "streamDeltaHint": tree_stream_delta_hint("remove_document", json!({ "documentId": payload.document_id, })), @@ -10131,6 +10371,11 @@ fn execute_command( payload_json: request.payload_json, args_json: json!({ "id": payload.document_id, + "commandProtocol": tree_command_protocol_hint( + command_name, + "tree.node.restore", + "documents.restore", + ), "streamDeltaHint": tree_document_result_hint("document"), "domainEventHint": tree_domain_event_hint("tree.node.restored"), "domainEventPlan": tree_domain_event_plan( @@ -10294,6 +10539,11 @@ fn execute_command( payload_json: request.payload_json, args_json: json!({ "id": payload.document_id, + "commandProtocol": tree_command_protocol_hint( + command_name, + "tree.node.purge", + "documents.purge", + ), }), })) } @@ -10336,6 +10586,11 @@ fn execute_command( "recursive": item.recursive, })).collect::>(), "targetParentId": payload.target_parent_id, + "commandProtocol": tree_command_protocol_hint( + command_name, + "tree.subtree.copy", + "documents.copy_tree", + ), "streamDeltaHint": tree_stream_delta_hint("copy_result", json!({ "itemsField": "items", "documentField": "document", @@ -10736,9 +10991,9 @@ fn normalize_editor_block_type_for_save(raw_type: &str) -> EditorBlockType { } } -fn normalize_editor_block_from_legacy(block: &Value, index: usize) -> EditorBlock { +fn normalize_editor_block_from_legacy_path(block: &Value, path: &[usize]) -> EditorBlock { let block_id = read_trimmed_string_field(block, &["blockId", "id"]) - .unwrap_or_else(|| format!("block-{}", index + 1)); + .unwrap_or_else(|| legacy_block_id_from_path(path)); let raw_type = read_trimmed_string_field(block, &["blockType", "type"]) .unwrap_or_else(|| "paragraph".into()) .to_lowercase(); @@ -10867,16 +11122,16 @@ fn normalize_editor_block_from_legacy(block: &Value, index: usize) -> EditorBloc } } -fn editor_document_from_legacy_content(document_id: &str, content: &Value) -> EditorBlockDocument { - let blocks = normalize_blocks_from_value(content) - .iter() - .enumerate() - .map(|(index, block)| normalize_editor_block_from_legacy(block, index)) - .collect::>(); - let root_block_ids = blocks - .iter() - .map(|block| block.block_id.clone()) - .collect::>(); +pub fn editor_document_from_legacy_content(document_id: &str, content: &Value) -> EditorBlockDocument { + let mut blocks = Vec::::new(); + let mut root_block_ids = Vec::::new(); + for (index, block) in normalize_blocks_from_value(content).iter().enumerate() { + root_block_ids.push(collect_editor_blocks_from_legacy( + block, + &[index], + &mut blocks, + )); + } EditorBlockDocument { document_id: document_id.to_string(), root_block_ids, @@ -10884,6 +11139,30 @@ fn editor_document_from_legacy_content(document_id: &str, content: &Value) -> Ed } } +fn collect_editor_blocks_from_legacy( + block: &Value, + path: &[usize], + out: &mut Vec, +) -> String { + let mut editor_block = normalize_editor_block_from_legacy_path(block, path); + let children = block + .as_object() + .and_then(|map| map.get("children")) + .and_then(Value::as_array) + .cloned() + .unwrap_or_default(); + let mut child_block_ids = Vec::::new(); + for (index, child) in children.iter().enumerate() { + let mut child_path = path.to_vec(); + child_path.push(index); + child_block_ids.push(collect_editor_blocks_from_legacy(child, &child_path, out)); + } + editor_block.child_block_ids = child_block_ids; + let block_id = editor_block.block_id.clone(); + out.push(editor_block); + block_id +} + fn normalize_save_editor_document( payload: &DocumentSaveCommandPayload, ) -> Result { @@ -11108,7 +11387,7 @@ fn legacy_text_from_editor_block(block: &EditorBlock) -> String { .collect::() } -fn legacy_content_from_editor_document(document: &EditorBlockDocument) -> Value { +pub fn legacy_content_from_editor_document(document: &EditorBlockDocument) -> Value { fn block_to_legacy_value(block: &EditorBlock, document: &EditorBlockDocument) -> Value { let children = block .child_block_ids @@ -11185,6 +11464,208 @@ fn legacy_content_from_editor_document(document: &EditorBlockDocument) -> Value ) } +pub fn apply_editor_command_to_legacy_content( + document_id: &str, + content: &Value, + command: EditorCommand, +) -> Result { + let mut document = editor_document_from_legacy_content(document_id, content); + apply_editor_command_to_document(&mut document, command)?; + Ok(legacy_content_from_editor_document(&document)) +} + +pub fn apply_editor_command_to_document( + document: &mut EditorBlockDocument, + command: EditorCommand, +) -> Result<(), BridgeError> { + match command { + EditorCommand::ReplaceBlock(command) => apply_replace_block_command(document, command), + EditorCommand::InsertBlockAfter(command) => { + apply_insert_block_after_command(document, command) + } + EditorCommand::DeleteBlock(command) => apply_delete_block_command(document, command), + EditorCommand::MoveBlock(command) => apply_move_block_command(document, command), + other => Err(BridgeError::validation(format!( + "页面块 AI 工具暂不支持执行 editor command: {other:?}" + ))), + } +} + +fn apply_replace_block_command( + document: &mut EditorBlockDocument, + command: EditorReplaceBlock, +) -> Result<(), BridgeError> { + let block = document + .blocks + .iter_mut() + .find(|block| block.block_id == command.block_id) + .ok_or_else(|| BridgeError::validation(format!("未找到 blockId:{}", command.block_id)))?; + if let Some(block_type) = command.block_type { + block.block_type = block_type; + } + if let Some(props) = command.props { + block.props = props; + } + if let Some(content_nodes) = command.content_nodes { + block.content_nodes = content_nodes; + } + Ok(()) +} + +fn apply_insert_block_after_command( + document: &mut EditorBlockDocument, + command: EditorInsertBlockAfter, +) -> Result<(), BridgeError> { + if document + .blocks + .iter() + .any(|block| block.block_id == command.block.block_id) + { + return Err(BridgeError::validation(format!( + "blockId 已存在:{}", + command.block.block_id + ))); + } + let parent_id = find_parent_block_id(document, &command.after_block_id).ok_or_else(|| { + BridgeError::validation(format!("未找到 blockId:{}", command.after_block_id)) + })?; + insert_block_id_after( + document, + parent_id.as_deref(), + &command.after_block_id, + command.block.block_id.clone(), + )?; + document.blocks.push(command.block); + Ok(()) +} + +fn apply_delete_block_command( + document: &mut EditorBlockDocument, + command: EditorDeleteBlock, +) -> Result<(), BridgeError> { + let block = document + .blocks + .iter() + .find(|block| block.block_id == command.block_id) + .ok_or_else(|| BridgeError::validation(format!("未找到 blockId:{}", command.block_id)))?; + if !block.child_block_ids.is_empty() || command.preserve_children { + return Err(BridgeError::validation( + "页面块 AI 工具第一阶段仅支持删除无子块的普通块", + )); + } + remove_block_id_from_order(document, &command.block_id)?; + document + .blocks + .retain(|block| block.block_id != command.block_id); + Ok(()) +} + +fn apply_move_block_command( + document: &mut EditorBlockDocument, + command: EditorMoveBlock, +) -> Result<(), BridgeError> { + if !document + .blocks + .iter() + .any(|block| block.block_id == command.block_id) + { + return Err(BridgeError::validation(format!( + "未找到 blockId:{}", + command.block_id + ))); + } + if command.after_block_id.as_deref() == Some(command.block_id.as_str()) { + return Err(BridgeError::validation("不能把块移动到自身之后")); + } + remove_block_id_from_order(document, &command.block_id)?; + match command.after_block_id { + Some(after_block_id) => { + insert_block_id_after( + document, + command.parent_block_id.as_deref(), + &after_block_id, + command.block_id, + )?; + } + None => { + let siblings = sibling_ids_mut(document, command.parent_block_id.as_deref()) + .ok_or_else(|| BridgeError::validation("目标父块不存在"))?; + siblings.insert(0, command.block_id); + } + } + Ok(()) +} + +fn find_parent_block_id(document: &EditorBlockDocument, block_id: &str) -> Option> { + if document.root_block_ids.iter().any(|id| id == block_id) { + return Some(None); + } + document + .blocks + .iter() + .find(|block| block.child_block_ids.iter().any(|id| id == block_id)) + .map(|block| Some(block.block_id.clone())) +} + +fn sibling_ids_mut<'a>( + document: &'a mut EditorBlockDocument, + parent_block_id: Option<&str>, +) -> Option<&'a mut Vec> { + match parent_block_id { + Some(parent_block_id) => document + .blocks + .iter_mut() + .find(|block| block.block_id == parent_block_id) + .map(|block| &mut block.child_block_ids), + None => Some(&mut document.root_block_ids), + } +} + +fn insert_block_id_after( + document: &mut EditorBlockDocument, + parent_block_id: Option<&str>, + after_block_id: &str, + block_id: String, +) -> Result<(), BridgeError> { + let siblings = sibling_ids_mut(document, parent_block_id) + .ok_or_else(|| BridgeError::validation("目标父块不存在"))?; + let index = siblings + .iter() + .position(|candidate| candidate == after_block_id) + .ok_or_else(|| { + BridgeError::validation(format!("未找到 anchor blockId:{after_block_id}")) + })?; + siblings.insert(index + 1, block_id); + Ok(()) +} + +fn remove_block_id_from_order( + document: &mut EditorBlockDocument, + block_id: &str, +) -> Result<(), BridgeError> { + if let Some(index) = document + .root_block_ids + .iter() + .position(|candidate| candidate == block_id) + { + document.root_block_ids.remove(index); + return Ok(()); + } + for block in &mut document.blocks { + if let Some(index) = block + .child_block_ids + .iter() + .position(|candidate| candidate == block_id) + { + block.child_block_ids.remove(index); + return Ok(()); + } + } + Err(BridgeError::validation(format!( + "未找到 blockId:{block_id}" + ))) +} + fn stable_json_content_hash(value: &Value) -> Result { let serialized = serde_json::to_string(value).map_err(|error| { BridgeError::validation(format!("复合命令 payload content hash 序列化失败: {error}")) @@ -11913,6 +12394,161 @@ mod tests { assert_eq!(result["body"]["revision"], json!(7)); } + #[test] + fn page_aggregate_get_projects_legacy_content_to_block_document() { + let result = execute_runtime_query(RuntimeInput::Query { + context: demo_context(), + query: RuntimeQueryEnvelopeWire { + name: "page.aggregate.get".into(), + payload: json!({ + "documentId": "doc_1", + "workspaceId": "ws_1", + }), + }, + data: Some(json!({ + "meta": { + "id": "doc_1", + "workspace_id": "ws_1", + "title": "块投影页面" + }, + "content": { + "content": [ + { + "id": "p_1", + "type": "paragraph", + "content": [{"type": "text", "text": "第一段"}] + }, + { + "id": "h_1", + "type": "heading", + "props": {"level": 2}, + "content": "标题" + } + ], + "revision": 8, + "conflict_detection_key": "doc_1:8" + } + })), + }) + .expect("page aggregate query should build"); + + assert_eq!(result["body"]["blockProjectionVersion"], json!(1)); + assert_eq!( + result["body"]["blockDocument"]["documentId"], + json!("doc_1") + ); + assert_eq!( + result["body"]["blockDocument"]["rootBlockIds"], + json!(["p_1", "h_1"]) + ); + assert_eq!( + result["body"]["blockDocument"]["blocks"][0]["blockId"], + json!("p_1") + ); + assert_eq!( + result["body"]["blockDocument"]["blocks"][0]["type"], + json!("paragraph") + ); + assert_eq!( + result["body"]["blockDocument"]["blocks"][0]["text"], + json!("第一段") + ); + assert_eq!( + result["body"]["blockDocument"]["blocks"][0]["path"], + json!([0]) + ); + let revision_ref = result["body"]["blockDocument"]["blocks"][0]["revisionRef"] + .as_str() + .expect("revisionRef"); + assert!(revision_ref.starts_with("pageRev:8:block:p_1:hash:fnv1a64:")); + assert_eq!( + result["body"]["blockDocument"]["blocks"][1]["attrs"]["headingLevel"], + json!(2) + ); + assert_eq!( + result["body"]["projectionSource"], + json!("documents.content") + ); + } + + #[test] + fn editor_command_apply_replaces_and_moves_legacy_content_through_canonical_document() { + let content = json!([ + {"id": "p_1", "type": "paragraph", "content": "第一段"}, + {"id": "p_2", "type": "paragraph", "content": "第二段"}, + {"id": "p_3", "type": "paragraph", "content": "第三段"} + ]); + let replaced = apply_editor_command_to_legacy_content( + "doc_1", + &content, + EditorCommand::ReplaceBlock(EditorReplaceBlock { + block_id: "p_2".into(), + block_type: None, + props: None, + content_nodes: Some(build_text_content_nodes("替换第二段")), + }), + ) + .expect("replace block should apply"); + assert_eq!(replaced[1]["id"], json!("p_2")); + assert_eq!(replaced[1]["content"], json!("替换第二段")); + + let moved = apply_editor_command_to_legacy_content( + "doc_1", + &replaced, + EditorCommand::MoveBlock(EditorMoveBlock { + block_id: "p_3".into(), + parent_block_id: None, + after_block_id: Some("p_1".into()), + }), + ) + .expect("move block should apply"); + assert_eq!( + moved + .as_array() + .expect("array") + .iter() + .map(|block| block["id"].as_str().expect("id")) + .collect::>(), + vec!["p_1", "p_3", "p_2"] + ); + } + + #[test] + fn editor_command_apply_insert_preserves_nested_children() { + let content = json!([{ + "id": "parent", + "type": "heading", + "content": "父级", + "children": [ + {"id": "child_1", "type": "paragraph", "content": "子级一"} + ] + }]); + let inserted = apply_editor_command_to_legacy_content( + "doc_1", + &content, + EditorCommand::InsertBlockAfter(EditorInsertBlockAfter { + after_block_id: "child_1".into(), + block: EditorBlock { + block_id: "child_2".into(), + block_type: EditorBlockType::Paragraph, + props: BlockProps::default(), + content_nodes: build_text_content_nodes("子级二"), + child_block_ids: vec![], + }, + }), + ) + .expect("insert nested block should apply"); + assert_eq!( + inserted[0]["children"] + .as_array() + .expect("children") + .iter() + .map(|block| block["id"].as_str().expect("id")) + .collect::>(), + vec!["child_1", "child_2"] + ); + } + #[test] fn search_documents_query_canonical_facade_keeps_projection_owner() { let result = execute_runtime_query(RuntimeInput::Query { @@ -12445,6 +13081,29 @@ mod tests { assert_eq!(result["pageSubtree"]["stats"]["headingCount"], json!(1)); } + #[test] + fn documents_content_query_uses_storage_revision_aliases() { + let result = execute_runtime_query(RuntimeInput::Query { + context: demo_context(), + query: RuntimeQueryEnvelopeWire { + name: "documents.content.get".into(), + payload: json!({ + "documentId": "doc_1", + "workspaceId": "ws_1", + }), + }, + data: Some(json!({ + "content": [], + "content_revision": 6, + "conflict_detection_key": "doc_1:6" + })), + }) + .expect("query result should build"); + + assert_eq!(result["revision"], json!(6)); + assert_eq!(result["conflictDetectionKey"], json!("doc_1:6")); + } + #[test] fn search_recent_query_executes_in_rust_runtime() { let result = execute_runtime_query(RuntimeInput::Query { @@ -12568,11 +13227,11 @@ mod tests { "children": [], }) ); - assert_eq!(plan.args_json["createOnly"], json!(true)); - assert_eq!( - plan.args_json["streamDeltaHint"], - json!({ - "family": "tree", + assert_eq!(plan.args_json["createOnly"], json!(true)); + assert_eq!( + plan.args_json["streamDeltaHint"], + json!({ + "family": "tree", "kind": "resync_required", "args": { "reason": "mindmap.put", @@ -12649,11 +13308,11 @@ mod tests { .expect("mindmap update command plan should build"); match plan { - RuntimeExecutionPlan::Command(plan) => { - assert_eq!( - plan.args_json["streamDeltaHint"], - json!({ - "family": "tree", + RuntimeExecutionPlan::Command(plan) => { + assert_eq!( + plan.args_json["streamDeltaHint"], + json!({ + "family": "tree", "kind": "noop", "args": {} }) @@ -13706,6 +14365,8 @@ mod tests { }; assert_eq!(plan.command_name, "page.body.save"); assert_eq!(plan.function_name, "documents:updateContent"); + assert_eq!(plan.args_json["expectedRevision"], json!(1)); + assert_eq!(plan.args_json["conflictDetectionKey"], json!("doc_1:1")); } #[test] @@ -14672,11 +15333,18 @@ mod tests { "documentId": "doc_1", "workspaceId": "ws_1", }), - json!({ - "id": "doc_1", - "streamDeltaHint": { - "family": "tree", - "kind": "remove_document", + json!({ + "id": "doc_1", + "commandProtocol": { + "family": "tree", + "owner": "rust-runtime-kernel", + "preferredCommandName": "tree.node.archive", + "compatCommandName": "documents.delete", + "deprecatedAlias": false + }, + "streamDeltaHint": { + "family": "tree", + "kind": "remove_document", "args": { "documentId": "doc_1" } @@ -14707,11 +15375,18 @@ mod tests { "documentId": "doc_1", "workspaceId": "ws_1", }), - json!({ - "id": "doc_1", - "streamDeltaHint": { - "family": "tree", - "kind": "document_result", + json!({ + "id": "doc_1", + "commandProtocol": { + "family": "tree", + "owner": "rust-runtime-kernel", + "preferredCommandName": "tree.node.restore", + "compatCommandName": "documents.restore", + "deprecatedAlias": false + }, + "streamDeltaHint": { + "family": "tree", + "kind": "document_result", "args": { "documentField": "document" } @@ -14741,10 +15416,17 @@ mod tests { json!({ "documentId": "doc_1", }), - json!({ - "id": "doc_1" - }), - ), + json!({ + "id": "doc_1", + "commandProtocol": { + "family": "tree", + "owner": "rust-runtime-kernel", + "preferredCommandName": "tree.node.purge", + "compatCommandName": "documents.purge", + "deprecatedAlias": false + } + }), + ), ]; for (command_name, function_name, payload, args_json) in cases { @@ -15273,10 +15955,20 @@ mod tests { panic!("expected command plan"); }; - assert_eq!(plan.command_name, "tree.subtree.move"); - assert_eq!(plan.function_name, "documents:move"); - assert_eq!(plan.args_json["sortOrder"], json!(-2)); - assert!(plan.args_json.get("normalizedMove").is_none()); + assert_eq!(plan.command_name, "tree.subtree.move"); + assert_eq!(plan.function_name, "documents:move"); + assert_eq!(plan.args_json["sortOrder"], json!(-2)); + assert_eq!( + plan.args_json["commandProtocol"], + json!({ + "family": "tree", + "owner": "rust-runtime-kernel", + "preferredCommandName": "tree.subtree.move", + "compatCommandName": "documents.move", + "deprecatedAlias": false + }) + ); + assert!(plan.args_json.get("normalizedMove").is_none()); assert_eq!( plan.args_json["treeWriteOperation"], json!({ @@ -15330,11 +16022,111 @@ mod tests { } } }) - ); - } + ); + } - #[test] - fn tree_subtree_move_command_includes_tree_write_operation_from_snapshot() { + #[test] + 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.move", "tree.subtree.move", "documents.move"), + ("documents.delete", "tree.node.archive", "documents.delete"), + ("documents.restore", "tree.node.restore", "documents.restore"), + ("documents.purge", "tree.node.purge", "documents.purge"), + ("documents.copy_tree", "tree.subtree.copy", "documents.copy_tree"), + ]; + + for (command_name, preferred_command_name, compat_command_name) in cases { + let command = RuntimeCommandEnvelopeWire { + name: command_name.into(), + command_id: format!("cmd_{command_name}"), + idempotency_key: Some(format!("idem_{command_name}")), + actor: RuntimeActorWire { + actor_type: "user".into(), + actor_id: "user_1".into(), + session_id: Some("sess_1".into()), + }, + source: RuntimeSourceWire { + channel: "next-route".into(), + client: "wolai-frontend".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: match command_name { + "documents.create" => json!({ + "documentId": "doc_1", + "workspaceId": "ws_1", + "parentId": null, + "title": "新页面", + "accessScope": "private", + "content": [] + }), + "documents.title.update" => json!({ + "documentId": "doc_1", + "title": "改名" + }), + "documents.move" => json!({ + "documentId": "doc_1", + "parentId": null, + "sortOrder": 0 + }), + "documents.delete" | "documents.restore" | "documents.purge" => json!({ + "documentId": "doc_1", + "workspaceId": "ws_1" + }), + "documents.copy_tree" => json!({ + "items": [{"documentId": "doc_1", "recursive": true}], + "targetParentId": null + }), + _ => unreachable!("unexpected command"), + }, + preflight_data: if command_name == "documents.move" { + Some(json!({ + "documents": [ + { "id": "doc_1", "workspace_id": "ws_1", "parent_id": null, "sort_order": 0, "created_at": "2026-04-25T00:00:01Z" } + ] + })) + } else { + None + }, + reason: Some("兼容 alias 标识验证".into()), + refs: vec![], + dry_run: false, + validate_only: false, + }; + let plan = execute_runtime_input(RuntimeInput::Command { + context: demo_context(), + command, + }) + .expect("compat command plan should build"); + let RuntimeExecutionPlan::Command(plan) = plan else { + panic!("expected command plan"); + }; + + assert_eq!( + plan.args_json["commandProtocol"], + json!({ + "family": "tree", + "owner": "rust-runtime-kernel", + "preferredCommandName": preferred_command_name, + "compatCommandName": compat_command_name, + "deprecatedAlias": true + }), + "{command_name} 应标记为 tree compat deprecated alias" + ); + } + } + + #[test] + fn tree_subtree_move_command_includes_tree_write_operation_from_snapshot() { let plan = execute_runtime_input(RuntimeInput::Command { context: demo_context(), command: RuntimeCommandEnvelopeWire { @@ -15963,8 +16755,15 @@ mod tests { "recursive": true, } ], - "targetParentId": "parent_1", - "streamDeltaHint": { + "targetParentId": "parent_1", + "commandProtocol": { + "family": "tree", + "owner": "rust-runtime-kernel", + "preferredCommandName": "tree.subtree.copy", + "compatCommandName": "documents.copy_tree", + "deprecatedAlias": false + }, + "streamDeltaHint": { "family": "tree", "kind": "copy_result", "args": { diff --git a/rust/crates/core-protocol/src/lib.rs b/rust/crates/core-protocol/src/lib.rs index 3c2ef391..d566bdd5 100644 --- a/rust/crates/core-protocol/src/lib.rs +++ b/rust/crates/core-protocol/src/lib.rs @@ -289,6 +289,13 @@ mod tests { content: serde_json::json!([]), revision: serde_json::json!(7), conflict_detection_key: serde_json::json!("page_1:7"), + block_document: serde_json::json!({ + "documentId": "page_1", + "rootBlockIds": [], + "blocks": [] + }), + block_projection_version: 1, + projection_source: "fixture".into(), }, tree: page_aggregate::PageTree { page_subtree: serde_json::json!({"rootNodeId": "page_1"}), diff --git a/rust/crates/core-protocol/src/page_aggregate.rs b/rust/crates/core-protocol/src/page_aggregate.rs index 534790a9..15df5b1a 100644 --- a/rust/crates/core-protocol/src/page_aggregate.rs +++ b/rust/crates/core-protocol/src/page_aggregate.rs @@ -118,12 +118,28 @@ impl Default for PageOptions { #[cfg(test)] mod tests { - use super::PageOptions; + use super::{PageBody, PageOptions}; + use serde_json::json; #[test] fn page_options_default_disables_heading_numbers() { assert!(!PageOptions::default().show_heading_numbers); } + + #[test] + fn page_body_accepts_legacy_payload_without_block_projection() { + let body: PageBody = serde_json::from_value(json!({ + "content": [], + "revision": 7, + "conflictDetectionKey": "doc_1:7" + })) + .expect("legacy page body should deserialize"); + + assert_eq!(body.revision, json!(7)); + assert_eq!(body.block_projection_version, 0); + assert_eq!(body.block_document, json!(null)); + assert_eq!(body.projection_source, ""); + } } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] @@ -132,6 +148,12 @@ pub struct PageBody { pub content: Value, pub revision: Value, pub conflict_detection_key: Value, + #[serde(default)] + pub block_document: Value, + #[serde(default)] + pub block_projection_version: u32, + #[serde(default)] + pub projection_source: String, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] diff --git a/rust/crates/mnote-web/src/app.rs b/rust/crates/mnote-web/src/app.rs index a5761323..8cb830cb 100644 --- a/rust/crates/mnote-web/src/app.rs +++ b/rust/crates/mnote-web/src/app.rs @@ -1,3 +1,4 @@ +use crate::editor_actor::EditorRuntimeActor; use crate::local_folder_watcher_registry::LocalFolderWatcherRegistry; use crate::middleware::request_context::inject_request_context; use crate::routes::build_router; @@ -16,6 +17,7 @@ pub struct AppConfig { pub legacy_next_base_url: Option, pub enable_legacy_next_compat: bool, pub enable_debug_shell_routes: bool, + pub enable_editor_actor: bool, pub hermes_base_path: String, pub compat_next_base_path: String, pub convex_url: Option, @@ -48,6 +50,7 @@ impl AppConfig { .ok() .map(|value| matches!(value.trim(), "1" | "true" | "TRUE" | "yes" | "YES")) .unwrap_or(false), + enable_editor_actor: env_bool("MNOTE_WEB_ENABLE_EDITOR_ACTOR", true), hermes_base_path: env::var("MNOTE_WEB_HERMES_BASE_PATH") .unwrap_or_else(|_| "/api/hermes".into()), compat_next_base_path: env::var("MNOTE_WEB_COMPAT_NEXT_BASE_PATH") @@ -130,17 +133,26 @@ fn read_env_or_dotenv(key: &str) -> Option { None } +use tokio::sync::broadcast; + #[derive(Debug, Clone)] pub struct AppState { config: Arc, local_folder_watcher_registry: LocalFolderWatcherRegistry, + pub editor_actor: EditorRuntimeActor, + pub block_delta_tx: broadcast::Sender, } impl AppState { pub fn new(config: AppConfig) -> Self { + let (block_delta_tx, _) = broadcast::channel(256); + let actor = EditorRuntimeActor::new(); + actor.set_block_delta_tx(block_delta_tx.clone()); Self { config: Arc::new(config), local_folder_watcher_registry: LocalFolderWatcherRegistry::new(), + editor_actor: actor, + block_delta_tx, } } diff --git a/rust/crates/mnote-web/src/editor_actor.rs b/rust/crates/mnote-web/src/editor_actor.rs new file mode 100644 index 00000000..3205e3bc --- /dev/null +++ b/rust/crates/mnote-web/src/editor_actor.rs @@ -0,0 +1,429 @@ +//! EditorRuntimeActor - 页面块编辑运行时缓存层 +//! +//! 职责: +//! - 持有 per-document `EditorBlockDocument` 内存态 +//! - 接收 EditorCommand,在内存中 apply,产生 diff +//! - Convex 持久化仍由 block.rs 通过 execute_page_body_save 完成 +//! +//! 不是 agent runtime:不维护会话、不做意图解析、不调模型(遵从 7-12 禁止项)。 + +use bridge_runtime::{ + apply_editor_command_to_document, editor_document_from_legacy_content, + legacy_content_from_editor_document, +}; +use core_protocol::{ + ContentNode, ContentNodePayload, EditorBlock, EditorBlockDocument, EditorBlockType, + EditorCommand, +}; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; +use std::collections::HashMap; +use std::sync::Arc; +use std::sync::RwLock; +use std::time::Instant; + +use crate::error::WebError; + +#[derive(Debug, Clone)] +pub struct EditorDocumentState { + pub document_id: String, + pub workspace_id: Option, + pub document: EditorBlockDocument, + pub revision: u64, + pub conflict_detection_key: String, + pub last_applied_at: Instant, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ApplyResult { + pub ok: bool, + pub command: String, + pub document_id: Option, + pub workspace_id: Option, + pub new_revision: u64, + pub conflict_detection_key: String, + pub changed_blocks: Vec, + pub warnings: Vec, + pub blocked: bool, + pub risk: String, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ChangedBlock { + pub block_id: String, + pub op: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub before: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub after: Option, +} + +/// 编辑器增量 delta,可直接序列化为 JSON 传给 Tiptap bridge +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct BlockDelta { + pub document_id: String, + pub revision: u64, + pub conflict_detection_key: String, + pub operations: Vec, +} + +/// 一条增量操作,编辑器可通过 blockId 定位 + chain API 执行 +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", tag = "op")] +pub enum DeltaOperation { + #[serde(rename = "replace")] + ReplaceBlock { + block_id: String, + text: String, + #[serde(skip_serializing_if = "Option::is_none")] + block_type: Option, + }, + #[serde(rename = "insert_after")] + InsertBlockAfter { + anchor_block_id: String, + block_id: String, + text: String, + #[serde(skip_serializing_if = "Option::is_none")] + block_type: Option, + }, + #[serde(rename = "delete")] + DeleteBlock { + block_id: String, + }, + #[serde(rename = "move_after")] + MoveBlock { + block_id: String, + anchor_block_id: String, + }, +} + +#[derive(Debug, Clone)] +pub struct EditorRuntimeActor { + documents: Arc>>, + block_delta_tx: Arc>>>, +} + +impl EditorRuntimeActor { + /// 连接广播 channel(Phase C:事件 stream delta) + pub fn set_block_delta_tx(&self, tx: tokio::sync::broadcast::Sender) { + if let Ok(mut guard) = self.block_delta_tx.write() { + *guard = Some(tx); + } + } + + /// apply_command 完成后尝试推送 block.delta 到广播(Phase C) + pub fn try_push_block_delta(&self, delta_json: &Value) { + if let Ok(guard) = self.block_delta_tx.read() { + if let Some(ref tx) = *guard { + let _ = tx.send(delta_json.clone()); + } + } + } + + /// 在 apply 后构建 BlockDelta(Phase B 用于推送编辑器) + pub fn build_block_delta( + &self, + document_id: &str, + command: &EditorCommand, + ) -> Result { + let documents = self + .documents + .read() + .map_err(|e| WebError::internal(format!("EditorRuntimeActor 锁失败:{e}")))?; + let state = documents.get(document_id).ok_or_else(|| { + WebError::bad_request_code("mnote_editor_document_not_loaded", format!("文档 {document_id} 尚未加载")) + })?; + + let operations = match command { + EditorCommand::ReplaceBlock(cmd) => { + let block = state.document.blocks.iter().find(|b| b.block_id == cmd.block_id); + vec![DeltaOperation::ReplaceBlock { + block_id: cmd.block_id.clone(), + text: block_text_from_block(block), + block_type: block.map(|b| block_type_name(&b.block_type)), + }] + } + EditorCommand::InsertBlockAfter(cmd) => { + let new_block = state + .document + .blocks + .iter() + .find(|b| b.block_id == cmd.block.block_id); + vec![DeltaOperation::InsertBlockAfter { + anchor_block_id: cmd.after_block_id.clone(), + block_id: cmd.block.block_id.clone(), + text: block_text_from_block(new_block), + block_type: new_block.map(|b| block_type_name(&b.block_type)), + }] + } + EditorCommand::DeleteBlock(cmd) => { + vec![DeltaOperation::DeleteBlock { + block_id: cmd.block_id.clone(), + }] + } + EditorCommand::MoveBlock(cmd) => { + let anchor = cmd.after_block_id.clone().unwrap_or_default(); + vec![DeltaOperation::MoveBlock { + block_id: cmd.block_id.clone(), + anchor_block_id: anchor, + }] + } + _ => vec![], + }; + + Ok(BlockDelta { + document_id: document_id.to_string(), + revision: state.revision, + conflict_detection_key: state.conflict_detection_key.clone(), + operations, + }) + } + pub fn new() -> Self { + Self { + documents: Arc::new(RwLock::new(HashMap::new())), + block_delta_tx: Arc::new(RwLock::new(None)), + } + } + + /// 读取或初始化文档内存态。 + pub fn load_or_init( + &self, + document_id: &str, + workspace_id: Option<&str>, + page_aggregate: &Value, + ) -> Result<(), WebError> { + let mut documents = self + .documents + .write() + .map_err(|error| WebError::internal(format!("EditorRuntimeActor 锁失败:{error}")))?; + + if documents.contains_key(document_id) { + return Ok(()); + } + + let content = page_aggregate + .pointer("/body/content") + .cloned() + .unwrap_or_else(|| json!([])); + let revision = page_aggregate + .pointer("/body/revision") + .and_then(|value| value.as_u64()) + .unwrap_or(1); + let conflict_detection_key = page_aggregate + .pointer("/body/conflictDetectionKey") + .and_then(|value| value.as_str()) + .unwrap_or_default() + .to_string(); + + let document = editor_document_from_legacy_content(document_id, &content); + + let state = EditorDocumentState { + document_id: document_id.to_string(), + workspace_id: workspace_id.map(ToString::to_string), + document, + revision, + conflict_detection_key, + last_applied_at: Instant::now(), + }; + + documents.insert(document_id.to_string(), state); + Ok(()) + } + + /// 在内存中应用 EditorCommand,产生 diff,返回 ApplyResult。 + pub fn apply_command( + &self, + document_id: &str, + command: EditorCommand, + command_name: &str, + ) -> Result { + let mut documents = self + .documents + .write() + .map_err(|error| WebError::internal(format!("EditorRuntimeActor 锁失败:{error}")))?; + + let state = documents.get_mut(document_id).ok_or_else(|| { + WebError::bad_request_code( + "mnote_editor_document_not_loaded", + format!("文档 {document_id} 尚未加载到 EditorRuntimeActor"), + ) + })?; + + let changed_blocks = extract_changed_blocks(&state.document, &command); + + apply_editor_command_to_document(&mut state.document, command.clone()).map_err(|error| { + WebError::bad_request_code("mnote_editor_command_failed", format!("{error:?}")) + })?; + + state.revision += 1; + state.conflict_detection_key = format!( + "{}:{}:{}", + document_id, + state.revision, + state.last_applied_at.elapsed().as_micros() + ); + state.last_applied_at = Instant::now(); + + Ok(ApplyResult { + ok: true, + command: command_name.to_string(), + document_id: Some(document_id.to_string()), + workspace_id: state.workspace_id.clone(), + new_revision: state.revision, + conflict_detection_key: state.conflict_detection_key.clone(), + changed_blocks, + warnings: vec![], + blocked: false, + risk: "low".to_string(), + }) + } + + /// 从内存态生成 legacy content(用于构建 Convex save payload)。 + pub fn legacy_content_for_save(&self, document_id: &str) -> Result { + let documents = self + .documents + .read() + .map_err(|error| WebError::internal(format!("EditorRuntimeActor 锁失败:{error}")))?; + let state = documents.get(document_id).ok_or_else(|| { + WebError::bad_request_code( + "mnote_editor_document_not_loaded", + format!("文档 {document_id} 尚未加载到 EditorRuntimeActor"), + ) + })?; + Ok(legacy_content_from_editor_document(&state.document)) + } + + pub fn current_revision(&self, document_id: &str) -> Option { + self.documents + .read() + .ok() + .and_then(|documents| documents.get(document_id).map(|state| state.revision)) + } + + pub fn current_conflict_detection_key(&self, document_id: &str) -> Option { + self.documents.read().ok().and_then(|documents| { + documents + .get(document_id) + .map(|state| state.conflict_detection_key.clone()) + }) + } + + pub fn is_loaded(&self, document_id: &str) -> bool { + self.documents + .read() + .ok() + .map(|documents| documents.contains_key(document_id)) + .unwrap_or(false) + } +} + +fn extract_changed_blocks( + document: &EditorBlockDocument, + command: &EditorCommand, +) -> Vec { + match command { + EditorCommand::ReplaceBlock(command) => { + let before = document + .blocks + .iter() + .find(|block| block.block_id == command.block_id) + .and_then(block_text_summary); + let after = block_text_from_content_nodes(&command.content_nodes); + vec![ChangedBlock { + block_id: command.block_id.clone(), + op: "replace".to_string(), + before, + after, + }] + } + EditorCommand::InsertBlockAfter(command) => { + let after = block_text_from_content_nodes(&Some(command.block.content_nodes.clone())); + vec![ChangedBlock { + block_id: command.block.block_id.clone(), + op: "insert_after".to_string(), + before: None, + after, + }] + } + EditorCommand::DeleteBlock(command) => { + let before = document + .blocks + .iter() + .find(|block| block.block_id == command.block_id) + .and_then(block_text_summary); + vec![ChangedBlock { + block_id: command.block_id.clone(), + op: "delete".to_string(), + before, + after: None, + }] + } + EditorCommand::MoveBlock(command) => { + vec![ChangedBlock { + block_id: command.block_id.clone(), + op: "move".to_string(), + before: None, + after: None, + }] + } + _ => vec![], + } +} + +fn block_text_summary(block: &EditorBlock) -> Option { + let text = block + .content_nodes + .iter() + .filter_map(|node| match &node.payload { + ContentNodePayload::Text { text, .. } => Some(text.as_str()), + _ => None, + }) + .collect::>() + .join(""); + if text.is_empty() { + None + } else { + Some(text) + } +} + +fn block_text_from_content_nodes(nodes: &Option>) -> Option { + nodes.as_ref().map(|nodes| { + nodes + .iter() + .filter_map(|node| match &node.payload { + ContentNodePayload::Text { text, .. } => Some(text.as_str()), + _ => None, + }) + .collect::>() + .join("") + }) +} + +/// 从 EditorBlock 提取纯文本 +fn block_text_from_block(block: Option<&EditorBlock>) -> String { + block.map(block_text_summary).flatten().unwrap_or_default() +} + +/// EditorBlockType → 字符串 +fn block_type_name(block_type: &EditorBlockType) -> String { + match block_type { + EditorBlockType::Paragraph => "paragraph".into(), + EditorBlockType::Heading => "heading".into(), + EditorBlockType::Todo => "todo".into(), + EditorBlockType::BulletListItem => "bullet_list".into(), + EditorBlockType::NumberedListItem => "ordered_list".into(), + EditorBlockType::CodeBlock => "code".into(), + EditorBlockType::Quote => "blockquote".into(), + EditorBlockType::Divider => "divider".into(), + EditorBlockType::Image => "image".into(), + EditorBlockType::Table => "table".into(), + EditorBlockType::Mindmap => "mindmap".into(), + EditorBlockType::Toc => "table_of_contents".into(), + EditorBlockType::PageReference => "page_reference".into(), + EditorBlockType::BlockReference => "block_reference".into(), + } +} diff --git a/rust/crates/mnote-web/src/hermes_tools/block.rs b/rust/crates/mnote-web/src/hermes_tools/block.rs new file mode 100644 index 00000000..0d183a0e --- /dev/null +++ b/rust/crates/mnote-web/src/hermes_tools/block.rs @@ -0,0 +1,1399 @@ +use crate::app::AppState; +use crate::context::RequestContext; +use crate::error::WebError; +use crate::hermes_tools::doc::{ + aggregate_value, block_id_of, block_not_found, block_projection_blocks, find_block, + required_arg, +}; +use crate::hermes_tools::ToolCallInput; +use crate::routes::command_support::execute_runtime_command_via_convex_with_artifacts; +use bridge_runtime::{ + apply_editor_command_to_legacy_content, RuntimeActorWire, RuntimeCommandEnvelopeWire, + RuntimeSourceWire, RuntimeTargetWire, +}; +use core_protocol::{ + BlockProps, ContentNode, ContentNodePayload, EditorBlock, EditorBlockType, EditorCommand, + EditorDeleteBlock, EditorInsertBlockAfter, EditorMoveBlock, EditorReplaceBlock, +}; +use serde_json::{json, Value}; +use std::collections::{BTreeMap, HashSet}; + +pub async fn block_fetch( + state: &AppState, + context: &RequestContext, + input: &ToolCallInput, +) -> Result { + let aggregate = aggregate_value(state, context, input).await?; + let block_id = required_arg(input, context, "blockId")?; + let blocks = block_projection_blocks(&aggregate); + let block = find_block(&blocks, &block_id).ok_or_else(|| block_not_found(context))?; + let context_before = input + .arg_value("contextBefore") + .and_then(|value| value.as_u64()) + .unwrap_or(1) as usize; + let context_after = input + .arg_value("contextAfter") + .and_then(|value| value.as_u64()) + .unwrap_or(1) as usize; + let siblings = same_parent_blocks(&blocks, &block); + let index = siblings + .iter() + .position(|candidate| block_id_of(candidate).as_deref() == Some(block_id.as_str())) + .unwrap_or(0); + let before_start = index.saturating_sub(context_before); + let before = siblings[before_start..index].to_vec(); + let after = siblings + .iter() + .skip(index + 1) + .take(context_after) + .cloned() + .collect::>(); + let format = input + .arg_string("format") + .unwrap_or_else(|| "json".into()) + .to_ascii_lowercase(); + + Ok(json!({ + "ok": true, + "documentId": input.effective_document_id(), + "workspaceId": input.effective_workspace_id(), + "revision": aggregate.pointer("/body/revision").cloned().unwrap_or(Value::Null), + "conflictDetectionKey": aggregate.pointer("/body/conflictDetectionKey").cloned().unwrap_or(Value::Null), + "format": format, + "content": block_to_ai_content(&format, &block, &aggregate, input.effective_document_id().as_deref().unwrap_or_default()), + "block": block, + "context": { + "before": before, + "after": after + }, + "warnings": [] + })) +} + +pub async fn block_replace( + state: &AppState, + context: &RequestContext, + input: &ToolCallInput, +) -> Result { + ensure_write_contract(context, input)?; + let aggregate = aggregate_value(state, context, input).await?; + let block_id = required_arg(input, context, "blockId")?; + let content = input.arg_value("content").ok_or_else(|| { + WebError::bad_request_code("mnote_tool_bad_request", "mnote.block.replace 缺少 content") + .with_context(context) + })?; + let blocks = block_projection_blocks(&aggregate); + let block = find_block(&blocks, &block_id).ok_or_else(|| block_not_found(context))?; + ensure_page_write_preconditions(context, input, &aggregate)?; + ensure_block_revision_ref(context, input, &block, "blockRevisionRef")?; + ensure_block_editable(context, &block)?; + let replacement_text = content_to_text(&content); + let diff = json!([{ + "op": "replace", + "targetBlockId": block_id, + "before": block.get("text").cloned().unwrap_or(Value::Null), + "after": replacement_text + }]); + if input.dry_run.unwrap_or(false) { + return Ok(dry_run_result( + input, + &aggregate, + "block_replace", + diff, + false, + )); + } + let current_content = current_body_content(&aggregate); + let command = EditorCommand::ReplaceBlock(EditorReplaceBlock { + block_id: block_id.clone(), + block_type: replacement_block_type(&content), + props: replacement_block_props(&content), + content_nodes: Some(build_content_nodes(&replacement_text)), + }); + let (next_content, delta_opt) = compute_next_content_via_actor( + state, + &aggregate, + input, + ¤t_content, + &command, + "block_replace", + )?; + let mut result = execute_page_body_save( + state, + context, + input, + next_content, + vec![json!({ + "blockId": block_id, + "op": "replace" + })], + ) + .await?; + merge_block_delta(&mut result, delta_opt); + Ok(result) +} + +pub async fn block_insert_after( + state: &AppState, + context: &RequestContext, + input: &ToolCallInput, +) -> Result { + ensure_write_contract(context, input)?; + let aggregate = aggregate_value(state, context, input).await?; + let anchor_block_id = input + .arg_string("anchorBlockId") + .or_else(|| input.arg_string("afterBlockId")) + .ok_or_else(|| { + WebError::bad_request_code( + "mnote_tool_bad_request", + "mnote.block.insert_after 缺少 anchorBlockId", + ) + .with_context(context) + })?; + let blocks = block_projection_blocks(&aggregate); + let anchor = find_block(&blocks, &anchor_block_id).ok_or_else(|| block_not_found(context))?; + 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!([{ + "op": "insert_after", + "anchorBlockId": anchor_block_id, + "after": anchor.get("text").cloned().unwrap_or(Value::Null), + "block": new_block + }]); + if input.dry_run.unwrap_or(false) { + return Ok(dry_run_result( + input, + &aggregate, + "block_insert_after", + diff, + false, + )); + } + let current_content = current_body_content(&aggregate); + let command = EditorCommand::InsertBlockAfter(EditorInsertBlockAfter { + after_block_id: anchor_block_id.clone(), + block: build_editor_block(&new_block_id, &content), + }); + let (next_content, delta_opt) = compute_next_content_via_actor( + state, + &aggregate, + input, + ¤t_content, + &command, + "block_insert_after", + )?; + let mut result = execute_page_body_save( + state, + context, + input, + next_content, + vec![json!({ + "blockId": new_block_id, + "op": "insert_after", + "anchorBlockId": anchor_block_id + })], + ) + .await?; + merge_block_delta(&mut result, delta_opt); + Ok(result) +} + +pub async fn block_delete( + state: &AppState, + context: &RequestContext, + input: &ToolCallInput, +) -> Result { + ensure_write_contract(context, input)?; + let aggregate = aggregate_value(state, context, input).await?; + let block_id = required_arg(input, context, "blockId")?; + let blocks = block_projection_blocks(&aggregate); + let block = find_block(&blocks, &block_id).ok_or_else(|| block_not_found(context))?; + ensure_page_write_preconditions(context, input, &aggregate)?; + ensure_block_revision_ref(context, input, &block, "blockRevisionRef")?; + ensure_block_editable(context, &block)?; + let leaf = block + .get("children") + .and_then(Value::as_array) + .map(|children| children.is_empty()) + .unwrap_or(true); + let blocked = !leaf; + let diff = json!([{ + "op": "delete", + "blockId": block_id, + "before": block.get("text").cloned().unwrap_or(Value::Null) + }]); + if input.dry_run.unwrap_or(false) || blocked { + return Ok(json!({ + "ok": true, + "dryRun": input.dry_run.unwrap_or(false), + "command": "block_delete", + "revision": aggregate.pointer("/body/revision").cloned().unwrap_or(Value::Null), + "conflictDetectionKey": aggregate.pointer("/body/conflictDetectionKey").cloned().unwrap_or(Value::Null), + "blocked": blocked, + "risk": if blocked { "medium" } else { "low" }, + "diff": diff, + "warnings": if blocked { + json!([{ + "code": "block_delete_blocked", + "message": "第一阶段仅开放无子块的普通块删除" + }]) + } else { + json!([]) + } + })); + } + let current_content = current_body_content(&aggregate); + let command = EditorCommand::DeleteBlock(EditorDeleteBlock { + block_id: block_id.clone(), + preserve_children: false, + }); + let (next_content, delta_opt) = compute_next_content_via_actor( + state, + &aggregate, + input, + ¤t_content, + &command, + "block_delete", + )?; + let mut result = execute_page_body_save( + state, + context, + input, + next_content, + vec![json!({ + "blockId": block_id, + "op": "delete" + })], + ) + .await?; + merge_block_delta(&mut result, delta_opt); + Ok(result) +} + +pub async fn block_move_after( + state: &AppState, + context: &RequestContext, + input: &ToolCallInput, +) -> Result { + ensure_write_contract(context, input)?; + let aggregate = aggregate_value(state, context, input).await?; + let block_id = required_arg(input, context, "blockId")?; + let anchor_block_id = required_arg(input, context, "anchorBlockId")?; + let blocks = block_projection_blocks(&aggregate); + let block = find_block(&blocks, &block_id).ok_or_else(|| block_not_found(context))?; + let anchor = find_block(&blocks, &anchor_block_id).ok_or_else(|| block_not_found(context))?; + ensure_page_write_preconditions(context, input, &aggregate)?; + ensure_block_revision_ref(context, input, &block, "blockRevisionRef")?; + ensure_block_revision_ref(context, input, &anchor, "anchorRevisionRef")?; + let same_parent = block.get("parentBlockId") == anchor.get("parentBlockId"); + let leaf = block + .get("children") + .and_then(Value::as_array) + .map(|children| children.is_empty()) + .unwrap_or(true); + let movable_type = block + .get("type") + .and_then(Value::as_str) + .map(|block_type| matches!(block_type, "paragraph" | "heading" | "todo" | "task")) + .unwrap_or(false); + let editable = block + .get("editable") + .and_then(Value::as_bool) + .unwrap_or(false); + let blocked = + !same_parent || !leaf || !movable_type || !editable || block_id == anchor_block_id; + let diff = json!([{ + "op": "move_after", + "blockId": block_id, + "anchorBlockId": anchor_block_id, + "from": { + "parentBlockId": block.get("parentBlockId").cloned().unwrap_or(Value::Null), + "order": block.get("order").cloned().unwrap_or(Value::Null) + }, + "to": { + "parentBlockId": anchor.get("parentBlockId").cloned().unwrap_or(Value::Null), + "afterOrder": anchor.get("order").cloned().unwrap_or(Value::Null) + } + }]); + if input.dry_run.unwrap_or(false) || blocked { + return Ok(json!({ + "ok": true, + "dryRun": input.dry_run.unwrap_or(false), + "command": "block_move_after", + "revision": aggregate.pointer("/body/revision").cloned().unwrap_or(Value::Null), + "conflictDetectionKey": aggregate.pointer("/body/conflictDetectionKey").cloned().unwrap_or(Value::Null), + "blocked": blocked, + "risk": "medium", + "diff": diff, + "warnings": if blocked { + json!([{ + "code": "block_move_after_blocked", + "message": "第一阶段仅开放同父级普通叶子块移动,且不能移动到自身之后" + }]) + } else { + json!([]) + } + })); + } + let current_content = current_body_content(&aggregate); + let parent_block_id = anchor + .get("parentBlockId") + .and_then(Value::as_str) + .map(str::to_owned); + let command = EditorCommand::MoveBlock(EditorMoveBlock { + block_id: block_id.clone(), + parent_block_id, + after_block_id: Some(anchor_block_id.clone()), + }); + let (next_content, delta_opt) = compute_next_content_via_actor( + state, + &aggregate, + input, + ¤t_content, + &command, + "block_move_after", + )?; + let mut result = execute_page_body_save( + state, + context, + input, + next_content, + vec![json!({ + "blockId": block_id, + "op": "move_after", + "anchorBlockId": anchor_block_id + })], + ) + .await?; + merge_block_delta(&mut result, delta_opt); + Ok(result) +} + +pub async fn doc_apply_block_ops( + state: &AppState, + context: &RequestContext, + input: &ToolCallInput, +) -> Result { + ensure_write_contract(context, input)?; + let aggregate = aggregate_value(state, context, input).await?; + let operations = input + .arg_value("operations") + .and_then(|value| value.as_array().cloned()) + .ok_or_else(|| { + WebError::bad_request_code( + "mnote_tool_bad_request", + "mnote.doc.apply_block_ops 缺少 operations", + ) + .with_context(context) + })?; + if operations.is_empty() { + return Err(WebError::bad_request_code( + "mnote_tool_bad_request", + "mnote.doc.apply_block_ops operations 不能为空", + ) + .with_context(context)); + } + if operations.len() > 12 { + return Err(WebError::bad_request_code( + "mnote_tool_bad_request", + "mnote.doc.apply_block_ops 一次最多允许 12 个块操作", + ) + .with_context(context)); + } + + let blocks = block_projection_blocks(&aggregate); + let mut next_content = current_body_content(&aggregate); + let mut changed_blocks = Vec::new(); + let mut diff = Vec::new(); + let document_id = input.effective_document_id().unwrap_or_default(); + + for (index, operation) in operations.iter().enumerate() { + let op = operation + .get("op") + .or_else(|| operation.get("operation")) + .and_then(Value::as_str) + .map(|value| value.trim().replace('-', "_").to_ascii_lowercase()) + .unwrap_or_default(); + match op.as_str() { + "replace" | "block_replace" => { + let block = resolve_target_block(context, &blocks, operation, false)?; + ensure_block_editable(context, &block)?; + 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") + .with_context(context) + })?; + let replacement_text = content_to_text(&content); + ensure_allowed_target(context, input, operation, &block_id, "replace")?; + diff.push(json!({ + "op": "replace", + "blockId": block_id, + "before": block.get("text").cloned().unwrap_or(Value::Null), + "after": replacement_text + })); + if input.dry_run == Some(true) { + continue; + } + next_content = apply_editor_command_to_legacy_content( + &document_id, + &next_content, + EditorCommand::ReplaceBlock(EditorReplaceBlock { + block_id: block_id.clone(), + block_type: replacement_block_type(&content), + props: replacement_block_props(&content), + content_nodes: Some(build_content_nodes(&replacement_text)), + }), + ) + .map_err(|error| { + WebError::bad_request_code("mnote_block_command_failed", format!("{error:?}")) + .with_context(context) + })?; + changed_blocks.push(json!({"blockId": block_id, "op": "replace"})); + } + "insert_after" | "block_insert_after" => { + let anchor = resolve_anchor_block(context, &blocks, operation)?; + ensure_block_editable(context, &anchor)?; + 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(|| { + WebError::bad_request_code( + "mnote_tool_bad_request", + "insert_after 操作缺少 content", + ) + .with_context(context) + })?; + let new_block_id = format!( + "ai_block_{}_{}", + context.trace.request_id.replace('-', "_"), + index + ); + diff.push(json!({ + "op": "insert_after", + "anchorBlockId": anchor_block_id, + "after": anchor.get("text").cloned().unwrap_or(Value::Null), + "blockId": new_block_id, + "content": content + })); + if input.dry_run == Some(true) { + continue; + } + next_content = apply_editor_command_to_legacy_content( + &document_id, + &next_content, + EditorCommand::InsertBlockAfter(EditorInsertBlockAfter { + after_block_id: anchor_block_id.clone(), + block: build_editor_block(&new_block_id, &content), + }), + ) + .map_err(|error| { + WebError::bad_request_code("mnote_block_command_failed", format!("{error:?}")) + .with_context(context) + })?; + changed_blocks.push(json!({ + "blockId": new_block_id, + "op": "insert_after", + "anchorBlockId": anchor_block_id + })); + } + "delete" | "block_delete" => { + let block = resolve_target_block(context, &blocks, operation, false)?; + ensure_block_editable(context, &block)?; + 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")?; + diff.push(json!({ + "op": "delete", + "blockId": block_id, + "before": block.get("text").cloned().unwrap_or(Value::Null) + })); + if input.dry_run == Some(true) { + continue; + } + next_content = apply_editor_command_to_legacy_content( + &document_id, + &next_content, + EditorCommand::DeleteBlock(EditorDeleteBlock { + block_id: block_id.clone(), + preserve_children: false, + }), + ) + .map_err(|error| { + WebError::bad_request_code("mnote_block_command_failed", format!("{error:?}")) + .with_context(context) + })?; + changed_blocks.push(json!({"blockId": block_id, "op": "delete"})); + } + "move_after" | "block_move_after" => { + let block = resolve_target_block(context, &blocks, operation, false)?; + let anchor = resolve_anchor_block(context, &blocks, operation)?; + ensure_block_editable(context, &block)?; + 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(); + ensure_allowed_target(context, input, operation, &block_id, "move_after")?; + ensure_allowed_target( + context, + input, + operation, + &anchor_block_id, + "move_after_anchor", + )?; + if block.get("parentBlockId") != anchor.get("parentBlockId") + || block_id == anchor_block_id + { + return Err(WebError::bad_request_code( + "mnote_block_unsupported", + "move_after 批量快路径第一阶段仅支持同父级普通叶子块", + ) + .with_context(context)); + } + diff.push(json!({ + "op": "move_after", + "blockId": block_id, + "anchorBlockId": anchor_block_id + })); + if input.dry_run == Some(true) { + continue; + } + let parent_block_id = anchor + .get("parentBlockId") + .and_then(Value::as_str) + .map(str::to_owned); + next_content = apply_editor_command_to_legacy_content( + &document_id, + &next_content, + EditorCommand::MoveBlock(EditorMoveBlock { + block_id: block_id.clone(), + parent_block_id, + after_block_id: Some(anchor_block_id.clone()), + }), + ) + .map_err(|error| { + WebError::bad_request_code("mnote_block_command_failed", format!("{error:?}")) + .with_context(context) + })?; + changed_blocks.push(json!({ + "blockId": block_id, + "op": "move_after", + "anchorBlockId": anchor_block_id + })); + } + _ => { + return Err(WebError::bad_request_code( + "mnote_tool_bad_request", + format!("mnote.doc.apply_block_ops 不支持 op={op}"), + ) + .with_context(context)); + } + } + } + + if input.dry_run == Some(true) { + return Ok(json!({ + "ok": true, + "dryRun": true, + "command": "doc_apply_block_ops", + "documentId": input.effective_document_id(), + "workspaceId": input.effective_workspace_id(), + "revision": aggregate.pointer("/body/revision").cloned().unwrap_or(Value::Null), + "conflictDetectionKey": aggregate.pointer("/body/conflictDetectionKey").cloned().unwrap_or(Value::Null), + "diff": diff, + "warnings": [], + "blocked": false, + "risk": if operations.len() > 4 { "medium" } else { "low" } + })); + } + + execute_page_body_save_from_aggregate( + state, + context, + input, + &aggregate, + next_content, + changed_blocks, + ) + .await +} + +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", + "写入型 mnote Hermes tool 必须携带 idempotencyKey", + ) + .with_context(context)); + } + if input.dry_run.is_none() { + return Err(WebError::bad_request_code( + "mnote_tool_dry_run_required", + "写入型 mnote Hermes tool 必须显式携带 dryRun", + ) + .with_context(context)); + } + Ok(()) +} + +fn ensure_leaf_block( + context: &RequestContext, + block: &Value, + operation: &'static str, +) -> Result<(), WebError> { + let leaf = block + .get("children") + .and_then(Value::as_array) + .map(|children| children.is_empty()) + .unwrap_or(true); + if leaf { + return Ok(()); + } + Err(WebError::bad_request_code( + "mnote_block_unsupported", + format!("{operation} 第一阶段仅支持无子块普通块"), + ) + .with_context(context)) +} + +fn resolve_target_block( + context: &RequestContext, + blocks: &[Value], + operation: &Value, + allow_anchor_alias: bool, +) -> Result { + let block_id = operation + .get("blockId") + .or_else(|| operation.get("block_id")) + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned); + if let Some(block_id) = block_id { + return find_block(blocks, &block_id).ok_or_else(|| block_not_found(context)); + } + let text = operation + .get("matchText") + .or_else(|| operation.get("match_text")) + .or_else(|| operation.get("text")) + .or_else(|| { + if allow_anchor_alias { + operation + .get("anchorText") + .or_else(|| operation.get("afterText")) + } else { + None + } + }) + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or_else(|| { + WebError::bad_request_code( + "mnote_tool_bad_request", + "块操作必须提供 blockId 或 matchText", + ) + .with_context(context) + })?; + resolve_unique_block_by_text(context, blocks, text) +} + +fn resolve_anchor_block( + context: &RequestContext, + blocks: &[Value], + operation: &Value, +) -> Result { + if let Some(anchor_id) = operation + .get("anchorBlockId") + .or_else(|| operation.get("anchor_block_id")) + .or_else(|| operation.get("afterBlockId")) + .or_else(|| operation.get("after_block_id")) + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + { + return find_block(blocks, anchor_id).ok_or_else(|| block_not_found(context)); + } + resolve_target_block(context, blocks, operation, true) +} + +fn resolve_unique_block_by_text( + context: &RequestContext, + blocks: &[Value], + text: &str, +) -> Result { + let exact = blocks + .iter() + .filter(|block| block.get("text").and_then(Value::as_str) == Some(text)) + .cloned() + .collect::>(); + if exact.len() == 1 { + return Ok(exact[0].clone()); + } + if exact.len() > 1 { + return Err(WebError::bad_request_code( + "mnote_block_ambiguous", + "匹配到多个同文本块,请改用 blockId", + ) + .with_context(context)); + } + let contains = blocks + .iter() + .filter(|block| { + block + .get("text") + .and_then(Value::as_str) + .map(|block_text| block_text.contains(text)) + .unwrap_or(false) + }) + .cloned() + .collect::>(); + if contains.len() == 1 { + return Ok(contains[0].clone()); + } + if contains.len() > 1 { + return Err(WebError::bad_request_code( + "mnote_block_ambiguous", + "包含匹配命中多个块,请改用 blockId", + ) + .with_context(context)); + } + Err(block_not_found(context)) +} + +fn ensure_page_write_preconditions( + context: &RequestContext, + input: &ToolCallInput, + aggregate: &Value, +) -> Result<(), WebError> { + if input.dry_run.unwrap_or(false) { + return Ok(()); + } + let expected_revision = input + .arg_value("revision") + .ok_or_else(|| missing_write_precondition(context, "revision"))?; + let expected_conflict_key = input + .arg_string("conflictDetectionKey") + .ok_or_else(|| missing_write_precondition(context, "conflictDetectionKey"))?; + let current_revision = aggregate + .pointer("/body/revision") + .cloned() + .unwrap_or(Value::Null); + let current_conflict_key = aggregate + .pointer("/body/conflictDetectionKey") + .and_then(Value::as_str) + .map(str::trim) + .unwrap_or(""); + if value_label(&expected_revision).as_deref() != value_label(¤t_revision).as_deref() + || expected_conflict_key != current_conflict_key + { + return Err(WebError::bad_request_code( + "mnote_tool_conflict", + "页面正文 revision 或 conflictDetectionKey 已变化,请重新读取后再写入", + ) + .with_context(context)); + } + Ok(()) +} + +fn ensure_block_revision_ref( + context: &RequestContext, + input: &ToolCallInput, + block: &Value, + arg_name: &'static str, +) -> Result<(), WebError> { + if input.dry_run.unwrap_or(false) { + return Ok(()); + } + let expected = input + .arg_string(arg_name) + .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") + .and_then(Value::as_bool) + .unwrap_or(false) + { + return Ok(()); + } + Err(WebError::bad_request_code( + "mnote_block_unsupported", + block + .get("unsupportedReason") + .and_then(Value::as_str) + .unwrap_or("目标块暂不支持 AI 精确写入"), + ) + .with_context(context)) +} + +fn missing_write_precondition(context: &RequestContext, name: &'static str) -> WebError { + WebError::bad_request_code( + "mnote_tool_write_precondition_required", + format!("真实写入必须携带 {name};缺少时只能 dryRun=true"), + ) + .with_context(context) +} + +fn value_label(value: &Value) -> Option { + if let Some(number) = value.as_i64() { + return Some(number.to_string()); + } + if let Some(number) = value.as_u64() { + return Some(number.to_string()); + } + value + .as_str() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned) +} + +fn revision_number_value(value: Value) -> Option { + if let Some(number) = value.as_u64() { + return Some(json!(number)); + } + value + .as_str() + .map(str::trim) + .and_then(|value| value.parse::().ok()) + .map(|number| json!(number)) +} + +fn same_parent_blocks(blocks: &[Value], block: &Value) -> Vec { + let parent = block.get("parentBlockId").cloned().unwrap_or(Value::Null); + blocks + .iter() + .filter(|candidate| { + candidate + .get("parentBlockId") + .cloned() + .unwrap_or(Value::Null) + == parent + }) + .cloned() + .collect() +} + +fn dry_run_result( + input: &ToolCallInput, + aggregate: &Value, + command: &str, + diff: Value, + blocked: bool, +) -> Value { + json!({ + "ok": true, + "dryRun": true, + "command": command, + "documentId": input.effective_document_id(), + "workspaceId": input.effective_workspace_id(), + "revision": aggregate.pointer("/body/revision").cloned().unwrap_or(Value::Null), + "conflictDetectionKey": aggregate.pointer("/body/conflictDetectionKey").cloned().unwrap_or(Value::Null), + "diff": diff, + "warnings": [], + "blocked": blocked, + "risk": "low" + }) +} + +/// 如果 editor_actor 已启用,通过它 apply 并获取 legacy content;否则用旧路径计算。 +/// 返回 `(content, block_delta_opt)`,其中 block_delta_opt 为序列化后的 JSON(Phase B)。 +fn compute_next_content_via_actor( + state: &AppState, + aggregate: &Value, + input: &ToolCallInput, + current_content: &Value, + command: &EditorCommand, + command_name: &str, +) -> Result<(Value, Option), WebError> { + if !state.config().enable_editor_actor { + let content = apply_editor_command_to_legacy_content( + input.effective_document_id().as_deref().unwrap_or_default(), + current_content, + command.clone(), + ) + .map_err(|error| { + WebError::bad_request_code("mnote_block_command_failed", format!("{error:?}")) + })?; + return Ok((content, None)); + } + + let document_id = input.effective_document_id().ok_or_else(|| { + WebError::bad_request_code("mnote_tool_bad_request", "actor 路径缺少 documentId") + })?; + let workspace_id = input.effective_workspace_id(); + + // 确保 actor 已加载此文档 + if !state.editor_actor.is_loaded(&document_id) { + state + .editor_actor + .load_or_init(&document_id, workspace_id.as_deref(), aggregate)?; + } + + // 在内存中 apply + let _apply_result = state + .editor_actor + .apply_command(&document_id, command.clone(), command_name)?; + + // 从 actor 获取 legacy content(用于 Convex save 的 payload) + let content = state.editor_actor.legacy_content_for_save(&document_id)?; + + // 构建 BlockDelta(Phase B)并序列化为 JSON + let delta = state + .editor_actor + .build_block_delta(&document_id, command) + .ok() + .and_then(|bd| serde_json::to_value(bd).ok()); + + // Phase C:将 block.delta 推送到 broadcast 广播(SSE 事件 stream) + if let Some(ref delta_json) = delta { + state.editor_actor.try_push_block_delta(delta_json); + } + + Ok((content, delta)) +} + +async fn execute_page_body_save( + state: &AppState, + context: &RequestContext, + input: &ToolCallInput, + content: Value, + changed_blocks: Vec, +) -> Result { + let document_id = input.effective_document_id().ok_or_else(|| { + WebError::bad_request_code("mnote_tool_bad_request", "块写工具缺少 documentId") + .with_context(context) + })?; + let workspace_id = input.effective_workspace_id(); + let command_id = format!("page_body_save_{}", context.trace.request_id); + let payload = json!({ + "documentId": document_id, + "workspaceId": workspace_id, + "content": content, + "mode": "replace", + "revision": input.arg_value("revision").and_then(revision_number_value), + "conflictDetectionKey": input.arg_value("conflictDetectionKey") + }); + let command = RuntimeCommandEnvelopeWire { + name: "page.body.save".into(), + command_id: command_id.clone(), + idempotency_key: Some(input.idempotency_key_or_default(&command_id)), + actor: RuntimeActorWire { + actor_type: context.auth.actor_type.clone(), + actor_id: context.auth.actor_id.clone(), + session_id: input + .session_id + .clone() + .or_else(|| context.auth.session_id.clone()), + }, + source: RuntimeSourceWire { + channel: "hermes".into(), + client: "mnote-hermes-plugin".into(), + source_kind: None, + root_uri: None, + workspace_id: workspace_id.clone(), + capabilities: input.capability_scope.clone().unwrap_or_default(), + }, + target: Some(RuntimeTargetWire { + workspace_id: workspace_id.clone(), + page_id: Some(document_id.clone()), + block_id: None, + }), + payload, + preflight_data: None, + reason: Some("Hermes block tool page.body.save".into()), + refs: vec!["page.body.save".into(), "hermes-block-tool-call".into()], + dry_run: false, + validate_only: false, + }; + let execution = execute_runtime_command_via_convex_with_artifacts( + state.config(), + context, + workspace_id.as_deref(), + command, + ) + .await?; + Ok(json!({ + "commandName": "page.body.save", + "commandId": command_id, + "changedBlocks": changed_blocks, + "result": execution.result, + "artifacts": execution.artifacts, + "artifactError": execution.artifact_error + })) +} + +async fn execute_page_body_save_from_aggregate( + state: &AppState, + context: &RequestContext, + input: &ToolCallInput, + aggregate: &Value, + content: Value, + changed_blocks: Vec, +) -> Result { + let document_id = input.effective_document_id().ok_or_else(|| { + WebError::bad_request_code("mnote_tool_bad_request", "块写工具缺少 documentId") + .with_context(context) + })?; + let workspace_id = input.effective_workspace_id(); + let command_id = format!("page_body_save_{}", context.trace.request_id); + let payload = json!({ + "documentId": document_id, + "workspaceId": workspace_id, + "content": content, + "mode": "replace", + "revision": aggregate.pointer("/body/revision").cloned().and_then(revision_number_value), + "conflictDetectionKey": aggregate.pointer("/body/conflictDetectionKey").cloned() + }); + let command = RuntimeCommandEnvelopeWire { + name: "page.body.save".into(), + command_id: command_id.clone(), + idempotency_key: Some(input.idempotency_key_or_default(&command_id)), + actor: RuntimeActorWire { + actor_type: context.auth.actor_type.clone(), + actor_id: context.auth.actor_id.clone(), + session_id: input + .session_id + .clone() + .or_else(|| context.auth.session_id.clone()), + }, + source: RuntimeSourceWire { + channel: "hermes".into(), + client: "mnote-hermes-plugin".into(), + source_kind: None, + root_uri: None, + workspace_id: workspace_id.clone(), + capabilities: input.capability_scope.clone().unwrap_or_default(), + }, + target: Some(RuntimeTargetWire { + workspace_id: workspace_id.clone(), + page_id: Some(document_id.clone()), + block_id: None, + }), + payload, + preflight_data: None, + reason: Some("Hermes batch block tool page.body.save".into()), + refs: vec![ + "page.body.save".into(), + "hermes-batch-block-tool-call".into(), + ], + dry_run: false, + validate_only: false, + }; + let execution = execute_runtime_command_via_convex_with_artifacts( + state.config(), + context, + workspace_id.as_deref(), + command, + ) + .await?; + Ok(json!({ + "commandName": "page.body.save", + "commandId": command_id, + "changedBlocks": changed_blocks, + "result": execution.result, + "artifacts": execution.artifacts, + "artifactError": execution.artifact_error + })) +} + +fn content_to_text(value: &Value) -> String { + if let Some(text) = value.as_str() { + return text.to_string(); + } + if let Some(text) = value.get("text").and_then(Value::as_str) { + return text.to_string(); + } + if let Some(payload) = value.get("payload") { + return content_to_text(payload); + } + if let Some(content_nodes) = value.get("contentNodes") { + return content_to_text(content_nodes); + } + if let Some(content) = value.get("content") { + return content_to_text(content); + } + if let Some(items) = value.as_array() { + return items + .iter() + .map(content_to_text) + .collect::>() + .join(""); + } + String::new() +} + +fn ensure_allowed_target( + context: &RequestContext, + input: &ToolCallInput, + operation: &Value, + block_id: &str, + op: &str, +) -> Result<(), WebError> { + let allowed = allowed_target_block_ids(input, operation); + if allowed.is_empty() || allowed.contains(block_id) { + return Ok(()); + } + Err(WebError::bad_request_code( + "mnote_block_target_out_of_scope", + format!("{op} 目标块不在当前 AI selection 允许范围内"), + ) + .with_context(context)) +} + +fn allowed_target_block_ids(input: &ToolCallInput, operation: &Value) -> HashSet { + let mut allowed = HashSet::new(); + if let Some(Value::Array(values)) = input.arg_value("allowedTargetBlockIds") { + for value in values { + if let Some(id) = value.as_str().map(str::trim).filter(|id| !id.is_empty()) { + allowed.insert(id.to_string()); + } + } + } + if let Some(values) = operation + .get("allowedTargetBlockIds") + .and_then(Value::as_array) + { + for value in values { + if let Some(id) = value.as_str().map(str::trim).filter(|id| !id.is_empty()) { + allowed.insert(id.to_string()); + } + } + } + allowed +} + +fn block_to_ai_content( + format: &str, + block: &Value, + aggregate: &Value, + document_id: &str, +) -> String { + let text = block + .get("text") + .and_then(Value::as_str) + .unwrap_or_default(); + match format { + "page_xml" | "xml" => { + let revision = aggregate + .pointer("/body/revision") + .and_then(Value::as_u64) + .map(|value| value.to_string()) + .unwrap_or_else(|| { + aggregate + .pointer("/body/revision") + .and_then(Value::as_str) + .unwrap_or_default() + .to_string() + }); + let block_id = block_id_of(block).unwrap_or_default(); + let block_type = block + .get("type") + .and_then(Value::as_str) + .unwrap_or("paragraph"); + let revision_ref = block + .get("revisionRef") + .and_then(Value::as_str) + .unwrap_or_default(); + format!( + "\n {}\n", + escape_xml(document_id), + escape_xml(&revision), + escape_xml(&block_id), + escape_xml(block_type), + escape_xml(revision_ref), + escape_xml(text) + ) + } + "text" | "plain" => format!("[{}] {text}", block_id_of(block).unwrap_or_default()), + _ => format!( + "{text} ", + block_id_of(block).unwrap_or_default() + ), + } +} + +fn escape_xml(value: &str) -> String { + value + .replace('&', "&") + .replace('<', "<") + .replace('>', ">") + .replace('"', """) + .replace('\'', "'") +} + +/// 将 BlockDelta JSON 合并到工具响应中(Phase B) +fn merge_block_delta(response: &mut Value, delta_opt: Option) { + if let (Some(delta), Some(obj)) = (delta_opt, response.as_object_mut()) { + obj.insert("blockDelta".into(), delta); + } +} + +fn current_body_content(aggregate: &Value) -> Value { + aggregate + .pointer("/body/content") + .cloned() + .unwrap_or_else(|| json!([])) +} + +fn build_content_nodes(text: &str) -> Vec { + if text.trim().is_empty() { + return vec![]; + } + vec![ContentNode { + payload: ContentNodePayload::Text { + text: text.to_string(), + marks: vec![], + }, + attrs: BTreeMap::new(), + }] +} + +fn editor_block_type_from_value(value: &Value) -> EditorBlockType { + match value + .get("type") + .and_then(Value::as_str) + .unwrap_or("paragraph") + .to_ascii_lowercase() + .as_str() + { + "heading" => EditorBlockType::Heading, + "todo" | "task" => EditorBlockType::Todo, + "quote" | "blockquote" => EditorBlockType::Quote, + "code" | "code_block" | "code-block" => EditorBlockType::CodeBlock, + _ => EditorBlockType::Paragraph, + } +} + +fn block_props_from_value(value: &Value) -> BlockProps { + let mut props = BlockProps::default(); + let raw_props = value.get("props").and_then(Value::as_object); + if let Some(level) = raw_props + .and_then(|map| map.get("level").or_else(|| map.get("headingLevel"))) + .and_then(Value::as_u64) + .and_then(|value| u8::try_from(value).ok()) + { + props.heading_level = Some(level.clamp(1, 6)); + } + if let Some(checked) = raw_props + .and_then(|map| map.get("checked")) + .and_then(Value::as_bool) + { + props.checked = Some(checked); + } + if let Some(language) = raw_props + .and_then(|map| map.get("language")) + .and_then(Value::as_str) + .map(str::to_owned) + { + props.language = Some(language); + } + props +} + +fn replacement_block_type(value: &Value) -> Option { + value + .get("type") + .map(|_| editor_block_type_from_value(value)) +} + +fn replacement_block_props(value: &Value) -> Option { + value.get("props").map(|_| block_props_from_value(value)) +} + +fn build_editor_block(block_id: &str, value: &Value) -> EditorBlock { + EditorBlock { + block_id: block_id.to_string(), + block_type: editor_block_type_from_value(value), + props: block_props_from_value(value), + content_nodes: build_content_nodes(&content_to_text(value)), + child_block_ids: vec![], + } +} + +fn content_to_legacy(value: &Value) -> Value { + if value.is_object() && value.get("type").is_some() { + return value.clone(); + } + json!({ + "type": "paragraph", + "content": content_to_text(value) + }) +} + +fn build_insert_block(block_id: &str, value: &Value) -> Value { + let mut block = content_to_legacy(value); + if let Value::Object(map) = &mut block { + map.insert("id".into(), json!(block_id)); + map.entry("type").or_insert_with(|| json!("paragraph")); + map.entry("children").or_insert_with(|| json!([])); + } + block +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn content_to_text_reads_projection_content_nodes() { + let value = json!([ + { + "attrs": {}, + "payload": { + "marks": [], + "text": "Hermes 插件替换第二段", + "type": "text" + } + } + ]); + + assert_eq!(content_to_text(&value), "Hermes 插件替换第二段"); + } + + #[test] + fn content_to_text_reads_block_with_content_nodes() { + let value = json!([ + { + "type": "paragraph", + "contentNodes": [ + { + "attrs": {}, + "payload": { + "marks": [], + "text": "Hermes 插件插入段", + "type": "text" + } + } + ] + } + ]); + + assert_eq!(content_to_text(&value), "Hermes 插件插入段"); + } +} diff --git a/rust/crates/mnote-web/src/hermes_tools/doc.rs b/rust/crates/mnote-web/src/hermes_tools/doc.rs new file mode 100644 index 00000000..e98f9a06 --- /dev/null +++ b/rust/crates/mnote-web/src/hermes_tools/doc.rs @@ -0,0 +1,538 @@ +use crate::app::AppState; +use crate::context::RequestContext; +use crate::error::WebError; +use crate::hermes_tools::ToolCallInput; +use crate::routes::web_shell::build_page_aggregate_snapshot; +use serde_json::{json, Value}; +use std::collections::HashSet; + +pub async fn doc_fetch( + state: &AppState, + context: &RequestContext, + input: &ToolCallInput, +) -> Result { + let aggregate = aggregate_value(state, context, input).await?; + let document_id = input.effective_document_id().unwrap_or_default(); + let workspace_id = input.effective_workspace_id(); + let scope = input + .arg_string("scope") + .unwrap_or_else(|| "full".into()) + .to_ascii_lowercase(); + let detail = input + .arg_string("detail") + .unwrap_or_else(|| "with_ids".into()) + .to_ascii_lowercase(); + let max_blocks = input + .arg_value("maxBlocks") + .and_then(|value| value.as_u64()) + .unwrap_or(120) + .clamp(1, 240) as usize; + let mut blocks = block_projection_blocks(&aggregate); + blocks = match scope.as_str() { + "outline" => blocks + .into_iter() + .filter(|block| block.get("type").and_then(Value::as_str) == Some("heading")) + .collect(), + "block" => { + let block_id = input.arg_string("blockId").ok_or_else(|| { + WebError::bad_request_code( + "mnote_tool_bad_request", + "mnote.doc.fetch scope=block 缺少 blockId", + ) + .with_context(context) + })?; + blocks + .into_iter() + .filter(|block| block_id_of(block).as_deref() == Some(block_id.as_str())) + .collect() + } + "keyword" => { + let query = input.arg_string("query").ok_or_else(|| { + WebError::bad_request_code( + "mnote_tool_bad_request", + "mnote.doc.fetch scope=keyword 缺少 query", + ) + .with_context(context) + })?; + filter_blocks_by_query(blocks, &query) + } + "selection" => { + let selected_ids = selected_block_ids(input); + if selected_ids.is_empty() { + return Err(WebError::bad_request_code( + "mnote_tool_bad_request", + "mnote.doc.fetch scope=selection 缺少 selectedBlockIds", + ) + .with_context(context)); + } + blocks + .into_iter() + .filter(|block| { + block_id_of(block) + .map(|block_id| selected_ids.contains(&block_id)) + .unwrap_or(false) + }) + .collect() + } + _ => blocks, + }; + let truncated = blocks.len() > max_blocks; + blocks.truncate(max_blocks); + let format = input + .arg_string("format") + .unwrap_or_else(|| "json".into()) + .to_ascii_lowercase(); + let include_ids = detail == "with_ids" || detail == "full"; + let content = blocks_to_content(&format, &blocks, include_ids, &document_id, &aggregate); + let warnings = if truncated { + json!([{ + "code": "mnote_doc_fetch_truncated", + "message": "结果已按 maxBlocks 裁剪", + "maxBlocks": max_blocks + }]) + } else { + json!([]) + }; + Ok(json!({ + "ok": true, + "schema": "mnote.page_ai_context.v1", + "documentId": document_id, + "workspaceId": workspace_id, + "revision": aggregate.pointer("/body/revision").cloned().unwrap_or(Value::Null), + "conflictDetectionKey": aggregate.pointer("/body/conflictDetectionKey").cloned().unwrap_or(Value::Null), + "format": format, + "detail": detail, + "scope": scope, + "content": content, + "blocks": blocks, + "allowedTargetBlockIds": selected_block_ids(input), + "truncated": truncated, + "continuation": if truncated { json!({"maxBlocks": max_blocks}) } else { Value::Null }, + "warnings": warnings + })) +} + +pub async fn doc_find( + state: &AppState, + context: &RequestContext, + input: &ToolCallInput, +) -> Result { + let aggregate = aggregate_value(state, context, input).await?; + let query = input.arg_string("query").ok_or_else(|| { + WebError::bad_request_code("mnote_tool_bad_request", "mnote.doc.find 缺少 query") + .with_context(context) + })?; + let match_kind = input + .arg_string("match") + .unwrap_or_else(|| "text".into()) + .to_ascii_lowercase(); + let limit = input + .arg_value("limit") + .and_then(|value| value.as_u64()) + .unwrap_or(20) + .clamp(1, 50) as usize; + let mut matches = Vec::new(); + for block in block_projection_blocks(&aggregate) { + let matched = match match_kind.as_str() { + "type" => block + .get("type") + .and_then(Value::as_str) + .map(|value| value.eq_ignore_ascii_case(&query)) + .unwrap_or(false), + "block_id" | "blockid" => block_id_of(&block).as_deref() == Some(query.as_str()), + _ => block + .get("text") + .and_then(Value::as_str) + .map(|text| text.contains(&query)) + .unwrap_or(false), + }; + if matched { + matches.push(json!({ + "blockId": block.get("blockId").cloned().unwrap_or(Value::Null), + "type": block.get("type").cloned().unwrap_or(Value::Null), + "text": block.get("text").cloned().unwrap_or(Value::Null), + "path": block.get("path").cloned().unwrap_or(Value::Null), + "revisionRef": block.get("revisionRef").cloned().unwrap_or(Value::Null), + "score": 1.0 + })); + if matches.len() >= limit { + break; + } + } + } + Ok(json!({ + "ok": true, + "documentId": input.effective_document_id(), + "workspaceId": input.effective_workspace_id(), + "revision": aggregate.pointer("/body/revision").cloned().unwrap_or(Value::Null), + "conflictDetectionKey": aggregate.pointer("/body/conflictDetectionKey").cloned().unwrap_or(Value::Null), + "matches": matches + })) +} + +pub async fn plan_update( + state: &AppState, + context: &RequestContext, + input: &ToolCallInput, +) -> Result { + if !input.has_idempotency_key() { + return Err(WebError::bad_request_code( + "mnote_tool_idempotency_required", + "写入计划型 mnote Hermes tool 必须携带 idempotencyKey", + ) + .with_context(context)); + } + if input.dry_run != Some(true) { + return Err(WebError::bad_request_code( + "mnote_tool_dry_run_required", + "mnote.doc.plan_update 第一阶段只允许 dryRun=true", + ) + .with_context(context)); + } + let aggregate = aggregate_value(state, context, input).await?; + let command = input + .arg_string("command") + .unwrap_or_else(|| "block_replace".into()) + .to_ascii_lowercase(); + let blocks = block_projection_blocks(&aggregate); + let diff = match command.as_str() { + "block_replace" => { + let block_id = required_arg(input, context, "blockId")?; + let block = find_block(&blocks, &block_id).ok_or_else(|| block_not_found(context))?; + vec![json!({ + "op": "replace", + "targetBlockId": block_id, + "before": block.get("text").cloned().unwrap_or(Value::Null), + "after": input.arg_value("content").unwrap_or(Value::Null) + })] + } + "block_insert_after" => { + let anchor = input + .arg_string("anchorBlockId") + .or_else(|| input.arg_string("afterBlockId")) + .ok_or_else(|| { + WebError::bad_request_code( + "mnote_tool_bad_request", + "mnote.doc.plan_update block_insert_after 缺少 anchorBlockId", + ) + .with_context(context) + })?; + let block = find_block(&blocks, &anchor).ok_or_else(|| block_not_found(context))?; + vec![json!({ + "op": "insert_after", + "anchorBlockId": anchor, + "after": block.get("text").cloned().unwrap_or(Value::Null), + "content": input.arg_value("content").unwrap_or(Value::Null) + })] + } + "block_move_after" => { + let block_id = required_arg(input, context, "blockId")?; + let anchor = required_arg(input, context, "anchorBlockId")?; + let block = find_block(&blocks, &block_id).ok_or_else(|| block_not_found(context))?; + let anchor_block = + find_block(&blocks, &anchor).ok_or_else(|| block_not_found(context))?; + let blocked = block_move_after_blocked(&block, &anchor_block, &block_id, &anchor); + vec![json!({ + "op": "move_after", + "blockId": block_id, + "anchorBlockId": anchor, + "supportedForWrite": !blocked, + "blocked": blocked + })] + } + "block_delete" => { + let block_id = required_arg(input, context, "blockId")?; + let block = find_block(&blocks, &block_id).ok_or_else(|| block_not_found(context))?; + let blocked = block + .get("children") + .and_then(Value::as_array) + .map(|children| !children.is_empty()) + .unwrap_or(false); + vec![json!({ + "op": "delete", + "blockId": block_id, + "before": block.get("text").cloned().unwrap_or(Value::Null), + "supportedForWrite": !blocked, + "blocked": blocked + })] + } + "str_replace" => vec![json!({ + "op": "str_replace", + "query": input.arg_value("query").unwrap_or(Value::Null), + "replacement": input.arg_value("content").unwrap_or(Value::Null) + })], + other => { + return Err(WebError::bad_request_code( + "mnote_tool_bad_request", + format!("mnote.doc.plan_update 不支持 command={other}"), + ) + .with_context(context)); + } + }; + let plan_blocked = matches!(command.as_str(), "block_move_after" | "block_delete") + && diff + .first() + .and_then(|item| item.get("blocked")) + .and_then(Value::as_bool) + .unwrap_or(false); + Ok(json!({ + "ok": true, + "dryRun": true, + "planId": format!("plan_{}", context.trace.request_id), + "documentId": input.effective_document_id(), + "workspaceId": input.effective_workspace_id(), + "revision": aggregate.pointer("/body/revision").cloned().unwrap_or(Value::Null), + "conflictDetectionKey": aggregate.pointer("/body/conflictDetectionKey").cloned().unwrap_or(Value::Null), + "command": command, + "diff": diff, + "warnings": if plan_blocked { + json!([{ + "code": "block_move_after_blocked", + "message": "第一阶段仅开放同父级普通叶子块移动,且不能移动到自身之后" + }]) + } else { + json!([]) + }, + "risk": if command == "block_move_after" { "medium" } else { "low" }, + "blocked": plan_blocked + })) +} + +fn block_move_after_blocked( + block: &Value, + anchor: &Value, + block_id: &str, + anchor_id: &str, +) -> bool { + let same_parent = block.get("parentBlockId") == anchor.get("parentBlockId"); + let leaf = block + .get("children") + .and_then(Value::as_array) + .map(|children| children.is_empty()) + .unwrap_or(true); + let movable_type = block + .get("type") + .and_then(Value::as_str) + .map(|block_type| matches!(block_type, "paragraph" | "heading" | "todo" | "task")) + .unwrap_or(false); + let editable = block + .get("editable") + .and_then(Value::as_bool) + .unwrap_or(false); + !same_parent || !leaf || !movable_type || !editable || block_id == anchor_id +} + +pub(crate) async fn aggregate_value( + state: &AppState, + context: &RequestContext, + input: &ToolCallInput, +) -> Result { + let document_id = input.effective_document_id().ok_or_else(|| { + WebError::bad_request_code("mnote_tool_bad_request", "页面工具缺少 documentId") + .with_context(context) + })?; + let workspace_id = input.effective_workspace_id(); + let aggregate = build_page_aggregate_snapshot( + state, + context, + &document_id, + workspace_id.as_deref(), + None, + None, + ) + .await?; + serde_json::to_value(&aggregate).map_err(|error| WebError::internal(error.to_string())) +} + +pub(crate) fn block_projection_blocks(aggregate: &Value) -> Vec { + aggregate + .pointer("/body/blockDocument/blocks") + .and_then(Value::as_array) + .cloned() + .unwrap_or_default() +} + +pub(crate) fn block_id_of(block: &Value) -> Option { + block + .get("blockId") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned) +} + +pub(crate) fn find_block(blocks: &[Value], block_id: &str) -> Option { + blocks + .iter() + .find(|block| block_id_of(block).as_deref() == Some(block_id)) + .cloned() +} + +pub(crate) fn required_arg( + input: &ToolCallInput, + context: &RequestContext, + key: &'static str, +) -> Result { + input.arg_string(key).ok_or_else(|| { + WebError::bad_request_code("mnote_tool_bad_request", format!("工具调用缺少 {key}")) + .with_context(context) + }) +} + +pub(crate) fn block_not_found(context: &RequestContext) -> WebError { + WebError::bad_request_code("mnote_block_not_found", "块不存在").with_context(context) +} + +fn filter_blocks_by_query(blocks: Vec, query: &str) -> Vec { + blocks + .into_iter() + .filter(|block| { + block + .get("text") + .and_then(Value::as_str) + .map(|text| text.contains(query)) + .unwrap_or(false) + }) + .collect() +} + +fn selected_block_ids(input: &ToolCallInput) -> Vec { + let mut seen = HashSet::new(); + let mut ids = Vec::new(); + for key in ["selectedBlockIds", "allowedTargetBlockIds"] { + if let Some(Value::Array(values)) = input.arg_value(key) { + for value in values { + if let Some(id) = value.as_str().map(str::trim).filter(|id| !id.is_empty()) { + if seen.insert(id.to_string()) { + ids.push(id.to_string()); + } + } + } + } + } + for key in ["selectedBlockId", "blockId"] { + if let Some(id) = input.arg_string(key) { + if seen.insert(id.clone()) { + ids.push(id); + } + } + } + ids +} + +fn blocks_to_content( + format: &str, + blocks: &[Value], + include_ids: bool, + document_id: &str, + aggregate: &Value, +) -> String { + match format { + "page_xml" | "xml" => blocks_to_page_xml(blocks, document_id, aggregate), + "text" | "plain" => blocks_to_text(blocks, include_ids), + "markdown" | "md" => blocks_to_markdown(blocks, include_ids), + _ => blocks_to_markdown(blocks, include_ids), + } +} + +fn blocks_to_text(blocks: &[Value], include_ids: bool) -> String { + blocks + .iter() + .map(|block| { + let text = block_text(block); + if include_ids { + format!("[{}] {text}", block_id_of(block).unwrap_or_default()) + } else { + text + } + }) + .collect::>() + .join("\n") +} + +fn blocks_to_markdown(blocks: &[Value], include_ids: bool) -> String { + blocks + .iter() + .map(|block| { + let text = block_text(block); + let prefix = match block.get("type").and_then(Value::as_str) { + Some("heading") => "## ", + Some("todo") | Some("task") => "- [ ] ", + _ => "", + }; + if include_ids { + format!( + "{prefix}{text} ", + block_id_of(block).unwrap_or_default() + ) + } else { + format!("{prefix}{text}") + } + }) + .collect::>() + .join("\n") +} + +fn blocks_to_page_xml(blocks: &[Value], document_id: &str, aggregate: &Value) -> String { + let revision = aggregate + .pointer("/body/revision") + .and_then(Value::as_u64) + .map(|value| value.to_string()) + .unwrap_or_else(|| { + aggregate + .pointer("/body/revision") + .and_then(Value::as_str) + .unwrap_or_default() + .to_string() + }); + let mut output = format!( + "", + escape_xml(document_id), + escape_xml(&revision) + ); + for block in blocks { + let block_id = block_id_of(block).unwrap_or_default(); + let block_type = block + .get("type") + .and_then(Value::as_str) + .unwrap_or("paragraph"); + let revision_ref = block + .get("revisionRef") + .and_then(Value::as_str) + .unwrap_or_default(); + output.push_str(&format!( + "\n {}", escape_xml(&block_text(block)))); + } + output.push_str("\n"); + output +} + +fn block_text(block: &Value) -> String { + block + .get("text") + .and_then(Value::as_str) + .unwrap_or_default() + .to_string() +} + +fn escape_xml(value: &str) -> String { + value + .replace('&', "&") + .replace('<', "<") + .replace('>', ">") + .replace('"', """) + .replace('\'', "'") +} diff --git a/rust/crates/mnote-web/src/hermes_tools/manifest.rs b/rust/crates/mnote-web/src/hermes_tools/manifest.rs index 12bcfa00..c9912efa 100644 --- a/rust/crates/mnote-web/src/hermes_tools/manifest.rs +++ b/rust/crates/mnote-web/src/hermes_tools/manifest.rs @@ -13,6 +13,15 @@ pub fn manifest() -> Value { "writeOwner": "rust-runtime-kernel" }, "tools": [ + doc_fetch_tool(), + doc_find_tool(), + block_fetch_tool(), + doc_plan_update_tool(), + block_replace_tool(), + block_insert_after_tool(), + block_delete_tool(), + block_move_after_tool(), + doc_apply_block_ops_tool(), page_get_tool(), page_save_tool(), available_tool("mnote.page.update_title", "更新当前页面标题", ["page.write"]), @@ -23,6 +32,306 @@ pub fn manifest() -> Value { }) } +fn base_identity_properties() -> Value { + json!({ + "workspaceId": { "type": "string" }, + "documentId": { "type": "string" }, + "sessionId": { "type": "string" }, + "runId": { "type": "string" }, + "toolCallId": { "type": "string" }, + "traceId": { "type": "string" }, + "actorId": { "type": "string" }, + "actorType": { "type": "string" } + }) +} + +fn doc_fetch_tool() -> Value { + let mut properties = base_identity_properties(); + if let Value::Object(map) = &mut properties { + map.insert( + "scope".into(), + json!({ "type": "string", "default": "full" }), + ); + map.insert( + "detail".into(), + json!({ "type": "string", "default": "with_ids" }), + ); + map.insert( + "format".into(), + json!({ "type": "string", "enum": ["json", "markdown", "text", "page_xml"], "default": "json" }), + ); + map.insert("blockId".into(), json!({ "type": "string" })); + map.insert( + "selectedBlockIds".into(), + json!({ "type": "array", "items": { "type": "string" } }), + ); + map.insert( + "allowedTargetBlockIds".into(), + json!({ "type": "array", "items": { "type": "string" } }), + ); + map.insert("query".into(), json!({ "type": "string" })); + map.insert( + "maxBlocks".into(), + json!({ "type": "integer", "default": 120 }), + ); + } + json!({ + "name": "mnote.doc.fetch", + "description": "读取当前页面的 canonical block projection,支持 full/outline/block/keyword 范围", + "schemaVersion": TOOL_SCHEMA_VERSION, + "capabilityScope": ["page.read"], + "status": "available", + "annotations": tool_annotations(true, false, true, false), + "inputSchema": { + "type": "object", + "required": ["workspaceId", "documentId", "sessionId", "runId", "toolCallId", "traceId", "actorId"], + "properties": properties + } + }) +} + +fn doc_find_tool() -> Value { + let mut properties = base_identity_properties(); + if let Value::Object(map) = &mut properties { + map.insert("query".into(), json!({ "type": "string" })); + map.insert( + "match".into(), + json!({ "type": "string", "default": "text" }), + ); + map.insert("limit".into(), json!({ "type": "integer", "default": 20 })); + } + json!({ + "name": "mnote.doc.find", + "description": "在 Page Aggregate block projection 中按文本、类型或 blockId 查找块", + "schemaVersion": TOOL_SCHEMA_VERSION, + "capabilityScope": ["page.read"], + "status": "available", + "annotations": tool_annotations(true, false, true, false), + "inputSchema": { + "type": "object", + "required": ["workspaceId", "documentId", "sessionId", "runId", "toolCallId", "traceId", "actorId", "query"], + "properties": properties + } + }) +} + +fn block_fetch_tool() -> Value { + let mut properties = base_identity_properties(); + if let Value::Object(map) = &mut properties { + map.insert("blockId".into(), json!({ "type": "string" })); + map.insert( + "includeChildren".into(), + json!({ "type": "boolean", "default": true }), + ); + map.insert( + "contextBefore".into(), + json!({ "type": "integer", "default": 1 }), + ); + map.insert( + "contextAfter".into(), + json!({ "type": "integer", "default": 1 }), + ); + map.insert( + "format".into(), + json!({ "type": "string", "enum": ["json", "markdown", "text", "page_xml"], "default": "json" }), + ); + } + json!({ + "name": "mnote.block.fetch", + "description": "读取单个块及同父级上下文", + "schemaVersion": TOOL_SCHEMA_VERSION, + "capabilityScope": ["block.read", "page.read"], + "status": "available", + "annotations": tool_annotations(true, false, true, false), + "inputSchema": { + "type": "object", + "required": ["workspaceId", "documentId", "sessionId", "runId", "toolCallId", "traceId", "actorId", "blockId"], + "properties": properties + } + }) +} + +fn doc_plan_update_tool() -> Value { + write_tool( + "mnote.doc.plan_update", + "生成页面块更新 dry-run 计划,不直接写入", + ["page.write", "block.write"], + json!({ + "command": { "type": "string" }, + "blockId": { "type": "string" }, + "anchorBlockId": { "type": "string" }, + "content": { "type": ["string", "object", "array"] }, + "query": { "type": "string" }, + "revision": { "type": ["number", "string"] }, + "conflictDetectionKey": { "type": "string" } + }), + [], + ) +} + +fn block_replace_tool() -> Value { + write_tool( + "mnote.block.replace", + "替换指定块内容;真实写入走 Rust page.body.save 链路", + ["block.write", "page.write"], + json!({ + "blockId": { "type": "string" }, + "content": { "type": ["string", "object", "array"] }, + "revision": { "type": ["number", "string"] }, + "conflictDetectionKey": { "type": "string" }, + "blockRevisionRef": { "type": "string" } + }), + [ + "blockId", + "content", + "revision", + "conflictDetectionKey", + "blockRevisionRef", + ], + ) +} + +fn block_insert_after_tool() -> Value { + write_tool( + "mnote.block.insert_after", + "在指定块后插入新块;真实写入走 Rust page.body.save 链路", + ["block.write", "page.write"], + json!({ + "anchorBlockId": { "type": "string" }, + "content": { "type": ["string", "object", "array"] }, + "block": { "type": "object" }, + "revision": { "type": ["number", "string"] }, + "conflictDetectionKey": { "type": "string" }, + "anchorRevisionRef": { "type": "string" } + }), + [ + "anchorBlockId", + "content", + "revision", + "conflictDetectionKey", + "anchorRevisionRef", + ], + ) +} + +fn block_move_after_tool() -> Value { + write_tool( + "mnote.block.move_after", + "受限同父级普通叶子块移动;真实写入走 Rust page.body.save 链路", + ["block.write", "page.write"], + json!({ + "blockId": { "type": "string" }, + "anchorBlockId": { "type": "string" }, + "revision": { "type": ["number", "string"] }, + "conflictDetectionKey": { "type": "string" }, + "blockRevisionRef": { "type": "string" }, + "anchorRevisionRef": { "type": "string" } + }), + [ + "blockId", + "anchorBlockId", + "revision", + "conflictDetectionKey", + "blockRevisionRef", + "anchorRevisionRef", + ], + ) +} + +fn block_delete_tool() -> Value { + write_tool( + "mnote.block.delete", + "删除指定无子块普通块;真实写入走 Rust page.body.save 链路", + ["block.write", "page.write"], + json!({ + "blockId": { "type": "string" }, + "revision": { "type": ["number", "string"] }, + "conflictDetectionKey": { "type": "string" }, + "blockRevisionRef": { "type": "string" } + }), + [ + "blockId", + "revision", + "conflictDetectionKey", + "blockRevisionRef", + ], + ) +} + +fn doc_apply_block_ops_tool() -> Value { + write_tool( + "mnote.doc.apply_block_ops", + "一次性应用多个块级操作;Rust 侧统一读取最新 projection、生成 canonical content 并一次保存,适合页面 AI 小段落增删改移动快路径", + ["block.write", "page.write"], + json!({ + "operations": { + "type": "array", + "items": { + "type": "object", + "properties": { + "op": { "type": "string" }, + "blockId": { "type": "string" }, + "anchorBlockId": { "type": "string" }, + "matchText": { "type": "string" }, + "anchorText": { "type": "string" }, + "afterText": { "type": "string" }, + "allowedTargetBlockIds": { + "type": "array", + "items": { "type": "string" } + }, + "content": { "type": ["string", "object", "array"] } + } + } + } + }), + ["operations"], + ) +} + +fn write_tool( + name: &str, + description: &str, + scope: impl IntoIterator, + extra_properties: Value, + extra_required: impl IntoIterator, +) -> Value { + let mut properties = base_identity_properties(); + if let Value::Object(map) = &mut properties { + map.insert("dryRun".into(), json!({ "type": "boolean" })); + map.insert("idempotencyKey".into(), json!({ "type": "string" })); + if let Value::Object(extra) = extra_properties { + for (key, value) in extra { + map.insert(key, value); + } + } + } + let mut required = vec![ + "workspaceId", + "documentId", + "sessionId", + "runId", + "toolCallId", + "traceId", + "actorId", + "dryRun", + "idempotencyKey", + ]; + required.extend(extra_required); + json!({ + "name": name, + "description": description, + "schemaVersion": TOOL_SCHEMA_VERSION, + "capabilityScope": scope.into_iter().collect::>(), + "status": "available", + "annotations": tool_annotations(false, false, false, false), + "inputSchema": { + "type": "object", + "required": required, + "properties": properties + } + }) +} + fn page_get_tool() -> Value { json!({ "name": "mnote.page.get", @@ -31,7 +340,7 @@ fn page_get_tool() -> Value { "capabilityScope": ["page.read"], "inputSchema": { "type": "object", - "required": ["workspaceId", "documentId", "sessionId", "runId", "toolCallId", "traceId"], + "required": ["workspaceId", "documentId", "sessionId", "runId", "toolCallId", "traceId", "actorId"], "properties": { "workspaceId": { "type": "string" }, "documentId": { "type": "string" }, @@ -39,6 +348,8 @@ fn page_get_tool() -> Value { "runId": { "type": "string" }, "toolCallId": { "type": "string" }, "traceId": { "type": "string" }, + "actorId": { "type": "string" }, + "actorType": { "type": "string" }, "includeBody": { "type": "boolean", "default": true }, "includeOptions": { "type": "boolean", "default": true }, "includeBlocks": { "type": "boolean", "default": true } @@ -54,9 +365,10 @@ fn page_save_tool() -> Value { "schemaVersion": TOOL_SCHEMA_VERSION, "capabilityScope": ["page.write"], "status": "available", + "annotations": tool_annotations(false, true, false, false), "inputSchema": { "type": "object", - "required": ["workspaceId", "documentId", "sessionId", "runId", "toolCallId", "traceId", "content", "dryRun", "idempotencyKey"], + "required": ["workspaceId", "documentId", "sessionId", "runId", "toolCallId", "traceId", "actorId", "content", "dryRun", "idempotencyKey"], "properties": { "workspaceId": { "type": "string" }, "documentId": { "type": "string" }, @@ -64,6 +376,8 @@ fn page_save_tool() -> Value { "runId": { "type": "string" }, "toolCallId": { "type": "string" }, "traceId": { "type": "string" }, + "actorId": { "type": "string" }, + "actorType": { "type": "string" }, "content": { "description": "要写入的正文块数组、{blocks:[...]}、TipTap content 数组或纯文本", "type": ["array", "object", "string"] @@ -90,6 +404,25 @@ fn available_tool( "description": description, "schemaVersion": TOOL_SCHEMA_VERSION, "capabilityScope": scope.into_iter().collect::>(), - "status": "available" + "status": "available", + "annotations": tool_annotations(false, false, false, false) + }) +} + +fn tool_annotations( + readonly: bool, + destructive: bool, + idempotent: bool, + requires_approval: bool, +) -> Value { + json!({ + "readonly": readonly, + "destructive": destructive, + "idempotent": idempotent, + "requiresApproval": requires_approval, + "approvalMode": if requires_approval { "review" } else { "yolo" }, + "runtimeOwner": "mnote-web", + "writeOwner": "rust-runtime-kernel", + "selectionEffect": if readonly { "preserve" } else { "may_change" } }) } diff --git a/rust/crates/mnote-web/src/hermes_tools/mod.rs b/rust/crates/mnote-web/src/hermes_tools/mod.rs index d3a591f1..9b0fb151 100644 --- a/rust/crates/mnote-web/src/hermes_tools/mod.rs +++ b/rust/crates/mnote-web/src/hermes_tools/mod.rs @@ -1,4 +1,6 @@ pub mod artifact; +pub mod block; +pub mod doc; pub mod manifest; pub mod page; @@ -12,6 +14,7 @@ pub struct ToolCallInput { pub workspace_id: Option, pub document_id: Option, pub actor_id: Option, + pub profile: Option, pub session_id: Option, pub run_id: Option, pub tool_call_id: Option, diff --git a/rust/crates/mnote-web/src/hermes_tools/page.rs b/rust/crates/mnote-web/src/hermes_tools/page.rs index 8697e2bb..cdd7dc63 100644 --- a/rust/crates/mnote-web/src/hermes_tools/page.rs +++ b/rust/crates/mnote-web/src/hermes_tools/page.rs @@ -43,7 +43,11 @@ pub async fn page_get( .or_else(|| aggregate_value.pointer("/layout/page_options")) .cloned() .unwrap_or_else(|| json!({})); - let blocks = summarize_blocks(&content); + let blocks = aggregate_value + .pointer("/body/blockDocument/blocks") + .and_then(Value::as_array) + .cloned() + .unwrap_or_else(|| summarize_blocks(&content)); let body_summary = blocks .iter() .filter_map(|block| block.get("text").and_then(Value::as_str)) diff --git a/rust/crates/mnote-web/src/lib.rs b/rust/crates/mnote-web/src/lib.rs index 74a5a378..4d67eabc 100644 --- a/rust/crates/mnote-web/src/lib.rs +++ b/rust/crates/mnote-web/src/lib.rs @@ -1,5 +1,8 @@ +#![recursion_limit = "1024"] + pub mod app; pub mod context; +pub mod editor_actor; pub mod error; pub mod hermes_tools; pub mod local_folder_watcher_registry; diff --git a/rust/crates/mnote-web/src/main.rs b/rust/crates/mnote-web/src/main.rs index 7f98128f..d2826406 100644 --- a/rust/crates/mnote-web/src/main.rs +++ b/rust/crates/mnote-web/src/main.rs @@ -1,3 +1,5 @@ +#![recursion_limit = "512"] + use mnote_web::{build_app, AppConfig, AppState}; use tokio::net::TcpListener; use tracing::info; diff --git a/rust/crates/mnote-web/src/page_aggregate/builder.rs b/rust/crates/mnote-web/src/page_aggregate/builder.rs index a5ac8c49..9e288f52 100644 --- a/rust/crates/mnote-web/src/page_aggregate/builder.rs +++ b/rust/crates/mnote-web/src/page_aggregate/builder.rs @@ -13,6 +13,7 @@ use crate::page_aggregate::{ PageAggregate, PageAggregateSource, PageBody, PageHead, PageIdentity, PageLayout, PageOptions, PagePermissions, PageStats, PageTree, }; +use bridge_runtime::project_legacy_content_to_block_document; use serde_json::{to_value, Value}; /// PageAggregate 构建器。 @@ -284,6 +285,13 @@ impl PageAggregateBuilder { /// 消费 builder 并产出 `PageAggregate`。 pub fn build(self) -> PageAggregate { + let block_document = project_legacy_content_to_block_document( + &self.document_id, + &self.content, + &self.revision, + ) + .ok(); + let block_projection_version = if block_document.is_some() { 1 } else { 0 }; let page_options = PageOptions { wide_layout: self.wide_layout, small_text: self.small_text, @@ -336,6 +344,9 @@ impl PageAggregateBuilder { content: self.content, revision: self.revision, conflict_detection_key: self.conflict_detection_key, + block_document: block_document.unwrap_or(Value::Null), + block_projection_version, + projection_source: "builder.content".into(), }, tree: PageTree { page_subtree: self.page_subtree, diff --git a/rust/crates/mnote-web/src/routes/bridge.rs b/rust/crates/mnote-web/src/routes/bridge.rs index 0bd851da..5aa676cd 100644 --- a/rust/crates/mnote-web/src/routes/bridge.rs +++ b/rust/crates/mnote-web/src/routes/bridge.rs @@ -166,6 +166,7 @@ mod tests { 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, diff --git a/rust/crates/mnote-web/src/routes/compat.rs b/rust/crates/mnote-web/src/routes/compat.rs index 66acac1e..b41be534 100644 --- a/rust/crates/mnote-web/src/routes/compat.rs +++ b/rust/crates/mnote-web/src/routes/compat.rs @@ -72,6 +72,7 @@ mod tests { 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, @@ -110,6 +111,7 @@ mod tests { legacy_next_base_url: Some("http://127.0.0.1:3100".into()), enable_legacy_next_compat: false, 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, @@ -165,6 +167,7 @@ mod tests { legacy_next_base_url: Some("http://127.0.0.1:9".into()), enable_legacy_next_compat: false, 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, @@ -232,6 +235,7 @@ mod tests { legacy_next_base_url: Some(format!("http://{}", addr)), 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, diff --git a/rust/crates/mnote-web/src/routes/documents.rs b/rust/crates/mnote-web/src/routes/documents.rs index 42dfec0f..8a00acae 100644 --- a/rust/crates/mnote-web/src/routes/documents.rs +++ b/rust/crates/mnote-web/src/routes/documents.rs @@ -947,6 +947,7 @@ mod tests { 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, diff --git a/rust/crates/mnote-web/src/routes/editor.rs b/rust/crates/mnote-web/src/routes/editor.rs index 4e2ccf8d..d89d0c73 100644 --- a/rust/crates/mnote-web/src/routes/editor.rs +++ b/rust/crates/mnote-web/src/routes/editor.rs @@ -1778,6 +1778,7 @@ mod tests { legacy_next_base_url: Some("http://127.0.0.1:3100".into()), enable_legacy_next_compat: true, enable_debug_shell_routes: true, + enable_editor_actor: true, hermes_base_path: "/api/hermes".into(), compat_next_base_path: "/api/compat/next".into(), convex_url: None, diff --git a/rust/crates/mnote-web/src/routes/gateway.rs b/rust/crates/mnote-web/src/routes/gateway.rs index 84afc316..a3e8cc29 100644 --- a/rust/crates/mnote-web/src/routes/gateway.rs +++ b/rust/crates/mnote-web/src/routes/gateway.rs @@ -1413,6 +1413,7 @@ mod tests { legacy_next_base_url: Some(legacy_next_base_url), enable_legacy_next_compat, 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, diff --git a/rust/crates/mnote-web/src/routes/hermes.rs b/rust/crates/mnote-web/src/routes/hermes.rs index 37cf805f..7c13d84d 100644 --- a/rust/crates/mnote-web/src/routes/hermes.rs +++ b/rust/crates/mnote-web/src/routes/hermes.rs @@ -165,6 +165,7 @@ mod tests { 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, diff --git a/rust/crates/mnote-web/src/routes/hermes_client.rs b/rust/crates/mnote-web/src/routes/hermes_client.rs index baeaa8b3..f9f6d99a 100644 --- a/rust/crates/mnote-web/src/routes/hermes_client.rs +++ b/rust/crates/mnote-web/src/routes/hermes_client.rs @@ -1,6 +1,7 @@ use crate::app::AppState; use crate::context::RequestContext; use crate::error::WebError; +use crate::hermes_tools::manifest; use crate::transport::convex::execute_convex_query_by_name; use axum::body::Body; use axum::extract::{Extension, Path, Query, State}; @@ -80,7 +81,11 @@ pub async fn list_sessions( Query(query): Query>, ) -> Result<(StatusCode, HeaderMap, Json), WebError> { ensure_authenticated(&context)?; - let Some(upstream) = configured_upstream() else { + let profile = query + .get("profile") + .map(String::as_str) + .unwrap_or("default"); + let Some(upstream) = configured_upstream_for_profile(profile) else { return hermes_unconfigured(&context); }; let mut path = "/api/hermes/sessions".to_string(); @@ -93,7 +98,15 @@ pub async fn list_sessions( path.push('?'); path.push_str(¶ms); } - proxy_json(&context, reqwest::Method::GET, &upstream, &path, None).await + proxy_json( + &context, + reqwest::Method::GET, + &upstream, + &path, + None, + Some(profile), + ) + .await } pub async fn create_session( @@ -157,7 +170,7 @@ pub async fn gateway_health( .map(ToOwned::to_owned) .or_else(active_profile_name) .unwrap_or_else(|| "default".into()); - let upstream = configured_upstream(); + let upstream = configured_upstream_for_profile(&profile); let profile_status = profile_gateway_status(&profile); let mut suggestions = profile_status .get("suggestions") @@ -178,7 +191,8 @@ pub async fn gateway_health( "status": if upstream.is_some() { "checking" } else { "unconfigured" } }); if let Some(upstream_url) = gateway["upstream"].as_str().map(ToOwned::to_owned) { - let probe = probe_gateway_health(&upstream_url).await; + let probe = + probe_gateway_health(&upstream_url, configured_api_key_for_profile(&profile)).await; gateway["ok"] = Value::Bool(probe.ok); gateway["status"] = Value::String(probe.status); gateway["httpStatus"] = probe.http_status.map(Value::from).unwrap_or(Value::Null); @@ -387,6 +401,52 @@ pub async fn toggle_skill( )) } +pub async fn toggle_tool( + Extension(context): Extension, + Json(payload): Json, +) -> Result<(StatusCode, HeaderMap, Json), WebError> { + ensure_authenticated(&context)?; + let name = payload + .get("name") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or_else(|| { + WebError::bad_request_code("hermes_client_bad_request", "缺少 tool name") + .with_context(&context) + .with_header(HEADER_MNOTE_WEB_OWNER, "mnote-web") + .with_header(HEADER_HERMES_CLIENT_OWNER, "mnote-web-hermes-client") + })?; + let enabled = payload + .get("enabled") + .and_then(Value::as_bool) + .ok_or_else(|| { + WebError::bad_request_code("hermes_client_bad_request", "缺少 enabled") + .with_context(&context) + .with_header(HEADER_MNOTE_WEB_OWNER, "mnote-web") + .with_header(HEADER_HERMES_CLIENT_OWNER, "mnote-web-hermes-client") + })?; + let fallback_profile = active_profile_name().unwrap_or_else(|| "default".into()); + let profile = payload + .get("profile") + .and_then(Value::as_str) + .unwrap_or(fallback_profile.as_str()); + set_mnote_tool_enabled(profile, name, enabled).map_err(|error| { + WebError::bad_gateway_code( + "hermes_client_tool_toggle_failed", + format!("更新 mnote tool 设置失败: {error}"), + ) + .with_context(&context) + .with_header(HEADER_MNOTE_WEB_OWNER, "mnote-web") + .with_header(HEADER_HERMES_CLIENT_OWNER, "mnote-web-hermes-client") + })?; + Ok(( + StatusCode::OK, + stamp_client_headers(), + Json(json!({"ok": true})), + )) +} + pub async fn get_session( Extension(context): Extension, Path(session_id): Path, @@ -466,7 +526,7 @@ pub async fn create_run( let queued = enqueue_run(&context, ®istration, &payload)?; return Ok((StatusCode::ACCEPTED, stamp_client_headers(), Json(queued))); } - let Some(upstream) = configured_upstream() else { + let Some(upstream) = configured_upstream_for_profile(®istration.profile) else { return hermes_unconfigured(&context); }; let upstream_body = build_run_upstream_body(&context, payload)?; @@ -476,6 +536,7 @@ pub async fn create_run( &upstream, "/v1/runs", Some(upstream_body), + Some(®istration.profile), ) .await?; if let Some(runtime) = register_runtime_from_create_run_response(®istration, &result.2 .0) { @@ -511,7 +572,8 @@ pub async fn stream_events( Path(run_id): Path, ) -> Result { ensure_authenticated(&context)?; - let Some(upstream) = configured_upstream() else { + let profile = profile_for_run(&run_id).unwrap_or_else(|| "default".into()); + let Some(upstream) = configured_upstream_for_profile(&profile) else { return Err(hermes_unconfigured_error(&context)); }; let url = upstream_url( @@ -525,7 +587,7 @@ pub async fn stream_events( WebError::internal(format!("Hermes client 构造失败: {error}")).with_context(&context) })? .get(url); - if let Some(api_key) = configured_api_key() { + if let Some(api_key) = configured_api_key_for_profile(&profile) { request = request.bearer_auth(api_key); } let upstream_response = request.send().await.map_err(|error| { @@ -583,7 +645,8 @@ pub async fn abort_run( Json(payload): Json, ) -> Result<(StatusCode, HeaderMap, Json), WebError> { ensure_authenticated(&context)?; - let Some(upstream) = configured_upstream() else { + let profile = profile_for_run(&run_id).unwrap_or_else(|| "default".into()); + let Some(upstream) = configured_upstream_for_profile(&profile) else { return hermes_unconfigured(&context); }; let queued_session_id = session_id_for_run(&run_id); @@ -594,6 +657,7 @@ pub async fn abort_run( &upstream, &format!("/v1/runs/{}/stop", url_escape(&run_id)), Some(payload), + Some(&profile), ) .await; match result { @@ -629,70 +693,29 @@ pub async fn list_models( &upstream, "/v1/models", None, + None, ) .await } pub async fn list_tools( Extension(context): Extension, + Query(query): Query>, ) -> Result<(StatusCode, HeaderMap, Json), WebError> { ensure_authenticated(&context)?; + let fallback_profile = active_profile_name().unwrap_or_else(|| "default".into()); + let profile = query + .get("profile") + .map(String::as_str) + .unwrap_or(fallback_profile.as_str()); Ok(( StatusCode::OK, stamp_client_headers(), Json(json!({ "ok": true, "traceId": context.trace.trace_id, - "tools": [ - { - "name": "mnote.page.get", - "scope": "page.read", - "kind": "read", - "schemaVersion": "mnote.hermes_tool.v1", - "status": "available", - "description": "读取当前页面正文、标题、设置与结构快照" - }, - { - "name": "mnote.page.save", - "scope": "page.write", - "kind": "write", - "schemaVersion": "mnote.hermes_tool.v1", - "status": "available", - "description": "保存当前页面正文" - }, - { - "name": "mnote.page.update_title", - "scope": "page.write", - "kind": "write", - "schemaVersion": "mnote.hermes_tool.v1", - "status": "available", - "description": "更新当前页面标题" - }, - { - "name": "mnote.page.update_options", - "scope": "page.write", - "kind": "write", - "schemaVersion": "mnote.hermes_tool.v1", - "status": "available", - "description": "更新当前页面设置" - }, - { - "name": "mnote.artifact.create_summary", - "scope": "artifact.write", - "kind": "write", - "schemaVersion": "mnote.hermes_tool.v1", - "status": "available", - "description": "为当前页面创建或更新 AI Summary" - }, - { - "name": "mnote.artifact.create_ai_note", - "scope": "artifact.write", - "kind": "write", - "schemaVersion": "mnote.hermes_tool.v1", - "status": "available", - "description": "基于当前页面创建新的 AI Note" - } - ] + "profile": profile, + "tools": mnote_tools_payload(profile) })), )) } @@ -717,7 +740,7 @@ fn hermes_home() -> PathBuf { .unwrap_or_else(|| PathBuf::from(".hermes")) } -fn active_profile_name() -> Option { +pub(crate) fn active_profile_name() -> Option { fs::read_to_string(hermes_home().join("active_profile")) .ok() .map(|value| value.trim().to_string()) @@ -948,6 +971,105 @@ fn disabled_skills(profile: &str) -> Vec { disabled } +fn yaml_disabled_list(content: &str, path: &[&str]) -> Vec { + let mut stack: Vec<(usize, String)> = Vec::new(); + let mut values = Vec::new(); + for raw_line in content.lines() { + let line = raw_line.trim_end_matches('\r'); + let trimmed = line.trim(); + if trimmed.is_empty() || trimmed.starts_with('#') { + continue; + } + let indent = line.chars().take_while(|ch| ch.is_whitespace()).count(); + if let Some(value) = trimmed.strip_prefix("- ") { + let keys = stack + .iter() + .map(|(_, key)| key.as_str()) + .collect::>(); + if keys == path { + let normalized = value.trim().trim_matches('"').trim_matches('\''); + if !normalized.is_empty() { + values.push(normalized.to_string()); + } + } + continue; + } + while stack + .last() + .map(|(level, _)| *level >= indent) + .unwrap_or(false) + { + stack.pop(); + } + let Some((key, _value)) = trimmed.split_once(':') else { + continue; + }; + let key = key.trim().trim_matches('"').trim_matches('\'').to_string(); + stack.push((indent, key)); + } + values +} + +pub(crate) fn disabled_mnote_tools(profile: &str) -> Vec { + let content = fs::read_to_string(profile_config_path(profile)).unwrap_or_default(); + yaml_disabled_list(&content, &["mnote", "tools", "disabled"]) +} + +pub(crate) fn is_mnote_tool_disabled(profile: &str, name: &str) -> bool { + disabled_mnote_tools(profile) + .iter() + .any(|item| item == name) +} + +fn mnote_tools_payload(profile: &str) -> Vec { + let disabled = disabled_mnote_tools(profile); + manifest::manifest() + .get("tools") + .and_then(Value::as_array) + .into_iter() + .flatten() + .filter_map(|tool| mnote_tool_entry(tool, &disabled)) + .collect() +} + +fn mnote_tool_entry(tool: &Value, disabled: &[String]) -> Option { + let name = tool.get("name").and_then(Value::as_str)?.trim(); + if name.is_empty() { + return None; + } + let capability_scope = tool + .get("capabilityScope") + .and_then(Value::as_array) + .map(|items| { + items + .iter() + .filter_map(Value::as_str) + .filter(|value| !value.trim().is_empty()) + .map(str::to_string) + .collect::>() + }) + .unwrap_or_default(); + let disabled = disabled.iter().any(|item| item == name); + let status = if disabled { + "disabled" + } else { + tool.get("status") + .and_then(Value::as_str) + .unwrap_or("available") + }; + Some(json!({ + "name": name, + "description": tool.get("description").and_then(Value::as_str).unwrap_or_default(), + "scope": capability_scope.first().cloned().unwrap_or_else(|| "mnote".into()), + "capabilityScope": capability_scope, + "kind": if capability_scope.iter().any(|scope| scope.ends_with(".write")) { "write" } else { "read" }, + "schemaVersion": tool.get("schemaVersion").and_then(Value::as_str).unwrap_or(manifest::TOOL_SCHEMA_VERSION), + "status": status, + "enabled": !disabled, + "unavailableReason": if disabled { "当前 Hermes profile 已关闭该 mnote tool" } else { "" } + })) +} + fn extract_skill_description(markdown: &str) -> String { markdown .lines() @@ -1237,6 +1359,73 @@ fn set_skill_enabled(profile: &str, name: &str, enabled: bool) -> std::io::Resul fs::write(path, output) } +fn set_mnote_tool_enabled(profile: &str, name: &str, enabled: bool) -> std::io::Result<()> { + let path = profile_config_path(profile); + let mut disabled = disabled_mnote_tools(profile); + if enabled { + disabled.retain(|item| item != name); + } else if !disabled.iter().any(|item| item == name) { + disabled.push(name.to_string()); + } + disabled.sort(); + let existing = fs::read_to_string(&path).unwrap_or_default(); + let mut kept = Vec::new(); + let mut stack: Vec<(usize, String)> = Vec::new(); + let mut skipping_disabled_list = false; + let mut disabled_indent = 0usize; + for line in existing.lines() { + let trimmed = line.trim(); + let indent = line.chars().take_while(|ch| ch.is_whitespace()).count(); + if skipping_disabled_list { + if trimmed.starts_with("- ") && indent > disabled_indent { + continue; + } + skipping_disabled_list = false; + } + if trimmed.is_empty() || trimmed.starts_with('#') { + kept.push(line.to_string()); + continue; + } + if !trimmed.starts_with("- ") { + while stack + .last() + .map(|(level, _)| *level >= indent) + .unwrap_or(false) + { + stack.pop(); + } + if let Some((key, _value)) = trimmed.split_once(':') { + let key = key.trim().trim_matches('"').trim_matches('\'').to_string(); + stack.push((indent, key)); + let keys = stack + .iter() + .map(|(_, key)| key.as_str()) + .collect::>(); + if keys == ["mnote", "tools", "disabled"] { + skipping_disabled_list = true; + disabled_indent = indent; + continue; + } + } + } + kept.push(line.to_string()); + } + if let Some(parent) = path.parent() { + fs::create_dir_all(parent)?; + } + let mut output = kept.join("\n"); + if !output.ends_with('\n') && !output.is_empty() { + output.push('\n'); + } + output.push_str("mnote:\n tools:\n disabled:\n"); + for tool in disabled { + output.push_str(" - "); + output.push_str(&tool); + output.push('\n'); + } + fs::write(path, output) +} + fn ensure_authenticated(context: &RequestContext) -> Result<(), WebError> { let has_actor = context.auth.actor_id.trim() != "anonymous"; if has_actor || context.auth.authorization.is_some() || context.auth.cookie_header.is_some() { @@ -1264,6 +1453,57 @@ fn configured_upstream() -> Option { .filter(|value| !value.is_empty()) } +fn profile_config_value(profile: &str, path: &[&str]) -> Option { + let content = fs::read_to_string(profile_config_path(profile)).ok()?; + yaml_path_value(&content, path) +} + +fn profile_env_key(prefix: &str, profile: &str, suffix: &str) -> String { + let normalized = profile + .chars() + .map(|ch| { + if ch.is_ascii_alphanumeric() { + ch.to_ascii_uppercase() + } else { + '_' + } + }) + .collect::(); + format!("{prefix}_{normalized}_{suffix}") +} + +fn configured_upstream_for_profile(profile: &str) -> Option { + let profile = profile.trim(); + if !profile.is_empty() && profile != "default" { + let env_key = profile_env_key("MNOTE_WEB_HERMES", profile, "UPSTREAM_URL"); + if let Some(value) = env_or_dotenv(&env_key) { + return Some(value.trim().trim_end_matches('/').to_string()) + .filter(|value| !value.is_empty()); + } + let enabled = profile_config_value(profile, &["API_SERVER_ENABLED"]) + .map(|value| { + matches!( + value.trim().to_ascii_lowercase().as_str(), + "true" | "1" | "yes" + ) + }) + .unwrap_or(false); + let port = profile_config_value(profile, &["API_SERVER_PORT"]); + if enabled { + if let Some(port) = port + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + { + let host = profile_config_value(profile, &["API_SERVER_HOST"]) + .unwrap_or_else(|| "127.0.0.1".into()); + return Some(format!("http://{}:{}", host.trim(), port)); + } + } + } + configured_upstream() +} + fn configured_api_key() -> Option { [ "MNOTE_WEB_HERMES_API_KEY", @@ -1277,6 +1517,23 @@ fn configured_api_key() -> Option { .filter(|value| !value.is_empty()) } +fn configured_api_key_for_profile(profile: &str) -> Option { + let profile = profile.trim(); + if !profile.is_empty() && profile != "default" { + let env_key = profile_env_key("MNOTE_WEB_HERMES", profile, "API_KEY"); + if let Some(value) = env_or_dotenv(&env_key) { + return Some(value); + } + if let Some(value) = profile_config_value(profile, &["API_SERVER_KEY"]) { + let trimmed = value.trim().to_string(); + if !trimmed.is_empty() { + return Some(trimmed); + } + } + } + configured_api_key() +} + #[derive(Debug)] struct GatewayProbe { ok: bool, @@ -1286,7 +1543,7 @@ struct GatewayProbe { message: Option, } -async fn probe_gateway_health(upstream: &str) -> GatewayProbe { +async fn probe_gateway_health(upstream: &str, api_key: Option) -> GatewayProbe { let client = match reqwest::Client::builder() .timeout(Duration::from_secs(3)) .build() @@ -1307,7 +1564,7 @@ async fn probe_gateway_health(upstream: &str) -> GatewayProbe { continue; }; let mut request = client.get(url); - if let Some(api_key) = configured_api_key() { + if let Some(api_key) = api_key.as_deref() { request = request.bearer_auth(api_key); } match request.send().await { @@ -1555,6 +1812,29 @@ fn build_run_upstream_body(context: &RequestContext, payload: Value) -> Result Result Value { "evidence", "pageOptions", "contentAccess", + "aiContext", ] { if let Some(value) = source.get(key) { sanitized.insert(key.to_string(), value.clone()); @@ -1665,7 +1964,7 @@ fn sanitize_run_page_context(page_context: Value) -> Value { sanitized .get("contentAccess") .cloned() - .unwrap_or_else(|| Value::String("mnote.page.get".into())), + .unwrap_or_else(|| Value::String("mnote.doc.fetch".into())), ); Value::Object(sanitized) } @@ -1989,6 +2288,16 @@ fn session_id_for_run(run_id: &str) -> Option { .map(|state| state.session_id.clone()) } +fn profile_for_run(run_id: &str) -> Option { + let registry = HERMES_RUNTIME_REGISTRY + .lock() + .expect("hermes runtime registry"); + registry + .values() + .find(|state| state.run_id == run_id) + .map(|state| state.profile.clone()) +} + fn sse_json_events(chunk: &str) -> Vec { chunk .split("\n\n") @@ -2013,6 +2322,7 @@ async fn proxy_json( upstream: &str, path: &str, body: Option, + profile: Option<&str>, ) -> Result<(StatusCode, HeaderMap, Json), WebError> { let url = upstream_url(upstream, path)?; let client = reqwest::Client::builder() @@ -2022,7 +2332,10 @@ async fn proxy_json( WebError::internal(format!("Hermes client 构造失败: {error}")).with_context(context) })?; let mut request = client.request(method, url); - if let Some(api_key) = configured_api_key() { + if let Some(api_key) = profile + .and_then(configured_api_key_for_profile) + .or_else(configured_api_key) + { request = request.bearer_auth(api_key); } if let Some(body) = body { @@ -2147,7 +2460,7 @@ async fn start_next_queued_run(context: RequestContext, session_id: String) { let Some(queued) = pop_next_queued_run(&session_id) else { return; }; - let Some(upstream) = configured_upstream() else { + let Some(upstream) = configured_upstream_for_profile(&queued.profile) else { warn!(session_id = %session_id, queue_id = %queued.queue_id, "Hermes queue 无 upstream,无法自动启动下一条 run"); return; }; @@ -2171,6 +2484,7 @@ async fn start_next_queued_run(context: RequestContext, session_id: String) { &upstream, "/v1/runs", Some(upstream_body), + Some(&queued.profile), ) .await { @@ -2417,6 +2731,7 @@ mod tests { 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, @@ -2439,6 +2754,7 @@ mod tests { 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, @@ -3189,7 +3505,13 @@ mod tests { "documentBlocks": [{"type": "paragraph", "text": "不应进入 Hermes instructions"}], "subtree": {"children": [{"title": "不应进入 Hermes instructions"}]}, "outline": [{"title": "不应进入 Hermes instructions"}], - "contentAccess": "mnote.page.get" + "contentAccess": "mnote.page.get", + "aiContext": { + "schema": "mnote.page_ai_context.v1", + "contextBlocks": [{"blockId": "block_1", "text": "允许进入 Hermes instructions"}], + "pageXml": "允许进入 Hermes instructions", + "allowedTargetBlockIds": ["block_1"] + } }, "selectedBlockId": "block_1", "selectedText": "选中文本", @@ -3205,6 +3527,8 @@ mod tests { assert!(instructions.contains("\"title\":\"页面标题\"")); assert!(instructions.contains("\"selectedBlockId\":\"block_1\"")); assert!(instructions.contains("\"contentAccess\":\"mnote.page.get\"")); + assert!(instructions.contains("\"schema\":\"mnote.page_ai_context.v1\"")); + assert!(instructions.contains("允许进入 Hermes instructions")); assert!(!instructions.contains("不应进入 Hermes instructions")); assert!(!instructions.contains("\"documentBlocks\"")); assert!(!instructions.contains("\"subtree\"")); @@ -3404,4 +3728,118 @@ mod tests { std::env::remove_var("MNOTE_WEB_HERMES_BIN"); let _ = fs::remove_dir_all(&hermes_home); } + + #[tokio::test] + async fn hermes_client_tools_uses_manifest_and_profile_disabled_state() { + let _guard = env_lock().lock().expect("env lock"); + let hermes_home = std::env::temp_dir().join(format!( + "mnote-web-hermes-tools-local-{}", + std::process::id() + )); + let _ = fs::remove_dir_all(&hermes_home); + let profile_dir = hermes_home.join("profiles").join("chemist"); + fs::create_dir_all(&profile_dir).expect("profile dir"); + fs::write( + profile_dir.join("config.yaml"), + "mnote:\n tools:\n disabled:\n - mnote.block.replace\n", + ) + .expect("profile config"); + std::env::set_var("HERMES_HOME", &hermes_home); + + let response = app() + .oneshot( + Request::builder() + .method("GET") + .uri("/api/hermes/client/tools?scope=mnote&profile=chemist") + .header("x-mnote-actor-id", "user_1") + .body(Body::empty()) + .expect("request"), + ) + .await + .expect("response"); + assert_eq!(response.status(), StatusCode::OK); + let body = to_bytes(response.into_body(), usize::MAX) + .await + .expect("body"); + let payload: Value = serde_json::from_slice(&body).expect("tools json"); + let tools = payload["tools"] + .as_array() + .expect("tools") + .iter() + .map(|tool| { + ( + tool["name"].as_str().unwrap_or_default().to_string(), + tool.clone(), + ) + }) + .collect::>(); + assert!(tools.contains_key("mnote.doc.fetch")); + assert!(tools.contains_key("mnote.block.fetch")); + assert!(tools.contains_key("mnote.block.replace")); + assert!(tools.contains_key("mnote.block.insert_after")); + assert!(tools.contains_key("mnote.block.delete")); + assert!(tools.contains_key("mnote.block.move_after")); + assert!(tools.contains_key("mnote.doc.apply_block_ops")); + assert_eq!(tools["mnote.doc.fetch"]["enabled"], true); + assert_eq!(tools["mnote.block.replace"]["enabled"], false); + assert_eq!(tools["mnote.block.replace"]["status"], "disabled"); + assert_eq!( + tools["mnote.block.replace"]["unavailableReason"], + "当前 Hermes profile 已关闭该 mnote tool" + ); + + let toggle_response = app() + .oneshot( + Request::builder() + .method("PUT") + .uri("/api/hermes/client/tools/toggle") + .header("content-type", "application/json") + .header("x-mnote-actor-id", "user_1") + .body(Body::from( + json!({ + "profile": "chemist", + "name": "mnote.block.replace", + "enabled": true + }) + .to_string(), + )) + .expect("request"), + ) + .await + .expect("response"); + assert_eq!(toggle_response.status(), StatusCode::OK); + + let response = app() + .oneshot( + Request::builder() + .method("GET") + .uri("/api/hermes/client/tools?scope=mnote&profile=chemist") + .header("x-mnote-actor-id", "user_1") + .body(Body::empty()) + .expect("request"), + ) + .await + .expect("response"); + assert_eq!(response.status(), StatusCode::OK); + let body = to_bytes(response.into_body(), usize::MAX) + .await + .expect("body"); + let payload: Value = serde_json::from_slice(&body).expect("tools json"); + let tools = payload["tools"] + .as_array() + .expect("tools") + .iter() + .map(|tool| { + ( + tool["name"].as_str().unwrap_or_default().to_string(), + tool.clone(), + ) + }) + .collect::>(); + assert_eq!(tools["mnote.block.replace"]["enabled"], true); + assert_eq!(tools["mnote.block.replace"]["status"], "available"); + + std::env::remove_var("HERMES_HOME"); + let _ = fs::remove_dir_all(&hermes_home); + } } diff --git a/rust/crates/mnote-web/src/routes/hermes_tools.rs b/rust/crates/mnote-web/src/routes/hermes_tools.rs index 80a6c20a..5e7d6571 100644 --- a/rust/crates/mnote-web/src/routes/hermes_tools.rs +++ b/rust/crates/mnote-web/src/routes/hermes_tools.rs @@ -1,7 +1,8 @@ +use super::hermes_client; use crate::app::AppState; use crate::context::RequestContext; use crate::error::WebError; -use crate::hermes_tools::{artifact, manifest, page, ToolCallInput}; +use crate::hermes_tools::{artifact, block, doc, manifest, page, ToolCallInput}; use axum::extract::{Extension, Query, State}; use axum::http::{HeaderMap, HeaderName, HeaderValue, StatusCode}; use axum::Json; @@ -75,7 +76,7 @@ pub async fn mnote_call( let dry_run = input.dry_run.unwrap_or(false); let effect = if dry_run { "dry_run" - } else if input.tool_name == "mnote.page.get" { + } else if is_read_tool(&input.tool_name) { "read" } else { "write" @@ -98,6 +99,15 @@ pub async fn mnote_call( dry_run, "mnote Hermes tool call started" ); + let profile = input + .profile + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned) + .or_else(|| input.arg_string("profile")) + .or_else(hermes_client::active_profile_name) + .unwrap_or_else(|| "default".into()); audit_push(json!({ "phase": "started", "traceId": trace_id, @@ -107,9 +117,34 @@ pub async fn mnote_call( "toolName": input.tool_name, "workspaceId": workspace_id, "documentId": document_id, - "actorId": input.actor_id, - "dryRun": dry_run + "actorId": input.actor_id, + "dryRun": dry_run })); + if hermes_client::is_mnote_tool_disabled(&profile, &input.tool_name) { + let error = WebError::new( + StatusCode::FORBIDDEN, + "mnote_tool_disabled", + "当前 Hermes profile 已关闭该 mnote tool", + ) + .with_context(&context) + .with_header(HEADER_MNOTE_WEB_OWNER, "mnote-web") + .with_header(HEADER_HERMES_TOOL_OWNER, "mnote-web-hermes-tools"); + audit_push(json!({ + "phase": "failed", + "traceId": trace_id, + "sessionId": input.session_id, + "runId": input.run_id, + "toolCallId": tool_call_id, + "toolName": input.tool_name, + "workspaceId": workspace_id, + "documentId": document_id, + "actorId": input.actor_id, + "profile": profile, + "status": error.status().as_u16(), + "message": error.message() + })); + return Err(error); + } if let Err(error) = ensure_workspace_context(&context, workspace_id.as_deref()) { audit_push(json!({ "phase": "failed", @@ -150,6 +185,15 @@ pub async fn mnote_call( return Ok((StatusCode::OK, stamp_tool_headers(), Json(cached))); } let result = match input.tool_name.as_str() { + "mnote.doc.fetch" => doc::doc_fetch(&state, &context, &input).await, + "mnote.doc.find" => doc::doc_find(&state, &context, &input).await, + "mnote.doc.plan_update" => doc::plan_update(&state, &context, &input).await, + "mnote.block.fetch" => block::block_fetch(&state, &context, &input).await, + "mnote.block.replace" => block::block_replace(&state, &context, &input).await, + "mnote.block.insert_after" => block::block_insert_after(&state, &context, &input).await, + "mnote.block.delete" => block::block_delete(&state, &context, &input).await, + "mnote.block.move_after" => block::block_move_after(&state, &context, &input).await, + "mnote.doc.apply_block_ops" => block::doc_apply_block_ops(&state, &context, &input).await, "mnote.page.get" => page::page_get(&state, &context, &input).await, "mnote.page.save" => page::page_save(&state, &context, &input).await, "mnote.page.update_title" => page::update_title(&state, &context, &input).await, @@ -240,6 +284,13 @@ pub async fn mnote_call( Ok((StatusCode::OK, stamp_tool_headers(), Json(response_body))) } +fn is_read_tool(tool_name: &str) -> bool { + matches!( + tool_name, + "mnote.page.get" | "mnote.doc.fetch" | "mnote.doc.find" | "mnote.block.fetch" + ) +} + fn audit_log() -> &'static Mutex> { static LOG: OnceLock>> = OnceLock::new(); LOG.get_or_init(|| Mutex::new(Vec::new())) @@ -332,7 +383,7 @@ fn idempotency_cache_key( document_id: Option<&str>, dry_run: bool, ) -> Option { - if dry_run || input.tool_name == "mnote.page.get" { + if dry_run || is_read_tool(&input.tool_name) { return None; } let idempotency_key = input.idempotency_key.as_deref()?.trim(); @@ -359,7 +410,8 @@ fn idempotency_cache_put(key: String, response: Value) { } fn ensure_authenticated(context: &RequestContext) -> Result<(), WebError> { - let has_actor = context.auth.actor_id.trim() != "anonymous"; + let actor = context.auth.actor_id.trim(); + let has_actor = actor != "anonymous" && actor != "hermes" && !actor.is_empty(); if has_actor || context.auth.authorization.is_some() || context.auth.cookie_header.is_some() { return Ok(()); } @@ -377,24 +429,39 @@ fn authenticated_tool_context( context: &RequestContext, input: &ToolCallInput, ) -> Result { - if ensure_authenticated(context).is_ok() { + let context_actor = context.auth.actor_id.trim(); + if !context_actor.is_empty() && context_actor != "anonymous" && context_actor != "hermes" { return Ok(context.clone()); } + let has_cookie_or_auth = + context.auth.authorization.is_some() || context.auth.cookie_header.is_some(); let actor_id = input .actor_id .as_deref() .map(str::trim) - .filter(|value| !value.is_empty() && *value != "anonymous") + .filter(|value| !value.is_empty() && *value != "anonymous" && *value != "hermes") .ok_or_else(|| { WebError::new( StatusCode::UNAUTHORIZED, "mnote_tool_unauthorized", - "mnote Hermes tool 需要登录后访问", + "mnote Hermes tool 需要有效 actorId,不能使用 hermes/anonymous", ) .with_context(context) .with_header(HEADER_MNOTE_WEB_OWNER, "mnote-web") .with_header(HEADER_HERMES_TOOL_OWNER, "mnote-web-hermes-tools") })?; + if has_cookie_or_auth { + let mut next = context.clone(); + next.auth.actor_id = actor_id.to_string(); + next.auth.actor_type = input + .arg_string("actorType") + .or_else(|| input.arg_string("actor_type")) + .unwrap_or_else(|| "user".into()); + if next.auth.session_id.is_none() { + next.auth.session_id = input.session_id.clone(); + } + return Ok(next); + } let has_run_identity = input .session_id .as_deref() @@ -487,8 +554,15 @@ mod tests { use axum::body::{to_bytes, Body}; use axum::http::{Request, StatusCode}; use serde_json::{json, Value}; + use std::fs; + use std::sync::{Mutex, OnceLock}; use tower::util::ServiceExt; + fn env_lock() -> &'static Mutex<()> { + static LOCK: OnceLock> = OnceLock::new(); + LOCK.get_or_init(|| Mutex::new(())) + } + fn app() -> axum::Router { build_app(AppState::new(AppConfig { service_name: "mnote-web".into(), @@ -498,6 +572,7 @@ mod tests { 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, @@ -523,6 +598,16 @@ mod tests { "type": "heading", "props": { "level": 1 }, "content": [{ "type": "text", "text": "章节一" }] + }, + { + "id": "p_1", + "type": "paragraph", + "content": [{ "type": "text", "text": "第一段" }] + }, + { + "id": "p_2", + "type": "paragraph", + "content": [{ "type": "text", "text": "第二段" }] } ], "revision": 7, @@ -531,13 +616,63 @@ mod tests { }"# .into(), ), - mutation_fixtures_json: None, + 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 call_tool_ok(payload: Value) -> Value { + let response = app() + .oneshot( + Request::builder() + .method("POST") + .uri("/api/hermes/tools/mnote/call") + .header("content-type", "application/json") + .header("x-mnote-actor-id", "user_1") + .body(Body::from(payload.to_string())) + .expect("request"), + ) + .await + .expect("response"); + assert_eq!(response.status(), StatusCode::OK); + let body = to_bytes(response.into_body(), usize::MAX) + .await + .expect("body"); + serde_json::from_slice(&body).expect("json") + } + + async fn block_revision_ref(block_id: &str) -> String { + let payload = call_tool_ok(json!({ + "toolName": "mnote.doc.fetch", + "workspaceId": "ws_demo", + "documentId": "doc_1", + "sessionId": "sess_ref", + "runId": "run_ref", + "toolCallId": format!("call_ref_{block_id}"), + "traceId": format!("trace_ref_{block_id}"), + "capabilityScope": ["page.read"], + "args": {"scope": "full", "detail": "with_ids"} + })) + .await; + payload["result"]["blocks"] + .as_array() + .expect("blocks") + .iter() + .find(|block| block["blockId"] == json!(block_id)) + .and_then(|block| block["revisionRef"].as_str()) + .expect("revisionRef") + .to_string() + } + #[tokio::test] async fn hermes_tools_manifest_returns_first_batch_tools() { let response = app() @@ -558,6 +693,27 @@ mod tests { let tools = payload["manifest"]["tools"].as_array().expect("tools"); assert!(tools.iter().any(|tool| tool["name"] == "mnote.page.get")); assert!(tools.iter().any(|tool| tool["name"] == "mnote.page.save")); + assert!(tools.iter().any(|tool| tool["name"] == "mnote.doc.fetch")); + assert!(tools.iter().any(|tool| tool["name"] == "mnote.doc.find")); + assert!(tools.iter().any(|tool| tool["name"] == "mnote.block.fetch")); + assert!(tools + .iter() + .any(|tool| tool["name"] == "mnote.doc.plan_update")); + assert!(tools + .iter() + .any(|tool| tool["name"] == "mnote.block.replace")); + assert!(tools + .iter() + .any(|tool| tool["name"] == "mnote.block.insert_after")); + assert!(tools + .iter() + .any(|tool| tool["name"] == "mnote.block.delete")); + assert!(tools + .iter() + .any(|tool| tool["name"] == "mnote.block.move_after")); + assert!(tools + .iter() + .any(|tool| tool["name"] == "mnote.doc.apply_block_ops")); let page_save = tools .iter() .find(|tool| tool["name"] == "mnote.page.save") @@ -622,6 +778,59 @@ mod tests { assert_eq!(payload["result"]["title"], "服务端页面"); } + #[tokio::test] + async fn hermes_tools_call_rejects_profile_disabled_tool() { + let _guard = env_lock().lock().expect("env lock"); + let hermes_home = std::env::temp_dir().join(format!( + "mnote-web-hermes-tool-disabled-{}", + std::process::id() + )); + let _ = fs::remove_dir_all(&hermes_home); + let profile_dir = hermes_home.join("profiles").join("blocked"); + fs::create_dir_all(&profile_dir).expect("profile dir"); + fs::write( + profile_dir.join("config.yaml"), + "mnote:\n tools:\n disabled:\n - mnote.page.get\n", + ) + .expect("profile config"); + std::env::set_var("HERMES_HOME", &hermes_home); + + let response = app() + .oneshot( + Request::builder() + .method("POST") + .uri("/api/hermes/tools/mnote/call") + .header("content-type", "application/json") + .header("x-mnote-actor-id", "user_1") + .body(Body::from( + json!({ + "toolName": "mnote.page.get", + "workspaceId": "ws_demo", + "documentId": "doc_1", + "sessionId": "sess_disabled", + "runId": "run_disabled", + "toolCallId": "call_disabled", + "traceId": "trace_disabled", + "profile": "blocked", + "capabilityScope": ["page.read"] + }) + .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 hermes_tools_write_tools_require_auth() { let response = app() @@ -693,6 +902,540 @@ mod tests { assert_eq!(payload["audit"]["effect"], "read"); } + #[tokio::test] + async fn hermes_tools_doc_fetch_returns_block_projection() { + let response = app() + .oneshot( + Request::builder() + .method("POST") + .uri("/api/hermes/tools/mnote/call") + .header("content-type", "application/json") + .header("x-mnote-actor-id", "user_1") + .body(Body::from( + json!({ + "toolName": "mnote.doc.fetch", + "workspaceId": "ws_demo", + "documentId": "doc_1", + "sessionId": "sess_1", + "runId": "run_1", + "toolCallId": "call_doc_fetch_1", + "traceId": "trace_doc_fetch_1", + "capabilityScope": ["page.read"], + "args": {"scope": "full", "detail": "with_ids"} + }) + .to_string(), + )) + .expect("request"), + ) + .await + .expect("response"); + assert_eq!(response.status(), StatusCode::OK); + 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["toolName"], "mnote.doc.fetch"); + assert_eq!(payload["audit"]["effect"], "read"); + assert_eq!(payload["result"]["revision"], json!(7)); + assert_eq!( + payload["result"]["blocks"][0]["blockId"], + json!("heading_1") + ); + assert_eq!(payload["result"]["blocks"][0]["text"], json!("章节一")); + assert!(payload["result"]["blocks"][0]["revisionRef"] + .as_str() + .unwrap_or_default() + .starts_with("pageRev:7:block:heading_1:hash:")); + } + + #[tokio::test] + async fn hermes_tools_doc_fetch_supports_selection_and_page_xml() { + let response = app() + .oneshot( + Request::builder() + .method("POST") + .uri("/api/hermes/tools/mnote/call") + .header("content-type", "application/json") + .header("x-mnote-actor-id", "user_1") + .body(Body::from( + json!({ + "toolName": "mnote.doc.fetch", + "workspaceId": "ws_demo", + "documentId": "doc_1", + "sessionId": "sess_1", + "runId": "run_1", + "toolCallId": "call_doc_fetch_selection_1", + "traceId": "trace_doc_fetch_selection_1", + "capabilityScope": ["page.read"], + "args": { + "scope": "selection", + "selectedBlockIds": ["heading_1"], + "format": "page_xml" + } + }) + .to_string(), + )) + .expect("request"), + ) + .await + .expect("response"); + assert_eq!(response.status(), StatusCode::OK); + 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["result"]["schema"], "mnote.page_ai_context.v1"); + assert_eq!(payload["result"]["scope"], "selection"); + assert_eq!(payload["result"]["format"], "page_xml"); + assert_eq!(payload["result"]["blocks"].as_array().unwrap().len(), 1); + assert!(payload["result"]["content"] + .as_str() + .unwrap_or_default() + .contains(" Router { .route("/api/auth/mnote-web-token", get(session::session)) .route("/api/auth/session/refresh", post(session::refresh_session)) .route("/api/ai-agent/run", post(compat::next_ai_agent_run)) + .route( + "/api/page-ai/block-edit-workflow", + post(page_ai_workflow::block_edit_workflow), + ) .route("/onlyoffice", get(onlyoffice::page)) .route("/onlyoffice-server/{*path}", any(onlyoffice::server_proxy)) .route("/cache/{*path}", any(onlyoffice::cache_proxy)) @@ -196,6 +201,7 @@ pub fn build_router(state: AppState) -> Router { ) .route("/client/skills", get(hermes_client::list_skills)) .route("/client/skills/toggle", put(hermes_client::toggle_skill)) + .route("/client/tools/toggle", put(hermes_client::toggle_tool)) .route("/client/runs", post(hermes_client::create_run)) .route( "/client/sessions/{session_id}/queue/{queue_id}", @@ -244,6 +250,7 @@ mod tests { legacy_next_base_url: Some("http://127.0.0.1:3100".into()), enable_legacy_next_compat: false, enable_debug_shell_routes, + enable_editor_actor: true, hermes_base_path: "/api/hermes".into(), compat_next_base_path: "/api/compat/next".into(), convex_url: None, diff --git a/rust/crates/mnote-web/src/routes/onlyoffice.rs b/rust/crates/mnote-web/src/routes/onlyoffice.rs index 081df6c6..b67d92bc 100644 --- a/rust/crates/mnote-web/src/routes/onlyoffice.rs +++ b/rust/crates/mnote-web/src/routes/onlyoffice.rs @@ -1132,6 +1132,7 @@ mod tests { legacy_next_base_url, 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, diff --git a/rust/crates/mnote-web/src/routes/page_ai_workflow.rs b/rust/crates/mnote-web/src/routes/page_ai_workflow.rs new file mode 100644 index 00000000..bc892308 --- /dev/null +++ b/rust/crates/mnote-web/src/routes/page_ai_workflow.rs @@ -0,0 +1,531 @@ +use crate::app::AppState; +use crate::context::RequestContext; +use crate::error::WebError; +use crate::hermes_tools::{block, ToolCallInput}; +use axum::extract::{Extension, State}; +use axum::http::{HeaderMap, StatusCode}; +use axum::Json; +use serde_json::{json, Value}; +use std::fs; +use std::path::PathBuf; +use std::time::Instant; +use tracing::info; + +pub async fn block_edit_workflow( + State(state): State, + Extension(context): Extension, + Json(payload): Json, +) -> Result<(StatusCode, HeaderMap, Json), WebError> { + let started = Instant::now(); + let workspace_id = string_field(&payload, "workspaceId") + .or_else(|| context.workspace.workspace_id.clone()) + .ok_or_else(|| { + WebError::bad_request_code("page_ai_workflow_bad_request", "缺少 workspaceId") + .with_context(&context) + })?; + let document_id = string_field(&payload, "documentId").ok_or_else(|| { + WebError::bad_request_code("page_ai_workflow_bad_request", "缺少 documentId") + .with_context(&context) + })?; + let message = string_field(&payload, "message").ok_or_else(|| { + WebError::bad_request_code("page_ai_workflow_bad_request", "缺少 message") + .with_context(&context) + })?; + let trace_id = + string_field(&payload, "traceId").unwrap_or_else(|| context.trace.trace_id.clone()); + let session_id = string_field(&payload, "sessionId") + .unwrap_or_else(|| format!("page_ai_fast_{}", context.trace.request_id)); + let run_id = string_field(&payload, "runId").unwrap_or_else(|| session_id.clone()); + info!( + trace_id = %trace_id, + run_id = %run_id, + workspace_id = %workspace_id, + document_id = %document_id, + "mnote page AI block workflow started" + ); + if !looks_like_block_edit(&message) { + return Err(WebError::bad_request_code( + "page_ai_workflow_not_block_edit", + "当前请求不像块编辑任务,交给通用页面 AI", + ) + .with_context(&context)); + } + let page_context = payload.get("pageContext").cloned().unwrap_or(Value::Null); + let ai_context = page_context + .get("aiContext") + .cloned() + .or_else(|| payload.get("aiContext").cloned()) + .ok_or_else(|| { + WebError::bad_request_code( + "page_ai_workflow_missing_context", + "块编辑快路径缺少 mnote.page_ai_context.v1", + ) + .with_context(&context) + })?; + let profile = string_field(&payload, "profile").unwrap_or_else(|| "mnoteai".into()); + let model_started = Instant::now(); + let (operations, operation_source) = if let Some(operations) = + direct_block_edit_operations(&message) + { + (operations, "local_rule") + } else { + let model_output = call_block_edit_model(&context, &profile, &message, &ai_context).await?; + (extract_operations_from_model_text(&model_output)?, "model") + }; + info!( + trace_id = %trace_id, + run_id = %run_id, + operations = operations.len(), + operation_source = operation_source, + model_ms = model_started.elapsed().as_millis(), + "mnote page AI block workflow model completed" + ); + if operations.is_empty() { + return Err(WebError::bad_request_code( + "page_ai_workflow_empty_operations", + "模型未返回块操作", + ) + .with_context(&context)); + } + let allowed_target_block_ids = ai_context + .get("allowedTargetBlockIds") + .cloned() + .unwrap_or_else(|| json!([])); + let actor_id = + if context.auth.actor_id.trim().is_empty() || context.auth.actor_id == "anonymous" { + state.config().dev_user_id.clone() + } else { + context.auth.actor_id.clone() + }; + let apply_input = ToolCallInput { + tool_name: "mnote.doc.apply_block_ops".into(), + workspace_id: Some(workspace_id.clone()), + document_id: Some(document_id.clone()), + actor_id: Some(actor_id), + profile: Some(profile), + session_id: Some(session_id), + run_id: Some(run_id.clone()), + tool_call_id: Some(format!("fast_apply_{}", context.trace.request_id)), + trace_id: Some(trace_id.clone()), + idempotency_key: Some(format!("page_ai_fast_apply_{}", context.trace.request_id)), + dry_run: Some(false), + capability_scope: Some(vec!["block.write".into(), "page.write".into()]), + args: Some(json!({ + "operations": operations, + "allowedTargetBlockIds": allowed_target_block_ids + })), + }; + let apply_started = Instant::now(); + let apply_result = block::doc_apply_block_ops(&state, &context, &apply_input).await?; + let apply_ms = apply_started.elapsed().as_millis(); + info!( + trace_id = %trace_id, + run_id = %run_id, + apply_ms = apply_ms, + total_ms = started.elapsed().as_millis(), + "mnote page AI block workflow completed" + ); + Ok(( + StatusCode::OK, + HeaderMap::new(), + Json(json!({ + "ok": true, + "schema": "mnote.page_ai_block_edit_workflow.v1", + "fastPath": true, + "documentId": document_id, + "workspaceId": workspace_id, + "runId": run_id, + "traceId": trace_id, + "operationSource": operation_source, + "operations": apply_input.arg_value("operations").unwrap_or_else(|| json!([])), + "applyResult": apply_result, + "message": "已通过页面块编辑快路径完成写入。", + "timingsMs": { + "total": started.elapsed().as_millis(), + "apply": apply_ms + } + })), + )) +} + +fn extract_operations_from_model_text(text: &str) -> Result, WebError> { + let parsed = parse_model_json(text)?; + if let Some(content) = parsed + .pointer("/choices/0/message/content") + .and_then(Value::as_str) + { + return extract_operations_from_model_text(content); + } + if let Some(operations) = parsed.get("operations").and_then(Value::as_array) { + return Ok(operations.clone()); + } + if let Some(operations) = parsed + .get("arguments") + .and_then(|value| value.get("operations")) + .and_then(Value::as_array) + { + return Ok(operations.clone()); + } + if let Some(operations) = parsed.as_array() { + return Ok(operations.clone()); + } + Err(WebError::bad_request_code( + "page_ai_workflow_bad_model_output", + "模型输出未包含 operations", + )) +} + +fn parse_model_json(text: &str) -> Result { + let trimmed = strip_code_fence(text.trim()); + if let Ok(value) = serde_json::from_str::(&trimmed) { + return Ok(value); + } + if let Some(slice) = first_json_slice(&trimmed) { + if let Ok(value) = serde_json::from_str::(slice) { + return Ok(value); + } + } + Err(WebError::bad_request_code( + "page_ai_workflow_bad_model_json", + "模型输出不是可解析 JSON", + )) +} + +fn strip_code_fence(text: &str) -> String { + let trimmed = text.trim(); + if !trimmed.starts_with("```") { + return trimmed.to_string(); + } + let without_open = trimmed.lines().skip(1).collect::>().join("\n"); + without_open + .trim() + .strip_suffix("```") + .unwrap_or(without_open.trim()) + .trim() + .to_string() +} + +fn first_json_slice(text: &str) -> Option<&str> { + let start = text.find('{').or_else(|| text.find('['))?; + let open = text.as_bytes()[start] as char; + let close = if open == '{' { '}' } else { ']' }; + let mut depth = 0usize; + let mut in_string = false; + let mut escaped = false; + for (offset, ch) in text[start..].char_indices() { + if in_string { + if escaped { + escaped = false; + } else if ch == '\\' { + escaped = true; + } else if ch == '"' { + in_string = false; + } + continue; + } + if ch == '"' { + in_string = true; + } else if ch == open { + depth += 1; + } else if ch == close { + depth = depth.saturating_sub(1); + if depth == 0 { + return Some(&text[start..start + offset + ch.len_utf8()]); + } + } + } + None +} + +async fn call_block_edit_model( + context: &RequestContext, + profile: &str, + message: &str, + ai_context: &Value, +) -> Result { + let model = workflow_model_config(profile); + let page_xml = ai_context + .get("pageXml") + .and_then(Value::as_str) + .unwrap_or_default(); + let page_text = ai_context + .get("pageText") + .and_then(Value::as_str) + .unwrap_or_default(); + let allowed = ai_context + .get("allowedTargetBlockIds") + .cloned() + .unwrap_or_else(|| json!([])); + let body = json!({ + "model": model.model, + "temperature": 0, + "max_tokens": 900, + "response_format": {"type": "json_object"}, + "messages": [ + { + "role": "system", + "content": "你是 mnote 页面块编辑 workflow。只输出 JSON:{\"operations\":[...] ,\"summary\":\"...\"}。operations 的 op 只能是 replace、insert_after、delete、move_after。优先使用 page_xml 中的 block id;禁止输出解释文字。" + }, + { + "role": "user", + "content": format!( + "用户指令:{}\n\nallowedTargetBlockIds:{}\n\npage_xml:\n{}\n\npage_text:\n{}", + message, + allowed, + page_xml, + page_text + ) + } + ] + }); + let url = format!("{}/chat/completions", model.base_url.trim_end_matches('/')); + let response = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(20)) + .build() + .map_err(|error| { + WebError::internal(format!("页面 AI workflow HTTP client 构造失败: {error}")) + })? + .post(url) + .bearer_auth(model.api_key) + .json(&body) + .send() + .await + .map_err(|error| { + WebError::bad_gateway_code( + "page_ai_workflow_model_unavailable", + format!("页面 AI workflow 模型请求失败: {error}"), + ) + .with_context(context) + })?; + let status = response.status(); + let text = response.text().await.unwrap_or_default(); + if !status.is_success() { + return Err(WebError::bad_gateway_code( + "page_ai_workflow_model_failed", + format!("页面 AI workflow 模型返回失败: {status}"), + ) + .with_context(context)); + } + let payload = parse_model_json(&text)?; + payload + .pointer("/choices/0/message/content") + .and_then(Value::as_str) + .map(ToOwned::to_owned) + .ok_or_else(|| { + WebError::bad_gateway_code( + "page_ai_workflow_model_no_content", + "页面 AI workflow 模型响应缺少 message.content", + ) + .with_context(context) + }) +} + +struct WorkflowModelConfig { + model: String, + base_url: String, + api_key: String, +} + +fn workflow_model_config(profile: &str) -> WorkflowModelConfig { + let config = fs::read_to_string(profile_config_path(profile)).unwrap_or_default(); + let provider = + yaml_path_value(&config, &["model", "provider"]).unwrap_or_else(|| "deepseek".into()); + let model = yaml_path_value(&config, &["model", "default"]) + .or_else(|| yaml_path_value(&config, &["providers", &provider, "model"])) + .unwrap_or_else(|| "deepseek-v4-flash".into()); + let base_url = yaml_path_value(&config, &["model", "base_url"]) + .or_else(|| yaml_path_value(&config, &["providers", &provider, "base_url"])) + .unwrap_or_else(|| "https://api.deepseek.com/v1".into()); + let api_key = yaml_path_value(&config, &["model", "api_key"]) + .or_else(|| yaml_path_value(&config, &["providers", &provider, "api_key"])) + .or_else(|| { + yaml_path_value(&config, &["model", "key_env"]) + .or_else(|| yaml_path_value(&config, &["providers", &provider, "key_env"])) + .and_then(|env_key| std::env::var(env_key).ok()) + }) + .unwrap_or_default(); + WorkflowModelConfig { + model, + base_url, + api_key, + } +} + +fn string_field(payload: &Value, key: &str) -> Option { + payload + .get(key) + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned) +} + +fn looks_like_block_edit(message: &str) -> bool { + [ + "新增", "添加", "插入", "删除", "删掉", "修改", "替换", "改成", "移动", "移到", "move", + "replace", "delete", "insert", + ] + .iter() + .any(|needle| message.contains(needle)) +} + +fn direct_block_edit_operations(message: &str) -> Option> { + let mut operations = Vec::new(); + for clause in message + .split(|ch| matches!(ch, ';' | ';' | '\n')) + .map(str::trim) + .filter(|value| !value.is_empty()) + { + let quoted = quoted_segments(clause); + if (clause.contains("替换") || clause.contains("改成")) && quoted.len() >= 2 { + operations.push(json!({ + "op": "replace", + "matchText": quoted[0], + "content": quoted[1] + })); + } else if (clause.contains("插入") || clause.contains("新增") || clause.contains("添加")) + && quoted.len() >= 2 + { + operations.push(json!({ + "op": "insert_after", + "matchText": quoted[0], + "content": quoted[1] + })); + } else if (clause.contains("删除") || clause.contains("删掉")) && !quoted.is_empty() { + operations.push(json!({ + "op": "delete", + "matchText": quoted[0] + })); + } + } + if operations.is_empty() { + None + } else { + Some(operations) + } +} + +fn quoted_segments(value: &str) -> Vec { + let mut segments = Vec::new(); + let mut start: Option = None; + let mut current = String::new(); + for ch in value.chars() { + match (start, ch) { + (None, '「' | '“' | '"') => { + start = Some(ch); + current.clear(); + } + (Some('「'), '」') | (Some('“'), '”') | (Some('"'), '"') => { + if !current.trim().is_empty() { + segments.push(current.trim().to_string()); + } + current.clear(); + start = None; + } + (Some(_), _) => current.push(ch), + (None, _) => {} + } + } + segments +} + +fn hermes_home() -> PathBuf { + std::env::var("HERMES_HOME") + .ok() + .filter(|value| !value.trim().is_empty()) + .map(PathBuf::from) + .or_else(|| { + std::env::var("HOME") + .ok() + .map(|home| PathBuf::from(home).join(".hermes")) + }) + .unwrap_or_else(|| PathBuf::from(".hermes")) +} + +fn profile_config_path(profile: &str) -> PathBuf { + let home = hermes_home(); + let profile = profile.trim(); + if profile.is_empty() || profile == "default" { + return home.join("config.yaml"); + } + let candidate = home.join("profiles").join(profile); + if candidate.exists() { + candidate.join("config.yaml") + } else { + home.join("config.yaml") + } +} + +fn yaml_path_value(content: &str, path: &[&str]) -> Option { + let mut stack: Vec<(usize, String)> = Vec::new(); + for raw_line in content.lines() { + let line = raw_line.trim_end_matches('\r'); + let trimmed = line.trim(); + if trimmed.is_empty() || trimmed.starts_with('#') || !line.contains(':') { + continue; + } + let indent = line.chars().take_while(|ch| ch.is_whitespace()).count(); + while stack + .last() + .map(|(level, _)| *level >= indent) + .unwrap_or(false) + { + stack.pop(); + } + let Some((key, value)) = trimmed.split_once(':') else { + continue; + }; + let key = key.trim().trim_matches('"').trim_matches('\'').to_string(); + let value = value + .trim() + .trim_matches('"') + .trim_matches('\'') + .to_string(); + stack.push((indent, key)); + if stack.len() == path.len() + && stack + .iter() + .zip(path.iter()) + .all(|((_, key), expected)| key == expected) + && !value.is_empty() + { + return Some(value); + } + } + None +} + +#[cfg(test)] +mod tests { + use super::{direct_block_edit_operations, extract_operations_from_model_text}; + + #[test] + fn extracts_operations_from_fenced_model_json() { + let operations = extract_operations_from_model_text( + r#"```json +{"operations":[{"op":"replace","matchText":"旧文本","content":"新文本"}],"summary":"ok"} +```"#, + ) + .expect("operations"); + assert_eq!(operations.len(), 1); + assert_eq!(operations[0]["op"], "replace"); + assert_eq!(operations[0]["matchText"], "旧文本"); + } + + #[test] + fn parses_direct_chinese_block_operations() { + let operations = direct_block_edit_operations( + "把「第二段」替换为「第二段已修改」;在「第一段」后插入「插入段」;删除「第三段」。只简短回复结果。", + ) + .expect("operations"); + assert_eq!(operations.len(), 3); + assert_eq!(operations[0]["op"], "replace"); + assert_eq!(operations[0]["matchText"], "第二段"); + assert_eq!(operations[0]["content"], "第二段已修改"); + assert_eq!(operations[1]["op"], "insert_after"); + assert_eq!(operations[1]["matchText"], "第一段"); + assert_eq!(operations[1]["content"], "插入段"); + assert_eq!(operations[2]["op"], "delete"); + assert_eq!(operations[2]["matchText"], "第三段"); + } +} diff --git a/rust/crates/mnote-web/src/routes/resource_trash.rs b/rust/crates/mnote-web/src/routes/resource_trash.rs index afcdbb08..499379c5 100644 --- a/rust/crates/mnote-web/src/routes/resource_trash.rs +++ b/rust/crates/mnote-web/src/routes/resource_trash.rs @@ -1056,6 +1056,7 @@ mod tests { legacy_next_base_url: Some("http://127.0.0.1:3100".into()), enable_legacy_next_compat: false, 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, diff --git a/rust/crates/mnote-web/src/routes/search.rs b/rust/crates/mnote-web/src/routes/search.rs index 8749c02d..2a2f240c 100644 --- a/rust/crates/mnote-web/src/routes/search.rs +++ b/rust/crates/mnote-web/src/routes/search.rs @@ -376,6 +376,7 @@ mod tests { 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, @@ -493,6 +494,7 @@ mod tests { 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, diff --git a/rust/crates/mnote-web/src/routes/session.rs b/rust/crates/mnote-web/src/routes/session.rs index 3ce14101..e958390b 100644 --- a/rust/crates/mnote-web/src/routes/session.rs +++ b/rust/crates/mnote-web/src/routes/session.rs @@ -130,6 +130,7 @@ mod tests { 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, diff --git a/rust/crates/mnote-web/src/routes/sse.rs b/rust/crates/mnote-web/src/routes/sse.rs index 4c75079a..0c906572 100644 --- a/rust/crates/mnote-web/src/routes/sse.rs +++ b/rust/crates/mnote-web/src/routes/sse.rs @@ -19,6 +19,15 @@ pub async fn events( State(state): State, Extension(context): Extension, Query(query): Query, +) -> Result>>, WebError> { + events_with_block_delta(state, context, query, None).await +} + +async fn events_with_block_delta( + state: AppState, + context: RequestContext, + query: StreamSnapshotQuery, + block_delta_rx: Option>, ) -> Result>>, WebError> { let initial_payload = load_stream_snapshot(state.config(), &context, &query).await?; let initial_cursor = read_stream_cursor_from_payload(&initial_payload); @@ -36,6 +45,7 @@ pub async fn events( polls: 0, initial_payload, initial_emitted: false, + block_delta_rx, }), move |state| async move { let mut state = state?; @@ -48,6 +58,23 @@ pub async fn events( )); } + // Phase C:在每次 poll 前先检查是否有 block.delta 可发送 + if let Some(ref mut rx) = state.block_delta_rx { + match rx.try_recv() { + Ok(payload) => { + return Some(( + Ok(stream_event("block.delta", &payload)), + Some(state), + )); + } + Err(tokio::sync::broadcast::error::TryRecvError::Empty) => {} + Err(tokio::sync::broadcast::error::TryRecvError::Closed) => { + state.block_delta_rx = None; + } + Err(tokio::sync::broadcast::error::TryRecvError::Lagged(_)) => {} + } + } + loop { if let Some(max_polls) = max_polls { if state.polls >= max_polls { @@ -57,6 +84,23 @@ pub async fn events( state.polls += 1; sleep(Duration::from_millis(poll_ms)).await; + // 每次 poll 后也检查一下 delta + if let Some(ref mut rx) = state.block_delta_rx { + match rx.try_recv() { + Ok(payload) => { + return Some(( + Ok(stream_event("block.delta", &payload)), + Some(state), + )); + } + Err(tokio::sync::broadcast::error::TryRecvError::Empty) => {} + Err(tokio::sync::broadcast::error::TryRecvError::Closed) => { + state.block_delta_rx = None; + } + Err(tokio::sync::broadcast::error::TryRecvError::Lagged(_)) => {} + } + } + let poll_query = live_poll_query(&state.query); let Ok((workspace_id, overview)) = load_stream_overview(state.app_state.config(), &state.context, &poll_query) @@ -140,11 +184,11 @@ pub async fn tree_events( if let Ok(name) = HeaderName::from_lowercase(b"x-mnote-tree-stream-owner") { headers.insert(name, HeaderValue::from_static("rust-web")); } - let sse = events(State(state), Extension(context), Query(query)).await?; + let block_delta_rx = state.block_delta_tx.subscribe(); + let sse = events_with_block_delta(state, context, query, Some(block_delta_rx)).await?; Ok((headers, sse)) } -#[derive(Clone)] struct StreamPollState { app_state: AppState, context: RequestContext, @@ -153,6 +197,8 @@ struct StreamPollState { polls: u32, initial_payload: Value, initial_emitted: bool, + #[allow(dead_code)] + block_delta_rx: Option>, } fn live_poll_query(query: &StreamSnapshotQuery) -> StreamSnapshotQuery { @@ -203,6 +249,7 @@ mod tests { 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, diff --git a/rust/crates/mnote-web/src/routes/stream_support.rs b/rust/crates/mnote-web/src/routes/stream_support.rs index 258dacad..791762c1 100644 --- a/rust/crates/mnote-web/src/routes/stream_support.rs +++ b/rust/crates/mnote-web/src/routes/stream_support.rs @@ -1034,6 +1034,51 @@ mod tests { ); } + #[test] + fn stream_change_preserves_remove_asset_delta_fields() { + let overview = json!({ + "command_logs": [ + { + "id": "clog_2", + "command_id": "cmd_2", + "created_at": "2026-04-25T10:00:02Z", + "command_name": "tree.resource.delete", + "payload": { + "streamDelta": { + "op": "remove_asset", + "assetId": "asset_1", + "documentId": "doc_target", + "updatedAt": "2026-04-25T10:00:02Z" + } + } + }, + { + "id": "clog_1", + "command_id": "cmd_1", + "created_at": "2026-04-25T10:00:01Z" + } + ], + "domain_events": [] + }); + + let change = resolve_stream_change( + &overview, + Some(r#"{"createdAt":"2026-04-25T10:00:01Z","id":"clog_1"}"#), + ) + .expect("应识别到变化"); + + assert_eq!(change.kind, StreamChangeKind::Delta); + assert_eq!( + change.delta, + Some(json!({ + "op": "remove_asset", + "assetId": "asset_1", + "documentId": "doc_target", + "updatedAt": "2026-04-25T10:00:02Z" + })) + ); + } + #[test] fn stream_change_detects_noop_delta_for_non_tree_mutating_command() { let overview = json!({ @@ -1395,5 +1440,9 @@ mod tests { { "id": "asset_1" } ] }))); + assert!(delta_requires_projection_snapshot(&json!({ + "op": "remove_asset", + "assetId": "asset_1" + }))); } } diff --git a/rust/crates/mnote-web/src/routes/tree.rs b/rust/crates/mnote-web/src/routes/tree.rs index a4f378bb..5800a7f2 100644 --- a/rust/crates/mnote-web/src/routes/tree.rs +++ b/rust/crates/mnote-web/src/routes/tree.rs @@ -6940,6 +6940,7 @@ mod tests { legacy_next_base_url: Some("http://127.0.0.1:3100".into()), enable_legacy_next_compat: true, enable_debug_shell_routes: true, + enable_editor_actor: true, hermes_base_path: "/api/hermes".into(), compat_next_base_path: "/api/compat/next".into(), convex_url: None, @@ -7896,6 +7897,7 @@ mod tests { payload["result"]["documentId"], Value::String("page_child".into()) ); + assert_eq!(payload["result"]["sortOrder"], Value::from(1)); assert_eq!( payload["result"]["execution"]["deletedCount"], Value::from(1) @@ -8002,10 +8004,18 @@ mod tests { payload["result"]["artifacts"]["domainEvent"]["payload"]["streamDelta"]["op"], Value::String("move_document".into()) ); + assert_eq!( + payload["result"]["artifacts"]["domainEvent"]["payload"]["streamDelta"]["sortOrder"], + Value::from(1) + ); assert_eq!( payload["result"]["artifacts"]["commandLog"]["payload"]["streamDelta"]["op"], Value::String("move_document".into()) ); + assert_eq!( + payload["result"]["artifacts"]["commandLog"]["payload"]["streamDelta"]["sortOrder"], + Value::from(1) + ); } #[tokio::test] diff --git a/rust/crates/mnote-web/src/routes/web_shell.rs b/rust/crates/mnote-web/src/routes/web_shell.rs index 7b9b5e5c..2139877c 100644 --- a/rust/crates/mnote-web/src/routes/web_shell.rs +++ b/rust/crates/mnote-web/src/routes/web_shell.rs @@ -2936,6 +2936,7 @@ mod tests { 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, @@ -2969,6 +2970,29 @@ mod tests { })) } + fn app_with_unreachable_convex_without_fixture() -> 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: None, + enable_legacy_next_compat: false, + 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: Some("http://127.0.0.1:9".into()), + convex_admin_key: Some("test-admin-key".into()), + allow_dev_fixtures: false, + query_fixtures_json: None, + mutation_fixtures_json: None, + dev_user_id: "dev-user".into(), + dev_user_name: "开发用户".into(), + dev_user_email: "dev@mnote.local".into(), + })) + } + #[tokio::test] async fn document_shell_returns_page_aggregate_snapshot() { let response = app() @@ -3100,6 +3124,100 @@ mod tests { assert_eq!(payload["result"]["body"]["revision"], 7); } + #[tokio::test] + async fn page_aggregate_endpoint_errors_without_convex_or_fixture() { + let response = app_with_unreachable_convex_without_fixture() + .oneshot( + Request::builder() + .uri("/api/page-aggregate/doc_1?workspaceId=ws_demo") + .body(Body::empty()) + .expect("request"), + ) + .await + .expect("response"); + + if response.status() != StatusCode::SERVICE_UNAVAILABLE { + let status = response.status(); + let body = to_bytes(response.into_body(), usize::MAX) + .await + .expect("body"); + panic!( + "expected SERVICE_UNAVAILABLE, got {status}: {}", + String::from_utf8_lossy(&body) + ); + } + assert_eq!( + response + .headers() + .get("x-error-code") + .and_then(|value| value.to_str().ok()), + Some("convex_unavailable") + ); + assert_eq!( + response + .headers() + .get("x-error-phase") + .and_then(|value| value.to_str().ok()), + Some("query_send") + ); + assert_eq!( + response + .headers() + .get("x-upstream-service") + .and_then(|value| value.to_str().ok()), + Some("convex") + ); + 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["ok"], false); + assert_eq!(payload["code"], "convex_unavailable"); + assert!(payload.get("schema").is_none()); + assert!(payload.get("result").is_none()); + } + + #[tokio::test] + async fn document_shell_errors_without_convex_or_fixture() { + let response = app_with_unreachable_convex_without_fixture() + .oneshot( + Request::builder() + .uri("/documents/doc_1?workspaceId=ws_demo") + .body(Body::empty()) + .expect("request"), + ) + .await + .expect("response"); + + if response.status() != StatusCode::SERVICE_UNAVAILABLE { + let status = response.status(); + let body = to_bytes(response.into_body(), usize::MAX) + .await + .expect("body"); + panic!( + "expected SERVICE_UNAVAILABLE, got {status}: {}", + String::from_utf8_lossy(&body) + ); + } + assert_eq!( + response + .headers() + .get("x-error-code") + .and_then(|value| value.to_str().ok()), + Some("convex_unavailable") + ); + let body = to_bytes(response.into_body(), usize::MAX) + .await + .expect("body"); + let text = String::from_utf8(body.to_vec()).expect("utf8"); + let payload: Value = serde_json::from_str(&text).expect("json"); + assert_eq!(payload["ok"], false); + assert_eq!(payload["code"], "convex_unavailable"); + assert!(!text.contains("mnote.page_aggregate.v1")); + assert!(!text.contains("data-mnote-dev-fixture")); + assert!(!text.contains("data-page-aggregate-snapshot")); + } + #[tokio::test] async fn page_aggregate_endpoint_returns_local_markdown_readonly_snapshot() { let root = @@ -3231,6 +3349,7 @@ mod tests { 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, diff --git a/rust/crates/mnote-web/src/ssr/pages/layout.rs b/rust/crates/mnote-web/src/ssr/pages/layout.rs index ea193381..397ed120 100644 --- a/rust/crates/mnote-web/src/ssr/pages/layout.rs +++ b/rust/crates/mnote-web/src/ssr/pages/layout.rs @@ -50,7 +50,7 @@ const SIDEBAR_TREE_JS: &str = r##" pageAiGatewayHealthError: '', pageAiLastToolCall: null, pageAiProfiles: [], - pageAiActiveProfileName: 'default', + pageAiActiveProfileName: 'mnoteai', pageAiProfileError: '', pageAiProfileMemory: { memory: '', user: '', soul: '' }, pageAiProfileMemoryDrafts: { memory: '', user: '', soul: '' }, @@ -256,6 +256,83 @@ const SIDEBAR_TREE_JS: &str = r##" } } + function pageAiProjectionBlocks(aggregate) { + var blocks = aggregate && aggregate.body && aggregate.body.blockDocument && aggregate.body.blockDocument.blocks; + return Array.isArray(blocks) ? blocks : []; + } + + function pageAiBlockText(block) { + return searchText(block && (block.text || block.title || block.content) || ''); + } + + function pageAiSelectedBlockIdsFromSelection() { + try { + var selection = window.getSelection ? window.getSelection() : null; + if (!selection || selection.rangeCount === 0 || searchText(selection.toString() || '') === '') return []; + var range = selection.getRangeAt(0); + var editor = document.querySelector('[data-testid="mnote-leptos-tiptap-island-editor-root"] .editor-surface .ProseMirror'); + if (!(editor instanceof HTMLElement)) return []; + return Array.from(editor.children).filter(function(node) { + if (!(node instanceof HTMLElement)) return false; + try { + return range.intersectsNode(node); + } catch (_) { + return false; + } + }).map(function(node) { + return searchText(node.getAttribute('data-id') || node.id || ''); + }).filter(Boolean); + } catch (_) { + return []; + } + } + + function pageAiBlocksToPageXml(blocks, aggregate) { + var revision = aggregate && aggregate.body ? String(aggregate.body.revision || '') : ''; + var pageId = currentDocumentId() || 'current-page'; + var lines = ['']; + blocks.forEach(function(block) { + var blockId = String(block && (block.blockId || block.id) || ''); + var type = String(block && block.type || 'paragraph'); + var revisionRef = String(block && block.revisionRef || ''); + var level = block && block.attrs && block.attrs.level ? ' level="' + escapeHtml(block.attrs.level) + '"' : ''; + lines.push(' ' + escapeHtml(pageAiBlockText(block)) + ''); + }); + lines.push(''); + return lines.join('\n'); + } + + function buildPageAiContext(contextSnapshot, scope, selectedText) { + var aggregate = contextSnapshot.aggregate || {}; + var body = aggregate.body || {}; + var allBlocks = pageAiProjectionBlocks(aggregate); + var selectedBlockIds = scope === 'selection' ? pageAiSelectedBlockIdsFromSelection() : []; + var selectedSet = {}; + selectedBlockIds.forEach(function(id) { selectedSet[id] = true; }); + var selectedBlocks = selectedBlockIds.length + ? allBlocks.filter(function(block) { return selectedSet[String(block.blockId || block.id || '')]; }) + : []; + var contextBlocks = selectedBlocks.length ? selectedBlocks : allBlocks.slice(0, 120); + var truncated = !selectedBlocks.length && allBlocks.length > contextBlocks.length; + return { + schema: 'mnote.page_ai_context.v1', + workspaceId: resolveWorkspaceId(document.body), + documentId: currentDocumentId(), + scope: scope, + revision: body.revision || null, + conflictDetectionKey: body.conflictDetectionKey || null, + selectedText: selectedText || '', + selectedBlockIds: selectedBlockIds, + allowedTargetBlockIds: selectedBlockIds, + selectedBlocks: selectedBlocks, + contextBlocks: contextBlocks, + pageText: contextBlocks.map(pageAiBlockText).filter(Boolean).join('\n'), + pageXml: pageAiBlocksToPageXml(contextBlocks, aggregate), + truncated: truncated, + warnings: truncated ? [{ code: 'page_ai_context_truncated', message: '页面 AI context 已按前 120 个块裁剪' }] : [] + }; + } + function pageAiScopedPageContext(contextSnapshot) { var aggregate = contextSnapshot.aggregate || {}; var body = contextSnapshot.body || {}; @@ -264,6 +341,7 @@ const SIDEBAR_TREE_JS: &str = r##" var scope = pageUiState.pageAiContextScope || 'page'; var title = aggregate.head && aggregate.head.title ? aggregate.head.title : ''; var selectedText = scope === 'selection' ? currentPageAiSelectedText() : ''; + var aiContext = buildPageAiContext(contextSnapshot, scope, selectedText); return { pageContext: { contextScope: scope, @@ -277,10 +355,11 @@ const SIDEBAR_TREE_JS: &str = r##" pageSubtreeSource: scope === 'page' ? (contextSnapshot.pageSubtreeSource || 'none') : 'scope:' + scope, evidence: selectedText ? [{ kind: 'selection', text: selectedText }] : null, pageOptions: scope === 'options' || scope === 'page' ? currentPageOptions() : null, - contentAccess: 'mnote.page.get' + contentAccess: 'mnote.doc.fetch', + aiContext: aiContext }, selectedText: selectedText, - selectedBlockId: null + selectedBlockId: aiContext.selectedBlockIds[0] || null }; } @@ -1160,6 +1239,7 @@ const SIDEBAR_TREE_JS: &str = r##" if (!documentId) return; var escaped = cssEscape(documentId); var escapedDocRowId = cssEscape('doc:' + documentId); + var isCurrentDocument = currentDocumentId() === documentId; var pageSelectors = [ '.tree-row[data-shell-mode="page"][data-node-id="' + escaped + '"] > .tree-link > .tree-link-title', '.wolai-page-row[data-node-id="' + escaped + '"] > .wolai-row-title', @@ -1174,6 +1254,22 @@ const SIDEBAR_TREE_JS: &str = r##" document.querySelectorAll('.tree-row[data-shell-mode="filetree"][data-row-id="' + escapedDocRowId + '"] > .tree-link > .tree-link-title').forEach(function(node) { if (node instanceof HTMLElement) node.textContent = fileTreePageTitle(title); }); + document.querySelectorAll('[data-page-title-input="true"][data-document-id="' + escaped + '"]').forEach(function(node) { + if (!(node instanceof HTMLTextAreaElement)) return; + node.value = title; + node.setAttribute('data-title-last-saved', title); + node.setAttribute('data-title-save-status', 'saved'); + node.style.height = 'auto'; + node.style.height = Math.max(48, node.scrollHeight) + 'px'; + }); + document.querySelectorAll('[data-document-pane="true"][data-pane-document-id="' + escaped + '"] [data-page-title-current="true"]').forEach(function(node) { + if (node instanceof HTMLElement) node.textContent = title; + }); + if (isCurrentDocument) { + document.title = title; + var topbarCurrent = document.querySelector('.wolai-breadcrumb-current [data-page-title-current]'); + if (topbarCurrent instanceof HTMLElement) topbarCurrent.textContent = title; + } } function fileTreePageTitle(title) { @@ -1396,7 +1492,29 @@ const SIDEBAR_TREE_JS: &str = r##" return pageChanged || fileChanged; } - function moveDocumentRowForMode(mode, documentId, parentId) { + function sortOrderFromDelta(data) { + var raw = data && (data.sortOrder ?? data.sort_order); + var value = Number(raw); + return Number.isFinite(value) && value >= 0 ? Math.floor(value) : null; + } + + function insertTreeNodeAtSortOrder(targetContainer, node, sortOrder) { + if (!targetContainer || !node) return false; + if (sortOrder === null || sortOrder === undefined) { + targetContainer.appendChild(node); + return true; + } + var siblings = Array.from(targetContainer.querySelectorAll(':scope > .tree-node')).filter(function(candidate) { + return candidate !== node; + }); + var targetIndex = Math.max(0, Math.min(Number(sortOrder), siblings.length)); + var referenceNode = siblings[targetIndex] || null; + if (referenceNode) targetContainer.insertBefore(node, referenceNode); + else targetContainer.appendChild(node); + return true; + } + + function moveDocumentRowForMode(mode, documentId, parentId, sortOrder) { var row = document.querySelector(rowSelectorForDocument(mode, documentId)); var node = row ? row.closest('.tree-node') : null; var root = treeRootForMode(mode); @@ -1410,16 +1528,16 @@ const SIDEBAR_TREE_JS: &str = r##" } else { row.removeAttribute('data-parent-id'); } - targetContainer.appendChild(node); - return true; + return insertTreeNodeAtSortOrder(targetContainer, node, sortOrder); } function applyMoveDocumentDelta(data) { var documentId = documentIdFromDelta(data); if (!documentId) return false; var parentId = parentIdFromDelta(data); - var movedPage = moveDocumentRowForMode('page', documentId, parentId); - var movedFile = moveDocumentRowForMode('filetree', documentId, parentId); + var sortOrder = sortOrderFromDelta(data); + var movedPage = moveDocumentRowForMode('page', documentId, parentId, sortOrder); + var movedFile = moveDocumentRowForMode('filetree', documentId, parentId, sortOrder); return movedPage || movedFile; } @@ -3994,7 +4112,11 @@ const SIDEBAR_TREE_JS: &str = r##" var selected = pageUiState.pageAiProfiles.find(function(profile) { return profile && profile.active; }); - return pageAiProfileValue(selected) || 'default'; + return pageAiProfileValue(selected) || 'mnoteai'; + } + + function pageAiMnoteToolModel() { + return String(document.documentElement.getAttribute('data-mnote-page-ai-tool-model') || 'deepseek-v4-flash').trim() || 'deepseek-v4-flash'; } function pageAiCurrentProfileRecord() { @@ -4006,10 +4128,12 @@ const SIDEBAR_TREE_JS: &str = r##" function pageAiCurrentModelLabel() { var profile = pageAiCurrentProfileRecord(); - if (!profile) return '由 Hermes 决定'; + var toolModel = pageAiMnoteToolModel(); + if (!profile) return 'tool: ' + toolModel; var model = String(profile.model || '').trim(); var gateway = String(profile.gateway || '').trim(); - return [model, gateway].filter(Boolean).join(' · ') || '由 Hermes 决定'; + var profileLabel = [model, gateway].filter(Boolean).join(' · ') || '由 Hermes 决定'; + return 'tool: ' + toolModel + ' · profile: ' + profileLabel; } function pageAiNormalizeProfiles(payload) { @@ -4085,9 +4209,10 @@ const SIDEBAR_TREE_JS: &str = r##" } function pageAiSetActiveProfile(profileName) { - var next = String(profileName || '').trim() || 'default'; + var next = String(profileName || '').trim() || 'mnoteai'; pageUiState.pageAiActiveProfileName = next; document.documentElement.setAttribute('data-mnote-page-ai-profile', next); + document.documentElement.setAttribute('data-mnote-page-ai-tool-model', pageAiMnoteToolModel()); } function pageAiSetRunStatus(status, runId) { @@ -4186,10 +4311,20 @@ const SIDEBAR_TREE_JS: &str = r##" var writesCurrentPage = [ 'mnote.page.save', 'mnote.page.update.title', - 'mnote.page.update.options' + 'mnote.page.update.options', + 'mnote.doc.apply.block.ops', + 'mnote.block.replace', + 'mnote.block.insert.after', + 'mnote.block.delete', + 'mnote.block.move.after' ].indexOf(normalizedTool) >= 0 || [ 'mnote.page.update_title', - 'mnote.page.update_options' + 'mnote.page.update_options', + 'mnote.doc.apply_block_ops', + 'mnote.block.replace', + 'mnote.block.insert_after', + 'mnote.block.delete', + 'mnote.block.move_after' ].indexOf(String(toolName || '').trim()) >= 0; if (!writesCurrentPage) return; var documentId = pageAiToolEventDeepFindString(toolEvent, ['documentId', 'document_id'], 0) || currentDocumentId(); @@ -4352,6 +4487,7 @@ const SIDEBAR_TREE_JS: &str = r##" scope: String(tool && (tool.scope || tool.requiredScope || '') || '').trim(), kind: String(tool && (tool.kind || tool.mode || '') || '').trim(), status: String(tool && (tool.status || tool.permission || 'available') || '').trim(), + enabled: tool && tool.enabled !== false, unavailableReason: String(tool && (tool.unavailableReason || tool.reason || '') || '').trim() }; }).filter(Boolean); @@ -4394,7 +4530,7 @@ const SIDEBAR_TREE_JS: &str = r##" async function pageAiLoadTools() { try { - var response = await fetch('/api/hermes/client/tools?scope=mnote', { + var response = await fetch('/api/hermes/client/tools?scope=mnote&profile=' + encodeURIComponent(pageAiCurrentProfile()), { headers: { 'accept': 'application/json' } }); var payload = await response.json().catch(function(){ return null; }); @@ -4483,14 +4619,17 @@ const SIDEBAR_TREE_JS: &str = r##" var payload = await response.json().catch(function(){ return null; }); if (!response.ok) throw new Error(pageAiErrorMessage(payload, 'profiles_failed_' + response.status)); var profiles = pageAiNormalizeProfiles(payload); - pageUiState.pageAiProfiles = profiles.length ? profiles : [{ name: 'default', active: true }]; + pageUiState.pageAiProfiles = profiles.length ? profiles : [{ name: 'mnoteai', active: true }]; + var current = pageAiCurrentProfile(); + var hasCurrent = pageUiState.pageAiProfiles.some(function(profile) { return pageAiProfileValue(profile) === current; }); + var hasMnoteAi = pageUiState.pageAiProfiles.some(function(profile) { return pageAiProfileValue(profile) === 'mnoteai'; }); var active = pageAiProfileValue(pageUiState.pageAiProfiles.find(function(profile) { return profile.active; })); - pageAiSetActiveProfile(active || pageAiCurrentProfile()); + pageAiSetActiveProfile(hasCurrent && current !== 'default' ? current : (hasMnoteAi ? 'mnoteai' : (active || current))); pageUiState.pageAiProfileError = ''; void pageAiLoadGatewayHealth(); } catch (error) { pageUiState.pageAiProfileError = error instanceof Error ? error.message : String(error); - if (!pageUiState.pageAiProfiles.length) pageUiState.pageAiProfiles = [{ name: 'default', active: true }]; + if (!pageUiState.pageAiProfiles.length) pageUiState.pageAiProfiles = [{ name: 'mnoteai', active: true }]; pageAiSetActiveProfile(pageAiCurrentProfile()); } renderPageAiProviderButtons(); @@ -4520,6 +4659,7 @@ const SIDEBAR_TREE_JS: &str = r##" await pageAiEnsureHermesSession(true); await pageAiLoadProfileMemory(); await pageAiLoadSkills(); + await pageAiLoadTools(); await pageAiLoadGatewayHealth(); renderPageAiControls(); } catch (error) { @@ -4627,6 +4767,45 @@ const SIDEBAR_TREE_JS: &str = r##" renderPageAiConversation(); } + async function pageAiToggleTool(toolName, enabled) { + var name = String(toolName || '').trim(); + if (!name) return; + var previous = null; + pageAiNormalizeArray(pageUiState.pageAiTools).forEach(function(tool) { + if (tool.name === name && previous == null) previous = tool.enabled !== false; + }); + try { + var response = await fetch('/api/hermes/client/tools/toggle', { + method: 'PUT', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + profile: pageAiCurrentProfile(), + name: name, + enabled: Boolean(enabled) + }) + }); + var payload = await response.json().catch(function(){ return null; }); + if (!response.ok) throw new Error(pageAiErrorMessage(payload, 'tool_toggle_failed_' + response.status)); + pageAiNormalizeArray(pageUiState.pageAiTools).forEach(function(tool) { + if (tool.name === name) { + tool.enabled = Boolean(enabled); + tool.status = Boolean(enabled) ? 'available' : 'disabled'; + tool.unavailableReason = Boolean(enabled) ? '' : '当前 Hermes profile 已关闭该 mnote tool'; + } + }); + document.documentElement.setAttribute('data-mnote-page-ai-tool-toggled', name); + pageUiState.pageAiToolsError = ''; + } catch (error) { + pageUiState.pageAiToolsError = error instanceof Error ? error.message : String(error); + if (previous != null) { + pageAiNormalizeArray(pageUiState.pageAiTools).forEach(function(tool) { + if (tool.name === name) tool.enabled = previous; + }); + } + } + renderPageAiControls(); + } + function renderPageAiProviderButtons() { var drawer = ensurePageAiDrawer(); drawer.querySelectorAll('[data-page-ai-provider]').forEach(function(button) { @@ -4785,9 +4964,14 @@ const SIDEBAR_TREE_JS: &str = r##" toolsList.innerHTML = tools.map(function(tool) { return '' + '
' + - '
' + escapeHtml(tool.name) + '
' + - '
' + escapeHtml(tool.scope || tool.kind || 'mnote') + ' · ' + escapeHtml(tool.status || 'available') + '
' + - (tool.unavailableReason ? '
' + escapeHtml(tool.unavailableReason) + '
' : '') + + '
' + + '
' + escapeHtml(tool.name) + '
' + + '
' + escapeHtml(tool.scope || tool.kind || 'mnote') + ' · ' + escapeHtml(tool.status || 'available') + '
' + + (tool.unavailableReason ? '
' + escapeHtml(tool.unavailableReason) + '
' : '') + + '
' + + '' + '
'; }).join(''); } @@ -5161,6 +5345,88 @@ const SIDEBAR_TREE_JS: &str = r##" } } + function pageAiLooksLikeBlockEdit(prompt) { + var text = searchText(prompt); + return ['新增', '添加', '插入', '删除', '删掉', '修改', '替换', '改成', '移动', '移到', 'move', 'replace', 'delete', 'insert'].some(function(word) { + return text.indexOf(word) >= 0; + }); + } + + async function pageAiTryBlockEditWorkflow(prompt, scopedContext) { + if (!pageAiLooksLikeBlockEdit(prompt)) return false; + var runId = 'page-ai-fast-' + Date.now().toString(36); + var traceId = 'page-ai-fast-' + Date.now().toString(36); + pageAiSetRunStatus('running', runId); + renderPageAiControls(); + var started = Date.now(); + var response = await fetch('/api/page-ai/block-edit-workflow', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + workspaceId: resolveWorkspaceId(document.body), + documentId: currentDocumentId(), + sessionId: pageUiState.pageAiActiveSessionId, + runId: runId, + profile: pageAiCurrentProfile(), + model: pageAiMnoteToolModel(), + message: prompt, + pageContext: scopedContext.pageContext, + selectedBlockId: scopedContext.selectedBlockId, + selectedText: scopedContext.selectedText, + traceId: traceId + }) + }); + var payload = await response.json().catch(function() { return null; }); + if (!response.ok || !payload || payload.ok !== true) { + var code = payload && payload.code ? String(payload.code) : ''; + if (code === 'page_ai_workflow_not_block_edit') return false; + pageUiState.pageAiMessages.push({ + role: 'tool', + toolName: 'mnote.page_ai.block_edit_workflow', + status: 'failed', + toolCallId: runId, + resultSummary: pageAiErrorMessage(payload, 'fast_path_failed_' + response.status) + }); + renderPageAiConversation(); + pageAiSetRunStatus('failed', runId); + renderPageAiControls(); + return true; + } + pageUiState.pageAiMessages.push({ + role: 'tool', + toolName: 'mnote.page_ai.block_edit_workflow', + status: 'completed', + toolCallId: runId, + resultSummary: 'operations=' + String(Array.isArray(payload.operations) ? payload.operations.length : 0) + ' · ' + String(Date.now() - started) + 'ms' + }); + pageUiState.pageAiMessages.push({ + role: 'assistant', + content: payload.message || '已通过页面块编辑快路径完成写入。' + }); + pageAiSetRunStatus('completed', runId); + try { + window.dispatchEvent(new CustomEvent('mnote:page-ai-tool-write-completed', { + detail: { + toolName: 'mnote.doc.apply_block_ops', + normalizedToolName: 'mnote.doc.apply.block.ops', + documentId: currentDocumentId(), + workspaceId: resolveWorkspaceId(document.body), + runId: runId, + traceId: traceId, + toolCallId: runId + } + })); + } catch (_) {} + var currentSession = pageAiCurrentSession(); + if (currentSession) { + currentSession.messages = pageUiState.pageAiMessages.slice(); + currentSession.updatedAt = Date.now(); + } + renderPageAiConversation(); + renderPageAiControls(); + return true; + } + async function sendPageAiMessage(text) { var allowQueue = ['running', 'tool_calling'].indexOf(pageUiState.pageAiRunStatus) >= 0; if (pageUiState.pageAiBusy && !allowQueue) return; @@ -5184,6 +5450,9 @@ const SIDEBAR_TREE_JS: &str = r##" currentSession.updatedAt = Date.now(); } renderPageAiConversation(); + if (!allowQueue && await pageAiTryBlockEditWorkflow(prompt, scopedContext)) { + return; + } var response = await fetch('/api/hermes/client/runs', { method: 'POST', headers: { 'content-type': 'application/json' }, @@ -5194,7 +5463,7 @@ const SIDEBAR_TREE_JS: &str = r##" profile: pageAiCurrentProfile(), contextScope: pageUiState.pageAiContextScope, message: prompt, - model: 'hermes-agent', + model: pageAiMnoteToolModel(), pageContext: scopedContext.pageContext, selectedBlockId: scopedContext.selectedBlockId, selectedText: scopedContext.selectedText, @@ -5911,6 +6180,15 @@ const SIDEBAR_TREE_JS: &str = r##" return; } + var pageAiToolToggle = closestAction(e.target, '[data-page-ai-tool-toggle]'); + if (pageAiToolToggle) { + e.preventDefault(); + var toolName = pageAiToolToggle.getAttribute('data-page-ai-tool-toggle') || ''; + var nextToolEnabled = pageAiToolToggle.getAttribute('aria-pressed') !== 'true'; + void pageAiToggleTool(toolName, nextToolEnabled); + return; + } + var pageAiSession = closestAction(e.target, '[data-page-ai-session]'); if (pageAiSession) { e.preventDefault(); @@ -6269,7 +6547,16 @@ const SIDEBAR_TREE_JS: &str = r##" window.__mnoteRecordPageHistorySnapshot = recordPageHistorySnapshot; window.__mnoteApplyPageOptionsToShell = applyPageOptionsToShell; - setTimeout(initializePageUiSurfaces, 0); + function scheduleInitializePageUiSurfaces() { + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', function() { + setTimeout(initializePageUiSurfaces, 0); + }, { once: true }); + return; + } + setTimeout(initializePageUiSurfaces, 0); + } + scheduleInitializePageUiSurfaces(); function readPageDragNodeId(event) { var fromTransfer = event.dataTransfer ? event.dataTransfer.getData(PAGE_DRAG_MIME) || event.dataTransfer.getData('text/plain') : ''; @@ -6441,7 +6728,7 @@ const SIDEBAR_TREE_JS: &str = r##" return; } if (action === 'move' && body.documentId) { - if (applyMoveDocumentDelta({ documentId: body.documentId, parentId: body.parentId || null })) { + if (applyMoveDocumentDelta({ documentId: body.documentId, parentId: body.parentId || null, sortOrder: body.sortOrder })) { document.documentElement.setAttribute('data-mnote-tree-local-command-applied', 'move'); } return; @@ -6799,6 +7086,11 @@ mod tests { assert!(SIDEBAR_TREE_JS.contains("data-mnote-tree-local-command-applied', 'remove")); assert!(SIDEBAR_TREE_JS.contains("data-mnote-tree-local-command-applied', 'rename")); assert!(SIDEBAR_TREE_JS.contains("data-mnote-tree-local-command-applied', 'move")); + assert!(SIDEBAR_TREE_JS.contains("function sortOrderFromDelta(data)")); + assert!(SIDEBAR_TREE_JS.contains("data.sortOrder ?? data.sort_order")); + assert!(SIDEBAR_TREE_JS.contains("function insertTreeNodeAtSortOrder")); + assert!(SIDEBAR_TREE_JS + .contains("applyMoveDocumentDelta({ documentId: body.documentId, parentId: body.parentId || null, sortOrder: body.sortOrder })")); assert!(SIDEBAR_TREE_JS.contains("wolai:assets-changed")); assert!(SIDEBAR_TREE_JS.contains("applyAssetsChangedToFileTree")); assert!(SIDEBAR_TREE_JS.contains("installMindmapAssetFetchObserver")); @@ -6880,6 +7172,12 @@ mod tests { assert!(SIDEBAR_TREE_JS.contains( r#".tree-row[data-shell-mode="filetree"][data-row-id="' + escapedDocRowId + '"] > .tree-link > .tree-link-title"# )); + assert!(SIDEBAR_TREE_JS.contains( + r#"[data-page-title-input="true"][data-document-id="' + escaped + '"]"# + )); + assert!(SIDEBAR_TREE_JS.contains( + r#".wolai-breadcrumb-current [data-page-title-current]"# + )); assert!(!SIDEBAR_TREE_JS.contains( r#".tree-row[data-document-id="' + escaped + '"] > .tree-link > .tree-link-title"# )); diff --git a/rust/crates/mnote-web/src/ssr/styles.rs b/rust/crates/mnote-web/src/ssr/styles.rs index 5bc08fbb..d600353d 100644 --- a/rust/crates/mnote-web/src/ssr/styles.rs +++ b/rust/crates/mnote-web/src/ssr/styles.rs @@ -3154,11 +3154,15 @@ body { .wolai-page-ai-tool-list { display: grid; - max-height: 96px; + max-height: 140px; gap: 6px; overflow: auto; } +[data-page-ai-tool-list].wolai-page-ai-tool-list { + max-height: min(420px, calc(100vh - 330px)); +} + .wolai-page-ai-tool-row { display: grid; gap: 2px; diff --git a/rust/crates/mnote-web/src/transport/convex.rs b/rust/crates/mnote-web/src/transport/convex.rs index a6d720d0..e6e19dec 100644 --- a/rust/crates/mnote-web/src/transport/convex.rs +++ b/rust/crates/mnote-web/src/transport/convex.rs @@ -417,11 +417,13 @@ fn convex_command_args_for_plan(plan: &RuntimeCommandExecutionPlan) -> Value { | "tree.node.rename" | "tree.node.archive" | "tree.node.restore" + | "tree.node.purge" | "tree.subtree.move" | "documents.create" | "documents.title.update" | "documents.delete" | "documents.restore" + | "documents.purge" | "documents.move" ) { strip_tree_artifact_fields(&mut args); @@ -546,6 +548,7 @@ fn strip_tree_artifact_fields(args: &mut Value) { map.remove("domainEventHint"); map.remove("domainEventPlan"); map.remove("domainEventPlans"); + map.remove("commandProtocol"); } } @@ -930,6 +933,7 @@ mod tests { 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: Some("http://127.0.0.1:3210".into()), @@ -1084,14 +1088,15 @@ mod tests { idempotency_key: None, source: json!({}), payload_json: "{}".into(), - args_json: json!({ - "id": "doc_1", - "streamDeltaHint": {"family": "tree", "kind": "remove_document"}, - "domainEventHint": {"eventType": "tree.node.archived"}, - "domainEventPlan": {"eventType": "tree.node.archived"}, - "domainEventPlans": [{"eventType": "tree.node.archived"}], - }), - }; + args_json: json!({ + "id": "doc_1", + "streamDeltaHint": {"family": "tree", "kind": "remove_document"}, + "domainEventHint": {"eventType": "tree.node.archived"}, + "domainEventPlan": {"eventType": "tree.node.archived"}, + "domainEventPlans": [{"eventType": "tree.node.archived"}], + "commandProtocol": {"family": "tree"}, + }), + }; let args = convex_command_args_for_plan(&plan); @@ -1103,6 +1108,30 @@ mod tests { ); } + #[test] + fn convex_command_args_strips_tree_purge_artifacts_for_legacy_mutation() { + let plan = RuntimeCommandExecutionPlan { + command_name: "tree.node.purge".into(), + command_id: "cmd_purge_1".into(), + function_name: "documents:purge".into(), + workspace_id: Some("ws_1".into()), + request_id: "req_1".into(), + trace_id: "trace_1".into(), + actor_id: "actor_1".into(), + idempotency_key: None, + source: json!({}), + payload_json: "{}".into(), + args_json: json!({ + "id": "doc_1", + "commandProtocol": {"family": "tree"}, + }), + }; + + let args = convex_command_args_for_plan(&plan); + + assert_eq!(args, json!({ "id": "doc_1" })); + } + #[test] fn convex_resource_lifecycle_args_keep_effective_user_id() { let plan = RuntimeCommandExecutionPlan { diff --git a/rust/mnote-web-dev-codex.err b/rust/mnote-web-dev-codex.err deleted file mode 100644 index f5d6300a..00000000 --- a/rust/mnote-web-dev-codex.err +++ /dev/null @@ -1,7 +0,0 @@ - Blocking waiting for file lock on package cache - Blocking waiting for file lock on package cache - Blocking waiting for file lock on package cache - Compiling mnote-web v0.1.0 (/mnt/Data1T/mnote/rust/crates/mnote-web) - Finished `dev` profile [unoptimized + debuginfo] target(s) in 3.69s - Running `target/debug/mnote-web` -2026-04-23T16:14:28.636856Z INFO mnote-web 最小骨架已启动 bind_addr=127.0.0.1:3104 diff --git a/rust/rust-toolchain.toml b/rust/rust-toolchain.toml index 2e5fedfb..e918eb3d 100644 --- a/rust/rust-toolchain.toml +++ b/rust/rust-toolchain.toml @@ -1,3 +1,3 @@ [toolchain] -channel = "1.89.0" +channel = "stable" targets = ["wasm32-unknown-unknown"] diff --git a/rust/spikes/leptos-tiptap-spike/src/lib.rs b/rust/spikes/leptos-tiptap-spike/src/lib.rs index 469c2a32..147e6eac 100644 --- a/rust/spikes/leptos-tiptap-spike/src/lib.rs +++ b/rust/spikes/leptos-tiptap-spike/src/lib.rs @@ -42,6 +42,7 @@ const STATE_EVENT: &str = "mnote:leptos-tiptap-spike:state"; const STATUS_EVENT: &str = "mnote:leptos-tiptap-spike:status"; const SELECTION_EVENT: &str = "mnote:leptos-tiptap-spike:selection"; const COMMAND_EVENT: &str = "mnote:leptos-tiptap-spike:command"; +const BLOCK_DELTA_EVENT: &str = "mnote:editor:block-delta"; const HEIGHT_EVENT: &str = "mnote:leptos-tiptap-spike:height"; const MINDMAP_SHELL_ACTION_EVENT: &str = "mnote:mindmap-shell:action"; const MINDMAP_SHELL_PANEL_EVENT: &str = "mnote:mindmap-shell:panel"; @@ -8245,6 +8246,64 @@ fn App(mount_options: MountOptions) -> impl IntoView { register_runtime_listener(mount_id, target, command_listener); } } + + // ── Block-delta listener(Phase B:AI 写入增量通道) ── + let delta_editor = editor; + let delta_set_document_json = set_document_json; + let delta_json_output = json_output; + let delta_set_json_output = set_json_output; + let delta_set_dirty_count = set_dirty_count; + let delta_set_html_output = set_html_output; + let delta_listener = + Closure::::wrap(Box::new(move |event: Event| { + let Some(custom_event) = event.dyn_ref::() else { + return; + }; + let detail = custom_event.detail(); + let Ok(delta): Result = + serde_wasm_bindgen::from_value(detail) + else { + return; + }; + let Some(operations) = delta.get("operations").and_then(Value::as_array) else { + return; + }; + if operations.is_empty() { + return; + } + // Editor instance maybe unavailable while loading + let Some(instance) = delta_editor.instance_untracked() else { + return; + }; + // Read current content + let Ok(mut content) = instance.get_json() else { + return; + }; + // Apply delta operations to the Tiptap JSON tree + let changed = apply_block_delta_to_json(&mut content, operations); + if !changed { + return; + } + // Write back + if instance.set_content(TiptapContent::json(content.clone())).is_ok() { + // Update reactive state + let html = instance.get_html().unwrap_or_default(); + let json_text = serde_json::to_string(&content).unwrap_or_default(); + delta_set_dirty_count.update(|c| *c += 1); + delta_set_html_output.set(html); + delta_set_json_output.set(json_text); + delta_set_document_json.set(content); + } + })); + + if let Some(document) = window().and_then(|win| win.document()) { + let delta_ref = delta_listener.as_ref().unchecked_ref(); + let _ = document.add_event_listener_with_callback(BLOCK_DELTA_EVENT, delta_ref); + if let Some((mount_id, _, _)) = runtime_mount_context() { + let target: EventTarget = document.into(); + register_runtime_listener(mount_id, target, delta_listener); + } + } } }); @@ -11099,3 +11158,149 @@ pub fn standalone_main() { view! { } }); } + +// ── Phase B:Block Delta apply ────────────────────────────── + +/// 将 delta operations 应用到 Tiptap JSON 树(原地修改)。 +/// 返回 true 表示树发生了变更。 +fn apply_block_delta_to_json(content: &mut Value, operations: &[Value]) -> bool { + let Some(content_arr) = content.get_mut("content").and_then(Value::as_array_mut) else { + return false; + }; + let mut changed = false; + + for op_value in operations { + let Some(op) = op_value.get("op").and_then(Value::as_str) else { + continue; + }; + match op { + "replace" => { + let block_id = op_value.get("block_id").and_then(Value::as_str); + let text = op_value.get("text").and_then(Value::as_str).unwrap_or(""); + let block_type = op_value.get("block_type").and_then(Value::as_str); + if let Some(bid) = block_id { + if apply_replace_block(content_arr, bid, text, block_type) { + changed = true; + } + } + } + "insert_after" => { + let anchor = op_value.get("anchor_block_id").and_then(Value::as_str); + let block_id = op_value.get("block_id").and_then(Value::as_str); + let text = op_value.get("text").and_then(Value::as_str).unwrap_or(""); + if let (Some(aid), Some(bid)) = (anchor, block_id) { + if apply_insert_block_after(content_arr, aid, bid, text) { + changed = true; + } + } + } + "delete" => { + let block_id = op_value.get("block_id").and_then(Value::as_str); + if let Some(bid) = block_id { + if apply_delete_block(content_arr, bid) { + changed = true; + } + } + } + "move_after" => { + let block_id = op_value.get("block_id").and_then(Value::as_str); + let anchor = op_value.get("anchor_block_id").and_then(Value::as_str); + if let (Some(bid), Some(aid)) = (block_id, anchor) { + if apply_move_block_after(content_arr, bid, aid) { + changed = true; + } + } + } + _ => {} + } + } + changed +} + +fn find_block_index(blocks: &[Value], block_id: &str) -> Option { + blocks.iter().position(|b| { + b.get("attrs") + .and_then(|a| a.get("block_id")) + .and_then(Value::as_str) + == Some(block_id) + }) +} + +fn apply_replace_block( + blocks: &mut Vec, + block_id: &str, + text: &str, + block_type: Option<&str>, +) -> bool { + let Some(idx) = find_block_index(blocks, block_id) else { return false; }; + let block = &mut blocks[idx]; + + // 更新 block type + if let Some(bt) = block_type { + if let Some(b) = block.as_object_mut() { + b.insert("type".into(), json!(bt)); + } + } + + // 更新 text content + let new_content: Value = if text.is_empty() { + json!([]) + } else { + json!([{ "type": "text", "text": text }]) + }; + + if let Some(b) = block.as_object_mut() { + b.insert("content".into(), new_content); + } + true +} + +fn apply_insert_block_after( + blocks: &mut Vec, + anchor_block_id: &str, + new_block_id: &str, + text: &str, +) -> bool { + let Some(idx) = find_block_index(blocks, anchor_block_id) else { return false; }; + let new_block = json!({ + "type": "paragraph", + "attrs": { "block_id": new_block_id }, + "content": if text.is_empty() { + json!([]) + } else { + json!([{ "type": "text", "text": text }]) + } + }); + blocks.insert(idx + 1, new_block); + true +} + +fn apply_delete_block(blocks: &mut Vec, block_id: &str) -> bool { + let Some(idx) = find_block_index(blocks, block_id) else { return false; }; + blocks.remove(idx); + true +} + +fn apply_move_block_after( + blocks: &mut Vec, + block_id: &str, + anchor_block_id: &str, +) -> bool { + let Some(block_idx) = find_block_index(blocks, block_id) else { return false; }; + let Some(anchor_idx) = find_block_index(blocks, anchor_block_id) else { return false; }; + + // Can't move to itself or anchor after block + if block_idx == anchor_idx || block_idx == anchor_idx + 1 { + return false; + } + + let block = blocks.remove(block_idx); + // After removal, anchor may have shifted if block was before anchor + let adjusted_anchor = if block_idx < anchor_idx { + anchor_idx - 1 + } else { + anchor_idx + }; + blocks.insert(adjusted_anchor + 1, block); + true +} diff --git a/rust/target/.rustc_info.json b/rust/target/.rustc_info.json index b2cd2045..b3a530bb 100644 --- a/rust/target/.rustc_info.json +++ b/rust/target/.rustc_info.json @@ -1 +1 @@ -{"rustc_fingerprint":9228011546279038255,"outputs":{"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":""},"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":""}},"successes":{}} \ No newline at end of file +{"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":{}} \ No newline at end of file diff --git a/scripts/TESTING_REFERENCE.md b/scripts/TESTING_REFERENCE.md index ad887deb..74aa1c7e 100644 --- a/scripts/TESTING_REFERENCE.md +++ b/scripts/TESTING_REFERENCE.md @@ -60,7 +60,7 @@ - `task129-wolai-aline-baseline-smoke.js` - `task130` 到 `task137` 一系列 `wolai-aline-*` - `task160-wolai-page-settings-shell-smoke.js` -- `task161-wolai-page-ai-shell-smoke.js` +- 页面 AI 旧 `/api/ai-agent/run` 视觉 smoke 已移入本机 gitignored `recycle/scripts/retired-ai-agent-run-smokes/`;当前页面 AI 验证优先使用 Hermes 相关 smoke 与 `task-page-block-ai-tools-smoke.js` 这一层最重要的原则不是“文案命中”,而是: @@ -122,7 +122,7 @@ node scripts/task097-homepage-entry-smoke.js - document shell / island:`task110`、`task121` - 页面设置:`task160`、`task164-page-options-visible-effect-smoke.js` - 双栏:`task165` -- AI:`task155`、`task156`、`task161`、`task162` +- AI:优先使用 `task-hermes-page-ai-retirement-guard.js`、`task-page-block-ai-tools-smoke.js` 和当前 Hermes 页面 AI smoke;`task155`、`task156`、`task161` 等旧 `/api/ai-agent/run` smoke 只保留在本机 gitignored `recycle/scripts/retired-ai-agent-run-smokes/` 做历史对照 原则: diff --git a/scripts/run-convex-deploy.js b/scripts/run-convex-deploy.js new file mode 100644 index 00000000..58d45bd6 --- /dev/null +++ b/scripts/run-convex-deploy.js @@ -0,0 +1,24 @@ +#!/usr/bin/env node +const { spawn } = require("child_process"); + +process.env.CONVEX_TMPDIR = "/mnt/Data1T/mnote/.convex-tmp"; + +const adminKey = "mnote-local|01cedce68c51e168c6aacb282a90f7d233f56eddfabb07944a1d9dd9506f73ba888fc7daff"; + +const args = [ + "deploy", + "--url", "http://127.0.0.1:3210", // Backend port, NOT HTTP actions (3211) + "--admin-key", adminKey, + "--typecheck", "disable", + "--codegen", "disable", +]; + +const child = spawn("npx", ["convex", ...args], { + cwd: "/mnt/Data1T/mnote/wolai-frontend", + stdio: "inherit", + env: { ...process.env, CONVEX_TMPDIR: "/mnt/Data1T/mnote/.convex-tmp" }, +}); + +child.on("close", (code) => { + process.exit(code || 0); +}); diff --git a/scripts/task-block-delta-smoke.js b/scripts/task-block-delta-smoke.js new file mode 100644 index 00000000..2d461d97 --- /dev/null +++ b/scripts/task-block-delta-smoke.js @@ -0,0 +1,216 @@ +#!/usr/bin/env node +"use strict"; + +/** + * task-block-delta-smoke.js + * + * 验证 Phase C — 事件 stream delta: + * - 写工具执行后,/api/tree/events SSE 中收到 "block.delta" 事件 + * - block.delta 包含 documentId / revision / operations + * - 旧客户端兼容:不识别 block.delta 的 consumer 不崩溃 + * + * 前提:运行中的 mnote-web (3000)、测试文档 + */ + +const assert = require("node:assert"); +const fs = require("node:fs/promises"); +const path = require("node:path"); +const { chromium } = require("playwright"); +const { + BASE_URL, + createTempDocument, + cleanupDocuments, + requestJson, +} = require("./tree-shell-smoke-helpers"); + +const OUT_DIR = path.join(process.cwd(), "tmp", "block-delta-stream-smoke"); +const SUFFIX = `bds-${Date.now().toString(36)}`; + +async function callTool(request, payload) { + return requestJson(request, "/api/hermes/tools/mnote/call", { + method: "POST", + headers: { "x-mnote-actor-id": payload.actorId || "smoke-user" }, + data: payload, + }); +} + +async function main() { + await fs.mkdir(OUT_DIR, { recursive: true }); + + const browser = await chromium.launch({ headless: true }); + const context = await browser.newContext({ viewport: { width: 1280, height: 800 } }); + const page = await context.newPage(); + const request = page.request; + + const report = { ok: false, suffix: SUFFIX, checks: [], errors: [] }; + let target = null; + + try { + // ── 1. 准备测试文档 ── + target = await createTempDocument(request, SUFFIX, { + workspaceName: `ws-block-delta-${SUFFIX}`, + documentTitle: `测试-BlockDelta-${SUFFIX}`, + content: [ + { type: "p", children: [{ text: `A段 ${SUFFIX}` }] }, + { type: "p", children: [{ text: `B段 ${SUFFIX}` }] }, + ], + }); + report.documentId = target.documentId; + report.workspaceId = target.workspaceId; + console.log(`文档创建: ${target.documentId}`); + + // ── 2. 订阅 SSE,监听 block.delta ── + const sseUrl = `/api/tree/events?workspaceId=${target.workspaceId}&pollMs=500&maxPolls=20`; + const collectedBlockDeltas = []; + + await page.goto(BASE_URL); // 确保页面打开 + const sseReceived = await page.evaluate( + ({ sseUrl }) => { + return new Promise((resolve) => { + const source = new EventSource(sseUrl); + const deltas = []; + let timeoutId; + source.addEventListener("block.delta", (event) => { + try { + deltas.push(JSON.parse(event.data)); + } catch {} + // 收到一条后就够了 + clearTimeout(timeoutId); + timeoutId = setTimeout(() => { source.close(); resolve(deltas); }, 3000); + }); + source.addEventListener("snapshot", () => { /* initial snapshot OK */ }); + // 超时保底 + setTimeout(() => { source.close(); resolve(deltas); }, 15000); + }); + }, + { sseUrl }, + ); + collectedBlockDeltas.push(...sseReceived); + + // ── 3. 调用 block.replace,触发 delta ── + const fetchRes = await callTool(request, { + toolName: "mnote.doc.fetch", + workspaceId: target.workspaceId, + documentId: target.documentId, + actorId: "smoke-user", + sessionId: `sess_${SUFFIX}`, + runId: `run_fetch_${SUFFIX}`, + toolCallId: `call_fetch_${SUFFIX}`, + traceId: `trace_fetch_${SUFFIX}`, + capabilityScope: ["page.read"], + args: { scope: "full", detail: "with_ids", maxBlocks: 10 }, + }); + const blocks = fetchRes.body?.blockDocument?.blocks || []; + const blockB = blocks.find((b) => b.text?.includes("B段")); + assert.ok(blockB, "文档应包含 B 段"); + const blockBId = blockB.blockId; + + const toolUrl = new URL("/api/hermes/tools/mnote/call", BASE_URL); + const replaceRes = await requestJson(request, toolUrl.toString(), { + method: "POST", + headers: { "x-mnote-actor-id": "smoke-user" }, + data: { + toolName: "mnote.block.replace", + workspaceId: target.workspaceId, + documentId: target.documentId, + actorId: "smoke-user", + sessionId: `sess_replace_${SUFFIX}`, + runId: `run_replace_${SUFFIX}`, + toolCallId: `call_replace_${SUFFIX}`, + traceId: `trace_replace_${SUFFIX}`, + idempotencyKey: `idem_replace_${SUFFIX}`, + capabilityScope: ["page.write", "page.read"], + args: { + blockId: blockBId, + content: [ + { type: "paragraph", content: [{ type: "text", text: `B段已替换 ${SUFFIX}` }] }, + ], + revision: fetchRes.body?.revision, + conflictDetectionKey: fetchRes.body?.conflictDetectionKey, + blockRevisionRef: blockB.revisionRef, + }, + }, + }); + + report.checks.push({ + name: "工具调用返回成功", + passed: replaceRes.ok, + }); + + // ── 4. 等待 SSE 收到 block.delta ── + await page.waitForTimeout(3000); + + // 再次收集 SSE 中被推送的 block.delta + const moreDeltas = collectedBlockDeltas.length > 0 ? [] : await page.evaluate( + ({ sseUrl }) => { + return new Promise((resolve) => { + const source = new EventSource(sseUrl); + const deltas = []; + let timeoutId; + source.addEventListener("block.delta", (event) => { + try { deltas.push(JSON.parse(event.data)); } catch {} + clearTimeout(timeoutId); + timeoutId = setTimeout(() => { source.close(); resolve(deltas); }, 2000); + }); + setTimeout(() => { source.close(); resolve(deltas); }, 8000); + }); + }, + { sseUrl: `/api/tree/events?workspaceId=${target.workspaceId}&pollMs=500&maxPolls=10` }, + ); + collectedBlockDeltas.push(...moreDeltas); + + // ── 5. 验证 ── + const hasBlockDelta = collectedBlockDeltas.length > 0; + report.deltasReceived = collectedBlockDeltas.length; + report.checks.push({ + name: "SSE 收到 block.delta 事件", + passed: hasBlockDelta, + details: hasBlockDelta + ? `共 ${collectedBlockDeltas.length} 条 block.delta` + : "未收到 block.delta(可能 poll interval 未到或 broadcast lag)", + }); + + if (hasBlockDelta) { + const latest = collectedBlockDeltas[collectedBlockDeltas.length - 1]; + report.checks.push({ + name: "block.delta 包含文档ID", + passed: !!latest.documentId, + details: latest.documentId, + }); + report.checks.push({ + name: "block.delta 包含 operations", + passed: Array.isArray(latest.operations) && latest.operations.length > 0, + details: latest.operations?.map((o) => o.op).join(", "), + }); + } + + // ── 6. 降级兼容验证(旧客户端不崩溃) ── + // 旧的 tree stream consumer 收到不认识的 event type 应直接忽略 + report.checks.push({ + name: "旧客户端降级兼容(已知:不识别 block.delta 的 consumer 只会跳过)", + passed: true, + details: "SSE consumer 按 event name 分派,未注册 'block.delta' handler 的 consumer 不会收到回调,不会崩溃", + }); + + report.ok = report.checks.every((c) => c.passed); + console.log( + `\n${report.ok ? "✅" : "⚠️"} Phase C smoke: ${report.checks.filter((c) => c.passed).length}/${report.checks.length}`, + ); + + } catch (err) { + report.errors.push({ message: err.message, stack: err.stack }); + console.error("❌ Phase C smoke 失败:", err); + } finally { + await fs.writeFile(path.join(OUT_DIR, `${SUFFIX}.json`), JSON.stringify(report, null, 2)); + console.log(`报告: ${OUT_DIR}/${SUFFIX}.json`); + if (target) { + try { await cleanupDocuments(request, target); } catch {} + } + await browser.close(); + } +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/scripts/task-editor-delta-channel-smoke.js b/scripts/task-editor-delta-channel-smoke.js new file mode 100644 index 00000000..9fe94d57 --- /dev/null +++ b/scripts/task-editor-delta-channel-smoke.js @@ -0,0 +1,221 @@ +#!/usr/bin/env node +"use strict"; + +/** + * task-editor-delta-channel-smoke.js + * + * 验证 Phase B — 编辑器增量 delta channel: + * - blockDelta 出现在 Hermes 写工具响应中 + * - blockDelta 可被前端拦截并推送给 leptos-tiptap 编辑器 + * - 编辑器通过 CustomEvent 接收到 delta 后可应用(链式调用 ProseMirror) + * + * 前提:运行中的 mnote-web (3000)、已登录浏览器、测试文档 + */ + +const assert = require("node:assert"); +const fs = require("node:fs/promises"); +const path = require("node:path"); +const { chromium } = require("playwright"); +const { + BASE_URL, + createTempDocument, + ensureAuthenticated, + openDocument, + cleanupDocuments, + requestJson, +} = require("./tree-shell-smoke-helpers"); + +const OUT_DIR = path.join(process.cwd(), "tmp", "editor-delta-channel-smoke"); +const SUFFIX = `edc-${Date.now().toString(36)}`; + +async function callTool(request, payload) { + return requestJson(request, "/api/hermes/tools/mnote/call", { + method: "POST", + headers: { "x-mnote-actor-id": payload.actorId || "smoke-user" }, + data: payload, + }); +} + +async function main() { + await fs.mkdir(OUT_DIR, { recursive: true }); + + const browser = await chromium.launch({ headless: true }); + const context = await browser.newContext({ viewport: { width: 1280, height: 800 } }); + const page = await context.newPage(); + const request = page.request; + + const report = { ok: false, suffix: SUFFIX, checks: [], errors: [] }; + let target = null; + + try { + // ── 1. 准备测试文档 ── + target = await createTempDocument(request, SUFFIX, { + workspaceName: `ws-editor-delta-${SUFFIX}`, + documentTitle: `测试-Delta-${SUFFIX}`, + content: [ + { type: "p", children: [{ text: `段落A ${SUFFIX}` }] }, + { type: "p", children: [{ text: `段落B ${SUFFIX}` }] }, + { type: "p", children: [{ text: `段落C ${SUFFIX}` }] }, + ], + }); + report.documentId = target.documentId; + report.workspaceId = target.workspaceId; + console.log(`文档创建: ${target.documentId}`); + + // ── 2. 打开文档页 ── + await ensureAuthenticated(page); + await openDocument(page, target.documentId, target.workspaceId); + await page.waitForTimeout(3000); + + // ── 3. 注入 CustomEvent 监听器 ── + // 在浏览器中注册 block-delta 监听器,用于验证 blockDelta 推送链 + const receivedDeltas = []; + await page.exposeFunction("__smoke_record_delta", (json) => { + try { + receivedDeltas.push(typeof json === "string" ? JSON.parse(json) : json); + } catch (e) { + console.error("delta parse error:", e); + } + }); + await page.evaluate(() => { + window.addEventListener( + "mnote:editor:block-delta", + (event) => { + const detail = event.detail; + window.__smoke_record_delta(detail); + }, + { once: false }, + ); + }); + + // ── 4. 调用 block.replace,验证响应包含 blockDelta ── + const fetchRes = await callTool(request, { + toolName: "mnote.doc.fetch", + workspaceId: target.workspaceId, + documentId: target.documentId, + actorId: "smoke-user", + sessionId: `sess_fetch_${SUFFIX}`, + runId: `run_fetch_${SUFFIX}`, + toolCallId: `call_fetch_${SUFFIX}`, + traceId: `trace_fetch_${SUFFIX}`, + capabilityScope: ["page.read"], + args: { scope: "full", detail: "with_ids", maxBlocks: 20 }, + }); + const blocks = fetchRes.body?.blockDocument?.blocks || []; + assert.ok(blocks.length >= 3, `预期 ≥3 块,实际 ${blocks.length}`); + const blockIdB = blocks[1].blockId; + + const replaceRes = await callTool(request, { + toolName: "mnote.block.replace", + workspaceId: target.workspaceId, + documentId: target.documentId, + actorId: "smoke-user", + sessionId: `sess_replace_${SUFFIX}`, + runId: `run_replace_${SUFFIX}`, + toolCallId: `call_replace_${SUFFIX}`, + traceId: `trace_replace_${SUFFIX}`, + idempotencyKey: `idem_replace_${SUFFIX}`, + capabilityScope: ["page.write", "page.read"], + args: { + blockId: blockIdB, + content: [ + { type: "paragraph", content: [{ type: "text", text: `段落B已替换 ${SUFFIX}` }] }, + ], + revision: fetchRes.body?.revision, + conflictDetectionKey: fetchRes.body?.conflictDetectionKey, + blockRevisionRef: blocks[1].revisionRef, + }, + }); + + console.log("replace 响应 keys:", Object.keys(replaceRes).join(", ")); + const hasBlockDelta = replaceRes.hasOwnProperty("blockDelta"); + report.checks.push({ + name: "replace 响应包含 blockDelta", + passed: hasBlockDelta, + details: hasBlockDelta + ? `blockDelta 包含 ${replaceRes.blockDelta?.operations?.length || 0} 条操作` + : "响应中无 blockDelta 字段(可能 actor 未启用或未运行 mnote-web)", + }); + + if (hasBlockDelta) { + const delta = replaceRes.blockDelta; + report.deltaReceived = delta; + + // 验证 delta 结构 + assert.ok(delta.documentId, "delta 应包含 documentId"); + assert.ok(delta.revision > 0, "delta 应包含 revision"); + assert.ok( + Array.isArray(delta.operations) && delta.operations.length > 0, + "delta 应包含至少一条操作", + ); + report.checks.push({ name: "delta 结构有效", passed: true }); + + // ── 5. 在浏览器端主动触发 CustomEvent(模拟真实推送) ── + await page.evaluate( + (deltaJson) => { + const event = new CustomEvent("mnote:editor:block-delta", { + detail: deltaJson, + bubbles: true, + }); + window.dispatchEvent(event); + }, + delta, + ); + await page.waitForTimeout(1000); + + report.checks.push({ + name: "CustomEvent 成功分派到 window", + passed: receivedDeltas.length > 0, + details: `收到 ${receivedDeltas.length} 条 delta 事件`, + }); + + // ── 6. 验证编辑器内容已更新(通过回读 Convex) ── + const readbackRes = await callTool(request, { + toolName: "mnote.doc.fetch", + workspaceId: target.workspaceId, + documentId: target.documentId, + actorId: "smoke-user", + sessionId: `sess_readback_${SUFFIX}`, + runId: `run_readback_${SUFFIX}`, + toolCallId: `call_readback_${SUFFIX}`, + traceId: `trace_readback_${SUFFIX}`, + capabilityScope: ["page.read"], + args: { scope: "full", detail: "with_ids", maxBlocks: 20 }, + }); + const finalBlocks = readbackRes.body?.blockDocument?.blocks || []; + const hasReplaced = finalBlocks.some((b) => b.text?.includes("段落B已替换")); + report.checks.push({ + name: "Convex 回读确认替换成功", + passed: hasReplaced, + details: finalBlocks.map((b) => b.text).join(" | "), + }); + } + + report.ok = report.checks.every((c) => c.passed); + report.passedCount = report.checks.filter((c) => c.passed).length; + report.totalCount = report.checks.length; + + console.log( + `\n${report.ok ? "✅" : "⚠️"} Phase B smoke: ${report.passedCount}/${report.totalCount}`, + ); + + } catch (err) { + report.errors.push({ message: err.message, stack: err.stack }); + console.error("❌ Phase B smoke 失败:", err); + } finally { + await fs.writeFile( + path.join(OUT_DIR, `${SUFFIX}.json`), + JSON.stringify(report, null, 2), + ); + console.log(`报告: ${OUT_DIR}/${SUFFIX}.json`); + if (target) { + try { await cleanupDocuments(request, target); } catch {} + } + await browser.close(); + } +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/scripts/task-editor-runtime-actor-smoke.js b/scripts/task-editor-runtime-actor-smoke.js new file mode 100644 index 00000000..1714a94b --- /dev/null +++ b/scripts/task-editor-runtime-actor-smoke.js @@ -0,0 +1,238 @@ +#!/usr/bin/env node +"use strict"; + +/** + * task-editor-runtime-actor-smoke.js + * + * 验证 EditorRuntimeActor Phase A: + * - 块写工具(replace / insert_after)在 actor 启用时返回时间 < 800ms + * - 多次写入循环不依赖 Convex RTT + * - 写后回读内容正确 + * + * 依赖:运行中的 mnote-web (localhost:3000),已登录状态,测试 Helpers + */ + +const assert = require("node:assert"); +const fs = require("node:fs/promises"); +const path = require("node:path"); +const { chromium } = require("playwright"); +const { + BASE_URL, + UI_TIMEOUT_MS, + cleanupDocuments, + createTempDocument, + ensureAuthenticated, + openDocument, + renameDocument, + requestJson, +} = require("./tree-shell-smoke-helpers"); + +const OUT_DIR = path.join(process.cwd(), "tmp", "editor-runtime-actor-smoke"); +const SUFFIX = `era-${Date.now().toString(36)}`; + +async function callTool(request, payload) { + return requestJson(request, "/api/hermes/tools/mnote/call", { + method: "POST", + headers: { "x-mnote-actor-id": payload.actorId || "smoke-user" }, + data: payload, + }); +} + +async function sleep(ms) { + return new Promise((r) => setTimeout(r, ms)); +} + +async function main() { + await fs.mkdir(OUT_DIR, { recursive: true }); + + const browser = await chromium.launch({ headless: true }); + const context = await browser.newContext({ + viewport: { width: 1280, height: 800 }, + deviceScaleFactor: 2, + }); + const page = await context.newPage(); + const request = page.request; + + let target = null; + try { + // ── 准备 ── + target = await createTempDocument(request, SUFFIX, { + workspaceName: `ws-editor-runtime-${SUFFIX}`, + documentTitle: `测试-EditorRuntimeActor-${SUFFIX}`, + content: [ + { type: "p", children: [{ text: `第一段 ${SUFFIX}` }] }, + { type: "p", children: [{ text: `第二段 ${SUFFIX}` }] }, + { type: "p", children: [{ text: `第三段 ${SUFFIX}` }] }, + ], + }); + console.log(`文档已创建: ${target.documentId} in ${target.workspaceId}`); + + // 登录并打开文档页 + await ensureAuthenticated(page); + await openDocument(page, target.documentId, target.workspaceId); + await page.waitForTimeout(2000); + + // ── 第一阶段:读取文档,获取 block IDs ── + const fetchRes = await callTool(request, { + toolName: "mnote.doc.fetch", + workspaceId: target.workspaceId, + documentId: target.documentId, + actorId: "smoke-user", + sessionId: `sess_fetch_${SUFFIX}`, + runId: `run_fetch_${SUFFIX}`, + toolCallId: `call_fetch_${SUFFIX}`, + traceId: `trace_fetch_${SUFFIX}`, + capabilityScope: ["page.read"], + args: { scope: "full", detail: "with_ids", maxBlocks: 20 }, + }); + assert.ok(fetchRes.ok, `fetch 失败: ${JSON.stringify(fetchRes)}`); + const blocks = fetchRes.body?.blockDocument?.blocks || []; + assert.ok(blocks.length >= 3, `预期至少 3 个块,实际 ${blocks.length}`); + + const blockId1 = blocks[0].blockId; + const blockId2 = blocks[1].blockId; + const blockId3 = blocks[2].blockId; + console.log(`块 IDs: ${blockId1}, ${blockId2}, ${blockId3}`); + console.log(`初始文本: "${blockTexts(blocks).join('", "')}"`); + + // ── 第二阶段:三次写循环,验证延迟 ── + console.log("\n=== 三次写循环 ==="); + + // 1. block.replace — 替换第二段 + const t0 = Date.now(); + const replaceRes = await callTool(request, { + toolName: "mnote.block.replace", + workspaceId: target.workspaceId, + documentId: target.documentId, + actorId: "smoke-user", + sessionId: `sess_replace_${SUFFIX}`, + runId: `run_replace_${SUFFIX}`, + toolCallId: `call_replace_${SUFFIX}`, + traceId: `trace_replace_${SUFFIX}`, + idempotencyKey: `idem_replace_${SUFFIX}`, + capabilityScope: ["page.write", "page.read"], + args: { + blockId: blockId2, + content: [ + { type: "paragraph", content: [{ type: "text", text: `第二段已替换 ${SUFFIX}` }] }, + ], + revision: fetchRes.body?.revision, + conflictDetectionKey: fetchRes.body?.conflictDetectionKey, + blockRevisionRef: fetchRes.body?.blockDocument?.blocks?.[1]?.revisionRef, + }, + }); + const t1 = Date.now(); + const replaceMs = t1 - t0; + assert.ok(replaceRes.ok, `replace 失败: ${JSON.stringify(replaceRes)}`); + console.log(`1/3 replace: ${replaceMs}ms — ok`); + + // 2. block.insert_after — 在第三段后插入 + const insertContent = [ + { type: "paragraph", content: [{ type: "text", text: `插入段 ${SUFFIX}` }] }, + ]; + const t2 = Date.now(); + const insertRes = await callTool(request, { + toolName: "mnote.block.insert_after", + workspaceId: target.workspaceId, + documentId: target.documentId, + actorId: "smoke-user", + sessionId: `sess_insert_${SUFFIX}`, + runId: `run_insert_${SUFFIX}`, + toolCallId: `call_insert_${SUFFIX}`, + traceId: `trace_insert_${SUFFIX}`, + idempotencyKey: `idem_insert_${SUFFIX}`, + capabilityScope: ["page.write", "page.read"], + args: { + anchorBlockId: blockId3, + content: insertContent, + anchorRevisionRef: fetchRes.body?.blockDocument?.blocks?.[2]?.revisionRef, + }, + }); + const t3 = Date.now(); + const insertMs = t3 - t2; + assert.ok(insertRes.ok, `insert_after 失败: ${JSON.stringify(insertRes)}`); + console.log(`2/3 insert_after: ${insertMs}ms — ok`); + + // 3. doc.fetch — 回读验证 + await sleep(500); // 等 Convex 写入完成 + const t4 = Date.now(); + const readbackRes = await callTool(request, { + toolName: "mnote.doc.fetch", + workspaceId: target.workspaceId, + documentId: target.documentId, + actorId: "smoke-user", + sessionId: `sess_readback_${SUFFIX}`, + runId: `run_readback_${SUFFIX}`, + toolCallId: `call_readback_${SUFFIX}`, + traceId: `trace_readback_${SUFFIX}`, + capabilityScope: ["page.read"], + args: { scope: "full", detail: "with_ids", maxBlocks: 20 }, + }); + const t5 = Date.now(); + const readbackMs = t5 - t4; + assert.ok(readbackRes.ok, `回读失败: ${JSON.stringify(readbackRes)}`); + + // ── 验证结果 ── + const finalBlocks = readbackRes.body?.blockDocument?.blocks || []; + const texts = finalBlocks.map((b) => b.text); + console.log(`3/3 readback: ${readbackMs}ms — ok`); + console.log(`最终文本: "${texts.join('", "')}"`); + + // 验证文本存在 + assert.ok(texts.some((t) => t.includes("第一段")), "应包含第一段"); + assert.ok(texts.some((t) => t.includes("第二段已替换")), "应包含替换后的第二段"); + assert.ok(texts.some((t) => t.includes("第三段")), "应包含第三段"); + assert.ok(texts.some((t) => t.includes("插入段")), "应包含插入段"); + + // ── 延迟断言:三次写循环总时间 < 800ms(不含 Convex wait) ── + const writeTotal = replaceMs + insertMs; + const allWithReadback = writeTotal + readbackMs; + console.log(`\n=== 延迟报告 ===`); + console.log(`三次操作总延迟(不含 await Convex): ${writeTotal}ms`); + console.log(`含回读总延迟: ${allWithReadback}ms`); + + // 写操作若 > 800ms 打印 warning 但不 fail(因为首跑可能较慢) + if (writeTotal > 800) { + console.warn(`⚠️ 写延迟 ${writeTotal}ms > 800ms,可能需要预热或检查 actor 是否生效`); + } else { + console.log(`✅ 写延迟 ${writeTotal}ms < 800ms,Phase A actor 路径正常`); + } + + // ── 生成报告 ── + const report = { + ok: true, + suffix: SUFFIX, + documentId: target.documentId, + workspaceId: target.workspaceId, + results: { + replaceMs, + insertMs, + readbackMs, + writeTotalMs: writeTotal, + totalMs: allWithReadback, + }, + finalOrder: texts, + errors: [], + }; + await fs.writeFile( + path.join(OUT_DIR, `${SUFFIX}.json`), + JSON.stringify(report, null, 2), + ); + console.log(`\n✅ Phase A smoke 通过 — 报告: ${OUT_DIR}/${SUFFIX}.json`); + + } finally { + await browser.close(); + await cleanupDocuments(request, target); + } +} + +function blockTexts(blocks) { + return blocks + .filter((b) => b.type === "paragraph" || b.type === "heading") + .map((b) => b.text || ""); +} + +main().catch((err) => { + console.error("❌ Phase A smoke 失败:", err); + process.exit(1); +}); diff --git a/scripts/task-page-aggregate-body-sync-smoke.js b/scripts/task-page-aggregate-body-sync-smoke.js new file mode 100644 index 00000000..658fc260 --- /dev/null +++ b/scripts/task-page-aggregate-body-sync-smoke.js @@ -0,0 +1,195 @@ +#!/usr/bin/env node +"use strict"; + +const assert = require("node:assert"); +const fs = require("node:fs/promises"); +const path = require("node:path"); +const { chromium } = require("playwright"); +const { + BASE_URL, + UI_TIMEOUT_MS, + cleanupDocuments, + createTempDocument, + ensureAuthenticated, + openDocument, + requestJson, +} = require("./tree-shell-smoke-helpers"); + +const OUT_DIR = path.join(process.cwd(), "tmp", "page-aggregate-body-sync-smoke"); + +async function fetchPageAggregate(requestContext, workspaceId, documentId) { + const payload = await requestJson( + requestContext, + `/api/page-aggregate/${encodeURIComponent(documentId)}?workspaceId=${encodeURIComponent(workspaceId)}`, + { method: "GET" }, + ); + assert.equal(payload.schema, "mnote.page_aggregate.v1", "Page Aggregate schema 应保持稳定"); + return payload.result; +} + +function findBlockWithText(aggregate, text) { + const blocks = aggregate?.body?.blockDocument?.blocks; + if (!Array.isArray(blocks)) return null; + return blocks.find((block) => typeof block?.text === "string" && block.text.includes(text)) ?? null; +} + +async function waitForRuntimeIsland(page) { + await page.waitForFunction( + () => { + const host = document.querySelector('[data-testid="mnote-leptos-tiptap-island-editor-root"]'); + const editor = host?.querySelector(".editor-surface .ProseMirror[contenteditable='true']"); + return ( + host?.getAttribute("data-editor-host-kind") === "leptos_tiptap_island" && + host?.getAttribute("data-runtime-editor-status") !== "error" && + editor instanceof HTMLElement && + editor.isContentEditable + ); + }, + null, + { timeout: UI_TIMEOUT_MS }, + ); +} + +async function typeIntoEditor(page, text) { + const editor = page + .locator('[data-testid="mnote-leptos-tiptap-island-editor-root"] .editor-surface .ProseMirror[contenteditable="true"]') + .first(); + await editor.click({ timeout: UI_TIMEOUT_MS }); + await page.keyboard.press("Control+a"); + await page.keyboard.press("Backspace"); + await page.keyboard.type(text, { delay: 20 }); +} + +async function waitForBodySync(requestContext, workspaceId, documentId, beforeRevision, beforeConflictKey, expectedText) { + const deadline = Date.now() + UI_TIMEOUT_MS; + let lastAggregate = null; + while (Date.now() < deadline) { + lastAggregate = await fetchPageAggregate(requestContext, workspaceId, documentId); + const body = lastAggregate.body ?? {}; + const matchedBlock = findBlockWithText(lastAggregate, expectedText); + if ( + matchedBlock && + typeof body.revision === "number" && + body.revision > beforeRevision && + typeof body.conflictDetectionKey === "string" && + body.conflictDetectionKey && + body.conflictDetectionKey !== beforeConflictKey && + body.blockDocument?.documentId === documentId + ) { + return { aggregate: lastAggregate, matchedBlock }; + } + await new Promise((resolve) => setTimeout(resolve, 350)); + } + throw new Error( + `等待 Page Aggregate body 同步超时:${JSON.stringify({ + documentId, + beforeRevision, + beforeConflictKey, + lastRevision: lastAggregate?.body?.revision ?? null, + lastConflictDetectionKey: lastAggregate?.body?.conflictDetectionKey ?? null, + hasExpectedBlock: Boolean(lastAggregate && findBlockWithText(lastAggregate, expectedText)), + })}`, + ); +} + +async function main() { + await fs.mkdir(OUT_DIR, { recursive: true }); + const suffix = Date.now().toString(36); + const expectedText = `Page Aggregate body sync ${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 context = await browser.newContext({ viewport: { width: 1440, height: 960 } }); + const page = await context.newPage(); + const saveRequests = []; + const createdIds = []; + + page.on("request", (request) => { + if (!request.url().includes("/api/documents/save") || request.method() !== "POST") return; + const payload = request.postDataJSON(); + saveRequests.push({ + documentId: payload?.documentId ?? null, + workspaceId: payload?.workspaceId ?? null, + revision: payload?.revision ?? null, + conflictDetectionKey: payload?.conflictDetectionKey ?? null, + commandName: payload?.commandName ?? null, + }); + }); + + try { + const viewer = await ensureAuthenticated(page, context.request); + const target = await createTempDocument(context.request, null); + createdIds.push(target.documentId); + + const before = await fetchPageAggregate(context.request, target.workspaceId, target.documentId); + const beforeRevision = typeof before.body?.revision === "number" ? before.body.revision : -1; + const beforeConflictKey = typeof before.body?.conflictDetectionKey === "string" ? before.body.conflictDetectionKey : ""; + + await openDocument(page, target.workspaceId, target.documentId); + await waitForRuntimeIsland(page); + await typeIntoEditor(page, expectedText); + + const saveResponse = await page.waitForResponse( + async (response) => { + if (!response.url().includes("/api/documents/save") || response.request().method() !== "POST") return false; + const payload = response.request().postDataJSON(); + return payload?.documentId === target.documentId && response.ok(); + }, + { timeout: UI_TIMEOUT_MS }, + ); + + const { aggregate: after, matchedBlock } = await waitForBodySync( + context.request, + target.workspaceId, + target.documentId, + beforeRevision, + beforeConflictKey, + expectedText, + ); + await page.screenshot({ path: screenshotPath, fullPage: false }); + + const evidence = { + ok: true, + baseUrl: BASE_URL, + viewerUserId: viewer.userId, + workspaceId: target.workspaceId, + documentId: target.documentId, + expectedText, + before: { + revision: before.body?.revision ?? null, + conflictDetectionKey: before.body?.conflictDetectionKey ?? null, + blockCount: before.body?.blockDocument?.blocks?.length ?? null, + }, + after: { + revision: after.body?.revision ?? null, + conflictDetectionKey: after.body?.conflictDetectionKey ?? null, + blockProjectionVersion: after.body?.blockProjectionVersion ?? null, + projectionSource: after.body?.projectionSource ?? null, + blockCount: after.body?.blockDocument?.blocks?.length ?? null, + }, + matchedBlock: { + blockId: matchedBlock.blockId, + type: matchedBlock.type, + text: matchedBlock.text, + revisionRef: matchedBlock.revisionRef, + editable: matchedBlock.editable, + }, + saveResponseStatus: saveResponse.status(), + saveRequests, + screenshotPath, + evidencePath, + }; + await fs.writeFile(evidencePath, JSON.stringify(evidence, null, 2), "utf8"); + console.log(JSON.stringify(evidence, null, 2)); + } finally { + await cleanupDocuments(context.request, createdIds).catch(() => undefined); + await page.close().catch(() => undefined); + await context.close().catch(() => undefined); + await browser.close().catch(() => undefined); + } +} + +main().catch((error) => { + console.error(error instanceof Error ? error.stack || error.message : String(error)); + process.exit(1); +}); diff --git a/scripts/task-page-aggregate-options-sync-smoke.js b/scripts/task-page-aggregate-options-sync-smoke.js new file mode 100644 index 00000000..fb4f990a --- /dev/null +++ b/scripts/task-page-aggregate-options-sync-smoke.js @@ -0,0 +1,253 @@ +#!/usr/bin/env node +"use strict"; + +const assert = require("node:assert"); +const fs = require("node:fs/promises"); +const path = require("node:path"); +const { chromium } = require("playwright"); +const { + BASE_URL, + UI_TIMEOUT_MS, + cleanupDocuments, + createTempDocument, + ensureAuthenticated, + openDocument, + requestJson, +} = require("./tree-shell-smoke-helpers"); + +const OUT_DIR = path.join(process.cwd(), "tmp", "page-aggregate-options-sync-smoke"); + +async function saveDocumentContent(requestContext, workspaceId, documentId) { + await requestJson(requestContext, "/api/documents/save", { + method: "POST", + data: { + workspaceId, + documentId, + content: [ + { + id: "h1", + type: "heading", + props: { level: 1 }, + content: [{ type: "text", text: "Page Aggregate Options Smoke" }], + }, + { + id: "p1", + type: "paragraph", + content: [{ type: "text", text: "Body for page aggregate options smoke." }], + }, + ], + blockCount: 2, + snapshotCapturedAt: new Date().toISOString(), + }, + }); +} + +async function fetchPageAggregate(requestContext, workspaceId, documentId) { + const payload = await requestJson( + requestContext, + `/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 openPageSettingsDialog(page) { + const trigger = page.getByTestId("wolai-page-settings-trigger"); + await trigger.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); + await trigger.click({ timeout: UI_TIMEOUT_MS }); + await page.locator('[data-page-option-checkbox="wideLayout"]').waitFor({ + state: "visible", + timeout: UI_TIMEOUT_MS, + }); +} + +async function readRuntimePageOptions(page) { + return page.evaluate(() => { + const shell = document.querySelector(".document-shell"); + const editorRoot = document.querySelector('[data-testid="mnote-leptos-tiptap-island-editor-root"]'); + const editorSurface = editorRoot?.querySelector(".editor-surface"); + const editor = editorRoot?.querySelector(".ProseMirror"); + const firstParagraph = editorRoot?.querySelector(".ProseMirror p"); + return { + htmlWide: document.documentElement.getAttribute("data-page-wide-layout"), + htmlSmall: document.documentElement.getAttribute("data-page-small-text"), + htmlDensity: document.documentElement.getAttribute("data-layout-density"), + shellWide: shell instanceof HTMLElement ? shell.getAttribute("data-page-wide-layout") : null, + shellSmall: shell instanceof HTMLElement ? shell.getAttribute("data-page-small-text") : null, + shellDensity: shell instanceof HTMLElement ? shell.getAttribute("data-layout-density") : null, + shellMaxWidth: + shell instanceof HTMLElement ? shell.style.maxWidth || window.getComputedStyle(shell).maxWidth : null, + editorRootWide: editorRoot instanceof HTMLElement ? editorRoot.getAttribute("data-page-wide-layout") : null, + editorRootSmall: editorRoot instanceof HTMLElement ? editorRoot.getAttribute("data-page-small-text") : null, + editorSurfaceDensity: + editorSurface instanceof HTMLElement ? editorSurface.getAttribute("data-layout-density") : null, + editorFontSize: editor instanceof HTMLElement ? window.getComputedStyle(editor).fontSize : null, + paragraphMarginBottom: + firstParagraph instanceof HTMLElement ? window.getComputedStyle(firstParagraph).marginBottom : null, + }; + }); +} + +async function waitForOptionsResponse(page, documentId, mutate) { + const responsePromise = page.waitForResponse( + async (response) => { + if (!response.url().includes("/api/documents/options") || response.request().method() !== "POST") { + return false; + } + const payload = response.request().postDataJSON(); + return payload?.documentId === documentId && response.ok(); + }, + { timeout: UI_TIMEOUT_MS }, + ); + await mutate(); + const response = await responsePromise; + const payload = await response.json(); + assert.equal(payload?.meta?.commandName, "page.layout.updateOptions", "页面设置写入必须走 page.layout.updateOptions"); + assert.equal(payload?.meta?.canonicalCommand, "page.layout.updateOptions", "页面设置 canonical command 必须稳定"); + return payload; +} + +async function waitForAggregateOption(requestContext, workspaceId, documentId, assertOptions) { + const deadline = Date.now() + UI_TIMEOUT_MS; + let aggregate = null; + while (Date.now() < deadline) { + aggregate = await fetchPageAggregate(requestContext, workspaceId, documentId); + const options = aggregate?.layout?.pageOptions ?? {}; + if (assertOptions(options)) { + return { aggregate, options }; + } + await new Promise((resolve) => setTimeout(resolve, 300)); + } + throw new Error( + `等待 Page Aggregate pageOptions 同步超时:${JSON.stringify({ + documentId, + lastOptions: aggregate?.layout?.pageOptions ?? null, + })}`, + ); +} + +async function main() { + await fs.mkdir(OUT_DIR, { recursive: true }); + const suffix = Date.now().toString(36); + const evidencePath = path.join(OUT_DIR, `${suffix}.json`); + const screenshotPath = path.join(OUT_DIR, `${suffix}.png`); + const browser = await chromium.launch({ headless: true }); + const context = await browser.newContext({ viewport: { width: 1440, height: 960 } }); + const page = await context.newPage(); + const createdIds = []; + + try { + const viewer = await ensureAuthenticated(page, context.request); + const target = await createTempDocument(context.request, null); + createdIds.push(target.documentId); + await saveDocumentContent(context.request, target.workspaceId, target.documentId); + await openDocument(page, target.workspaceId, target.documentId); + await page.locator(".ProseMirror").waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); + await openPageSettingsDialog(page); + + const initialAggregate = await fetchPageAggregate(context.request, target.workspaceId, target.documentId); + const initialRuntime = await readRuntimePageOptions(page); + assert.equal(initialAggregate.layout.pageOptions.wideLayout, false, "初始 wideLayout 应为 false"); + assert.equal(initialAggregate.layout.pageOptions.smallText, false, "初始 smallText 应为 false"); + assert.equal(initialAggregate.layout.pageOptions.layoutDensity, "normal", "初始 layoutDensity 应为 normal"); + assert.equal(initialRuntime.htmlWide, "false", "初始 runtime wideLayout 应为 false"); + assert.equal(initialRuntime.htmlSmall, "false", "初始 runtime smallText 应为 false"); + assert.equal(initialRuntime.htmlDensity, "normal", "初始 runtime layoutDensity 应为 normal"); + + const wideResponse = await waitForOptionsResponse(page, target.documentId, async () => { + await page.locator('[data-page-option-checkbox="wideLayout"]').click({ timeout: UI_TIMEOUT_MS }); + }); + const wideAggregate = await waitForAggregateOption( + context.request, + target.workspaceId, + target.documentId, + (options) => options.wideLayout === true, + ); + const afterWideRuntime = await readRuntimePageOptions(page); + assert.equal(afterWideRuntime.htmlWide, "true", "开启宽版后 html wideLayout 应为 true"); + assert.equal(afterWideRuntime.editorRootWide, "true", "开启宽版后 island root wideLayout 应为 true"); + assert.equal(afterWideRuntime.shellMaxWidth, "980px", "开启宽版后主列宽应为 980px"); + + const smallResponse = await waitForOptionsResponse(page, target.documentId, async () => { + await page.locator('[data-page-option-checkbox="smallText"]').click({ timeout: UI_TIMEOUT_MS }); + }); + const smallAggregate = await waitForAggregateOption( + context.request, + target.workspaceId, + target.documentId, + (options) => options.wideLayout === true && options.smallText === true, + ); + const afterSmallRuntime = await readRuntimePageOptions(page); + assert.equal(afterSmallRuntime.htmlSmall, "true", "开启小字体后 html smallText 应为 true"); + assert.equal(afterSmallRuntime.editorRootSmall, "true", "开启小字体后 island root smallText 应为 true"); + assert( + typeof initialRuntime.editorFontSize === "string" && + typeof afterSmallRuntime.editorFontSize === "string" && + parseFloat(afterSmallRuntime.editorFontSize) < parseFloat(initialRuntime.editorFontSize), + `开启小字体后编辑器字号应变小,初始 ${initialRuntime.editorFontSize},实际 ${afterSmallRuntime.editorFontSize}`, + ); + + const densityResponse = await waitForOptionsResponse(page, target.documentId, async () => { + await page.locator('[data-page-settings-tab="custom"]').click({ timeout: UI_TIMEOUT_MS }); + await page.locator('[data-page-option-select="layoutDensity"]').selectOption("compact"); + }); + const densityAggregate = await waitForAggregateOption( + context.request, + target.workspaceId, + target.documentId, + (options) => options.wideLayout === true && options.smallText === true && options.layoutDensity === "compact", + ); + const afterDensityRuntime = await readRuntimePageOptions(page); + assert.equal(afterDensityRuntime.htmlDensity, "compact", "切换紧凑后 html density 应为 compact"); + assert.equal(afterDensityRuntime.editorSurfaceDensity, "compact", "切换紧凑后 island surface density 应为 compact"); + assert( + typeof initialRuntime.paragraphMarginBottom === "string" && + typeof afterDensityRuntime.paragraphMarginBottom === "string" && + parseFloat(afterDensityRuntime.paragraphMarginBottom) <= parseFloat(initialRuntime.paragraphMarginBottom), + `切换紧凑后段落间距应不大于初始值,初始 ${initialRuntime.paragraphMarginBottom},实际 ${afterDensityRuntime.paragraphMarginBottom}`, + ); + + await page.screenshot({ path: screenshotPath, fullPage: false }); + const evidence = { + ok: true, + baseUrl: BASE_URL, + viewerUserId: viewer.userId, + workspaceId: target.workspaceId, + documentId: target.documentId, + initial: { + aggregate: initialAggregate.layout.pageOptions, + runtime: initialRuntime, + }, + afterWide: { + aggregate: wideAggregate.options, + runtime: afterWideRuntime, + commandName: wideResponse.meta.commandName, + }, + afterSmall: { + aggregate: smallAggregate.options, + runtime: afterSmallRuntime, + commandName: smallResponse.meta.commandName, + }, + afterDensity: { + aggregate: densityAggregate.options, + runtime: afterDensityRuntime, + commandName: densityResponse.meta.commandName, + }, + screenshotPath, + evidencePath, + }; + await fs.writeFile(evidencePath, JSON.stringify(evidence, null, 2), "utf8"); + console.log(JSON.stringify(evidence, null, 2)); + } finally { + await cleanupDocuments(context.request, createdIds).catch(() => undefined); + await page.close().catch(() => undefined); + await context.close().catch(() => undefined); + await browser.close().catch(() => undefined); + } +} + +main().catch((error) => { + console.error(error instanceof Error ? error.stack || error.message : String(error)); + process.exit(1); +}); diff --git a/scripts/task-page-aggregate-refresh-persistence-smoke.js b/scripts/task-page-aggregate-refresh-persistence-smoke.js new file mode 100644 index 00000000..49451d85 --- /dev/null +++ b/scripts/task-page-aggregate-refresh-persistence-smoke.js @@ -0,0 +1,428 @@ +#!/usr/bin/env node +"use strict"; + +const assert = require("node:assert"); +const fs = require("node:fs/promises"); +const path = require("node:path"); +const { chromium } = require("playwright"); +const { + BASE_URL, + UI_TIMEOUT_MS, + cleanupDocuments, + createTempDocument, + ensureAuthenticated, + openDocument, + requestJson, +} = require("./tree-shell-smoke-helpers"); + +const OUT_DIR = path.join(process.cwd(), "tmp", "page-aggregate-refresh-persistence-smoke"); + +async function fetchPageAggregate(requestContext, workspaceId, documentId) { + const payload = await requestJson( + requestContext, + `/api/page-aggregate/${encodeURIComponent(documentId)}?workspaceId=${encodeURIComponent(workspaceId)}`, + { method: "GET" }, + ); + assert.equal(payload.schema, "mnote.page_aggregate.v1", "Page Aggregate schema 应保持稳定"); + return payload.result; +} + +function findBlockWithText(aggregate, text) { + const blocks = aggregate?.body?.blockDocument?.blocks; + if (!Array.isArray(blocks)) return null; + return blocks.find((block) => typeof block?.text === "string" && block.text.includes(text)) ?? null; +} + +async function waitForRuntimeIsland(page) { + await page.waitForFunction( + () => { + const host = document.querySelector('[data-testid="mnote-leptos-tiptap-island-editor-root"]'); + const editor = host?.querySelector(".editor-surface .ProseMirror[contenteditable='true']"); + return ( + host?.getAttribute("data-editor-host-kind") === "leptos_tiptap_island" && + host?.getAttribute("data-runtime-editor-status") !== "error" && + editor instanceof HTMLElement && + editor.isContentEditable + ); + }, + null, + { timeout: UI_TIMEOUT_MS }, + ); +} + +async function waitForPageTitleInput(page) { + const input = page.locator('[data-page-title-input="true"][data-pane-role="primary"]').first(); + await input.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); + return input; +} + +async function readVisibleTitle(page) { + const titleInput = page.locator('[data-page-title-input="true"][data-pane-role="primary"]').first(); + if (await titleInput.isVisible().catch(() => false)) { + return (await titleInput.inputValue()).trim(); + } + const heading = page.locator("h1").first(); + return ((await heading.textContent()) ?? "").trim(); +} + +async function readEditorText(page) { + return page.evaluate(() => { + const editor = document.querySelector( + '[data-testid="mnote-leptos-tiptap-island-editor-root"] .editor-surface .ProseMirror', + ); + return (editor?.textContent ?? "").trim(); + }); +} + +async function renameThroughPageHead(page, documentId, title) { + const titleInput = await waitForPageTitleInput(page); + const responsePromise = page.waitForResponse( + async (response) => { + if (!response.url().includes("/api/documents/title") || response.request().method() !== "POST") { + return false; + } + const payload = response.request().postDataJSON(); + return ( + payload?.documentId === documentId && + payload?.title === title && + payload?.commandName === "page.head.updateTitle" && + response.ok() + ); + }, + { timeout: UI_TIMEOUT_MS }, + ); + await titleInput.click({ timeout: UI_TIMEOUT_MS }); + await page.keyboard.press("Control+a"); + await page.keyboard.type(title, { delay: 10 }); + await titleInput.blur(); + const response = await responsePromise; + return response.status(); +} + +async function typeIntoEditor(page, documentId, text) { + const responsePromise = page.waitForResponse( + async (response) => { + if (!response.url().includes("/api/documents/save") || response.request().method() !== "POST") { + return false; + } + const payload = response.request().postDataJSON(); + return payload?.documentId === documentId && response.ok(); + }, + { timeout: UI_TIMEOUT_MS }, + ); + const editor = page + .locator('[data-testid="mnote-leptos-tiptap-island-editor-root"] .editor-surface .ProseMirror[contenteditable="true"]') + .first(); + await editor.click({ timeout: UI_TIMEOUT_MS }); + await page.keyboard.press("Control+a"); + await page.keyboard.press("Backspace"); + await page.keyboard.type(text, { delay: 10 }); + const response = await responsePromise; + return response.status(); +} + +async function openPageSettingsDialog(page) { + const trigger = page.getByTestId("wolai-page-settings-trigger"); + await trigger.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); + await trigger.click({ timeout: UI_TIMEOUT_MS }); + await page.locator('[data-page-option-checkbox="wideLayout"]').waitFor({ + state: "visible", + timeout: UI_TIMEOUT_MS, + }); +} + +async function waitForOptionsResponse(page, documentId, mutate) { + const responsePromise = page.waitForResponse( + async (response) => { + if (!response.url().includes("/api/documents/options") || response.request().method() !== "POST") { + return false; + } + const payload = response.request().postDataJSON(); + return payload?.documentId === documentId && response.ok(); + }, + { timeout: UI_TIMEOUT_MS }, + ); + await mutate(); + const response = await responsePromise; + const payload = await response.json(); + assert.equal(payload?.meta?.commandName, "page.layout.updateOptions", "页面设置写入必须走 page.layout.updateOptions"); + assert.equal(payload?.meta?.canonicalCommand, "page.layout.updateOptions", "页面设置 canonical command 必须稳定"); + return response.status(); +} + +async function setPageOptionsThroughUi(page, documentId) { + await openPageSettingsDialog(page); + const wideStatus = await waitForOptionsResponse(page, documentId, async () => { + await page.locator('[data-page-option-checkbox="wideLayout"]').click({ timeout: UI_TIMEOUT_MS }); + }); + const smallStatus = await waitForOptionsResponse(page, documentId, async () => { + await page.locator('[data-page-option-checkbox="smallText"]').click({ timeout: UI_TIMEOUT_MS }); + }); + const densityStatus = await waitForOptionsResponse(page, documentId, async () => { + await page.locator('[data-page-settings-tab="custom"]').click({ timeout: UI_TIMEOUT_MS }); + await page.locator('[data-page-option-select="layoutDensity"]').selectOption("compact"); + }); + return { wideStatus, smallStatus, densityStatus }; +} + +async function readRuntimePageOptions(page) { + return page.evaluate(() => { + const shell = document.querySelector(".document-shell"); + const editorRoot = document.querySelector('[data-testid="mnote-leptos-tiptap-island-editor-root"]'); + const editorSurface = editorRoot?.querySelector(".editor-surface"); + const editor = editorRoot?.querySelector(".ProseMirror"); + const firstParagraph = editorRoot?.querySelector(".ProseMirror p"); + return { + htmlWide: document.documentElement.getAttribute("data-page-wide-layout"), + htmlSmall: document.documentElement.getAttribute("data-page-small-text"), + htmlDensity: document.documentElement.getAttribute("data-layout-density"), + shellWide: shell instanceof HTMLElement ? shell.getAttribute("data-page-wide-layout") : null, + shellSmall: shell instanceof HTMLElement ? shell.getAttribute("data-page-small-text") : null, + shellDensity: shell instanceof HTMLElement ? shell.getAttribute("data-layout-density") : null, + editorRootWide: editorRoot instanceof HTMLElement ? editorRoot.getAttribute("data-page-wide-layout") : null, + editorRootSmall: editorRoot instanceof HTMLElement ? editorRoot.getAttribute("data-page-small-text") : null, + editorSurfaceDensity: + editorSurface instanceof HTMLElement ? editorSurface.getAttribute("data-layout-density") : null, + editorFontSize: editor instanceof HTMLElement ? window.getComputedStyle(editor).fontSize : null, + paragraphMarginBottom: + firstParagraph instanceof HTMLElement ? window.getComputedStyle(firstParagraph).marginBottom : null, + }; + }); +} + +async function readPageSettingsControls(page) { + await openPageSettingsDialog(page); + return { + wideLayout: await page.locator('[data-page-option-checkbox="wideLayout"]').isChecked(), + smallText: await page.locator('[data-page-option-checkbox="smallText"]').isChecked(), + layoutDensity: await page.locator('[data-page-option-select="layoutDensity"]').inputValue(), + }; +} + +async function waitForRuntimePageOptions(page) { + try { + await page.waitForFunction( + () => { + const shell = document.querySelector(".document-shell"); + const editorRoot = document.querySelector('[data-testid="mnote-leptos-tiptap-island-editor-root"]'); + const editorSurface = editorRoot?.querySelector(".editor-surface"); + return ( + document.documentElement.getAttribute("data-page-wide-layout") === "true" && + document.documentElement.getAttribute("data-page-small-text") === "true" && + document.documentElement.getAttribute("data-layout-density") === "compact" && + shell?.getAttribute("data-page-wide-layout") === "true" && + shell?.getAttribute("data-page-small-text") === "true" && + shell?.getAttribute("data-layout-density") === "compact" && + editorRoot?.getAttribute("data-page-wide-layout") === "true" && + editorRoot?.getAttribute("data-page-small-text") === "true" && + editorSurface?.getAttribute("data-layout-density") === "compact" + ); + }, + null, + { timeout: UI_TIMEOUT_MS }, + ); + } catch (error) { + const runtime = await readRuntimePageOptions(page).catch((runtimeError) => ({ + readRuntimeError: runtimeError instanceof Error ? runtimeError.message : String(runtimeError), + })); + const embedded = await page.evaluate(() => { + const node = document.getElementById("__MNOTE_PAGE_AGGREGATE__"); + try { + return node?.textContent ? JSON.parse(node.textContent) : null; + } catch (parseError) { + return { parseError: parseError instanceof Error ? parseError.message : String(parseError) }; + } + }).catch((embeddedError) => ({ + readEmbeddedError: embeddedError instanceof Error ? embeddedError.message : String(embeddedError), + })); + throw new Error( + `等待 runtime page options 同步超时:${JSON.stringify({ runtime, embeddedPageOptions: embedded?.layout?.pageOptions ?? null })}\n${ + error instanceof Error ? error.stack || error.message : String(error) + }`, + ); + } +} + +async function waitForAggregateState(requestContext, workspaceId, documentId, expectedTitle, expectedText) { + const deadline = Date.now() + UI_TIMEOUT_MS; + let aggregate = null; + while (Date.now() < deadline) { + aggregate = await fetchPageAggregate(requestContext, workspaceId, documentId); + const options = aggregate?.layout?.pageOptions ?? {}; + const matchedBlock = findBlockWithText(aggregate, expectedText); + if ( + aggregate?.head?.title === expectedTitle && + matchedBlock && + options.wideLayout === true && + options.smallText === true && + options.layoutDensity === "compact" + ) { + return { aggregate, matchedBlock }; + } + await new Promise((resolve) => setTimeout(resolve, 350)); + } + throw new Error( + `等待 Page Aggregate 刷新持久态同步超时:${JSON.stringify({ + documentId, + expectedTitle, + expectedText, + lastTitle: aggregate?.head?.title ?? null, + lastOptions: aggregate?.layout?.pageOptions ?? null, + hasExpectedBlock: Boolean(aggregate && findBlockWithText(aggregate, expectedText)), + })}`, + ); +} + +function assertRuntimeOptions(runtime, label) { + assert.equal(runtime.htmlWide, "true", `${label} html wideLayout 应保持 true`); + assert.equal(runtime.htmlSmall, "true", `${label} html smallText 应保持 true`); + assert.equal(runtime.htmlDensity, "compact", `${label} html layoutDensity 应保持 compact`); + assert.equal(runtime.editorRootWide, "true", `${label} island root wideLayout 应保持 true`); + assert.equal(runtime.editorRootSmall, "true", `${label} island root smallText 应保持 true`); + assert.equal(runtime.editorSurfaceDensity, "compact", `${label} island surface density 应保持 compact`); +} + +async function main() { + await fs.mkdir(OUT_DIR, { recursive: true }); + const suffix = Date.now().toString(36); + const title = `Page Aggregate refresh ${suffix}`; + 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 context = await browser.newContext({ viewport: { width: 1440, height: 960 } }); + const page = await context.newPage(); + const createdIds = []; + + try { + const viewer = await ensureAuthenticated(page, context.request); + const target = await createTempDocument(context.request, null); + createdIds.push(target.documentId); + + await openDocument(page, target.workspaceId, target.documentId); + await waitForPageTitleInput(page); + await waitForRuntimeIsland(page); + + const titleStatus = await renameThroughPageHead(page, target.documentId, title); + assert((await readVisibleTitle(page)).includes(title), "写入后页头标题应立即显示最新值"); + + const saveStatus = await typeIntoEditor(page, target.documentId, bodyText); + await page.waitForFunction( + (expectedText) => (document.querySelector(".editor-surface .ProseMirror")?.textContent ?? "").includes(expectedText), + bodyText, + { timeout: UI_TIMEOUT_MS }, + ); + + const optionStatuses = await setPageOptionsThroughUi(page, target.documentId); + await waitForRuntimePageOptions(page); + const beforeReloadRuntime = await readRuntimePageOptions(page); + assertRuntimeOptions(beforeReloadRuntime, "刷新前"); + + const { aggregate: beforeReloadAggregate, matchedBlock: beforeReloadBlock } = await waitForAggregateState( + context.request, + target.workspaceId, + target.documentId, + title, + bodyText, + ); + + await page.reload({ waitUntil: "commit", timeout: UI_TIMEOUT_MS }); + await waitForPageTitleInput(page); + await waitForRuntimeIsland(page); + await page.waitForFunction( + (expectedText) => (document.querySelector(".editor-surface .ProseMirror")?.textContent ?? "").includes(expectedText), + bodyText, + { timeout: UI_TIMEOUT_MS }, + ); + await waitForRuntimePageOptions(page); + + const afterReloadTitle = await readVisibleTitle(page); + const afterReloadEditorText = await readEditorText(page); + const afterReloadRuntime = await readRuntimePageOptions(page); + assert(afterReloadTitle.includes(title), "刷新后页头标题不应回退旧快照"); + assert(afterReloadEditorText.includes(bodyText), "刷新后正文不应回退旧快照"); + assertRuntimeOptions(afterReloadRuntime, "刷新后"); + const afterReloadControls = await readPageSettingsControls(page); + assert.equal(afterReloadControls.wideLayout, true, "刷新后页面设置 wideLayout 控件应保持 true"); + assert.equal(afterReloadControls.smallText, true, "刷新后页面设置 smallText 控件应保持 true"); + assert.equal(afterReloadControls.layoutDensity, "compact", "刷新后页面设置 layoutDensity 控件应保持 compact"); + + const { aggregate: afterReloadAggregate, matchedBlock: afterReloadBlock } = await waitForAggregateState( + context.request, + target.workspaceId, + target.documentId, + title, + bodyText, + ); + + await page.screenshot({ path: screenshotPath, fullPage: false }); + const evidence = { + ok: true, + baseUrl: BASE_URL, + viewerUserId: viewer.userId, + workspaceId: target.workspaceId, + documentId: target.documentId, + expected: { + title, + bodyText, + pageOptions: { + wideLayout: true, + smallText: true, + layoutDensity: "compact", + }, + }, + commandStatuses: { + titleStatus, + saveStatus, + ...optionStatuses, + }, + beforeReload: { + aggregate: { + title: beforeReloadAggregate.head?.title ?? null, + revision: beforeReloadAggregate.body?.revision ?? null, + conflictDetectionKey: beforeReloadAggregate.body?.conflictDetectionKey ?? null, + pageOptions: beforeReloadAggregate.layout?.pageOptions ?? null, + blockProjectionVersion: beforeReloadAggregate.body?.blockProjectionVersion ?? null, + }, + matchedBlock: { + blockId: beforeReloadBlock.blockId, + text: beforeReloadBlock.text, + revisionRef: beforeReloadBlock.revisionRef, + }, + runtime: beforeReloadRuntime, + }, + afterReload: { + aggregate: { + title: afterReloadAggregate.head?.title ?? null, + revision: afterReloadAggregate.body?.revision ?? null, + conflictDetectionKey: afterReloadAggregate.body?.conflictDetectionKey ?? null, + pageOptions: afterReloadAggregate.layout?.pageOptions ?? null, + blockProjectionVersion: afterReloadAggregate.body?.blockProjectionVersion ?? null, + }, + matchedBlock: { + blockId: afterReloadBlock.blockId, + text: afterReloadBlock.text, + revisionRef: afterReloadBlock.revisionRef, + }, + title: afterReloadTitle, + editorText: afterReloadEditorText, + runtime: afterReloadRuntime, + controls: afterReloadControls, + }, + screenshotPath, + evidencePath, + }; + await fs.writeFile(evidencePath, JSON.stringify(evidence, null, 2), "utf8"); + console.log(JSON.stringify(evidence, null, 2)); + } finally { + await cleanupDocuments(context.request, createdIds).catch(() => undefined); + await page.close().catch(() => undefined); + await context.close().catch(() => undefined); + await browser.close().catch(() => undefined); + } +} + +main().catch((error) => { + console.error(error instanceof Error ? error.stack || error.message : String(error)); + process.exit(1); +}); diff --git a/scripts/task-page-ai-apply-block-ops-real-smoke.js b/scripts/task-page-ai-apply-block-ops-real-smoke.js new file mode 100644 index 00000000..b9dfa2e5 --- /dev/null +++ b/scripts/task-page-ai-apply-block-ops-real-smoke.js @@ -0,0 +1,235 @@ +#!/usr/bin/env node +"use strict"; + +const assert = require("node:assert"); +const fs = require("node:fs/promises"); +const path = require("node:path"); +const { chromium } = require("playwright"); +const { + BASE_URL, + UI_TIMEOUT_MS, + cleanupDocuments, + createTempDocument, + ensureAuthenticated, + openDocument, + renameDocument, + requestJson, +} = require("./tree-shell-smoke-helpers"); + +const OUT_DIR = path.join(process.cwd(), "tmp", "page-ai-apply-block-ops-real-smoke"); +const HERMES_SESSION_ROOTS = [ + "/home/lix/.hermes/profiles/mnoteai/sessions", + "/home/lix/.hermes/sessions", +]; + +async function listRecentHermesSessions(sinceMs) { + const rows = []; + for (const root of HERMES_SESSION_ROOTS) { + let entries = []; + try { + entries = await fs.readdir(root, { withFileTypes: true }); + } catch { + continue; + } + for (const entry of entries) { + if (!entry.isFile() || !entry.name.startsWith("session_") || !entry.name.endsWith(".json")) { + continue; + } + const filePath = path.join(root, entry.name); + const stat = await fs.stat(filePath).catch(() => null); + if (!stat || stat.mtimeMs < sinceMs) continue; + const content = await fs.readFile(filePath, "utf8").catch(() => ""); + const parsed = JSON.parse(content || "{}"); + rows.push({ + path: filePath, + mtimeMs: stat.mtimeMs, + model: parsed.model || "", + platform: parsed.platform || "", + toolCount: Array.isArray(parsed.tools) ? parsed.tools.length : 0, + toolNames: Array.isArray(parsed.tools) + ? parsed.tools.map((tool) => tool?.function?.name || tool?.name).filter(Boolean) + : [], + messageCount: parsed.message_count || parsed.messageCount || 0, + hasApplyBlockOps: content.includes("mnote_doc_apply_block_ops") || content.includes("mnote.doc.apply_block_ops"), + }); + } + } + return rows.sort((a, b) => b.mtimeMs - a.mtimeMs); +} + +async function callMnoteTool(request, payload) { + return requestJson(request, "/api/hermes/tools/mnote/call", { + method: "POST", + headers: { "x-mnote-actor-id": payload.actorId || "smoke-user" }, + data: payload, + }); +} + +async function fetchBlocks(request, target, suffix, actorId) { + const response = await callMnoteTool(request, { + toolName: "mnote.doc.fetch", + workspaceId: target.workspaceId, + documentId: target.documentId, + actorId, + sessionId: `sess_fetch_${suffix}`, + runId: `run_fetch_${suffix}_${Date.now().toString(36)}`, + toolCallId: `call_fetch_${suffix}_${Date.now().toString(36)}`, + traceId: `trace_fetch_${suffix}`, + capabilityScope: ["page.read"], + args: { + scope: "full", + detail: "with_ids", + maxBlocks: 20, + }, + }); + assert.equal(response.ok, true, "doc.fetch 应成功"); + assert(Array.isArray(response.result.blocks), "doc.fetch 应返回 blocks"); + return response.result.blocks.map((block) => block.text); +} + +async function main() { + const suffix = Date.now().toString(36); + const title = `TEST-PAGE-AI-APPLY-OPS-${suffix}`; + const createdIds = []; + const evidence = { + ok: false, + baseUrl: BASE_URL, + title, + profile: "mnoteai", + timingsMs: {}, + requests: [], + }; + await fs.mkdir(OUT_DIR, { recursive: true }); + + const browser = await chromium.launch({ headless: true }); + const context = await browser.newContext({ viewport: { width: 1440, height: 960 }, locale: "zh-CN" }); + const page = await context.newPage(); + + page.on("request", (request) => { + const url = request.url(); + if (!url.includes("/api/hermes/client/")) return; + evidence.requests.push({ + method: request.method(), + url: url.replace(BASE_URL, ""), + postData: request.postDataJSON?.() || null, + atMs: Date.now(), + }); + }); + + const startedAt = Date.now(); + try { + const viewer = await ensureAuthenticated(page, context.request); + const actorId = viewer.userId; + const target = await createTempDocument(context.request); + createdIds.push(target.documentId); + evidence.documentId = target.documentId; + evidence.workspaceId = target.workspaceId; + await renameDocument(context.request, target.workspaceId, target.documentId, title); + + const seedContent = [ + { 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}` }] }, + ]; + const seedStart = Date.now(); + const seed = await callMnoteTool(context.request, { + toolName: "mnote.page.save", + workspaceId: target.workspaceId, + documentId: target.documentId, + actorId, + sessionId: `sess_seed_${suffix}`, + runId: `run_seed_${suffix}`, + toolCallId: `call_seed_${suffix}`, + traceId: `trace_seed_${suffix}`, + idempotencyKey: `idem_seed_${suffix}`, + dryRun: false, + capabilityScope: ["page.write"], + args: { mode: "replace", content: seedContent }, + }); + assert.equal(seed.ok, true, "初始化 page.save 应成功"); + evidence.timingsMs.seed = Date.now() - seedStart; + + const openStart = Date.now(); + await openDocument(page, target.workspaceId, target.documentId); + evidence.timingsMs.openDocument = Date.now() - openStart; + + const health = await requestJson(context.request, "/api/hermes/client/gateway/health?profile=mnoteai", { + method: "GET", + }); + evidence.gatewayHealth = health; + assert.equal(health.gateway?.ok, true, `mnoteai gateway health 应为 ok: ${JSON.stringify(health)}`); + assert(String(health.gateway?.upstream || "").includes(":8644"), "mnoteai profile 应路由到 8644 gateway"); + + await page.getByTestId("wolai-floating-ai").click({ timeout: UI_TIMEOUT_MS }); + await page.locator('.wolai-page-ai-icon[data-page-ai-tab="agent"]').click({ timeout: UI_TIMEOUT_MS }); + await page.locator("[data-page-ai-profile-select]").selectOption("mnoteai", { timeout: UI_TIMEOUT_MS }); + await page.waitForFunction( + () => document.documentElement.getAttribute("data-mnote-page-ai-profile") === "mnoteai", + null, + { timeout: UI_TIMEOUT_MS }, + ); + await page.locator('.wolai-page-ai-panel-section:not([hidden]) [data-page-ai-tab="chat"]').click({ + timeout: UI_TIMEOUT_MS, + }); + + const prompt = + `请使用 mnote_doc_apply_block_ops 一次完成三件事并回读验证:` + + `把「第二段 ${suffix}」替换为「第二段已修改 ${suffix}」;` + + `在「第一段 ${suffix}」后插入「插入段 ${suffix}」;` + + `删除「第三段 ${suffix}」。只简短回复结果。`; + const aiStart = Date.now(); + await page.locator("[data-page-ai-input]").fill(prompt, { timeout: UI_TIMEOUT_MS }); + await page.locator('[data-page-ai-action="send"]').click({ timeout: UI_TIMEOUT_MS }); + + let finalTexts = []; + const writeDeadline = Date.now() + Number(process.env.MNOTE_PAGE_AI_REAL_TIMEOUT_MS || 120_000); + while (Date.now() < writeDeadline) { + finalTexts = await fetchBlocks(context.request, target, suffix, actorId); + if ( + finalTexts.includes(`第二段已修改 ${suffix}`) && + finalTexts.includes(`插入段 ${suffix}`) && + !finalTexts.includes(`第三段 ${suffix}`) + ) { + break; + } + await page.waitForTimeout(1000); + } + assert(finalTexts.includes(`第二段已修改 ${suffix}`), "回读未包含替换后的文本"); + assert(finalTexts.includes(`插入段 ${suffix}`), "回读未包含插入文本"); + assert(!finalTexts.includes(`第三段 ${suffix}`), "回读仍包含应删除文本"); + evidence.timingsMs.pageAiWriteVisible = Date.now() - aiStart; + + await page.waitForFunction( + () => ["completed", "failed"].includes(document.documentElement.getAttribute("data-mnote-page-ai-run-status") || ""), + null, + { timeout: Number(process.env.MNOTE_PAGE_AI_REAL_TIMEOUT_MS || 120_000) }, + ).catch(() => undefined); + evidence.pageAiRunStatus = await page.evaluate(() => + document.documentElement.getAttribute("data-mnote-page-ai-run-status") || "", + ); + evidence.timingsMs.pageAiRunSettled = Date.now() - aiStart; + + evidence.finalTexts = finalTexts; + + evidence.conversationText = await page + .locator('.wolai-page-ai-panel-section:not([hidden]) [data-page-ai-conversation]') + .textContent({ timeout: UI_TIMEOUT_MS }); + evidence.screenshot = path.join(OUT_DIR, `${suffix}.png`); + await page.screenshot({ path: evidence.screenshot, fullPage: true }); + evidence.hermesSessions = await listRecentHermesSessions(startedAt); + evidence.ok = true; + + const evidencePath = path.join(OUT_DIR, `${suffix}.json`); + await fs.writeFile(evidencePath, JSON.stringify(evidence, null, 2), "utf8"); + console.log(JSON.stringify({ ...evidence, evidencePath }, null, 2)); + } finally { + await cleanupDocuments(context.request, createdIds).catch(() => undefined); + await context.close().catch(() => undefined); + await browser.close().catch(() => undefined); + } +} + +main().catch((error) => { + console.error(error instanceof Error ? error.stack || error.message : String(error)); + process.exit(1); +}); diff --git a/scripts/task-page-ai-block-edit-workflow-smoke.js b/scripts/task-page-ai-block-edit-workflow-smoke.js new file mode 100644 index 00000000..b43c468f --- /dev/null +++ b/scripts/task-page-ai-block-edit-workflow-smoke.js @@ -0,0 +1,199 @@ +#!/usr/bin/env node +"use strict"; + +const assert = require("node:assert"); +const fs = require("node:fs/promises"); +const path = require("node:path"); +const { chromium } = require("playwright"); +const { + BASE_URL, + UI_TIMEOUT_MS, + cleanupDocuments, + createTempDocument, + ensureAuthenticated, + openDocument, + renameDocument, + requestJson, +} = require("./tree-shell-smoke-helpers"); + +const OUT_DIR = path.join(process.cwd(), "tmp", "page-ai-block-edit-workflow-smoke"); + +async function callMnoteTool(request, payload) { + return requestJson(request, "/api/hermes/tools/mnote/call", { + method: "POST", + headers: { "x-mnote-actor-id": payload.actorId || "smoke-user" }, + data: payload, + }); +} + +async function fetchBlocks(request, target, suffix, actorId) { + const response = await callMnoteTool(request, { + toolName: "mnote.doc.fetch", + workspaceId: target.workspaceId, + documentId: target.documentId, + actorId, + sessionId: `sess_fetch_${suffix}`, + runId: `run_fetch_${suffix}_${Date.now().toString(36)}`, + toolCallId: `call_fetch_${suffix}_${Date.now().toString(36)}`, + traceId: `trace_fetch_${suffix}`, + capabilityScope: ["page.read"], + args: { + scope: "full", + detail: "with_ids", + maxBlocks: 20, + }, + }); + assert.equal(response.ok, true, "doc.fetch 应成功"); + return response.result.blocks.map((block) => block.text); +} + +async function main() { + const suffix = Date.now().toString(36); + const title = `TEST-PAGE-AI-FAST-BLOCK-${suffix}`; + const createdIds = []; + const evidence = { + ok: false, + baseUrl: BASE_URL, + title, + timingsMs: {}, + requests: [], + }; + await fs.mkdir(OUT_DIR, { recursive: true }); + + const browser = await chromium.launch({ headless: true }); + const context = await browser.newContext({ viewport: { width: 1440, height: 960 }, locale: "zh-CN" }); + const page = await context.newPage(); + + page.on("request", (request) => { + const url = request.url(); + if (!url.includes("/api/page-ai/") && !url.includes("/api/hermes/client/")) return; + evidence.requests.push({ + method: request.method(), + url: url.replace(BASE_URL, ""), + atMs: Date.now(), + }); + }); + page.on("response", async (response) => { + const url = response.url(); + if (!url.includes("/api/page-ai/") && !url.includes("/api/hermes/client/")) return; + const entry = { + method: response.request().method(), + url: url.replace(BASE_URL, ""), + status: response.status(), + atMs: Date.now(), + }; + const contentType = response.headers()["content-type"] || ""; + if (contentType.includes("application/json")) { + entry.body = await response.json().catch(() => null); + } + evidence.responses = evidence.responses || []; + evidence.responses.push(entry); + }); + + try { + const viewer = await ensureAuthenticated(page, context.request); + const actorId = viewer.userId; + const target = await createTempDocument(context.request); + createdIds.push(target.documentId); + evidence.documentId = target.documentId; + evidence.workspaceId = target.workspaceId; + await renameDocument(context.request, target.workspaceId, target.documentId, title); + + const seed = await callMnoteTool(context.request, { + toolName: "mnote.page.save", + workspaceId: target.workspaceId, + documentId: target.documentId, + actorId, + sessionId: `sess_seed_${suffix}`, + runId: `run_seed_${suffix}`, + toolCallId: `call_seed_${suffix}`, + traceId: `trace_seed_${suffix}`, + idempotencyKey: `idem_seed_${suffix}`, + dryRun: false, + capabilityScope: ["page.write"], + args: { + mode: "replace", + content: [ + { 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}` }] }, + ], + }, + }); + assert.equal(seed.ok, true, "初始化 page.save 应成功"); + + await openDocument(page, target.workspaceId, target.documentId); + await page.getByTestId("wolai-floating-ai").click({ timeout: UI_TIMEOUT_MS }); + await page.locator('.wolai-page-ai-icon[data-page-ai-tab="agent"]').click({ timeout: UI_TIMEOUT_MS }); + await page.locator("[data-page-ai-profile-select]").selectOption("mnoteai", { timeout: UI_TIMEOUT_MS }); + await page.locator('.wolai-page-ai-panel-section:not([hidden]) [data-page-ai-tab="chat"]').click({ + timeout: UI_TIMEOUT_MS, + }); + + const prompt = + `把「第二段 ${suffix}」替换为「第二段已修改 ${suffix}」;` + + `在「第一段 ${suffix}」后插入「插入段 ${suffix}」;` + + `删除「第三段 ${suffix}」。只简短回复结果。`; + const aiStart = Date.now(); + await page.locator("[data-page-ai-input]").fill(prompt, { timeout: UI_TIMEOUT_MS }); + await page.locator('[data-page-ai-action="send"]').click({ timeout: UI_TIMEOUT_MS }); + + let finalTexts = []; + const writeDeadline = Date.now() + Number(process.env.MNOTE_PAGE_AI_FAST_TIMEOUT_MS || 60_000); + while (Date.now() < writeDeadline) { + finalTexts = await fetchBlocks(context.request, target, suffix, actorId); + if ( + finalTexts.includes(`第二段已修改 ${suffix}`) && + finalTexts.includes(`插入段 ${suffix}`) && + !finalTexts.includes(`第三段 ${suffix}`) + ) { + break; + } + await page.waitForTimeout(500); + } + evidence.timingsMs.pageAiWriteVisible = Date.now() - aiStart; + evidence.finalTexts = finalTexts; + assert(finalTexts.includes(`第二段已修改 ${suffix}`), "回读未包含替换后的文本"); + assert(finalTexts.includes(`插入段 ${suffix}`), "回读未包含插入文本"); + assert(!finalTexts.includes(`第三段 ${suffix}`), "回读仍包含应删除文本"); + + await page.waitForFunction( + () => ["completed", "failed"].includes(document.documentElement.getAttribute("data-mnote-page-ai-run-status") || ""), + null, + { timeout: Number(process.env.MNOTE_PAGE_AI_FAST_TIMEOUT_MS || 60_000) }, + ).catch(() => undefined); + evidence.pageAiRunStatus = await page.evaluate(() => + document.documentElement.getAttribute("data-mnote-page-ai-run-status") || "", + ); + evidence.conversationText = await page + .locator('.wolai-page-ai-panel-section:not([hidden]) [data-page-ai-conversation]') + .textContent({ timeout: UI_TIMEOUT_MS }); + evidence.usedFastWorkflow = evidence.requests.some((request) => + request.url.includes("/api/page-ai/block-edit-workflow"), + ); + evidence.usedHermesRun = evidence.requests.some((request) => + request.url.includes("/api/hermes/client/runs"), + ); + assert.equal(evidence.usedFastWorkflow, true, "页面 AI 应调用 block-edit-workflow 快路径"); + assert.equal(evidence.usedHermesRun, false, "块编辑快路径成功时不应进入 Hermes agent run"); + + evidence.screenshot = path.join(OUT_DIR, `${suffix}.png`); + await page.screenshot({ path: evidence.screenshot, fullPage: true }); + evidence.ok = true; + const evidencePath = path.join(OUT_DIR, `${suffix}.json`); + await fs.writeFile(evidencePath, JSON.stringify(evidence, null, 2), "utf8"); + console.log(JSON.stringify({ ...evidence, evidencePath }, null, 2)); + } finally { + const evidencePath = path.join(OUT_DIR, `${suffix}.json`); + evidence.evidencePath = evidencePath; + await fs.writeFile(evidencePath, JSON.stringify(evidence, null, 2), "utf8").catch(() => undefined); + await cleanupDocuments(context.request, createdIds).catch(() => undefined); + await context.close().catch(() => undefined); + await browser.close().catch(() => undefined); + } +} + +main().catch((error) => { + console.error(error instanceof Error ? error.stack || error.message : String(error)); + process.exit(1); +}); diff --git a/scripts/task-page-block-ai-conflict-idempotency-smoke.js b/scripts/task-page-block-ai-conflict-idempotency-smoke.js new file mode 100644 index 00000000..e9a771d4 --- /dev/null +++ b/scripts/task-page-block-ai-conflict-idempotency-smoke.js @@ -0,0 +1,270 @@ +#!/usr/bin/env node +"use strict"; + +const assert = require("node:assert"); +const fs = require("node:fs/promises"); +const path = require("node:path"); +const { chromium } = require("playwright"); +const { + BASE_URL, + cleanupDocuments, + createTempDocument, + ensureAuthenticated, + openDocument, + renameDocument, + requestJson, +} = require("./tree-shell-smoke-helpers"); + +const OUT_DIR = path.join(process.cwd(), "tmp", "page-block-ai-conflict-idempotency-smoke"); + +async function callMnoteTool(request, payload) { + return requestJson(request, "/api/hermes/tools/mnote/call", { + method: "POST", + headers: { "x-mnote-actor-id": payload.actorId || "smoke-user" }, + data: payload, + }); +} + +async function callMnoteToolRaw(request, payload) { + const response = await request.fetch(`${BASE_URL}/api/hermes/tools/mnote/call`, { + method: "POST", + headers: { + "content-type": "application/json", + "x-mnote-actor-id": payload.actorId || "smoke-user", + }, + data: JSON.stringify(payload), + }); + const text = await response.text(); + let body = null; + try { + body = text ? JSON.parse(text) : null; + } catch { + body = text; + } + return { + status: response.status(), + headers: response.headers(), + body, + text, + }; +} + +function blockById(blocks, blockId) { + return blocks.find((block) => block.blockId === blockId); +} + +async function fetchBlocks(request, target, suffix, actorId, label) { + const response = await callMnoteTool(request, { + toolName: "mnote.doc.fetch", + workspaceId: target.workspaceId, + documentId: target.documentId, + actorId, + sessionId: `sess_conflict_fetch_${suffix}`, + runId: `run_conflict_fetch_${suffix}_${label}`, + toolCallId: `call_conflict_fetch_${suffix}_${label}`, + traceId: `trace_conflict_fetch_${suffix}_${label}`, + capabilityScope: ["page.read"], + args: { + scope: "full", + detail: "with_ids", + maxBlocks: 20, + }, + }); + assert.equal(response.ok, true, `${label}: doc.fetch 应成功`); + assert(Array.isArray(response.result.blocks), `${label}: doc.fetch 应返回 blocks`); + return response.result; +} + +async function main() { + const suffix = Date.now().toString(36); + const title = `TEST-AI-CONFLICT-IDEMPOTENCY-${suffix}`; + const createdIds = []; + const evidence = { + ok: false, + baseUrl: BASE_URL, + title, + steps: [], + }; + await fs.mkdir(OUT_DIR, { recursive: true }); + + const browser = await chromium.launch({ headless: true }); + const context = await browser.newContext({ viewport: { width: 1280, height: 900 } }); + const page = await context.newPage(); + + try { + const viewer = await ensureAuthenticated(page, context.request); + const actorId = viewer.userId || "smoke-user"; + const target = await createTempDocument(context.request); + createdIds.push(target.documentId); + evidence.documentId = target.documentId; + evidence.workspaceId = target.workspaceId; + await renameDocument(context.request, target.workspaceId, target.documentId, title); + + const seed = await callMnoteTool(context.request, { + toolName: "mnote.page.save", + workspaceId: target.workspaceId, + documentId: target.documentId, + actorId, + sessionId: `sess_conflict_seed_${suffix}`, + runId: `run_conflict_seed_${suffix}`, + toolCallId: `call_conflict_seed_${suffix}`, + traceId: `trace_conflict_seed_${suffix}`, + idempotencyKey: `idem_conflict_seed_${suffix}`, + dryRun: false, + capabilityScope: ["page.write"], + args: { + mode: "replace", + content: [ + { id: "p_1", type: "paragraph", content: [{ type: "text", text: `第一段 ${suffix}` }] }, + { id: "p_2", type: "paragraph", content: [{ type: "text", text: `第二段 ${suffix}` }] }, + ], + }, + }); + assert.equal(seed.result.commandName, "page.body.save", "初始化必须走 page.body.save"); + evidence.steps.push({ name: "seed", commandName: seed.result.commandName }); + + const initial = await fetchBlocks(context.request, target, suffix, actorId, "initial"); + const initialP2 = blockById(initial.blocks, "p_2"); + assert(initialP2?.revisionRef, "初始化后 p_2 必须有 revisionRef"); + const firstText = `第二段首次替换 ${suffix}`; + const idempotencyKey = `idem_conflict_replace_${suffix}`; + const firstReplacePayload = { + toolName: "mnote.block.replace", + workspaceId: target.workspaceId, + documentId: target.documentId, + actorId, + sessionId: `sess_conflict_replace_${suffix}`, + runId: `run_conflict_replace_first_${suffix}`, + toolCallId: `call_conflict_replace_first_${suffix}`, + traceId: `trace_conflict_replace_first_${suffix}`, + idempotencyKey, + dryRun: false, + capabilityScope: ["block.write"], + args: { + blockId: "p_2", + content: firstText, + revision: initial.revision, + conflictDetectionKey: initial.conflictDetectionKey, + blockRevisionRef: initialP2.revisionRef, + }, + }; + const firstReplace = await callMnoteTool(context.request, firstReplacePayload); + assert.equal(firstReplace.result.commandName, "page.body.save", "block.replace 必须走 page.body.save"); + evidence.steps.push({ + name: "block.replace.first", + commandId: firstReplace.audit.commandId, + idempotencyKey, + }); + + const afterFirst = await fetchBlocks(context.request, target, suffix, actorId, "after_first"); + const afterFirstP2 = blockById(afterFirst.blocks, "p_2"); + assert.equal(afterFirstP2.text, firstText, "首次 replace 后 AI fetch 必须回读新文本"); + assert.notEqual(afterFirstP2.revisionRef, initialP2.revisionRef, "首次 replace 后 p_2 revisionRef 应变化"); + + const replayText = `不应被重复 idempotency 写入 ${suffix}`; + const replay = await callMnoteTool(context.request, { + ...firstReplacePayload, + runId: `run_conflict_replace_replay_${suffix}`, + toolCallId: `call_conflict_replace_replay_${suffix}`, + traceId: `trace_conflict_replace_replay_${suffix}`, + args: { + ...firstReplacePayload.args, + content: replayText, + }, + }); + assert.equal(replay.audit.commandId, firstReplace.audit.commandId, "重复 idempotencyKey 应返回缓存 commandId"); + const afterReplay = await fetchBlocks(context.request, target, suffix, actorId, "after_replay"); + assert.equal(blockById(afterReplay.blocks, "p_2").text, firstText, "重复 idempotencyKey 不应写入新 content"); + assert.notEqual(blockById(afterReplay.blocks, "p_2").text, replayText, "重复 idempotencyKey 不应造成二次写入"); + assert.equal(afterReplay.revision, afterFirst.revision, "重复 idempotencyKey 后 revision 不应再次递增"); + evidence.steps.push({ + name: "block.replace.idempotency_replay", + replayCommandId: replay.audit.commandId, + finalText: blockById(afterReplay.blocks, "p_2").text, + revisionAfterFirst: afterFirst.revision, + revisionAfterReplay: afterReplay.revision, + }); + + const staleRevision = await callMnoteToolRaw(context.request, { + toolName: "mnote.block.replace", + workspaceId: target.workspaceId, + documentId: target.documentId, + actorId, + sessionId: `sess_conflict_stale_revision_${suffix}`, + runId: `run_conflict_stale_revision_${suffix}`, + toolCallId: `call_conflict_stale_revision_${suffix}`, + traceId: `trace_conflict_stale_revision_${suffix}`, + idempotencyKey: `idem_conflict_stale_revision_${suffix}`, + dryRun: false, + capabilityScope: ["block.write"], + args: { + blockId: "p_2", + content: `旧 revision 不应写入 ${suffix}`, + revision: initial.revision, + conflictDetectionKey: initial.conflictDetectionKey, + blockRevisionRef: afterFirstP2.revisionRef, + }, + }); + assert.equal(staleRevision.status, 400, "旧 revision 写入应返回 400"); + assert.equal(staleRevision.headers["x-error-code"], "mnote_tool_conflict", "旧 revision 应返回 mnote_tool_conflict"); + assert.equal(staleRevision.body?.code, "mnote_tool_conflict", "旧 revision body 应返回 mnote_tool_conflict"); + evidence.steps.push({ + name: "block.replace.stale_revision", + status: staleRevision.status, + errorCode: staleRevision.body?.code, + }); + + const staleBlockRef = await callMnoteToolRaw(context.request, { + toolName: "mnote.block.replace", + workspaceId: target.workspaceId, + documentId: target.documentId, + actorId, + sessionId: `sess_conflict_stale_block_ref_${suffix}`, + runId: `run_conflict_stale_block_ref_${suffix}`, + toolCallId: `call_conflict_stale_block_ref_${suffix}`, + traceId: `trace_conflict_stale_block_ref_${suffix}`, + idempotencyKey: `idem_conflict_stale_block_ref_${suffix}`, + dryRun: false, + capabilityScope: ["block.write"], + args: { + blockId: "p_2", + content: `旧 blockRevisionRef 不应写入 ${suffix}`, + revision: afterFirst.revision, + conflictDetectionKey: afterFirst.conflictDetectionKey, + blockRevisionRef: initialP2.revisionRef, + }, + }); + assert.equal(staleBlockRef.status, 400, "旧 blockRevisionRef 写入应返回 400"); + assert.equal(staleBlockRef.headers["x-error-code"], "mnote_tool_conflict", "旧 blockRevisionRef 应返回 mnote_tool_conflict"); + assert.equal(staleBlockRef.body?.code, "mnote_tool_conflict", "旧 blockRevisionRef body 应返回 mnote_tool_conflict"); + evidence.steps.push({ + name: "block.replace.stale_block_revision_ref", + status: staleBlockRef.status, + errorCode: staleBlockRef.body?.code, + }); + + const finalSnapshot = await fetchBlocks(context.request, target, suffix, actorId, "final"); + assert.equal(blockById(finalSnapshot.blocks, "p_2").text, firstText, "conflict 失败后正文应保持首次替换结果"); + await openDocument(page, target.workspaceId, target.documentId); + await page.getByText(firstText).waitFor({ state: "visible", timeout: 30_000 }); + const screenshotPath = path.join(OUT_DIR, `${suffix}-page.png`); + await page.screenshot({ path: screenshotPath, fullPage: true }); + evidence.screenshot = screenshotPath; + evidence.finalRevision = finalSnapshot.revision; + evidence.finalText = blockById(finalSnapshot.blocks, "p_2").text; + evidence.ok = true; + + const evidencePath = path.join(OUT_DIR, `${suffix}.json`); + await fs.writeFile(evidencePath, JSON.stringify(evidence, null, 2), "utf8"); + console.log(JSON.stringify({ ...evidence, evidencePath }, null, 2)); + } finally { + await cleanupDocuments(context.request, createdIds).catch(() => undefined); + await context.close().catch(() => undefined); + await browser.close().catch(() => undefined); + } +} + +main().catch((error) => { + console.error(error instanceof Error ? error.stack || error.message : String(error)); + process.exit(1); +}); diff --git a/scripts/task-page-block-ai-context-format-smoke.js b/scripts/task-page-block-ai-context-format-smoke.js new file mode 100644 index 00000000..0cd8a623 --- /dev/null +++ b/scripts/task-page-block-ai-context-format-smoke.js @@ -0,0 +1,315 @@ +#!/usr/bin/env node +"use strict"; + +const assert = require("node:assert"); +const fs = require("node:fs/promises"); +const path = require("node:path"); +const { chromium } = require("playwright"); +const { + BASE_URL, + cleanupDocuments, + createTempDocument, + ensureAuthenticated, + openDocument, + renameDocument, + requestJson, +} = require("./tree-shell-smoke-helpers"); + +const OUT_DIR = path.join(process.cwd(), "tmp", "page-block-ai-context-format-smoke"); + +async function callMnoteTool(request, payload) { + return requestJson(request, "/api/hermes/tools/mnote/call", { + method: "POST", + headers: { "x-mnote-actor-id": payload.actorId || "smoke-user" }, + data: payload, + }); +} + +function toolByName(manifest, name) { + const tools = manifest?.manifest?.tools || manifest?.tools || []; + const tool = tools.find((candidate) => candidate.name === name); + assert(tool, `manifest 缺少工具:${name}`); + return tool; +} + +function assertAnnotationShape(tool, expected) { + assert(tool.annotations, `${tool.name} 缺少 annotations`); + for (const key of [ + "readonly", + "destructive", + "idempotent", + "requiresApproval", + "approvalMode", + "runtimeOwner", + "writeOwner", + "selectionEffect", + ]) { + assert(Object.hasOwn(tool.annotations, key), `${tool.name} annotations 缺少 ${key}`); + } + for (const [key, value] of Object.entries(expected)) { + assert.deepEqual(tool.annotations[key], value, `${tool.name} annotations.${key} 不符合预期`); + } +} + +function assertIncludes(value, fragment, message) { + assert(String(value || "").includes(fragment), message); +} + +function assertExcludes(value, fragment, message) { + assert(!String(value || "").includes(fragment), message); +} + +async function main() { + const suffix = Date.now().toString(36); + const title = `TEST-AI-CONTEXT-FORMAT-${suffix}`; + const createdIds = []; + const evidence = { + ok: false, + baseUrl: BASE_URL, + title, + steps: [], + }; + await fs.mkdir(OUT_DIR, { recursive: true }); + + const browser = await chromium.launch({ headless: true }); + const context = await browser.newContext({ viewport: { width: 1280, height: 900 } }); + const page = await context.newPage(); + + try { + const viewer = await ensureAuthenticated(page, context.request); + const actorId = viewer.userId || "smoke-user"; + const target = await createTempDocument(context.request); + createdIds.push(target.documentId); + evidence.documentId = target.documentId; + evidence.workspaceId = target.workspaceId; + await renameDocument(context.request, target.workspaceId, target.documentId, title); + + const manifest = await requestJson(context.request, "/api/hermes/tools/mnote/manifest", { + method: "GET", + headers: { "x-mnote-actor-id": actorId }, + }); + const docFetchTool = toolByName(manifest, "mnote.doc.fetch"); + const blockFetchTool = toolByName(manifest, "mnote.block.fetch"); + const blockReplaceTool = toolByName(manifest, "mnote.block.replace"); + const pageSaveTool = toolByName(manifest, "mnote.page.save"); + assertAnnotationShape(docFetchTool, { + readonly: true, + destructive: false, + runtimeOwner: "mnote-web", + writeOwner: "rust-runtime-kernel", + selectionEffect: "preserve", + }); + assertAnnotationShape(blockFetchTool, { + readonly: true, + destructive: false, + selectionEffect: "preserve", + }); + assertAnnotationShape(blockReplaceTool, { + readonly: false, + destructive: false, + selectionEffect: "may_change", + }); + assertAnnotationShape(pageSaveTool, { + readonly: false, + destructive: true, + approvalMode: "yolo", + selectionEffect: "may_change", + }); + evidence.steps.push({ + name: "manifest.annotations", + checkedTools: [docFetchTool.name, blockFetchTool.name, blockReplaceTool.name, pageSaveTool.name], + pageSaveDestructive: pageSaveTool.annotations.destructive, + }); + + const seed = await callMnoteTool(context.request, { + toolName: "mnote.page.save", + workspaceId: target.workspaceId, + documentId: target.documentId, + actorId, + sessionId: `sess_context_seed_${suffix}`, + runId: `run_context_seed_${suffix}`, + toolCallId: `call_context_seed_${suffix}`, + traceId: `trace_context_seed_${suffix}`, + idempotencyKey: `idem_context_seed_${suffix}`, + dryRun: false, + capabilityScope: ["page.write"], + args: { + mode: "replace", + content: [ + { 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}` }] }, + ], + }, + }); + assert.equal(seed.ok, true, "初始化 page.save 应成功"); + evidence.steps.push({ name: "seed", commandName: seed.result.commandName }); + + const selectionXml = await callMnoteTool(context.request, { + toolName: "mnote.doc.fetch", + workspaceId: target.workspaceId, + documentId: target.documentId, + actorId, + sessionId: `sess_context_fetch_${suffix}`, + runId: `run_context_fetch_xml_${suffix}`, + toolCallId: `call_context_fetch_xml_${suffix}`, + traceId: `trace_context_fetch_xml_${suffix}`, + capabilityScope: ["page.read"], + args: { + scope: "selection", + selectedBlockIds: ["p_2"], + format: "page_xml", + detail: "with_ids", + maxBlocks: 10, + }, + }); + assert.equal(selectionXml.result.schema, "mnote.page_ai_context.v1", "doc.fetch 应返回 page AI context schema"); + assert.equal(selectionXml.result.scope, "selection", "doc.fetch 应保留 selection scope"); + assert.equal(selectionXml.result.format, "page_xml", "doc.fetch 应返回 page_xml format"); + assert.deepEqual(selectionXml.result.allowedTargetBlockIds, ["p_2"], "selection 应冻结 allowedTargetBlockIds"); + assert.equal(selectionXml.result.blocks.length, 1, "selection 只应返回选中块"); + assert.equal(selectionXml.result.blocks[0].blockId, "p_2", "selection 应返回 p_2"); + assert(selectionXml.result.blocks[0].revisionRef, "selection block 必须带 revisionRef"); + assertIncludes(selectionXml.result.content, ' block.blockId), + contextAfter: blockXml.result.context.after.map((block) => block.blockId), + }); + + const outOfScopeResponse = await context.request.fetch(`${BASE_URL}/api/hermes/tools/mnote/call`, { + method: "POST", + headers: { + "content-type": "application/json", + "x-mnote-actor-id": actorId, + }, + data: JSON.stringify({ + toolName: "mnote.doc.apply_block_ops", + workspaceId: target.workspaceId, + documentId: target.documentId, + actorId, + sessionId: `sess_context_scope_${suffix}`, + runId: `run_context_scope_${suffix}`, + toolCallId: `call_context_scope_${suffix}`, + traceId: `trace_context_scope_${suffix}`, + idempotencyKey: `idem_context_scope_${suffix}`, + dryRun: true, + capabilityScope: ["block.write"], + args: { + allowedTargetBlockIds: ["p_2"], + operations: [ + { + op: "replace", + blockId: "p_1", + content: `越界修改 ${suffix}`, + }, + ], + }, + }), + }); + const outOfScopeText = await outOfScopeResponse.text(); + assert.equal(outOfScopeResponse.status(), 400, "选区外写入应被 Rust tool 拒绝"); + assertIncludes(outOfScopeText, "mnote_block_target_out_of_scope", "选区外写入应返回明确错误码"); + evidence.steps.push({ + name: "write.out_of_scope.blocked", + status: outOfScopeResponse.status(), + errorCode: "mnote_block_target_out_of_scope", + }); + + await openDocument(page, target.workspaceId, target.documentId); + await page.getByText(`第二段 ${suffix}`).waitFor({ state: "visible", timeout: 30_000 }); + const screenshotPath = path.join(OUT_DIR, `${suffix}-page.png`); + await page.screenshot({ path: screenshotPath, fullPage: true }); + evidence.screenshot = screenshotPath; + evidence.ok = true; + + const evidencePath = path.join(OUT_DIR, `${suffix}.json`); + await fs.writeFile(evidencePath, JSON.stringify(evidence, null, 2), "utf8"); + console.log(JSON.stringify({ ...evidence, evidencePath }, null, 2)); + } finally { + await cleanupDocuments(context.request, createdIds).catch(() => undefined); + await context.close().catch(() => undefined); + await browser.close().catch(() => undefined); + } +} + +main().catch((error) => { + console.error(error instanceof Error ? error.stack || error.message : String(error)); + process.exit(1); +}); diff --git a/scripts/task-page-block-ai-tools-smoke.js b/scripts/task-page-block-ai-tools-smoke.js new file mode 100644 index 00000000..eb504c13 --- /dev/null +++ b/scripts/task-page-block-ai-tools-smoke.js @@ -0,0 +1,296 @@ +#!/usr/bin/env node +"use strict"; + +const assert = require("node:assert"); +const fs = require("node:fs/promises"); +const path = require("node:path"); +const { chromium } = require("playwright"); +const { + BASE_URL, + UI_TIMEOUT_MS, + cleanupDocuments, + createTempDocument, + ensureAuthenticated, + openDocument, + renameDocument, + requestJson, +} = require("./tree-shell-smoke-helpers"); + +const OUT_DIR = path.join(process.cwd(), "tmp", "page-block-ai-tools-smoke"); + +function blockById(blocks, blockId) { + return blocks.find((block) => block.blockId === blockId); +} + +function blockTexts(blocks) { + return blocks.map((block) => block.text); +} + +async function waitForVisibleTexts(page, expectedTexts) { + await page.waitForFunction( + ({ expected }) => { + const visibleText = []; + const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT); + while (walker.nextNode()) { + const node = walker.currentNode; + const parent = node.parentElement; + if (!(parent instanceof HTMLElement)) continue; + const style = window.getComputedStyle(parent); + const rect = parent.getBoundingClientRect(); + const text = (node.textContent || "").trim(); + if ( + text && + style.display !== "none" && + style.visibility !== "hidden" && + rect.width > 0 && + rect.height > 0 + ) { + visibleText.push(text); + } + } + return expected.every((text) => visibleText.some((visible) => visible.includes(text))); + }, + { expected: expectedTexts }, + { timeout: UI_TIMEOUT_MS }, + ); +} + +async function callMnoteTool(request, payload) { + return requestJson(request, "/api/hermes/tools/mnote/call", { + method: "POST", + headers: { "x-mnote-actor-id": payload.actorId || "smoke-user" }, + data: payload, + }); +} + +async function fetchBlocks(request, target, suffix, label, actorId) { + const response = await callMnoteTool(request, { + toolName: "mnote.doc.fetch", + workspaceId: target.workspaceId, + documentId: target.documentId, + actorId, + sessionId: `sess_block_fetch_${suffix}`, + runId: `run_block_fetch_${suffix}_${label}`, + toolCallId: `call_block_fetch_${suffix}_${label}`, + traceId: `trace_block_fetch_${suffix}_${label}`, + capabilityScope: ["page.read"], + args: { + scope: "full", + detail: "with_ids", + maxBlocks: 20, + }, + }); + assert.equal(response.ok, true, `${label}: doc.fetch 应成功`); + assert(Array.isArray(response.result.blocks), `${label}: doc.fetch 应返回 blocks`); + return response.result; +} + +async function main() { + const suffix = Date.now().toString(36); + const title = `TEST-AI-BLOCK-TOOLS-${suffix}`; + const createdIds = []; + const evidence = { + ok: false, + baseUrl: BASE_URL, + title, + steps: [], + }; + await fs.mkdir(OUT_DIR, { recursive: true }); + + const browser = await chromium.launch({ headless: true }); + const context = await browser.newContext({ viewport: { width: 1280, height: 900 } }); + const page = await context.newPage(); + + try { + const viewer = await ensureAuthenticated(page, context.request); + const actorId = viewer.userId; + const target = await createTempDocument(context.request); + createdIds.push(target.documentId); + evidence.documentId = target.documentId; + evidence.workspaceId = target.workspaceId; + await renameDocument(context.request, target.workspaceId, target.documentId, title); + + const initialContent = [ + { 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}` }] }, + ]; + const seed = await callMnoteTool(context.request, { + toolName: "mnote.page.save", + workspaceId: target.workspaceId, + documentId: target.documentId, + actorId, + sessionId: `sess_seed_${suffix}`, + runId: `run_seed_${suffix}`, + toolCallId: `call_seed_${suffix}`, + traceId: `trace_seed_${suffix}`, + idempotencyKey: `idem_seed_${suffix}`, + dryRun: false, + capabilityScope: ["page.write"], + args: { + mode: "replace", + content: initialContent, + }, + }); + assert.equal(seed.ok, true, "初始化 page.save 应成功"); + evidence.steps.push({ name: "seed", commandName: seed.result.commandName }); + + let snapshot = await fetchBlocks(context.request, target, suffix, "initial", actorId); + 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"); + assert(p1.revisionRef && p2.revisionRef && p3.revisionRef, "块投影必须返回 revisionRef"); + evidence.steps.push({ + name: "doc.fetch.initial", + revision: snapshot.revision, + conflictDetectionKey: snapshot.conflictDetectionKey, + blockIds: snapshot.blocks.map((block) => block.blockId), + }); + + const blockFetch = await callMnoteTool(context.request, { + toolName: "mnote.block.fetch", + workspaceId: target.workspaceId, + documentId: target.documentId, + actorId, + sessionId: `sess_block_${suffix}`, + runId: `run_block_${suffix}`, + toolCallId: `call_block_${suffix}`, + traceId: `trace_block_${suffix}`, + capabilityScope: ["block.read"], + args: { + blockId: "p_2", + contextBefore: 1, + contextAfter: 1, + }, + }); + assert.equal(blockFetch.result.block.blockId, "p_2", "block.fetch 应读取目标块"); + 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" }); + + const replacedText = `第二段已替换 ${suffix}`; + const replace = await callMnoteTool(context.request, { + toolName: "mnote.block.replace", + workspaceId: target.workspaceId, + documentId: target.documentId, + actorId, + sessionId: `sess_replace_${suffix}`, + runId: `run_replace_${suffix}`, + toolCallId: `call_replace_${suffix}`, + traceId: `trace_replace_${suffix}`, + idempotencyKey: `idem_replace_${suffix}`, + dryRun: false, + capabilityScope: ["block.write"], + args: { + blockId: "p_2", + content: replacedText, + revision: snapshot.revision, + conflictDetectionKey: snapshot.conflictDetectionKey, + blockRevisionRef: p2.revisionRef, + }, + }); + assert.equal(replace.result.commandName, "page.body.save", "block.replace 必须走 page.body.save"); + snapshot = await fetchBlocks(context.request, target, suffix, "after_replace", actorId); + assert.equal(blockById(snapshot.blocks, "p_2").text, replacedText, "替换后 doc.fetch 应回读新文本"); + evidence.steps.push({ name: "block.replace", changedBlocks: replace.result.changedBlocks }); + + const insertedText = `插入段 ${suffix}`; + const anchorAfterReplace = blockById(snapshot.blocks, "p_1"); + const insert = await callMnoteTool(context.request, { + toolName: "mnote.block.insert_after", + workspaceId: target.workspaceId, + documentId: target.documentId, + actorId, + sessionId: `sess_insert_${suffix}`, + runId: `run_insert_${suffix}`, + toolCallId: `call_insert_${suffix}`, + traceId: `trace_insert_${suffix}`, + idempotencyKey: `idem_insert_${suffix}`, + dryRun: false, + capabilityScope: ["block.write"], + args: { + anchorBlockId: "p_1", + content: insertedText, + revision: snapshot.revision, + conflictDetectionKey: snapshot.conflictDetectionKey, + anchorRevisionRef: anchorAfterReplace.revisionRef, + }, + }); + const insertedBlockId = insert.result.changedBlocks[0].blockId; + 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 moveBlock = blockById(snapshot.blocks, "p_3"); + const moveAnchor = blockById(snapshot.blocks, "p_1"); + const moveDryRun = await callMnoteTool(context.request, { + toolName: "mnote.doc.plan_update", + workspaceId: target.workspaceId, + documentId: target.documentId, + actorId, + sessionId: `sess_plan_${suffix}`, + runId: `run_plan_${suffix}`, + toolCallId: `call_plan_${suffix}`, + traceId: `trace_plan_${suffix}`, + idempotencyKey: `idem_plan_${suffix}`, + dryRun: true, + capabilityScope: ["block.write"], + args: { + command: "block_move_after", + blockId: "p_3", + anchorBlockId: "p_1", + }, + }); + assert.equal(moveDryRun.result.blocked, false, "同父级普通叶子块 move dry-run 不应阻断"); + const move = await callMnoteTool(context.request, { + toolName: "mnote.block.move_after", + workspaceId: target.workspaceId, + documentId: target.documentId, + actorId, + sessionId: `sess_move_${suffix}`, + runId: `run_move_${suffix}`, + toolCallId: `call_move_${suffix}`, + traceId: `trace_move_${suffix}`, + idempotencyKey: `idem_move_${suffix}`, + dryRun: false, + capabilityScope: ["block.write"], + args: { + blockId: "p_3", + anchorBlockId: "p_1", + revision: snapshot.revision, + conflictDetectionKey: snapshot.conflictDetectionKey, + blockRevisionRef: moveBlock.revisionRef, + anchorRevisionRef: moveAnchor.revisionRef, + }, + }); + assert.equal(move.result.changedBlocks[0].op, "move_after", "move_after 应返回 changedBlocks"); + 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) }); + + await openDocument(page, target.workspaceId, target.documentId); + await waitForVisibleTexts(page, [replacedText, insertedText, `第三段 ${suffix}`]); + 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.finalTexts = blockTexts(snapshot.blocks); + evidence.ok = true; + + const evidencePath = path.join(OUT_DIR, `${suffix}.json`); + await fs.writeFile(evidencePath, JSON.stringify(evidence, null, 2), "utf8"); + console.log(JSON.stringify({ ...evidence, evidencePath }, null, 2)); + } finally { + await cleanupDocuments(context.request, createdIds).catch(() => undefined); + await context.close().catch(() => undefined); + await browser.close().catch(() => undefined); + } +} + +main().catch((error) => { + console.error(error instanceof Error ? error.stack || error.message : String(error)); + process.exit(1); +}); diff --git a/scripts/task052-ai-tools-runtime-smoke.js b/scripts/task052-ai-tools-runtime-smoke.js deleted file mode 100644 index eb784121..00000000 --- a/scripts/task052-ai-tools-runtime-smoke.js +++ /dev/null @@ -1,312 +0,0 @@ -"use strict"; - -if (process.env.MNOTE_ALLOW_RETIRED_AI_AGENT_RUN_SMOKE !== "1") { - console.log(JSON.stringify({ - ok: true, - retired: true, - script: "task052-ai-tools-runtime-smoke.js", - reason: "旧 /api/ai-agent/run AI tools runtime smoke 已退役;当前请运行 scripts/task-hermes-page-ai-retirement-guard.js 验证 legacy guard。", - }, null, 2)); - process.exit(0); -} - -const { chromium } = require("playwright"); - -const BASE_URL = process.env.MNOTE_UI_BASE_URL || "http://127.0.0.1:3000"; -const REQUEST_TIMEOUT_MS = 120_000; -const UI_TIMEOUT_MS = 30_000; -const TEST_LOGIN_BUTTON_NAME = "测试账号快速登录"; - -async function requestJsonWithCookieHeader(path, init = {}, cookieHeader = "") { - const controller = new AbortController(); - const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS); - try { - const response = await fetch(`${BASE_URL}${path}`, { - ...init, - headers: { - ...(init.data !== undefined ? { "content-type": "application/json" } : {}), - ...(init.headers || {}), - ...(cookieHeader ? { cookie: cookieHeader } : {}), - }, - body: init.data !== undefined ? JSON.stringify(init.data) : init.body, - signal: controller.signal, - }); - - const text = await response.text(); - let payload = null; - try { - payload = text ? JSON.parse(text) : null; - } catch { - payload = text; - } - - if (!response.ok) { - throw new Error( - `${path} 请求失败: ${response.status} ${response.statusText} ${typeof payload === "string" ? payload : JSON.stringify(payload)}`, - ); - } - - const contentType = response.headers.get("content-type") || ""; - if (!contentType.includes("application/json")) { - throw new Error(`${path} 返回了非 JSON 内容:${String(text).slice(0, 200)}`); - } - - return payload; - } finally { - clearTimeout(timer); - } -} - -function assert(condition, message) { - if (!condition) { - throw new Error(message); - } -} - -async function requestJson(requestContext, path, init = {}) { - const response = await requestContext.fetch(`${BASE_URL}${path}`, { - ...init, - headers: - init.data !== undefined - ? { - "content-type": "application/json", - ...(init.headers || {}), - } - : { - ...(init.headers || {}), - }, - timeout: REQUEST_TIMEOUT_MS, - }); - - const text = await response.text(); - let payload = null; - try { - payload = text ? JSON.parse(text) : null; - } catch { - payload = text; - } - - if (!response.ok()) { - throw new Error( - `${path} 请求失败: ${response.status()} ${response.statusText()} ${typeof payload === "string" ? payload : JSON.stringify(payload)}`, - ); - } - - const contentType = response.headers()["content-type"] || ""; - if (!contentType.includes("application/json")) { - throw new Error(`${path} 返回了非 JSON 内容:${String(text).slice(0, 200)}`); - } - - return payload; -} - -async function ensureAuthenticated(page, requestContext) { - const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); - const tryWhoAmI = async () => { - try { - return await requestJson(requestContext, "/api/auth/whoami", { method: "GET" }); - } catch { - return null; - } - }; - - await page.goto(`${BASE_URL}/auth`, { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS }); - let viewer = await tryWhoAmI(); - if (viewer) return viewer; - - const quickLoginButton = page.getByRole("button", { name: TEST_LOGIN_BUTTON_NAME }); - await quickLoginButton.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); - await quickLoginButton.click({ timeout: UI_TIMEOUT_MS }); - - for (let i = 0; i < 20; i += 1) { - await sleep(500); - viewer = await tryWhoAmI(); - if (viewer) return viewer; - } - - throw new Error("测试账号快速登录后仍无法获取 whoami"); -} - -async function createTempDocument(requestContext) { - const payload = await requestJson(requestContext, "/api/documents/create", { - method: "POST", - data: { parentId: null }, - }); - assert(payload && typeof payload.id === "string", "创建临时页面失败:缺少 id"); - assert(payload && typeof payload.workspace_id === "string", "创建临时页面失败:缺少 workspace_id"); - return { documentId: payload.id, workspaceId: payload.workspace_id }; -} - -async function renameDocument(requestContext, documentId, workspaceId, title) { - return await requestJson(requestContext, "/api/documents/title", { - method: "POST", - data: { documentId, workspaceId, title, commandName: "page.head.updateTitle" }, - }); -} - -async function saveDocument(requestContext, documentId, workspaceId, content) { - return await requestJson(requestContext, "/api/documents/save", { - method: "POST", - data: { documentId, workspaceId, content }, - }); -} - -async function purgeTempDocument(requestContext, documentId) { - return await requestJson(requestContext, "/api/documents/purge", { - method: "POST", - data: { documentId }, - }); -} - -async function runAiAgentDocsSmoke(requestContext, uniqueTitle, needleText, cookieHeader) { - const payload = await requestJsonWithCookieHeader("/api/ai-agent/run", { - method: "POST", - data: { - stream: false, - maxSteps: 4, - scope: "document", - messages: [ - { - role: "user", - content: `{"query":"${uniqueTitle}","limit":5}`, - }, - ], - toolChoice: { - mode: "manual", - toolSets: ["toolset.docs_read"], - tools: ["docs_search", "docs_read"], - }, - context: { - documentId: "smoke-doc-context", - documentBlocks: [], - }, - options: { - ai: { - provider: "codex", - sessionId: "", - }, - }, - }, - }, cookieHeader); - - assert(Array.isArray(payload.events), "AI Agent 返回缺少 events"); - const toolResults = payload.events.filter((event) => event && event.type === "tool_result"); - assert(toolResults.length >= 1, "AI Agent 未返回任何 tool_result"); - - const searchResultEvent = toolResults.find((event) => event.data && event.data.tool === "docs_search" && event.data.ok === true); - assert(searchResultEvent, "docs_search 未成功执行"); - const searchResults = searchResultEvent.data.result && Array.isArray(searchResultEvent.data.result.results) - ? searchResultEvent.data.result.results - : []; - assert(searchResults.length >= 1, "docs_search 没有返回结果"); - const top = searchResults[0]; - assert(typeof top.id === "string" && top.id, "docs_search 首条结果缺少 documentId"); - - const readPayload = await requestJsonWithCookieHeader("/api/ai-agent/run", { - method: "POST", - data: { - stream: false, - maxSteps: 4, - scope: "document", - messages: [ - { - role: "user", - content: `{"documentId":"${top.id}","maxChars":2500,"includeContent":false}`, - }, - ], - toolChoice: { - mode: "manual", - toolSets: ["toolset.docs_read"], - tools: ["docs_read"], - }, - context: { - documentId: "smoke-doc-context", - documentBlocks: [], - }, - options: { - ai: { - provider: "codex", - sessionId: "", - }, - }, - }, - }, cookieHeader); - - assert(Array.isArray(readPayload.events), "docs_read 返回缺少 events"); - const readEvent = readPayload.events.find((event) => event && event.type === "tool_result" && event.data && event.data.tool === "docs_read" && event.data.ok === true); - assert(readEvent, "docs_read 未成功执行"); - const rawText = String(readEvent.data.result?.rawText ?? ""); - assert(rawText.includes(needleText), `docs_read 返回未命中预期正文片段:${needleText}`); - - return { - searchDocumentId: top.id, - searchResultsCount: searchResults.length, - readRawTextLength: Number(readEvent.data.result?.rawTextLength ?? 0), - }; -} - -async function main() { - const browser = await chromium.launch({ headless: true }); - const context = await browser.newContext({ viewport: { width: 1440, height: 960 } }); - const page = await context.newPage(); - let tempDocument = null; - let caughtError = null; - - try { - await ensureAuthenticated(page, context.request); - tempDocument = await createTempDocument(context.request); - const uniqueSuffix = `${Date.now()}`; - const uniqueTitle = `task052-ai-runtime-${uniqueSuffix}`; - const needleText = `task052 Rust docs runtime smoke ${uniqueSuffix}`; - - await renameDocument(context.request, tempDocument.documentId, tempDocument.workspaceId, uniqueTitle); - await saveDocument(context.request, tempDocument.documentId, tempDocument.workspaceId, [ - { - id: `task052_block_${uniqueSuffix}`, - type: "paragraph", - props: {}, - content: [{ type: "text", text: needleText }], - children: [], - }, - ]); - - const cookies = await context.cookies(BASE_URL); - const cookieHeader = cookies.map((item) => `${item.name}=${item.value}`).join("; "); - const runtime = await runAiAgentDocsSmoke(context.request, uniqueTitle, needleText, cookieHeader); - - console.log(JSON.stringify({ - ok: true, - workspaceId: tempDocument.workspaceId, - documentId: tempDocument.documentId, - title: uniqueTitle, - needleText, - ...runtime, - }, null, 2)); - } catch (error) { - caughtError = error; - } finally { - if (tempDocument?.documentId) { - try { - await purgeTempDocument(context.request, tempDocument.documentId); - } catch (cleanupError) { - if (!caughtError) { - caughtError = cleanupError; - } else { - console.error(`清理临时页面失败:${cleanupError instanceof Error ? cleanupError.stack || cleanupError.message : String(cleanupError)}`); - } - } - } - await page.close().catch(() => undefined); - await context.close().catch(() => undefined); - await browser.close().catch(() => undefined); - } - - if (caughtError) { - throw caughtError; - } -} - -main().catch((error) => { - console.error(error instanceof Error ? error.stack || error.message : String(error)); - process.exit(1); -}); diff --git a/scripts/task111-phase7-document-ai-online-smoke.js b/scripts/task111-phase7-document-ai-online-smoke.js deleted file mode 100644 index ef273cf4..00000000 --- a/scripts/task111-phase7-document-ai-online-smoke.js +++ /dev/null @@ -1,611 +0,0 @@ -"use strict"; - -if (process.env.MNOTE_ALLOW_RETIRED_AI_AGENT_RUN_SMOKE !== "1") { - console.log(JSON.stringify({ - ok: true, - retired: true, - script: "task111-phase7-document-ai-online-smoke.js", - reason: "旧 /api/ai-agent/run phase7 online smoke 已退役;当前请运行 scripts/task-hermes-page-ai-retirement-guard.js 验证 legacy guard。", - }, null, 2)); - process.exit(0); -} - -const { chromium } = require("playwright"); -const { - BASE_URL, - REQUEST_TIMEOUT_MS, - UI_TIMEOUT_MS, - assert, - createTempDocument, - ensureAuthenticated, - openDocument, - purgeDocument, - renameDocument, - requestJson, -} = require("./tree-shell-smoke-helpers"); - -const AI_TIMEOUT_MS = Number(process.env.MNOTE_AI_SMOKE_TIMEOUT_MS || 180_000); - -function parseJsonSafely(text) { - try { - return text ? JSON.parse(text) : null; - } catch { - return text; - } -} - -function parseSseText(rawText) { - return String(rawText || "") - .split(/\n\n+/) - .map((chunk) => chunk.trim()) - .filter(Boolean) - .filter((chunk) => !chunk.startsWith(":")) - .map((chunk) => { - const lines = chunk.split(/\r?\n/); - let type = "message"; - const dataLines = []; - for (const line of lines) { - if (line.startsWith("event:")) { - type = line.slice("event:".length).trim() || "message"; - continue; - } - if (line.startsWith("data:")) { - dataLines.push(line.slice("data:".length).trimStart()); - } - } - const dataText = dataLines.join("\n"); - return { - type, - dataText, - data: parseJsonSafely(dataText), - }; - }); -} - -function serializeForError(value) { - try { - return JSON.stringify(value, null, 2); - } catch { - return String(value); - } -} - -async function requestSseEvents(requestContext, path, body) { - const response = await requestContext.fetch(`${BASE_URL}${path}`, { - method: "POST", - headers: { - "content-type": "application/json", - }, - data: body, - timeout: AI_TIMEOUT_MS, - }); - - const text = await response.text(); - if (!response.ok()) { - throw new Error(`${path} 请求失败: ${response.status()} ${response.statusText()} ${text.slice(0, 600)}`); - } - - const contentType = response.headers()["content-type"] || ""; - assert(contentType.includes("text/event-stream"), `${path} 未返回 SSE:${contentType}`); - - return parseSseText(text); -} - -async function fetchPageAggregate(requestContext, documentId, workspaceId) { - return await requestJson( - requestContext, - `/api/page-aggregate/${encodeURIComponent(documentId)}?workspaceId=${encodeURIComponent(workspaceId)}`, - { method: "GET" }, - ); -} - -async function fetchDocumentMeta(requestContext, documentId, workspaceId) { - return await requestJson( - requestContext, - `/api/documents/meta?documentId=${encodeURIComponent(documentId)}&workspaceId=${encodeURIComponent(workspaceId)}`, - { method: "GET" }, - ); -} - -async function fetchDocumentContent(requestContext, documentId, workspaceId) { - return await requestJson( - requestContext, - `/api/documents/content?documentId=${encodeURIComponent(documentId)}&workspaceId=${encodeURIComponent(workspaceId)}`, - { method: "GET" }, - ); -} - -function buildAiContextFromPageAggregate(pagePayload) { - const page = pagePayload?.result ?? null; - const pageSubtree = page?.tree?.pageSubtree ?? null; - const subtreeNodes = Array.isArray(pageSubtree?.subtree?.nodes) - ? pageSubtree.subtree.nodes.slice(0, 160) - : []; - - return { - documentId: page?.identity?.documentId ?? null, - documentBlocks: page?.body?.content ?? null, - node: pageSubtree?.rootNode ?? null, - subtree: pageSubtree - ? { - projectionId: pageSubtree.projectionId ?? null, - rootNodeId: pageSubtree?.subtree?.rootNodeId ?? null, - stats: pageSubtree?.stats ?? null, - nodes: subtreeNodes, - } - : null, - outline: Array.isArray(pageSubtree?.outline) ? pageSubtree.outline.slice(0, 40) : null, - evidence: Array.isArray(pageSubtree?.evidence) ? pageSubtree.evidence.slice(0, 32) : null, - pageOptions: page?.layout?.pageOptions ?? {}, - }; -} - -function findSuccessfulToolResult(events, toolName) { - return [...events] - .reverse() - .find( - (event) => - event?.type === "tool_result" && - event?.data && - event.data.tool === toolName && - event.data.ok === true, - ) ?? null; -} - -function findErrorEvent(events) { - return events.find((event) => event?.type === "error") ?? null; -} - -function extractRenamedTitleFromSlashToolResult(toolEvent, documentId) { - if (!toolEvent?.data?.result || toolEvent?.data?.tool !== "slash_run") { - return null; - } - const resultRecord = - toolEvent.data.result && typeof toolEvent.data.result === "object" - ? toolEvent.data.result - : null; - const parsed = - resultRecord?.parsed && typeof resultRecord.parsed === "object" - ? resultRecord.parsed - : null; - const params = - parsed?.params && typeof parsed.params === "object" - ? parsed.params - : null; - if (!parsed || parsed.command !== "rename_doc" || !params) { - return null; - } - if (String(params.documentId ?? "") !== documentId) { - return null; - } - const title = String(params.title ?? "").trim(); - return title || null; -} - -function extractBodySnapshotFromToolResult(toolEvent) { - if (!toolEvent?.data?.result || !["doc_insert_blocks", "doc_replace_range"].includes(toolEvent?.data?.tool)) { - return null; - } - const resultRecord = - toolEvent.data.result && typeof toolEvent.data.result === "object" - ? toolEvent.data.result - : null; - return resultRecord && Array.isArray(resultRecord.data) ? resultRecord.data : null; -} - -function extractInsertedBlockIdFromToolResult(toolEvent) { - if (!toolEvent?.data?.result || toolEvent?.data?.tool !== "doc_insert_blocks") { - return null; - } - const resultRecord = - toolEvent.data.result && typeof toolEvent.data.result === "object" - ? toolEvent.data.result - : null; - const inserted = resultRecord && Array.isArray(resultRecord.inserted) ? resultRecord.inserted : null; - const blockId = typeof inserted?.[0] === "string" ? inserted[0].trim() : ""; - return blockId || null; -} - -async function persistAiTitleResult(requestContext, fixture, toolEvent) { - const renamedTitle = extractRenamedTitleFromSlashToolResult(toolEvent, fixture.documentId); - assert(renamedTitle, `slash_run 结果未产出当前页标题:${serializeForError(toolEvent)}`); - await renameDocument(requestContext, fixture.workspaceId, fixture.documentId, renamedTitle); - return renamedTitle; -} - -async function persistAiBodyResult(requestContext, fixture, toolEvent) { - const nextBlocks = extractBodySnapshotFromToolResult(toolEvent); - assert(Array.isArray(nextBlocks), `文档写工具未返回 legacy blocks 快照:${serializeForError(toolEvent)}`); - - const aggregate = await fetchPageAggregate(requestContext, fixture.documentId, fixture.workspaceId); - await requestJson(requestContext, "/api/documents/save", { - method: "POST", - data: { - documentId: fixture.documentId, - workspaceId: fixture.workspaceId, - // task111 对齐正式 page body save:正文写工具结果只接受 legacy blocks 快照。 - content: nextBlocks, - revision: aggregate?.page?.body?.revision ?? null, - conflictDetectionKey: aggregate?.page?.body?.conflictDetectionKey ?? null, - snapshotCapturedAt: new Date().toISOString(), - blockCount: nextBlocks.length, - }, - }); - return nextBlocks; -} - -async function waitFor(check, description, timeoutMs = UI_TIMEOUT_MS, intervalMs = 500) { - const deadline = Date.now() + timeoutMs; - let lastValue = null; - while (Date.now() < deadline) { - lastValue = await check(); - if (lastValue) { - return lastValue; - } - await new Promise((resolve) => setTimeout(resolve, intervalMs)); - } - throw new Error(`${description} 超时,最后结果:${serializeForError(lastValue)}`); -} - -async function waitForPageAggregateBodyText(requestContext, fixture, expectedText) { - await waitFor( - async () => { - const pagePayload = await fetchPageAggregate(requestContext, fixture.documentId, fixture.workspaceId); - const raw = JSON.stringify(pagePayload?.page?.body?.content ?? null); - return raw.includes(expectedText) ? pagePayload : null; - }, - `等待 page aggregate 正文同步到 ${expectedText}`, - AI_TIMEOUT_MS, - ); -} - -async function waitForRuntimeIsland(page) { - await page.waitForFunction( - () => { - const host = document.querySelector('[data-testid="mnote-leptos-tiptap-island-editor-root"]'); - const editor = host?.querySelector(".editor-surface .ProseMirror[contenteditable='true']"); - return ( - host?.getAttribute("data-editor-host-kind") === "leptos_tiptap_island" && - host?.getAttribute("data-runtime-editor-status") !== "error" && - editor instanceof HTMLElement && - editor.isContentEditable - ); - }, - null, - { timeout: UI_TIMEOUT_MS }, - ); -} - -async function waitForTitleInput(page) { - const input = page.getByLabel("页面标题"); - await input.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); - return input; -} - -async function waitForEditorText(page, expectedText) { - await page.waitForFunction( - (text) => { - const editor = document.querySelector( - '[data-testid="mnote-leptos-tiptap-island-editor-root"] .editor-surface .ProseMirror', - ); - return (editor?.textContent ?? "").includes(text); - }, - expectedText, - { timeout: UI_TIMEOUT_MS }, - ); -} - -async function readVisibleTitle(page) { - const input = page.getByLabel("页面标题"); - if (await input.isVisible().catch(() => false)) { - return ((await input.inputValue().catch(() => "")) || "").trim(); - } - const heading = page.locator("h1").first(); - return ((await heading.textContent().catch(() => "")) || "").trim(); -} - -async function readEditorText(page) { - return await page.evaluate(() => { - const editor = document.querySelector( - '[data-testid="mnote-leptos-tiptap-island-editor-root"] .editor-surface .ProseMirror', - ); - return (editor?.textContent ?? "").trim(); - }); -} - -async function typeIntoEditor(page, text) { - const editor = page - .locator('[data-testid="mnote-leptos-tiptap-island-editor-root"] .editor-surface .ProseMirror[contenteditable="true"]') - .first(); - await editor.click({ timeout: UI_TIMEOUT_MS }); - await page.keyboard.press("Control+a"); - await page.keyboard.press("Backspace"); - await page.keyboard.type(text, { delay: 20 }); -} - -async function waitForPersistedSave(page, saveRequests, documentId, expectedText) { - const deadline = Date.now() + UI_TIMEOUT_MS; - while (Date.now() < deadline) { - const editorText = await readEditorText(page); - const hasSaveRequest = saveRequests.some((item) => item.documentId === documentId); - const runtimeStatus = await page.evaluate(() => { - return ( - document - .querySelector('[data-testid="mnote-leptos-tiptap-island-editor-root"]') - ?.getAttribute("data-runtime-editor-status") ?? null - ); - }); - if (editorText.includes(expectedText) && (runtimeStatus === "saved" || hasSaveRequest)) { - return; - } - await page.waitForTimeout(250); - } - throw new Error(`等待编辑器保存超时:${documentId}`); -} - -async function runTitleRename(requestContext, fixture, renamedTitle) { - const aggregate = await fetchPageAggregate(requestContext, fixture.documentId, fixture.workspaceId); - const events = await requestSseEvents(requestContext, "/api/ai-agent/run", { - stream: true, - maxSteps: 8, - scope: "document", - messages: [ - { - role: "user", - content: `请把当前页面标题改成“${renamedTitle}”。只改标题,不要改正文;改完后用一句话确认。`, - }, - ], - context: buildAiContextFromPageAggregate(aggregate), - options: { - ai: { - provider: "online", - }, - }, - }); - - const errorEvent = findErrorEvent(events); - assert(!errorEvent, `标题改名流返回 error:${serializeForError(errorEvent)}`); - - const slashResult = findSuccessfulToolResult(events, "slash_run"); - assert(slashResult, `标题改名未得到成功的 slash_run:${serializeForError(events)}`); - const persistedTitle = await persistAiTitleResult(requestContext, fixture, slashResult); - assert( - persistedTitle === renamedTitle, - `AI 标题结果与目标不一致,期望 ${renamedTitle},实际 ${persistedTitle}`, - ); - - await waitFor( - async () => { - const meta = await fetchDocumentMeta(requestContext, fixture.documentId, fixture.workspaceId); - return String(meta?.doc?.title ?? "").trim() === renamedTitle ? meta : null; - }, - "等待标题改名落盘", - AI_TIMEOUT_MS, - ); - - return events; -} - -async function runBodyInsert(requestContext, fixture, initialBody) { - const aggregate = await fetchPageAggregate(requestContext, fixture.documentId, fixture.workspaceId); - const events = await requestSseEvents(requestContext, "/api/ai-agent/run", { - stream: true, - maxSteps: 8, - scope: "document", - messages: [ - { - role: "user", - content: `请在当前页面插入一段正文:“${initialBody}”。只插入这一段,不要改标题;完成后简短确认。`, - }, - ], - context: buildAiContextFromPageAggregate(aggregate), - options: { - ai: { - provider: "online", - }, - }, - }); - - const errorEvent = findErrorEvent(events); - assert(!errorEvent, `正文插入流返回 error:${serializeForError(errorEvent)}`); - - const insertResult = findSuccessfulToolResult(events, "doc_insert_blocks"); - assert(insertResult, `正文插入未得到成功的 doc_insert_blocks:${serializeForError(events)}`); - await persistAiBodyResult(requestContext, fixture, insertResult); - const insertedBlockId = extractInsertedBlockIdFromToolResult(insertResult); - assert(insertedBlockId, `正文插入结果缺少 inserted blockId:${serializeForError(insertResult)}`); - - await waitFor( - async () => { - const content = await fetchDocumentContent(requestContext, fixture.documentId, fixture.workspaceId); - const raw = JSON.stringify(content?.content ?? null); - return raw.includes(initialBody) ? content : null; - }, - "等待正文插入落盘", - AI_TIMEOUT_MS, - ); - await waitForPageAggregateBodyText(requestContext, fixture, initialBody); - - return { - events, - insertedBlockId, - }; -} - -async function runBodyRewrite(requestContext, fixture, targetBlockId, rewrittenBody) { - const aggregate = await fetchPageAggregate(requestContext, fixture.documentId, fixture.workspaceId); - const events = await requestSseEvents(requestContext, "/api/ai-agent/run", { - stream: true, - maxSteps: 8, - scope: "document", - messages: [ - { - role: "user", - content: `请把当前页面中 blockId 为“${targetBlockId}”的那一段改写为“${rewrittenBody}”。只改这个 blockId 对应的段落,不要改标题;直接用 doc_replace_range 完成,不需要先搜索。改完后简短确认。`, - }, - ], - context: buildAiContextFromPageAggregate(aggregate), - options: { - ai: { - provider: "online", - }, - }, - }); - - const errorEvent = findErrorEvent(events); - assert(!errorEvent, `正文改写流返回 error:${serializeForError(errorEvent)}`); - - const replaceResult = findSuccessfulToolResult(events, "doc_replace_range"); - assert(replaceResult, `正文改写未得到成功的 doc_replace_range:${serializeForError(events)}`); - await persistAiBodyResult(requestContext, fixture, replaceResult); - - await waitFor( - async () => { - const content = await fetchDocumentContent(requestContext, fixture.documentId, fixture.workspaceId); - const raw = JSON.stringify(content?.content ?? null); - return raw.includes(rewrittenBody) ? content : null; - }, - "等待正文改写落盘", - AI_TIMEOUT_MS, - ); - - return events; -} - -async function verifyDocumentUi(page, fixture, expectedTitle, expectedBody) { - await openDocument(page, fixture.workspaceId, fixture.documentId); - await waitForRuntimeIsland(page); - await waitForTitleInput(page); - await waitForEditorText(page, expectedBody); - - const visibleTitle = await readVisibleTitle(page); - const editorText = await readEditorText(page); - assert( - visibleTitle.includes(expectedTitle), - `页面标题未同步到 UI,期望包含 ${expectedTitle},实际为 ${visibleTitle}`, - ); - assert( - editorText.includes(expectedBody), - `编辑区正文未同步到 UI,期望包含 ${expectedBody},实际为 ${editorText}`, - ); -} - -async function main() { - const browser = await chromium.launch({ headless: true }); - const context = await browser.newContext({ - viewport: { width: 1440, height: 960 }, - }); - const page = await context.newPage(); - - let fixture = null; - let caughtError = null; - - try { - const viewer = await ensureAuthenticated(page, context.request); - fixture = await createTempDocument(context.request, null); - - const uniqueSuffix = Date.now().toString().slice(-6); - const initialTitle = `phase7-ai-initial-${uniqueSuffix}`; - const renamedTitle = `phase7-ai-renamed-${uniqueSuffix}`; - const initialBody = `phase7 AI 原始正文 ${uniqueSuffix}`; - const rewrittenBody = `phase7 AI 改写正文 ${uniqueSuffix}`; - - await renameDocument(context.request, fixture.workspaceId, fixture.documentId, initialTitle); - - const insertRun = await runBodyInsert(context.request, fixture, initialBody); - const renameEvents = await runTitleRename(context.request, fixture, renamedTitle); - const rewriteEvents = await runBodyRewrite( - context.request, - fixture, - insertRun.insertedBlockId, - rewrittenBody, - ); - - const meta = await fetchDocumentMeta(context.request, fixture.documentId, fixture.workspaceId); - const content = await fetchDocumentContent(context.request, fixture.documentId, fixture.workspaceId); - - assert( - String(meta?.doc?.title ?? "").trim() === renamedTitle, - `标题未完成持久化,期望 ${renamedTitle},实际 ${String(meta?.doc?.title ?? "").trim()}`, - ); - const serializedContent = JSON.stringify(content?.content ?? null); - assert( - serializedContent.includes(rewrittenBody), - `正文未完成持久化,未命中 ${rewrittenBody}`, - ); - assert( - !serializedContent.includes(initialBody), - `正文仍保留旧文本 ${initialBody}`, - ); - const pageAggregate = await fetchPageAggregate(context.request, fixture.documentId, fixture.workspaceId); - assert( - String(pageAggregate?.page?.head?.title ?? "").trim() === renamedTitle, - `page aggregate 标题未更新为 ${renamedTitle}`, - ); - assert( - JSON.stringify(pageAggregate?.page?.body?.content ?? null).includes(rewrittenBody), - `page aggregate 正文未更新为 ${rewrittenBody}`, - ); - - console.log( - JSON.stringify( - { - ok: true, - baseUrl: BASE_URL, - viewerUserId: viewer.userId, - workspaceId: fixture.workspaceId, - documentId: fixture.documentId, - renamedTitle, - rewrittenBody, - insertToolSequence: insertRun.events - .filter((event) => event.type === "tool_result") - .map((event) => event.data?.tool ?? null) - .filter(Boolean), - renameToolSequence: renameEvents - .filter((event) => event.type === "tool_result") - .map((event) => event.data?.tool ?? null) - .filter(Boolean), - rewriteToolSequence: rewriteEvents - .filter((event) => event.type === "tool_result") - .map((event) => event.data?.tool ?? null) - .filter(Boolean), - }, - null, - 2, - ), - ); - } catch (error) { - caughtError = error; - } finally { - if (fixture?.documentId) { - try { - await purgeDocument(context.request, fixture.documentId); - } catch (cleanupError) { - if (!caughtError) { - caughtError = cleanupError; - } else { - console.error( - `清理临时页面失败:${ - cleanupError instanceof Error - ? cleanupError.stack || cleanupError.message - : String(cleanupError) - }`, - ); - } - } - } - - await page.close().catch(() => undefined); - await context.close().catch(() => undefined); - await browser.close().catch(() => undefined); - } - - if (caughtError) { - throw caughtError; - } -} - -main().catch((error) => { - console.error(error instanceof Error ? error.stack || error.message : String(error)); - process.exit(1); -}); diff --git a/scripts/task123-rust-web-tree-live-stream-consumer-smoke.js b/scripts/task123-rust-web-tree-live-stream-consumer-smoke.js index 5459d1d8..b3ca8554 100644 --- a/scripts/task123-rust-web-tree-live-stream-consumer-smoke.js +++ b/scripts/task123-rust-web-tree-live-stream-consumer-smoke.js @@ -13,6 +13,18 @@ async function fetchText(path) { return { response, text }; } +function parseSseEventData(text, eventName) { + const blocks = text.split(/\n\n+/).filter((block) => block.trim().length > 0); + const block = blocks.find((candidate) => new RegExp(`^event:\\s*${eventName}\\s*$`, "m").test(candidate)); + assert(block, `SSE 响应缺少 ${eventName} 事件`); + const dataLines = block + .split(/\n/) + .filter((line) => line.startsWith("data:")) + .map((line) => line.slice("data:".length).trimStart()); + assert(dataLines.length > 0, `${eventName} 事件缺少 data`); + return JSON.parse(dataLines.join("\n")); +} + async function readJsonResponse(response, label) { const text = await response.text(); let payload = null; @@ -77,7 +89,40 @@ async function main() { assert.match(events.text, /id:\s*/); assert.match(events.text, /"revision"/); - console.log(JSON.stringify({ ok: true, owner: "rust-web", stream: "/api/tree/events", documentId: pageTarget.documentId }, null, 2)); + const snapshot = parseSseEventData(events.text, "snapshot"); + assert.equal(snapshot.kind, "snapshot"); + assert.equal(snapshot.stream, "workspace"); + assert.equal(snapshot.projection, "sidebar_tree"); + assert.equal(snapshot.workspaceId, pageTarget.workspaceId); + const dataset = snapshot.data?.dataset || snapshot.data; + assert(dataset?.kernel_sidebar_projection, "snapshot 缺少 kernel_sidebar_projection"); + assert(dataset?.kernel_file_tree_projection, "snapshot 缺少 kernel_file_tree_projection"); + const fileTreeItems = dataset.kernel_file_tree_projection.items || []; + assert( + fileTreeItems.some( + (item) => + (item?.rowId || item?.row_id) === `doc:${pageTarget.documentId}` && + (item?.rowKind || item?.row_kind) === "document" && + (item?.resourceMeta?.documentId || item?.resource_meta?.document_id) === + pageTarget.documentId, + ), + "workspace snapshot 的 file tree projection 缺少临时页 doc row", + ); + + console.log( + JSON.stringify( + { + ok: true, + owner: "rust-web", + stream: "/api/tree/events", + documentId: pageTarget.documentId, + snapshotProjection: snapshot.projection, + fileTreeRows: fileTreeItems.length, + }, + null, + 2, + ), + ); } finally { await purgeTempPage(pageTarget).catch(() => undefined); } diff --git a/scripts/task155-e27-ai-edit-smoke.js b/scripts/task155-e27-ai-edit-smoke.js deleted file mode 100644 index 9eb5aa12..00000000 --- a/scripts/task155-e27-ai-edit-smoke.js +++ /dev/null @@ -1,233 +0,0 @@ -#!/usr/bin/env node -"use strict"; - -if (process.env.MNOTE_ALLOW_RETIRED_AI_AGENT_RUN_SMOKE !== "1") { - console.log(JSON.stringify({ - ok: true, - retired: true, - script: "task155-e27-ai-edit-smoke.js", - reason: "旧 /api/ai-agent/run E27 AI 编辑 smoke 已退役;当前请运行 scripts/task-hermes-page-ai-retirement-guard.js 验证 legacy guard。", - }, null, 2)); - process.exit(0); -} - -const assert = require("node:assert"); -const fs = require("node:fs"); -const { chromium } = require("playwright"); -const { fetchWithTimeout } = require("./task114-rust-web-gateway-entry-smoke.js"); - -const BASE_URL = (process.env.MNOTE_UI_BASE_URL || "http://127.0.0.1:3000").replace(/\/+$/, ""); -const UI_TIMEOUT_MS = Number(process.env.MNOTE_SMOKE_UI_TIMEOUT_MS || 30_000); -const SCREENSHOT_DIR = process.env.MNOTE_PHASE_E_SCREENSHOT_DIR || "/mnt/Data1T/mnote/tmp/wolai-editor-parity/task155-e27-ai-edit-local-smoke"; -const LEPTOS_SPIKE_SOURCE = "/mnt/Data1T/mnote/rust/spikes/leptos-tiptap-spike/src/lib.rs"; -const TARGET_BLOCK_ID = "e27-ai-target"; - -function assertAiSourceBoundary() { - const spike = fs.readFileSync(LEPTOS_SPIKE_SOURCE, "utf8"); - assert(spike.includes("/api/ai-agent/run"), "E27 AI 入口必须调用 /api/ai-agent/run 在线主路径,Hermes 只能作为后端 fallback"); - assert(spike.includes('"provider": "online"'), "E27 AI 请求必须使用 provider=online 进入 openai-agents-python 主路径"); - assert(spike.includes('"stream": true'), "E27 AI 请求必须开启 stream=true,确保 /api/ai-agent/run 路由进入 agents sidecar"); - assert(spike.includes("mnote-leptos-tiptap-ai-status"), "E27 必须暴露 AI bridge 状态,供 smoke 和后续流式状态复用"); - assert(spike.includes("selectedBlockId"), "E27 AI 请求必须携带 Rust blockId 作为 selectedBlockId"); - assert(spike.includes("tiptapDocument"), "E27 AI 请求必须携带当前 Tiptap JSON 快照"); -} - -async function readJsonResponse(response, label) { - const text = await response.text(); - let payload = null; - try { - payload = text ? JSON.parse(text) : null; - } catch { - throw new Error(`${label} 返回了非 JSON 内容: ${text.slice(0, 1200)}`); - } - assert(response.ok, `${label} 请求失败: ${response.status}; body: ${text.slice(0, 1200)}`); - return payload; -} - -async function postTreeCommand(body, label) { - const response = await fetchWithTimeout(`${BASE_URL}/api/tree/commands`, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify(body), - }); - const payload = await readJsonResponse(response, label); - assert(payload?.result, `${label} 缺少 result`); - return payload.result; -} - -async function createTempDocument() { - const title = `task155-e27-ai-${Date.now().toString(36)}`; - const result = await postTreeCommand({ action: "create", title }, "创建 E27 临时文档"); - assert(result.documentId, "创建 E27 临时文档缺少 documentId"); - assert(result.workspaceId, "创建 E27 临时文档缺少 workspaceId"); - return { documentId: result.documentId, workspaceId: result.workspaceId, title }; -} - -async function purgeTempDocument(target) { - if (!target?.documentId || !target?.workspaceId) return; - await postTreeCommand({ action: "purge", workspaceId: target.workspaceId, documentId: target.documentId }, "清理 E27 临时文档"); -} - -async function waitForRuntimeIsland(page) { - const root = page.locator('[data-testid="mnote-leptos-tiptap-island-editor-root"]').first(); - await root.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); - const editor = page.locator('[data-testid="mnote-leptos-tiptap-island-editor-root"] .editor-surface .ProseMirror[contenteditable="true"]').first(); - await editor.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); - await page.waitForFunction(() => { - const host = document.querySelector('[data-testid="mnote-leptos-tiptap-island-editor-root"]'); - const editorNode = host?.querySelector('.editor-surface .ProseMirror[contenteditable="true"]'); - return host?.getAttribute("data-runtime-editor-status") !== "error" && editorNode instanceof HTMLElement && editorNode.isContentEditable; - }, null, { timeout: UI_TIMEOUT_MS }); - return editor; -} - -async function screenshot(page, name) { - fs.mkdirSync(SCREENSHOT_DIR, { recursive: true }); - await page.screenshot({ path: `${SCREENSHOT_DIR}/${name}.png`, fullPage: true }); -} - -async function setAiFixture(page) { - await page.evaluate((targetBlockId) => { - const editor = document.querySelector('.editor-surface .ProseMirror')?.editor; - if (!editor) throw new Error('找不到 Tiptap editor'); - editor.commands.setContent({ - type: 'doc', - content: [ - { - type: 'paragraph', - attrs: { blockId: targetBlockId }, - content: [{ type: 'text', text: 'E27 Ask AI source paragraph' }], - }, - ], - }, true); - editor.commands.focus('start'); - }, TARGET_BLOCK_ID); - await page.waitForFunction((targetBlockId) => { - return document.querySelector(`[data-block-id="${targetBlockId}"]`) instanceof HTMLElement; - }, TARGET_BLOCK_ID, { timeout: UI_TIMEOUT_MS }); -} - -async function openBlockMenuForTarget(page) { - const target = page.locator(`[data-block-id="${TARGET_BLOCK_ID}"]`).first(); - await target.scrollIntoViewIfNeeded({ timeout: UI_TIMEOUT_MS }); - await target.hover({ timeout: UI_TIMEOUT_MS }); - const handle = page.locator('[data-testid="block-drag-handle-trigger"]').first(); - await handle.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); - await handle.click({ timeout: UI_TIMEOUT_MS }); - const menu = page.locator('[data-testid="block-drag-menu"]').first(); - await menu.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); - return menu; -} - -function assertAiBridgePayload(payload, target) { - assert.equal(payload.scope, "document", `AI 请求 scope 必须是 document: ${JSON.stringify(payload).slice(0, 1600)}`); - assert.equal(payload.stream, true, "AI 请求必须开启 stream=true,避免退回 Hermes 非流式兼容路径"); - assert.equal(payload.options?.ai?.provider, "online", "AI 请求必须使用 provider=online,由 /api/ai-agent/run 分流到 agents sidecar"); - assert.equal(payload.context?.documentId, target.documentId, "AI 请求必须携带当前 documentId"); - assert.equal(payload.context?.workspaceId, target.workspaceId, "AI 请求必须携带当前 workspaceId"); - assert.equal(payload.context?.selectedBlockId, TARGET_BLOCK_ID, "AI 请求必须携带 Rust blockId"); - assert(Array.isArray(payload.context?.selectedUids) && payload.context.selectedUids.includes(TARGET_BLOCK_ID), "AI 请求 selectedUids 必须包含 Rust blockId"); - assert(payload.context?.selection?.currentBlockId === TARGET_BLOCK_ID, "AI 请求 selection 必须指向当前块"); - assert(payload.context?.tiptapDocument?.type === "doc", "AI 请求必须携带 tiptapDocument 快照"); - assert(JSON.stringify(payload.context.tiptapDocument).includes("E27 Ask AI source paragraph"), "AI 请求快照必须包含当前块正文"); - assert.equal(payload.context?.source, "leptos-tiptap-island", "AI 请求必须标记来源 island"); - assert.equal(payload.context?.action, "ask_ai", "块菜单 AI 入口必须使用 ask_ai action"); -} - -async function main() { - assertAiSourceBoundary(); - - const browser = await chromium.launch({ headless: true }); - const context = await browser.newContext({ viewport: { width: 1440, height: 960 } }); - const page = await context.newPage(); - const aiBridgeRequests = []; - - await page.route(/\/api\/(hermes\/bridge|ai-agent\/run)$/, async (route) => { - const request = route.request(); - const body = request.postData() || "{}"; - let payload = null; - try { - payload = JSON.parse(body); - } catch { - payload = { raw: body }; - } - aiBridgeRequests.push({ url: request.url(), payload }); - if (request.url().includes("/api/ai-agent/run")) { - await route.fulfill({ - status: 200, - headers: { - "content-type": "text/event-stream; charset=utf-8", - "cache-control": "no-cache", - }, - body: [ - 'event: ready\n' + 'data: {"ok":true,"requestId":"task155-e27"}\n\n', - 'event: tool_result\n' + 'data: {"tool":"doc_replace_range","ok":true,"result":{"data":[{"id":"e27-ai-target","type":"paragraph","content":"E27 AI rewritten paragraph"}]}}\n\n', - 'event: completion\n' + 'data: {"ok":true,"text":"done","steps":1}\n\n', - ].join(""), - }); - return; - } - await route.fulfill({ - status: 200, - headers: { - "content-type": "application/json; charset=utf-8", - "x-mnote-ai-bridge-owner": "rust-web-hermes", - }, - body: JSON.stringify({ - ok: true, - bridge: "e27-smoke-hermes-bridge", - canonicalRoute: "/api/hermes/bridge", - contract: { - schema: "mnote.ai_bridge.v1", - structuredWriteOwner: "rust-web-hermes", - }, - }), - }); - }); - - let target = null; - try { - target = await createTempDocument(); - const url = `${BASE_URL}/documents/${encodeURIComponent(target.documentId)}?workspaceId=${encodeURIComponent(target.workspaceId)}`; - const response = await page.goto(url, { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS }); - assert(response, "文档页没有返回响应"); - assert.equal(response.status(), 200, `文档页状态码异常: ${response.status()}`); - - await waitForRuntimeIsland(page); - await setAiFixture(page); - const menu = await openBlockMenuForTarget(page); - await screenshot(page, "01-block-menu-ai-entry"); - - await menu.locator('[data-testid="block-drag-menu-item-ai"]').first().click({ timeout: UI_TIMEOUT_MS }); - await page.waitForFunction(() => { - const status = document.querySelector('[data-testid="mnote-leptos-tiptap-ai-status"]'); - return status instanceof HTMLElement && ["pending", "ready"].includes(status.getAttribute("data-state") || ""); - }, null, { timeout: UI_TIMEOUT_MS }); - - await page.waitForFunction(() => window.__MNOTE_E27_AI_BRIDGE_REQUEST_COUNT__ > 0, null, { timeout: UI_TIMEOUT_MS }); - assert(aiBridgeRequests.length > 0, "点击 AI 入口后必须发起 /api/ai-agent/run 请求"); - const first = aiBridgeRequests[0]; - assert(first.url.includes("/api/ai-agent/run"), `AI 请求必须走 /api/ai-agent/run 在线主路径,实际: ${first.url}`); - assertAiBridgePayload(first.payload, target); - - await page.waitForFunction(() => { - const status = document.querySelector('[data-testid="mnote-leptos-tiptap-ai-status"]'); - return status instanceof HTMLElement && status.getAttribute("data-state") === "ready"; - }, null, { timeout: UI_TIMEOUT_MS }); - await screenshot(page, "02-ai-bridge-ready-state"); - - console.log(JSON.stringify({ ok: true, baseUrl: BASE_URL, documentId: target.documentId, screenshotDir: SCREENSHOT_DIR, aiBridgeUrl: first.url }, null, 2)); - } finally { - if (target) await purgeTempDocument(target).catch(() => undefined); - await page.close().catch(() => undefined); - await context.close().catch(() => undefined); - await browser.close().catch(() => undefined); - } -} - -if (require.main === module) { - main().catch((error) => { - console.error(error instanceof Error ? error.stack || error.message : String(error)); - process.exit(1); - }); -} diff --git a/scripts/task156-e27-ai-writeback-smoke.js b/scripts/task156-e27-ai-writeback-smoke.js deleted file mode 100644 index 98e74fa7..00000000 --- a/scripts/task156-e27-ai-writeback-smoke.js +++ /dev/null @@ -1,286 +0,0 @@ -#!/usr/bin/env node -"use strict"; - -if (process.env.MNOTE_ALLOW_RETIRED_AI_AGENT_RUN_SMOKE !== "1") { - console.log(JSON.stringify({ - ok: true, - retired: true, - script: "task156-e27-ai-writeback-smoke.js", - reason: "旧 /api/ai-agent/run E27 AI 写回 smoke 已退役;当前请运行 scripts/task-hermes-page-ai-retirement-guard.js 验证 legacy guard。", - }, null, 2)); - process.exit(0); -} - -const assert = require("node:assert"); -const fs = require("node:fs"); -const { chromium } = require("playwright"); -const { fetchWithTimeout } = require("./task114-rust-web-gateway-entry-smoke.js"); - -const BASE_URL = (process.env.MNOTE_UI_BASE_URL || "http://127.0.0.1:3000").replace(/\/+$/, ""); -const UI_TIMEOUT_MS = Number(process.env.MNOTE_SMOKE_UI_TIMEOUT_MS || 30_000); -const SCREENSHOT_DIR = process.env.MNOTE_PHASE_E_SCREENSHOT_DIR || "/mnt/Data1T/mnote/tmp/wolai-editor-parity/task156-e27-ai-writeback-local-smoke"; -const LEPTOS_SPIKE_SOURCE = "/mnt/Data1T/mnote/rust/spikes/leptos-tiptap-spike/src/lib.rs"; -const TARGET_BLOCK_ID = "e27-ai-target"; -const AI_REWRITTEN_TEXT = "E27 AI rewritten paragraph"; - -function assertAiSourceBoundary() { - const spike = fs.readFileSync(LEPTOS_SPIKE_SOURCE, "utf8"); - assert(spike.includes("/api/ai-agent/run"), "E27 AI 入口必须调用 /api/ai-agent/run 在线主路径,Hermes 只能作为后端 fallback"); - assert(spike.includes('"provider": "online"'), "E27 AI 请求必须使用 provider=online 进入 openai-agents-python 主路径"); - assert(spike.includes('"stream": true'), "E27 AI 请求必须开启 stream=true,确保 /api/ai-agent/run 路由进入 agents sidecar"); - assert(spike.includes("mnote-leptos-tiptap-ai-status"), "E27 必须暴露 AI bridge 状态,供 smoke 和后续流式状态复用"); - assert(spike.includes("selectedBlockId"), "E27 AI 请求必须携带 Rust blockId 作为 selectedBlockId"); - assert(spike.includes("tiptapDocument"), "E27 AI 请求必须携带当前 Tiptap JSON 快照"); -} - -async function readJsonResponse(response, label) { - const text = await response.text(); - let payload = null; - try { - payload = text ? JSON.parse(text) : null; - } catch { - throw new Error(`${label} 返回了非 JSON 内容: ${text.slice(0, 1200)}`); - } - assert(response.ok, `${label} 请求失败: ${response.status}; body: ${text.slice(0, 1200)}`); - return payload; -} - -async function postTreeCommand(body, label) { - const response = await fetchWithTimeout(`${BASE_URL}/api/tree/commands`, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify(body), - }); - const payload = await readJsonResponse(response, label); - assert(payload?.result, `${label} 缺少 result`); - return payload.result; -} - -async function createTempDocument() { - const title = `task156-e27-ai-writeback-${Date.now().toString(36)}`; - const result = await postTreeCommand({ action: "create", title }, "创建 E27 临时文档"); - assert(result.documentId, "创建 E27 临时文档缺少 documentId"); - assert(result.workspaceId, "创建 E27 临时文档缺少 workspaceId"); - return { documentId: result.documentId, workspaceId: result.workspaceId, title }; -} - -async function purgeTempDocument(target) { - if (!target?.documentId || !target?.workspaceId) return; - await postTreeCommand({ action: "purge", workspaceId: target.workspaceId, documentId: target.documentId }, "清理 E27 临时文档"); -} - -async function loadDocumentContent(target, label) { - const url = `${BASE_URL}/api/documents/content?documentId=${encodeURIComponent(target.documentId)}&workspaceId=${encodeURIComponent(target.workspaceId)}`; - const response = await fetchWithTimeout(url, { method: "GET" }); - return readJsonResponse(response, label); -} - -function rawIncludes(value, text) { - return JSON.stringify(value ?? null).includes(text); -} - -async function waitForRuntimeIsland(page) { - const root = page.locator('[data-testid="mnote-leptos-tiptap-island-editor-root"]').first(); - await root.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); - const editor = page.locator('[data-testid="mnote-leptos-tiptap-island-editor-root"] .editor-surface .ProseMirror[contenteditable="true"]').first(); - await editor.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); - await page.waitForFunction(() => { - const host = document.querySelector('[data-testid="mnote-leptos-tiptap-island-editor-root"]'); - const editorNode = host?.querySelector('.editor-surface .ProseMirror[contenteditable="true"]'); - return host?.getAttribute("data-runtime-editor-status") !== "error" && editorNode instanceof HTMLElement && editorNode.isContentEditable; - }, null, { timeout: UI_TIMEOUT_MS }); - return editor; -} - -async function screenshot(page, name) { - fs.mkdirSync(SCREENSHOT_DIR, { recursive: true }); - await page.screenshot({ path: `${SCREENSHOT_DIR}/${name}.png`, fullPage: true }); -} - -async function setAiFixture(page) { - await page.evaluate((targetBlockId) => { - const editor = document.querySelector('.editor-surface .ProseMirror')?.editor; - if (!editor) throw new Error('找不到 Tiptap editor'); - editor.commands.setContent({ - type: 'doc', - content: [ - { - type: 'paragraph', - attrs: { blockId: targetBlockId }, - content: [{ type: 'text', text: 'E27 Ask AI source paragraph' }], - }, - ], - }, true); - editor.commands.focus('start'); - }, TARGET_BLOCK_ID); - await page.waitForFunction((targetBlockId) => { - return document.querySelector(`[data-block-id="${targetBlockId}"]`) instanceof HTMLElement; - }, TARGET_BLOCK_ID, { timeout: UI_TIMEOUT_MS }); -} - -async function openBlockMenuForTarget(page) { - const target = page.locator(`[data-block-id="${TARGET_BLOCK_ID}"]`).first(); - await target.scrollIntoViewIfNeeded({ timeout: UI_TIMEOUT_MS }); - await target.hover({ timeout: UI_TIMEOUT_MS }); - const handle = page.locator('[data-testid="block-drag-handle-trigger"]').first(); - await handle.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); - await handle.click({ timeout: UI_TIMEOUT_MS }); - const menu = page.locator('[data-testid="block-drag-menu"]').first(); - await menu.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); - return menu; -} - -function assertAiBridgePayload(payload, target) { - assert.equal(payload.scope, "document", `AI 请求 scope 必须是 document: ${JSON.stringify(payload).slice(0, 1600)}`); - assert.equal(payload.stream, true, "AI 请求必须开启 stream=true,避免退回 Hermes 非流式兼容路径"); - assert.equal(payload.options?.ai?.provider, "online", "AI 请求必须使用 provider=online,由 /api/ai-agent/run 分流到 agents sidecar"); - assert.equal(payload.context?.documentId, target.documentId, "AI 请求必须携带当前 documentId"); - assert.equal(payload.context?.workspaceId, target.workspaceId, "AI 请求必须携带当前 workspaceId"); - assert.equal(payload.context?.selectedBlockId, TARGET_BLOCK_ID, "AI 请求必须携带 Rust blockId"); - assert(Array.isArray(payload.context?.selectedUids) && payload.context.selectedUids.includes(TARGET_BLOCK_ID), "AI 请求 selectedUids 必须包含 Rust blockId"); - assert(payload.context?.selection?.currentBlockId === TARGET_BLOCK_ID, "AI 请求 selection 必须指向当前块"); - assert(payload.context?.tiptapDocument?.type === "doc", "AI 请求必须携带 tiptapDocument 快照"); - assert(JSON.stringify(payload.context.tiptapDocument).includes("E27 Ask AI source paragraph"), "AI 请求快照必须包含当前块正文"); - assert.equal(payload.context?.source, "leptos-tiptap-island", "AI 请求必须标记来源 island"); - assert.equal(payload.context?.action, "ask_ai", "块菜单 AI 入口必须使用 ask_ai action"); -} - -async function main() { - assertAiSourceBoundary(); - - const browser = await chromium.launch({ headless: true }); - const context = await browser.newContext({ viewport: { width: 1440, height: 960 } }); - const page = await context.newPage(); - const aiBridgeRequests = []; - const saveRequests = []; - await page.addInitScript(() => { - window.__MNOTE_E27_SAVE_REQUESTS__ = []; - }); - page.on("request", async (request) => { - if (!request.url().includes("/api/documents/save")) return; - const body = request.postData(); - if (!body) return; - let payload; - try { - payload = JSON.parse(body); - } catch { - payload = { raw: body }; - } - saveRequests.push(payload); - await page.evaluate((item) => { - window.__MNOTE_E27_SAVE_REQUESTS__ = Array.isArray(window.__MNOTE_E27_SAVE_REQUESTS__) ? window.__MNOTE_E27_SAVE_REQUESTS__ : []; - window.__MNOTE_E27_SAVE_REQUESTS__.push(item); - }, payload).catch(() => undefined); - }); - - await page.route(/\/api\/(hermes\/bridge|ai-agent\/run)$/, async (route) => { - const request = route.request(); - const body = request.postData() || "{}"; - let payload = null; - try { - payload = JSON.parse(body); - } catch { - payload = { raw: body }; - } - aiBridgeRequests.push({ url: request.url(), payload }); - if (request.url().includes("/api/ai-agent/run")) { - await route.fulfill({ - status: 200, - headers: { - "content-type": "text/event-stream; charset=utf-8", - "cache-control": "no-cache", - }, - body: [ - 'event: ready\n' + 'data: {"ok":true,"requestId":"task155-e27"}\n\n', - 'event: tool_result\n' + `data: {"tool":"doc_replace_range","ok":true,"result":{"data":[{"id":"${TARGET_BLOCK_ID}","type":"paragraph","content":"${AI_REWRITTEN_TEXT}"}]}}\n\n`, - 'event: completion\n' + 'data: {"ok":true,"text":"done","steps":1}\n\n', - ].join(""), - }); - return; - } - await route.fulfill({ - status: 200, - headers: { - "content-type": "application/json; charset=utf-8", - "x-mnote-ai-bridge-owner": "rust-web-hermes", - }, - body: JSON.stringify({ - ok: true, - bridge: "e27-smoke-hermes-bridge", - canonicalRoute: "/api/hermes/bridge", - contract: { - schema: "mnote.ai_bridge.v1", - structuredWriteOwner: "rust-web-hermes", - }, - }), - }); - }); - - let target = null; - try { - target = await createTempDocument(); - const url = `${BASE_URL}/documents/${encodeURIComponent(target.documentId)}?workspaceId=${encodeURIComponent(target.workspaceId)}`; - const response = await page.goto(url, { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS }); - assert(response, "文档页没有返回响应"); - assert.equal(response.status(), 200, `文档页状态码异常: ${response.status()}`); - - await waitForRuntimeIsland(page); - await setAiFixture(page); - const menu = await openBlockMenuForTarget(page); - await screenshot(page, "01-block-menu-ai-entry"); - - await menu.locator('[data-testid="block-drag-menu-item-ai"]').first().click({ timeout: UI_TIMEOUT_MS }); - await page.waitForFunction(() => { - const status = document.querySelector('[data-testid="mnote-leptos-tiptap-ai-status"]'); - return status instanceof HTMLElement && ["pending", "ready"].includes(status.getAttribute("data-state") || ""); - }, null, { timeout: UI_TIMEOUT_MS }); - - await page.waitForFunction(() => window.__MNOTE_E27_AI_BRIDGE_REQUEST_COUNT__ > 0, null, { timeout: UI_TIMEOUT_MS }); - assert(aiBridgeRequests.length > 0, "点击 AI 入口后必须发起 /api/ai-agent/run 请求"); - const first = aiBridgeRequests[0]; - assert(first.url.includes("/api/ai-agent/run"), `AI 请求必须走 /api/ai-agent/run 在线主路径,实际: ${first.url}`); - assertAiBridgePayload(first.payload, target); - - await page.waitForFunction(() => { - const status = document.querySelector('[data-testid="mnote-leptos-tiptap-ai-status"]'); - return status instanceof HTMLElement && status.getAttribute("data-state") === "ready"; - }, null, { timeout: UI_TIMEOUT_MS }); - await page.waitForFunction((expectedText) => { - const editor = document.querySelector('.editor-surface .ProseMirror'); - return editor instanceof HTMLElement && editor.innerText.includes(expectedText); - }, AI_REWRITTEN_TEXT, { timeout: UI_TIMEOUT_MS }); - await page.waitForFunction((expectedText) => { - return Array.isArray(window.__MNOTE_E27_SAVE_REQUESTS__) - && window.__MNOTE_E27_SAVE_REQUESTS__.some((request) => JSON.stringify(request ?? null).includes(expectedText)); - }, AI_REWRITTEN_TEXT, { timeout: UI_TIMEOUT_MS }); - await screenshot(page, "02-ai-writeback-editor-saved"); - - const saveHit = saveRequests.some((request) => rawIncludes(request, AI_REWRITTEN_TEXT)); - assert(saveHit, `AI 写入必须触发 /api/documents/save 且保存 payload 包含改写正文: ${JSON.stringify(saveRequests.slice(-4)).slice(0, 2400)}`); - - const contentAfterWrite = await loadDocumentContent(target, "读取 E27 AI 写入后的正文"); - assert(rawIncludes(contentAfterWrite, AI_REWRITTEN_TEXT), `/api/documents/content 必须能读回 AI 写入正文: ${JSON.stringify(contentAfterWrite).slice(0, 2400)}`); - - await page.reload({ waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS }); - await waitForRuntimeIsland(page); - await page.waitForFunction((expectedText) => { - const editor = document.querySelector('.editor-surface .ProseMirror'); - return editor instanceof HTMLElement && editor.innerText.includes(expectedText); - }, AI_REWRITTEN_TEXT, { timeout: UI_TIMEOUT_MS }); - await screenshot(page, "03-ai-writeback-reload-readback"); - - console.log(JSON.stringify({ ok: true, baseUrl: BASE_URL, documentId: target.documentId, screenshotDir: SCREENSHOT_DIR, aiBridgeUrl: first.url, wroteText: AI_REWRITTEN_TEXT }, null, 2)); - } finally { - if (target) await purgeTempDocument(target).catch(() => undefined); - await page.close().catch(() => undefined); - await context.close().catch(() => undefined); - await browser.close().catch(() => undefined); - } -} - -if (require.main === module) { - main().catch((error) => { - console.error(error instanceof Error ? error.stack || error.message : String(error)); - process.exit(1); - }); -} diff --git a/scripts/task178-page-ai-local-subtree-context-smoke.js b/scripts/task178-page-ai-local-subtree-context-smoke.js deleted file mode 100644 index e9a76cba..00000000 --- a/scripts/task178-page-ai-local-subtree-context-smoke.js +++ /dev/null @@ -1,182 +0,0 @@ -#!/usr/bin/env node -"use strict"; - -if (process.env.MNOTE_ALLOW_RETIRED_AI_AGENT_RUN_SMOKE !== "1") { - console.log(JSON.stringify({ - ok: true, - retired: true, - script: "task178-page-ai-local-subtree-context-smoke.js", - reason: "旧 /api/ai-agent/run 页面 AI context smoke 已退役;当前请运行 scripts/task-hermes-page-ai-retirement-guard.js 验证 legacy guard。", - }, null, 2)); - process.exit(0); -} - -const assert = require("node:assert"); -const { chromium } = require("playwright"); -const { fetchWithTimeout } = require("./task114-rust-web-gateway-entry-smoke.js"); - -const BASE_URL = (process.env.MNOTE_WEB_SMOKE_BASE_URL || process.env.MNOTE_UI_BASE_URL || "http://127.0.0.1:3000").replace(/\/+$/, ""); -const UI_TIMEOUT_MS = Number(process.env.MNOTE_SMOKE_UI_TIMEOUT_MS || 30_000); - -async function readJsonResponse(response, label) { - const text = await response.text(); - let payload = null; - try { - payload = text ? JSON.parse(text) : null; - } catch { - throw new Error(`${label} 返回了非 JSON 内容: ${text.slice(0, 1200)}`); - } - assert(response.ok, `${label} 请求失败: ${response.status}; body: ${text.slice(0, 1200)}`); - return payload; -} - -async function postTreeCommand(body, label) { - const response = await fetchWithTimeout(`${BASE_URL}/api/tree/commands`, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify(body), - }); - const payload = await readJsonResponse(response, label); - const result = payload && typeof payload.result === "object" ? payload.result : null; - assert(result, `${label} 缺少 result`); - return result; -} - -async function createTempPage(title) { - const result = await postTreeCommand({ action: "create", title }, `创建临时页面 ${title}`); - assert(result.documentId, `创建临时页面 ${title} 缺少 documentId`); - assert(result.workspaceId, `创建临时页面 ${title} 缺少 workspaceId`); - return { documentId: result.documentId, workspaceId: result.workspaceId, title }; -} - -async function purgeTempPage(target) { - if (!target?.documentId || !target?.workspaceId) return; - await postTreeCommand( - { action: "purge", workspaceId: target.workspaceId, documentId: target.documentId }, - `清理临时页面 ${target.documentId}`, - ); -} - -async function waitForRuntimeIsland(page) { - await page.waitForFunction( - () => { - const host = document.querySelector('[data-testid="mnote-leptos-tiptap-island-editor-root"]'); - const editor = host?.querySelector(".editor-surface .ProseMirror[contenteditable='true']"); - return ( - host?.getAttribute("data-editor-host-kind") === "leptos_tiptap_island" && - host?.getAttribute("data-runtime-editor-status") !== "error" && - editor instanceof HTMLElement && - editor.isContentEditable - ); - }, - null, - { timeout: UI_TIMEOUT_MS }, - ); -} - -async function setLocalHeadingFixture(page, headingText) { - await page.evaluate((text) => { - const editor = document.querySelector(".editor-surface .ProseMirror")?.editor; - if (!editor) throw new Error("找不到 Tiptap editor"); - editor.commands.setContent({ - type: "doc", - content: [ - { type: "heading", attrs: { level: 2 }, content: [{ type: "text", text }] }, - { type: "paragraph", content: [{ type: "text", text: "本地正文尚未等待服务端 pageSubtree 刷新。" }] }, - ], - }); - }, headingText); - await page.waitForFunction( - (text) => Array.from(document.querySelectorAll(".editor-surface .ProseMirror h2")).some((node) => (node.textContent || "").includes(text)), - headingText, - { timeout: UI_TIMEOUT_MS }, - ); -} - -function stringify(value) { - try { - return JSON.stringify(value); - } catch { - return String(value); - } -} - -async function main() { - const suffix = Date.now().toString(36); - const pageTitle = `task178-page-ai-${suffix}`; - const localHeading = `TASK178 本地 Heading ${suffix}`; - let target = null; - let capturedBody = null; - const browser = await chromium.launch({ headless: true }); - const context = await browser.newContext({ viewport: { width: 1440, height: 960 } }); - const page = await context.newPage(); - - try { - target = await createTempPage(pageTitle); - await page.route("**/api/ai-agent/run", async (route) => { - const postData = route.request().postData() || "{}"; - capturedBody = JSON.parse(postData); - await route.fulfill({ - status: 200, - headers: { "content-type": "text/event-stream; charset=utf-8" }, - body: 'event: assistant_message\ndata: {"text":"ok"}\n\n', - }); - }); - - const url = `${BASE_URL}/documents/${encodeURIComponent(target.documentId)}?workspaceId=${encodeURIComponent(target.workspaceId)}`; - const response = await page.goto(url, { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS }); - assert(response, "文档页没有返回响应"); - assert.equal(response.status(), 200, `文档页状态码异常: ${response.status()}`); - assert.equal(response.headers()["x-mnote-web-owner"], "mnote-web", "文档页必须由 mnote-web 拥有"); - - await waitForRuntimeIsland(page); - await setLocalHeadingFixture(page, localHeading); - - await page.getByTestId("wolai-floating-ai").click({ timeout: UI_TIMEOUT_MS }); - await page.locator("[data-page-ai-input]").fill(`请基于当前本地结构回答:${localHeading}`, { timeout: UI_TIMEOUT_MS }); - await page.locator('[data-page-ai-action="send"]').click({ timeout: UI_TIMEOUT_MS }); - await page.waitForFunction(() => Boolean(window.__task178Noop) || true, null, { timeout: 10 }); - await page.waitForFunction( - () => document.querySelector('[data-testid="wolai-page-ai-drawer"] .wolai-page-ai-message--assistant'), - null, - { timeout: UI_TIMEOUT_MS }, - ); - - assert(capturedBody, "未捕获 /api/ai-agent/run 请求"); - const contextPayload = capturedBody.context || {}; - const rawContext = stringify(contextPayload); - assert.equal(contextPayload.pageSubtreeSource, "local", `AI context 应标记本地 pageSubtree,实际: ${rawContext}`); - assert(rawContext.includes(localHeading), `AI context 应包含本地 heading: ${rawContext.slice(0, 1600)}`); - assert(Array.isArray(contextPayload.documentBlocks), `AI context 应包含本地 documentBlocks: ${rawContext.slice(0, 1600)}`); - assert(contextPayload.outline?.some((item) => stringify(item).includes(localHeading)), `AI context outline 应包含本地 heading: ${rawContext.slice(0, 1600)}`); - assert(contextPayload.subtree?.stats?.headingCount >= 1, `AI context subtree stats 应包含 headingCount: ${rawContext.slice(0, 1600)}`); - - console.log( - JSON.stringify( - { - ok: true, - baseUrl: BASE_URL, - documentId: target.documentId, - workspaceId: target.workspaceId, - localHeading, - pageSubtreeSource: contextPayload.pageSubtreeSource, - headingCount: contextPayload.subtree?.stats?.headingCount ?? null, - }, - null, - 2, - ), - ); - } finally { - if (target) await purgeTempPage(target).catch(() => undefined); - await page.close().catch(() => undefined); - await context.close().catch(() => undefined); - await browser.close().catch(() => undefined); - } -} - -if (require.main === module) { - main().catch((error) => { - console.error(error instanceof Error ? error.stack || error.message : String(error)); - process.exit(1); - }); -} diff --git a/scripts/task446-tree-rename-dual-browser-live-smoke.js b/scripts/task446-tree-rename-dual-browser-live-smoke.js new file mode 100644 index 00000000..b0388976 --- /dev/null +++ b/scripts/task446-tree-rename-dual-browser-live-smoke.js @@ -0,0 +1,356 @@ +#!/usr/bin/env node +"use strict"; + +const assert = require("node:assert/strict"); +const fs = require("node:fs/promises"); +const path = require("node:path"); +const { chromium } = require("playwright"); + +const TASK = "task446-tree-rename-dual-browser-live-smoke"; +const BASE_URL = (process.env.MNOTE_UI_BASE_URL || "http://127.0.0.1:3000").replace(/\/+$/, ""); +const AUTH_BASE_URL = (process.env.MNOTE_AUTH_BASE_URL || BASE_URL).replace(/\/+$/, ""); +const UI_TIMEOUT_MS = Number(process.env.MNOTE_SMOKE_UI_TIMEOUT_MS || 35_000); +const OUTPUT_DIR = path.join(process.cwd(), "tmp", "tree-live-cache-smoke", "20260516-task446-rename"); +const RESULT_PATH = path.join(OUTPUT_DIR, "result.json"); +const SCREENSHOT_B_DOCUMENT = path.join(OUTPUT_DIR, "b-document-after-rename.png"); +const SCREENSHOT_B_FILETREE = path.join(OUTPUT_DIR, "b-filetree-after-rename.png"); + +const e2ePassword = () => process.env.MNOTE_E2E_PASSWORD || ["Mnote", "E2E", "123!"].join(""); + +function cssString(value) { + return String(value).replace(/\\/g, "\\\\").replace(/"/g, '\\"'); +} + +function docRowSelector(documentId) { + return `#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-row-id="doc:${cssString(documentId)}"]`; +} + +async function writeResult(payload) { + await fs.mkdir(OUTPUT_DIR, { recursive: true }); + await fs.writeFile(RESULT_PATH, `${JSON.stringify({ ...payload, resultPath: RESULT_PATH }, null, 2)}\n`, "utf8"); +} + +async function requestJson(request, baseUrl, requestPath, init = {}) { + const response = await request.fetch(`${baseUrl}${requestPath}`, { + ...init, + headers: { + ...(init.data !== undefined ? { "content-type": "application/json" } : {}), + ...(init.headers || {}), + }, + timeout: 20_000, + }); + const text = await response.text(); + let payload = null; + try { + payload = text ? JSON.parse(text) : null; + } catch { + payload = text; + } + if (!response.ok()) { + throw new Error(`${requestPath} 请求失败: ${response.status()} ${typeof payload === "string" ? payload : JSON.stringify(payload)}`); + } + return payload; +} + +async function authenticate(request, email, name) { + await requestJson(request, AUTH_BASE_URL, "/api/auth", { + method: "POST", + data: { + action: "auth:signIn", + args: { + provider: "password", + params: { email, password: e2ePassword(), flow: "signUp", name }, + }, + }, + }); +} + +async function createPage(request, workspaceId, title, parentId = null) { + const payload = await requestJson(request, BASE_URL, "/api/tree/commands", { + method: "POST", + data: { + action: "create", + workspaceId, + parentId, + title, + }, + }); + const result = payload.result || payload; + const documentId = result.documentId || payload.documentId || result.id || ""; + const resolvedWorkspaceId = result.workspaceId || payload.workspaceId || payload.workspace_id || workspaceId || ""; + assert(documentId, `创建页面失败: ${JSON.stringify(payload)}`); + assert(resolvedWorkspaceId, `创建页面缺少 workspaceId: ${JSON.stringify(payload)}`); + return { documentId, workspaceId: resolvedWorkspaceId, payload }; +} + +async function treeCommand(request, workspaceId, action, documentId, extra = {}) { + return await requestJson(request, BASE_URL, "/api/tree/commands", { + method: "POST", + data: { action, workspaceId, documentId, ...extra }, + }); +} + +async function emptyDocumentTrash(request, workspaceId) { + return await requestJson(request, BASE_URL, "/api/documents/empty-trash", { + method: "POST", + data: { workspaceId }, + }); +} + +async function cleanup(request, workspaceId, documentIds) { + if (!workspaceId) return; + for (const documentId of documentIds.filter(Boolean)) { + await treeCommand(request, workspaceId, "archive", documentId).catch(() => null); + } + await emptyDocumentTrash(request, workspaceId).catch(() => null); +} + +async function openDocument(page, workspaceId, documentId) { + await page.goto(`${BASE_URL}/documents/${encodeURIComponent(documentId)}?workspaceId=${encodeURIComponent(workspaceId)}`, { + waitUntil: "commit", + timeout: UI_TIMEOUT_MS, + }); + await page.locator(`[data-page-title-input="true"][data-document-id="${cssString(documentId)}"]`).first().waitFor({ + state: "visible", + timeout: UI_TIMEOUT_MS, + }); +} + +async function openDocumentFileTree(page, workspaceId, documentId) { + await openDocument(page, workspaceId, documentId); + await page.evaluate(() => { + const visible = (node) => + node instanceof HTMLElement && + !node.hidden && + getComputedStyle(node).display !== "none" && + getComputedStyle(node).visibility !== "hidden" && + node.getClientRects().length > 0; + const fileRoot = document.getElementById("sidebar-file-tree-root"); + if (visible(fileRoot)) return; + const tab = document.querySelector('[data-mnote-sidebar-tree-tab="filetree"], button[aria-label="文件"]'); + if (tab instanceof HTMLElement) tab.click(); + }); + await page.locator("#sidebar-file-tree-root").waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); +} + +async function waitForBDocumentRename(page, documentId, expectedTitle) { + await page.waitForFunction( + ({ id, title }) => { + const titleInput = document.querySelector(`[data-page-title-input="true"][data-document-id="${CSS.escape(id)}"]`); + const titleValue = titleInput instanceof HTMLTextAreaElement ? titleInput.value : ""; + const breadcrumb = document.querySelector(".wolai-breadcrumb-current [data-page-title-current]")?.textContent?.trim() || ""; + const pageRow = document.querySelector(`#sidebar-tree-root .tree-row[data-node-id="${CSS.escape(id)}"] .tree-link-title`)?.textContent?.trim() || ""; + return titleValue === title && breadcrumb === title && pageRow === title; + }, + { id: documentId, title: expectedTitle }, + { timeout: UI_TIMEOUT_MS }, + ); +} + +async function waitForBFileTreeRename(page, documentId, expectedTitle) { + await page.waitForFunction( + ({ id, title }) => { + const fileRow = document.querySelector(`#sidebar-file-tree-root .tree-row[data-row-id="doc:${CSS.escape(id)}"]`); + const fileTitle = fileRow instanceof HTMLElement ? fileRow.querySelector(".tree-link-title")?.textContent?.trim() || "" : ""; + return fileTitle === `${title}.md`; + }, + { id: documentId, title: expectedTitle }, + { timeout: UI_TIMEOUT_MS }, + ); +} + +async function installTreeEventRecorder(page, label, records) { + await page.addInitScript(() => { + window.__MNOTE_TASK446_TREE_EVENTS__ = []; + const record = (name, event) => { + const detail = event && event.detail ? event.detail : {}; + const payload = detail.payload || detail || {}; + const raw = (() => { + try { + return JSON.stringify(payload); + } catch { + return ""; + } + })(); + window.__MNOTE_TASK446_TREE_EVENTS__.push({ + name, + at: Date.now(), + revision: detail.revision || payload.revision || payload.cursor || "", + op: payload && payload.data && payload.data.op ? payload.data.op : payload.op || "", + raw: raw.slice(0, 2000), + }); + }; + window.addEventListener("tree:snapshot", (event) => record("tree:snapshot", event)); + window.addEventListener("tree:delta", (event) => record("tree:delta", event)); + window.addEventListener("tree:resync", (event) => record("tree:resync", event)); + }); + page.on("request", (request) => { + const url = request.url(); + if (url.includes("/api/tree/events")) { + records.push({ label, type: "request", method: request.method(), url, at: Date.now() }); + } + }); + page.on("requestfailed", (request) => { + const url = request.url(); + if (url.includes("/api/tree/events")) { + records.push({ + label, + type: "requestfailed", + method: request.method(), + url, + failure: request.failure()?.errorText || "", + at: Date.now(), + }); + } + }); + page.on("console", (message) => { + const text = message.text(); + if (/tree live|EventSource|rename|error|failed/i.test(text)) { + records.push({ label, type: "console", level: message.type(), text: text.slice(0, 2000), at: Date.now() }); + } + }); +} + +function recordNavigation(page, label, records) { + page.on("framenavigated", (frame) => { + if (frame === page.mainFrame()) { + records.push({ label, url: frame.url(), at: Date.now() }); + } + }); +} + +async function readState(page, documentId) { + return await page.evaluate((id) => { + const pageRow = document.querySelector(`#sidebar-tree-root .tree-row[data-node-id="${CSS.escape(id)}"]`); + const fileRow = document.querySelector(`#sidebar-file-tree-root .tree-row[data-row-id="doc:${CSS.escape(id)}"]`); + const titleInput = document.querySelector(`[data-page-title-input="true"][data-document-id="${CSS.escape(id)}"]`); + return { + url: window.location.href, + documentTitle: document.title, + liveStatus: document.documentElement.getAttribute("data-mnote-tree-live-status") || "", + liveApplied: document.documentElement.getAttribute("data-mnote-tree-live-applied") || "", + liveRevision: document.documentElement.getAttribute("data-mnote-tree-live-revision") || "", + liveError: document.documentElement.getAttribute("data-mnote-tree-live-apply-error") || "", + titleInputValue: titleInput instanceof HTMLTextAreaElement ? titleInput.value : "", + breadcrumbTitle: document.querySelector(".wolai-breadcrumb-current [data-page-title-current]")?.textContent?.trim() || "", + sidebarTitle: pageRow instanceof HTMLElement ? pageRow.querySelector(".tree-link-title")?.textContent?.trim() || "" : "", + fileTreeTitle: fileRow instanceof HTMLElement ? fileRow.querySelector(".tree-link-title")?.textContent?.trim() || "" : "", + fileTreeRowExists: fileRow instanceof HTMLElement, + treeEvents: window.__MNOTE_TASK446_TREE_EVENTS__ || [], + }; + }, documentId); +} + +(async () => { + const stamp = Date.now(); + const email = `mnote.stage8.rename.${stamp}@example.com`; + const prefix = `TEST-10REVIEW-08-RENAME-${stamp}`; + const initialTitle = `${prefix}-initial`; + const renamedTitle = `${prefix}-renamed`; + const result = { + ok: false, + task: TASK, + baseUrl: BASE_URL, + authBaseUrl: AUTH_BASE_URL, + email, + prefix, + fixture: {}, + requests: [], + navigationEvents: [], + treeEventRequests: [], + states: {}, + screenshots: { + bDocument: SCREENSHOT_B_DOCUMENT, + bFileTree: SCREENSHOT_B_FILETREE, + }, + }; + + const browser = await chromium.launch({ headless: process.env.HEADFUL !== "1" }); + const contextA = await browser.newContext(); + const contextB = await browser.newContext(); + const pageA = await contextA.newPage(); + const documentB = await contextB.newPage(); + const fileTreeB = await contextB.newPage(); + const requestA = contextA.request; + let workspaceId = ""; + const cleanupIds = []; + + pageA.on("request", (request) => { + const url = request.url(); + if (url.includes("/api/tree/commands") || url.includes("/api/documents/empty-trash")) { + result.requests.push({ side: "A-page", method: request.method(), url, body: request.postData() || null, at: Date.now() }); + } + }); + await installTreeEventRecorder(documentB, "B-document", result.treeEventRequests); + await installTreeEventRecorder(fileTreeB, "B-filetree", result.treeEventRequests); + recordNavigation(documentB, "B-document", result.navigationEvents); + recordNavigation(fileTreeB, "B-filetree", result.navigationEvents); + + try { + for (const requestContext of [requestA, contextB.request]) { + await authenticate(requestContext, email, `stage8-rename-${stamp}`); + } + + const root = await createPage(requestA, null, `${prefix}-root`); + workspaceId = root.workspaceId; + cleanupIds.push(root.documentId); + result.fixture.rootId = root.documentId; + result.fixture.workspaceId = workspaceId; + + const target = await createPage(requestA, workspaceId, initialTitle, root.documentId); + cleanupIds.push(target.documentId); + result.fixture.targetId = target.documentId; + result.fixture.initialTitle = initialTitle; + result.fixture.renamedTitle = renamedTitle; + + await pageA.goto(`${BASE_URL}/documents/${encodeURIComponent(root.documentId)}?workspaceId=${encodeURIComponent(workspaceId)}`, { + waitUntil: "commit", + timeout: UI_TIMEOUT_MS, + }); + await openDocument(documentB, workspaceId, target.documentId); + await openDocumentFileTree(fileTreeB, workspaceId, target.documentId); + await fileTreeB.locator(docRowSelector(target.documentId)).first().waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); + await waitForBDocumentRename(documentB, target.documentId, initialTitle); + await waitForBFileTreeRename(fileTreeB, target.documentId, initialTitle); + result.states.before = { + document: await readState(documentB, target.documentId), + fileTree: await readState(fileTreeB, target.documentId), + }; + + const navigationStart = result.navigationEvents.length; + await treeCommand(requestA, workspaceId, "rename", target.documentId, { title: renamedTitle }); + await waitForBDocumentRename(documentB, target.documentId, renamedTitle); + await waitForBFileTreeRename(fileTreeB, target.documentId, renamedTitle); + result.states.after = { + document: await readState(documentB, target.documentId), + fileTree: await readState(fileTreeB, target.documentId), + navigationEvents: result.navigationEvents.slice(navigationStart), + }; + + const unexpectedNavigations = result.navigationEvents.slice(navigationStart); + assert.equal(unexpectedNavigations.length, 0, `B 浏览器发生了刷新/导航: ${JSON.stringify(unexpectedNavigations)}`); + assert.equal(result.states.after.document.titleInputValue, renamedTitle, "B 文档页页头标题未 live 更新"); + assert.equal(result.states.after.document.breadcrumbTitle, renamedTitle, "B 文档页 Breadcrumb 未 live 更新"); + assert.equal(result.states.after.document.sidebarTitle, renamedTitle, "B 文档页 Sidebar 未 live 更新"); + assert.equal(result.states.after.fileTree.fileTreeTitle, `${renamedTitle}.md`, "B File Tree 未 live 更新为 .md 文件名"); + assert(!result.states.after.document.liveError, `B 文档页存在 live apply error: ${result.states.after.document.liveError}`); + assert(!result.states.after.fileTree.liveError, `B File Tree 存在 live apply error: ${result.states.after.fileTree.liveError}`); + + await documentB.screenshot({ path: SCREENSHOT_B_DOCUMENT, fullPage: true }); + await fileTreeB.screenshot({ path: SCREENSHOT_B_FILETREE, fullPage: true }); + result.ok = true; + } catch (error) { + result.error = error instanceof Error ? error.stack || error.message : String(error); + result.failure = { + document: await readState(documentB, result.fixture.targetId || "").catch(() => null), + fileTree: await readState(fileTreeB, result.fixture.targetId || "").catch(() => null), + }; + process.exitCode = 1; + } finally { + await cleanup(requestA, workspaceId, cleanupIds).catch((error) => { + result.cleanupError = error instanceof Error ? error.message : String(error); + }); + await browser.close().catch(() => undefined); + await writeResult(result); + } +})(); diff --git a/scripts/task447-tree-move-order-dual-browser-live-smoke.js b/scripts/task447-tree-move-order-dual-browser-live-smoke.js new file mode 100644 index 00000000..51dacbba --- /dev/null +++ b/scripts/task447-tree-move-order-dual-browser-live-smoke.js @@ -0,0 +1,391 @@ +#!/usr/bin/env node +"use strict"; + +const assert = require("node:assert/strict"); +const fs = require("node:fs/promises"); +const path = require("node:path"); +const { chromium } = require("playwright"); + +const TASK = "task447-tree-move-order-dual-browser-live-smoke"; +const BASE_URL = (process.env.MNOTE_UI_BASE_URL || "http://127.0.0.1:3000").replace(/\/+$/, ""); +const AUTH_BASE_URL = (process.env.MNOTE_AUTH_BASE_URL || BASE_URL).replace(/\/+$/, ""); +const UI_TIMEOUT_MS = Number(process.env.MNOTE_SMOKE_UI_TIMEOUT_MS || 35_000); +const OUTPUT_DIR = path.join(process.cwd(), "tmp", "tree-live-cache-smoke", "20260516-task447-move-order"); +const RESULT_PATH = path.join(OUTPUT_DIR, "result.json"); +const SCREENSHOT_B_DOCUMENT = path.join(OUTPUT_DIR, "b-document-after-move.png"); +const SCREENSHOT_B_FILETREE = path.join(OUTPUT_DIR, "b-filetree-after-move.png"); + +const e2ePassword = () => process.env.MNOTE_E2E_PASSWORD || ["Mnote", "E2E", "123!"].join(""); + +function cssString(value) { + return String(value).replace(/\\/g, "\\\\").replace(/"/g, '\\"'); +} + +async function writeResult(payload) { + await fs.mkdir(OUTPUT_DIR, { recursive: true }); + await fs.writeFile(RESULT_PATH, `${JSON.stringify({ ...payload, resultPath: RESULT_PATH }, null, 2)}\n`, "utf8"); +} + +async function requestJson(request, baseUrl, requestPath, init = {}) { + const response = await request.fetch(`${baseUrl}${requestPath}`, { + ...init, + headers: { + ...(init.data !== undefined ? { "content-type": "application/json" } : {}), + ...(init.headers || {}), + }, + timeout: 20_000, + }); + const text = await response.text(); + let payload = null; + try { + payload = text ? JSON.parse(text) : null; + } catch { + payload = text; + } + if (!response.ok()) { + throw new Error(`${requestPath} 请求失败: ${response.status()} ${typeof payload === "string" ? payload : JSON.stringify(payload)}`); + } + return payload; +} + +async function authenticate(request, email, name) { + await requestJson(request, AUTH_BASE_URL, "/api/auth", { + method: "POST", + data: { + action: "auth:signIn", + args: { + provider: "password", + params: { email, password: e2ePassword(), flow: "signUp", name }, + }, + }, + }); +} + +async function createPage(request, workspaceId, title, parentId = null) { + const payload = await requestJson(request, BASE_URL, "/api/tree/commands", { + method: "POST", + data: { action: "create", workspaceId, parentId, title }, + }); + const result = payload.result || payload; + const documentId = result.documentId || payload.documentId || result.id || ""; + const resolvedWorkspaceId = result.workspaceId || payload.workspaceId || payload.workspace_id || workspaceId || ""; + assert(documentId, `创建页面失败: ${JSON.stringify(payload)}`); + assert(resolvedWorkspaceId, `创建页面缺少 workspaceId: ${JSON.stringify(payload)}`); + return { documentId, workspaceId: resolvedWorkspaceId, title, payload }; +} + +async function treeCommand(request, workspaceId, action, documentId, extra = {}) { + return await requestJson(request, BASE_URL, "/api/tree/commands", { + method: "POST", + data: { action, workspaceId, documentId, ...extra }, + }); +} + +async function emptyDocumentTrash(request, workspaceId) { + return await requestJson(request, BASE_URL, "/api/documents/empty-trash", { + method: "POST", + data: { workspaceId }, + }); +} + +async function cleanup(request, workspaceId, documentIds) { + if (!workspaceId) return; + for (const documentId of documentIds.filter(Boolean)) { + await treeCommand(request, workspaceId, "archive", documentId).catch(() => null); + } + await emptyDocumentTrash(request, workspaceId).catch(() => null); +} + +async function openDocument(page, workspaceId, documentId) { + await page.goto(`${BASE_URL}/documents/${encodeURIComponent(documentId)}?workspaceId=${encodeURIComponent(workspaceId)}`, { + waitUntil: "commit", + timeout: UI_TIMEOUT_MS, + }); + await page.locator(`[data-page-title-input="true"][data-document-id="${cssString(documentId)}"]`).first().waitFor({ + state: "visible", + timeout: UI_TIMEOUT_MS, + }); +} + +async function openFileTree(page, workspaceId, documentId) { + await openDocument(page, workspaceId, documentId); + await page.evaluate(() => { + const visible = (node) => + node instanceof HTMLElement && + !node.hidden && + getComputedStyle(node).display !== "none" && + getComputedStyle(node).visibility !== "hidden" && + node.getClientRects().length > 0; + const fileRoot = document.getElementById("sidebar-file-tree-root"); + if (visible(fileRoot)) return; + const tab = document.querySelector('[data-mnote-sidebar-tree-tab="filetree"], button[aria-label="文件"]'); + if (tab instanceof HTMLElement) tab.click(); + }); + await page.locator("#sidebar-file-tree-root").waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); +} + +async function installTreeEventRecorder(page, label, records) { + await page.addInitScript(() => { + window.__MNOTE_TASK447_TREE_EVENTS__ = []; + const record = (name, event) => { + const detail = event && event.detail ? event.detail : {}; + const payload = detail.payload || detail || {}; + const raw = (() => { + try { + return JSON.stringify(payload); + } catch { + return ""; + } + })(); + window.__MNOTE_TASK447_TREE_EVENTS__.push({ + name, + at: Date.now(), + revision: detail.revision || payload.revision || payload.cursor || "", + op: payload && payload.data && payload.data.op ? payload.data.op : payload.op || "", + raw: raw.slice(0, 2000), + }); + }; + window.addEventListener("tree:snapshot", (event) => record("tree:snapshot", event)); + window.addEventListener("tree:delta", (event) => record("tree:delta", event)); + window.addEventListener("tree:resync", (event) => record("tree:resync", event)); + }); + page.on("request", (request) => { + const url = request.url(); + if (url.includes("/api/tree/events")) { + records.push({ label, type: "request", method: request.method(), url, at: Date.now() }); + } + }); + page.on("requestfailed", (request) => { + const url = request.url(); + if (url.includes("/api/tree/events")) { + records.push({ + label, + type: "requestfailed", + method: request.method(), + url, + failure: request.failure()?.errorText || "", + at: Date.now(), + }); + } + }); +} + +function recordNavigation(page, label, records) { + page.on("framenavigated", (frame) => { + if (frame === page.mainFrame()) { + records.push({ label, url: frame.url(), at: Date.now() }); + } + }); +} + +async function installOrderRecorder(page, rootDocumentId) { + await page.evaluate((rootId) => { + const readDirectChildren = (mode) => { + const rootSelector = + mode === "filetree" + ? `#sidebar-file-tree-root .tree-row[data-row-id="doc:${CSS.escape(rootId)}"]` + : `#sidebar-tree-root .tree-row[data-node-id="${CSS.escape(rootId)}"]`; + const rootRow = document.querySelector(rootSelector); + const list = rootRow?.closest(".tree-node")?.querySelector(":scope > .tree-children"); + if (!list) return []; + return Array.from(list.querySelectorAll(":scope > .tree-node > .tree-row")).map((row) => ({ + documentId: row instanceof HTMLElement ? row.dataset.documentId || row.dataset.docId || row.dataset.nodeId || "" : "", + rowId: row instanceof HTMLElement ? row.dataset.rowId || "" : "", + title: row instanceof HTMLElement ? row.querySelector(".tree-link-title")?.textContent?.trim() || "" : "", + })); + }; + const push = () => { + const record = { + at: Date.now(), + page: readDirectChildren("page"), + filetree: readDirectChildren("filetree"), + }; + window.__MNOTE_TASK447_ORDER_HISTORY__ = window.__MNOTE_TASK447_ORDER_HISTORY__ || []; + const history = window.__MNOTE_TASK447_ORDER_HISTORY__; + const last = history[history.length - 1]; + if (!last || JSON.stringify(last.page) !== JSON.stringify(record.page) || JSON.stringify(last.filetree) !== JSON.stringify(record.filetree)) { + history.push(record); + } + }; + push(); + const observe = (root) => { + if (!(root instanceof HTMLElement)) return; + const observer = new MutationObserver(push); + observer.observe(root, { childList: true, subtree: true, attributes: true, attributeFilter: ["data-parent-id"] }); + }; + observe(document.getElementById("sidebar-tree-root")); + observe(document.getElementById("sidebar-file-tree-root")); + }, rootDocumentId); +} + +async function waitForExpectedOrder(page, rootDocumentId, expectedIds) { + await page.waitForFunction( + ({ rootId, ids }) => { + const read = (mode) => { + const rootSelector = + mode === "filetree" + ? `#sidebar-file-tree-root .tree-row[data-row-id="doc:${CSS.escape(rootId)}"]` + : `#sidebar-tree-root .tree-row[data-node-id="${CSS.escape(rootId)}"]`; + const rootRow = document.querySelector(rootSelector); + const list = rootRow?.closest(".tree-node")?.querySelector(":scope > .tree-children"); + if (!list) return []; + return Array.from(list.querySelectorAll(":scope > .tree-node > .tree-row")).map((row) => + row instanceof HTMLElement ? row.dataset.documentId || row.dataset.docId || row.dataset.nodeId || "" : "", + ); + }; + const pageOrder = read("page"); + const fileOrder = read("filetree"); + return ids.every((id, index) => pageOrder[index] === id && fileOrder[index] === id); + }, + { rootId: rootDocumentId, ids: expectedIds }, + { timeout: UI_TIMEOUT_MS }, + ); +} + +async function readState(page, rootDocumentId) { + return await page.evaluate((rootId) => { + const history = window.__MNOTE_TASK447_ORDER_HISTORY__ || []; + const read = (mode) => { + const rootSelector = + mode === "filetree" + ? `#sidebar-file-tree-root .tree-row[data-row-id="doc:${CSS.escape(rootId)}"]` + : `#sidebar-tree-root .tree-row[data-node-id="${CSS.escape(rootId)}"]`; + const rootRow = document.querySelector(rootSelector); + const list = rootRow?.closest(".tree-node")?.querySelector(":scope > .tree-children"); + if (!list) return []; + return Array.from(list.querySelectorAll(":scope > .tree-node > .tree-row")).map((row) => ({ + documentId: row instanceof HTMLElement ? row.dataset.documentId || row.dataset.docId || row.dataset.nodeId || "" : "", + parentId: row instanceof HTMLElement ? row.dataset.parentId || "" : "", + rowId: row instanceof HTMLElement ? row.dataset.rowId || "" : "", + title: row instanceof HTMLElement ? row.querySelector(".tree-link-title")?.textContent?.trim() || "" : "", + })); + }; + return { + url: window.location.href, + liveStatus: document.documentElement.getAttribute("data-mnote-tree-live-status") || "", + liveApplied: document.documentElement.getAttribute("data-mnote-tree-live-applied") || "", + liveRevision: document.documentElement.getAttribute("data-mnote-tree-live-revision") || "", + liveError: document.documentElement.getAttribute("data-mnote-tree-live-apply-error") || "", + pageOrder: read("page"), + fileTreeOrder: read("filetree"), + orderHistory: history, + treeEvents: window.__MNOTE_TASK447_TREE_EVENTS__ || [], + }; + }, rootDocumentId); +} + +function orderIds(rows) { + return rows.map((row) => row.documentId); +} + +(async () => { + const stamp = Date.now(); + const email = `mnote.stage8.move.${stamp}@example.com`; + const prefix = `TEST-10REVIEW-08-MOVE-${stamp}`; + const result = { + ok: false, + task: TASK, + baseUrl: BASE_URL, + authBaseUrl: AUTH_BASE_URL, + email, + prefix, + fixture: {}, + navigationEvents: [], + treeEventRequests: [], + states: {}, + screenshots: { + bDocument: SCREENSHOT_B_DOCUMENT, + bFileTree: SCREENSHOT_B_FILETREE, + }, + }; + + const browser = await chromium.launch({ headless: process.env.HEADFUL !== "1" }); + const contextA = await browser.newContext(); + const contextB = await browser.newContext(); + const documentB = await contextB.newPage(); + const fileTreeB = await contextB.newPage(); + const requestA = contextA.request; + let workspaceId = ""; + const cleanupIds = []; + + await installTreeEventRecorder(documentB, "B-document", result.treeEventRequests); + await installTreeEventRecorder(fileTreeB, "B-filetree", result.treeEventRequests); + recordNavigation(documentB, "B-document", result.navigationEvents); + recordNavigation(fileTreeB, "B-filetree", result.navigationEvents); + + try { + for (const requestContext of [requestA, contextB.request]) { + await authenticate(requestContext, email, `stage8-move-${stamp}`); + } + + const root = await createPage(requestA, null, `${prefix}-root`); + workspaceId = root.workspaceId; + cleanupIds.push(root.documentId); + const childA = await createPage(requestA, workspaceId, `${prefix}-a`, root.documentId); + const childB = await createPage(requestA, workspaceId, `${prefix}-b`, root.documentId); + const childC = await createPage(requestA, workspaceId, `${prefix}-c`, root.documentId); + cleanupIds.push(childA.documentId, childB.documentId, childC.documentId); + const initialOrder = [childA.documentId, childB.documentId, childC.documentId]; + const expectedOrder = [childA.documentId, childC.documentId, childB.documentId]; + result.fixture = { + workspaceId, + rootId: root.documentId, + childIds: initialOrder, + movedId: childC.documentId, + initialOrder, + expectedOrder, + }; + + await openDocument(documentB, workspaceId, root.documentId); + await openFileTree(fileTreeB, workspaceId, root.documentId); + await waitForExpectedOrder(documentB, root.documentId, initialOrder); + await waitForExpectedOrder(fileTreeB, root.documentId, initialOrder); + await installOrderRecorder(documentB, root.documentId); + await installOrderRecorder(fileTreeB, root.documentId); + result.states.before = { + document: await readState(documentB, root.documentId), + fileTree: await readState(fileTreeB, root.documentId), + }; + + const navigationStart = result.navigationEvents.length; + await treeCommand(requestA, workspaceId, "move", childC.documentId, { + parentId: root.documentId, + sortOrder: 1, + }); + await waitForExpectedOrder(documentB, root.documentId, expectedOrder); + await waitForExpectedOrder(fileTreeB, root.documentId, expectedOrder); + await documentB.waitForTimeout(1000); + await fileTreeB.waitForTimeout(1000); + + result.states.after = { + document: await readState(documentB, root.documentId), + fileTree: await readState(fileTreeB, root.documentId), + navigationEvents: result.navigationEvents.slice(navigationStart), + }; + + assert.deepEqual(orderIds(result.states.after.document.pageOrder).slice(0, 3), expectedOrder, "B 文档页 Page Tree 顺序未保持新顺序"); + assert.deepEqual(orderIds(result.states.after.document.fileTreeOrder).slice(0, 3), expectedOrder, "B 文档页 File Tree 顺序未保持新顺序"); + assert.deepEqual(orderIds(result.states.after.fileTree.pageOrder).slice(0, 3), expectedOrder, "B File Tree 页面 Page Tree 顺序未保持新顺序"); + assert.deepEqual(orderIds(result.states.after.fileTree.fileTreeOrder).slice(0, 3), expectedOrder, "B File Tree 页面 File Tree 顺序未保持新顺序"); + assert.equal(result.states.after.navigationEvents.length, 0, `B 浏览器发生了刷新/导航: ${JSON.stringify(result.states.after.navigationEvents)}`); + assert(!result.states.after.document.liveError, `B 文档页存在 live apply error: ${result.states.after.document.liveError}`); + assert(!result.states.after.fileTree.liveError, `B File Tree 存在 live apply error: ${result.states.after.fileTree.liveError}`); + + await documentB.screenshot({ path: SCREENSHOT_B_DOCUMENT, fullPage: true }); + await fileTreeB.screenshot({ path: SCREENSHOT_B_FILETREE, fullPage: true }); + result.ok = true; + } catch (error) { + result.error = error instanceof Error ? error.stack || error.message : String(error); + result.failure = { + document: await readState(documentB, result.fixture.rootId || "").catch(() => null), + fileTree: await readState(fileTreeB, result.fixture.rootId || "").catch(() => null), + }; + process.exitCode = 1; + } finally { + await cleanup(requestA, workspaceId, cleanupIds).catch((error) => { + result.cleanupError = error instanceof Error ? error.message : String(error); + }); + await browser.close().catch(() => undefined); + await writeResult(result); + } +})(); diff --git a/scripts/task448-tree-resync-recovery-dual-browser-smoke.js b/scripts/task448-tree-resync-recovery-dual-browser-smoke.js new file mode 100644 index 00000000..5554f8ab --- /dev/null +++ b/scripts/task448-tree-resync-recovery-dual-browser-smoke.js @@ -0,0 +1,370 @@ +#!/usr/bin/env node +"use strict"; + +const assert = require("node:assert/strict"); +const fs = require("node:fs/promises"); +const path = require("node:path"); +const { chromium } = require("playwright"); + +const TASK = "task448-tree-resync-recovery-dual-browser-smoke"; +const BASE_URL = (process.env.MNOTE_UI_BASE_URL || "http://127.0.0.1:3000").replace(/\/+$/, ""); +const AUTH_BASE_URL = (process.env.MNOTE_AUTH_BASE_URL || BASE_URL).replace(/\/+$/, ""); +const UI_TIMEOUT_MS = Number(process.env.MNOTE_SMOKE_UI_TIMEOUT_MS || 40_000); +const OUTPUT_DIR = path.join(process.cwd(), "tmp", "tree-live-cache-smoke", "20260516-task448-resync"); +const RESULT_PATH = path.join(OUTPUT_DIR, "result.json"); +const SCREENSHOT_B_DOCUMENT = path.join(OUTPUT_DIR, "b-document-after-resync.png"); +const SCREENSHOT_B_FILETREE = path.join(OUTPUT_DIR, "b-filetree-after-resync.png"); + +const e2ePassword = () => process.env.MNOTE_E2E_PASSWORD || ["Mnote", "E2E", "123!"].join(""); + +function cssString(value) { + return String(value).replace(/\\/g, "\\\\").replace(/"/g, '\\"'); +} + +async function writeResult(payload) { + await fs.mkdir(OUTPUT_DIR, { recursive: true }); + await fs.writeFile(RESULT_PATH, `${JSON.stringify({ ...payload, resultPath: RESULT_PATH }, null, 2)}\n`, "utf8"); +} + +async function requestJson(request, baseUrl, requestPath, init = {}) { + const response = await request.fetch(`${baseUrl}${requestPath}`, { + ...init, + headers: { + ...(init.data !== undefined ? { "content-type": "application/json" } : {}), + ...(init.headers || {}), + }, + timeout: 20_000, + }); + const text = await response.text(); + let payload = null; + try { + payload = text ? JSON.parse(text) : null; + } catch { + payload = text; + } + if (!response.ok()) { + throw new Error(`${requestPath} 请求失败: ${response.status()} ${typeof payload === "string" ? payload : JSON.stringify(payload)}`); + } + return payload; +} + +async function authenticate(request, email, name) { + await requestJson(request, AUTH_BASE_URL, "/api/auth", { + method: "POST", + data: { + action: "auth:signIn", + args: { + provider: "password", + params: { email, password: e2ePassword(), flow: "signUp", name }, + }, + }, + }); +} + +async function createPage(request, workspaceId, title, parentId = null) { + const payload = await requestJson(request, BASE_URL, "/api/tree/commands", { + method: "POST", + data: { action: "create", workspaceId, parentId, title }, + }); + const result = payload.result || payload; + const documentId = result.documentId || payload.documentId || result.id || ""; + const resolvedWorkspaceId = result.workspaceId || payload.workspaceId || payload.workspace_id || workspaceId || ""; + assert(documentId, `创建页面失败: ${JSON.stringify(payload)}`); + assert(resolvedWorkspaceId, `创建页面缺少 workspaceId: ${JSON.stringify(payload)}`); + return { documentId, workspaceId: resolvedWorkspaceId, title, payload }; +} + +async function treeCommand(request, workspaceId, action, documentId, extra = {}) { + return await requestJson(request, BASE_URL, "/api/tree/commands", { + method: "POST", + data: { action, workspaceId, documentId, ...extra }, + }); +} + +async function emptyDocumentTrash(request, workspaceId) { + return await requestJson(request, BASE_URL, "/api/documents/empty-trash", { + method: "POST", + data: { workspaceId }, + }); +} + +async function cleanup(request, workspaceId, documentIds) { + if (!workspaceId) return; + for (const documentId of documentIds.filter(Boolean)) { + await treeCommand(request, workspaceId, "archive", documentId).catch(() => null); + } + await emptyDocumentTrash(request, workspaceId).catch(() => null); +} + +async function installSlowTreeEventSource(page, pollMs) { + await page.addInitScript((value) => { + const OriginalEventSource = window.EventSource; + if (typeof OriginalEventSource !== "function" || window.__MNOTE_TASK448_PATCHED_EVENTSOURCE__) return; + window.__MNOTE_TASK448_PATCHED_EVENTSOURCE__ = true; + window.EventSource = function patchedEventSource(input, init) { + try { + const url = new URL(String(input), window.location.href); + if (url.pathname === "/api/tree/events") { + url.searchParams.set("pollMs", String(value)); + return new OriginalEventSource(url.toString(), init); + } + } catch (_) { + } + return new OriginalEventSource(input, init); + }; + window.EventSource.prototype = OriginalEventSource.prototype; + }, pollMs); +} + +async function installTreeEventRecorder(page, label, requests) { + await page.addInitScript(() => { + window.__MNOTE_TASK448_TREE_EVENTS__ = []; + const record = (name, event) => { + const detail = event && event.detail ? event.detail : {}; + const payload = detail.payload || detail || {}; + let raw = ""; + try { + raw = JSON.stringify(payload); + } catch { + raw = ""; + } + window.__MNOTE_TASK448_TREE_EVENTS__.push({ + name, + at: Date.now(), + revision: detail.revision || payload.revision || payload.cursor || "", + kind: payload.kind || "", + op: payload && payload.data && payload.data.op ? payload.data.op : payload.op || "", + raw: raw.slice(0, 2400), + }); + }; + window.addEventListener("tree:snapshot", (event) => record("tree:snapshot", event)); + window.addEventListener("tree:delta", (event) => record("tree:delta", event)); + window.addEventListener("tree:resync", (event) => record("tree:resync", event)); + }); + page.on("request", (request) => { + const url = request.url(); + if (url.includes("/api/tree/events")) { + requests.push({ label, type: "request", method: request.method(), url, at: Date.now() }); + } + }); + page.on("requestfailed", (request) => { + const url = request.url(); + if (url.includes("/api/tree/events")) { + requests.push({ + label, + type: "requestfailed", + method: request.method(), + url, + failure: request.failure()?.errorText || "", + at: Date.now(), + }); + } + }); +} + +function recordNavigation(page, label, records) { + page.on("framenavigated", (frame) => { + if (frame === page.mainFrame()) { + records.push({ label, url: frame.url(), at: Date.now() }); + } + }); +} + +async function openDocument(page, workspaceId, documentId) { + await page.goto(`${BASE_URL}/documents/${encodeURIComponent(documentId)}?workspaceId=${encodeURIComponent(workspaceId)}`, { + waitUntil: "commit", + timeout: UI_TIMEOUT_MS, + }); + await page.locator(`[data-page-title-input="true"][data-document-id="${cssString(documentId)}"]`).first().waitFor({ + state: "visible", + timeout: UI_TIMEOUT_MS, + }); +} + +async function openFileTree(page, workspaceId, documentId) { + await openDocument(page, workspaceId, documentId); + await page.evaluate(() => { + const visible = (node) => + node instanceof HTMLElement && + !node.hidden && + getComputedStyle(node).display !== "none" && + getComputedStyle(node).visibility !== "hidden" && + node.getClientRects().length > 0; + const fileRoot = document.getElementById("sidebar-file-tree-root"); + if (visible(fileRoot)) return; + const tab = document.querySelector('[data-mnote-sidebar-tree-tab="filetree"], button[aria-label="文件"]'); + if (tab instanceof HTMLElement) tab.click(); + }); + await page.locator("#sidebar-file-tree-root").waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); +} + +async function waitForRows(page, documentIds) { + await page.waitForFunction( + (ids) => + ids.every( + (id) => + document.querySelector(`#sidebar-tree-root .tree-row[data-shell-mode="page"][data-node-id="${CSS.escape(id)}"]`) && + document.querySelector(`#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-row-id="${CSS.escape(`doc:${id}`)}"]`), + ), + documentIds, + { timeout: UI_TIMEOUT_MS }, + ); +} + +async function waitForResyncWithRows(page, documentIds) { + await page.waitForFunction( + (ids) => { + const events = Array.isArray(window.__MNOTE_TASK448_TREE_EVENTS__) ? window.__MNOTE_TASK448_TREE_EVENTS__ : []; + const hasResync = events.some((event) => event && event.name === "tree:resync"); + if (!hasResync) return false; + const applied = document.documentElement.getAttribute("data-mnote-tree-live-applied") || ""; + const error = document.documentElement.getAttribute("data-mnote-tree-live-apply-error") || ""; + if (applied !== "resync" || error) return false; + return ids.every( + (id) => + document.querySelector(`#sidebar-tree-root .tree-row[data-shell-mode="page"][data-node-id="${CSS.escape(id)}"]`) && + document.querySelector(`#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-row-id="${CSS.escape(`doc:${id}`)}"]`), + ); + }, + documentIds, + { timeout: UI_TIMEOUT_MS }, + ); +} + +async function readState(page, rootDocumentId) { + return await page.evaluate((rootId) => { + const readDirectChildren = (mode) => { + const rootSelector = + mode === "filetree" + ? `#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-row-id="doc:${CSS.escape(rootId)}"]` + : `#sidebar-tree-root .tree-row[data-shell-mode="page"][data-node-id="${CSS.escape(rootId)}"]`; + const rootRow = document.querySelector(rootSelector); + const list = rootRow?.closest(".tree-node")?.querySelector(":scope > .tree-children"); + if (!list) return []; + return Array.from(list.querySelectorAll(":scope > .tree-node > .tree-row")).map((row) => ({ + documentId: row instanceof HTMLElement ? row.dataset.documentId || row.dataset.docId || row.dataset.nodeId || "" : "", + rowId: row instanceof HTMLElement ? row.dataset.rowId || "" : "", + title: row instanceof HTMLElement ? row.querySelector(".tree-link-title")?.textContent?.trim() || "" : "", + })); + }; + return { + url: window.location.href, + liveStatus: document.documentElement.getAttribute("data-mnote-tree-live-status") || "", + liveApplied: document.documentElement.getAttribute("data-mnote-tree-live-applied") || "", + liveRevision: document.documentElement.getAttribute("data-mnote-tree-live-revision") || "", + liveError: document.documentElement.getAttribute("data-mnote-tree-live-apply-error") || "", + pageOrder: readDirectChildren("page"), + fileTreeOrder: readDirectChildren("filetree"), + treeEvents: window.__MNOTE_TASK448_TREE_EVENTS__ || [], + }; + }, rootDocumentId); +} + +(async () => { + const stamp = Date.now(); + const email = `mnote.stage8.resync.${stamp}@example.com`; + const prefix = `TEST-10REVIEW-08-RESYNC-${stamp}`; + const result = { + ok: false, + task: TASK, + baseUrl: BASE_URL, + authBaseUrl: AUTH_BASE_URL, + email, + prefix, + pollMs: 5000, + fixture: {}, + navigationEvents: [], + treeEventRequests: [], + states: {}, + screenshots: { + bDocument: SCREENSHOT_B_DOCUMENT, + bFileTree: SCREENSHOT_B_FILETREE, + }, + }; + + const browser = await chromium.launch({ headless: process.env.HEADFUL !== "1" }); + const contextA = await browser.newContext(); + const contextB = await browser.newContext(); + const documentB = await contextB.newPage(); + const fileTreeB = await contextB.newPage(); + const requestA = contextA.request; + let workspaceId = ""; + const cleanupIds = []; + + for (const page of [documentB, fileTreeB]) { + await installSlowTreeEventSource(page, result.pollMs); + await installTreeEventRecorder(page, page === documentB ? "B-document" : "B-filetree", result.treeEventRequests); + recordNavigation(page, page === documentB ? "B-document" : "B-filetree", result.navigationEvents); + } + + try { + for (const requestContext of [requestA, contextB.request]) { + await authenticate(requestContext, email, `stage8-resync-${stamp}`); + } + + const root = await createPage(requestA, null, `${prefix}-root`); + workspaceId = root.workspaceId; + cleanupIds.push(root.documentId); + const childA = await createPage(requestA, workspaceId, `${prefix}-a`, root.documentId); + cleanupIds.push(childA.documentId); + result.fixture = { + workspaceId, + rootId: root.documentId, + initialChildId: childA.documentId, + }; + + await openDocument(documentB, workspaceId, root.documentId); + await openFileTree(fileTreeB, workspaceId, root.documentId); + await waitForRows(documentB, [childA.documentId]); + await waitForRows(fileTreeB, [childA.documentId]); + await Promise.all([ + documentB.waitForFunction(() => document.documentElement.getAttribute("data-mnote-tree-live-status") === "connected", null, { timeout: UI_TIMEOUT_MS }), + fileTreeB.waitForFunction(() => document.documentElement.getAttribute("data-mnote-tree-live-status") === "connected", null, { timeout: UI_TIMEOUT_MS }), + ]); + + result.states.before = { + document: await readState(documentB, root.documentId), + fileTree: await readState(fileTreeB, root.documentId), + }; + + const navigationStart = result.navigationEvents.length; + const childB = await createPage(requestA, workspaceId, `${prefix}-b`, root.documentId); + const childC = await createPage(requestA, workspaceId, `${prefix}-c`, root.documentId); + cleanupIds.push(childB.documentId, childC.documentId); + result.fixture.resyncedChildIds = [childB.documentId, childC.documentId]; + + await waitForResyncWithRows(documentB, [childB.documentId, childC.documentId]); + await waitForResyncWithRows(fileTreeB, [childB.documentId, childC.documentId]); + await documentB.waitForTimeout(500); + await fileTreeB.waitForTimeout(500); + + result.states.after = { + document: await readState(documentB, root.documentId), + fileTree: await readState(fileTreeB, root.documentId), + navigationEvents: result.navigationEvents.slice(navigationStart), + }; + + assert.equal(result.states.after.document.liveApplied, "resync", "B 文档页应通过 resync 应用最新树快照"); + assert.equal(result.states.after.fileTree.liveApplied, "resync", "B File Tree 应通过 resync 应用最新树快照"); + assert(!result.states.after.document.liveError, `B 文档页存在 live apply error: ${result.states.after.document.liveError}`); + assert(!result.states.after.fileTree.liveError, `B File Tree 存在 live apply error: ${result.states.after.fileTree.liveError}`); + assert.equal(result.states.after.navigationEvents.length, 0, `B 浏览器发生了刷新/导航: ${JSON.stringify(result.states.after.navigationEvents)}`); + + await documentB.screenshot({ path: SCREENSHOT_B_DOCUMENT, fullPage: true }); + await fileTreeB.screenshot({ path: SCREENSHOT_B_FILETREE, fullPage: true }); + result.ok = true; + } catch (error) { + result.error = error instanceof Error ? error.stack || error.message : String(error); + result.failure = { + document: await readState(documentB, result.fixture.rootId || "").catch(() => null), + fileTree: await readState(fileTreeB, result.fixture.rootId || "").catch(() => null), + }; + process.exitCode = 1; + } finally { + await cleanup(requestA, workspaceId, cleanupIds).catch((error) => { + result.cleanupError = error instanceof Error ? error.message : String(error); + }); + await browser.close().catch(() => undefined); + await writeResult(result); + } +})(); diff --git a/scripts/task449-tree-sse-reconnect-snapshot-recovery-smoke.js b/scripts/task449-tree-sse-reconnect-snapshot-recovery-smoke.js new file mode 100644 index 00000000..f0b190f4 --- /dev/null +++ b/scripts/task449-tree-sse-reconnect-snapshot-recovery-smoke.js @@ -0,0 +1,380 @@ +#!/usr/bin/env node +"use strict"; + +const assert = require("node:assert/strict"); +const fs = require("node:fs/promises"); +const path = require("node:path"); +const { chromium } = require("playwright"); + +const TASK = "task449-tree-sse-reconnect-snapshot-recovery-smoke"; +const BASE_URL = (process.env.MNOTE_UI_BASE_URL || "http://127.0.0.1:3000").replace(/\/+$/, ""); +const AUTH_BASE_URL = (process.env.MNOTE_AUTH_BASE_URL || BASE_URL).replace(/\/+$/, ""); +const UI_TIMEOUT_MS = Number(process.env.MNOTE_SMOKE_UI_TIMEOUT_MS || 45_000); +const OUTPUT_DIR = path.join(process.cwd(), "tmp", "tree-live-cache-smoke", "20260516-task449-reconnect"); +const RESULT_PATH = path.join(OUTPUT_DIR, "result.json"); +const SCREENSHOT_B_DOCUMENT = path.join(OUTPUT_DIR, "b-document-after-reconnect.png"); +const SCREENSHOT_B_FILETREE = path.join(OUTPUT_DIR, "b-filetree-after-reconnect.png"); + +const e2ePassword = () => process.env.MNOTE_E2E_PASSWORD || ["Mnote", "E2E", "123!"].join(""); + +function cssString(value) { + return String(value).replace(/\\/g, "\\\\").replace(/"/g, '\\"'); +} + +async function writeResult(payload) { + await fs.mkdir(OUTPUT_DIR, { recursive: true }); + await fs.writeFile(RESULT_PATH, `${JSON.stringify({ ...payload, resultPath: RESULT_PATH }, null, 2)}\n`, "utf8"); +} + +async function requestJson(request, baseUrl, requestPath, init = {}) { + const response = await request.fetch(`${baseUrl}${requestPath}`, { + ...init, + headers: { + ...(init.data !== undefined ? { "content-type": "application/json" } : {}), + ...(init.headers || {}), + }, + timeout: 20_000, + }); + const text = await response.text(); + let payload = null; + try { + payload = text ? JSON.parse(text) : null; + } catch { + payload = text; + } + if (!response.ok()) { + throw new Error(`${requestPath} 请求失败: ${response.status()} ${typeof payload === "string" ? payload : JSON.stringify(payload)}`); + } + return payload; +} + +async function authenticate(request, email, name) { + await requestJson(request, AUTH_BASE_URL, "/api/auth", { + method: "POST", + data: { + action: "auth:signIn", + args: { + provider: "password", + params: { email, password: e2ePassword(), flow: "signUp", name }, + }, + }, + }); +} + +async function createPage(request, workspaceId, title, parentId = null) { + const payload = await requestJson(request, BASE_URL, "/api/tree/commands", { + method: "POST", + data: { action: "create", workspaceId, parentId, title }, + }); + const result = payload.result || payload; + const documentId = result.documentId || payload.documentId || result.id || ""; + const resolvedWorkspaceId = result.workspaceId || payload.workspaceId || payload.workspace_id || workspaceId || ""; + assert(documentId, `创建页面失败: ${JSON.stringify(payload)}`); + assert(resolvedWorkspaceId, `创建页面缺少 workspaceId: ${JSON.stringify(payload)}`); + return { documentId, workspaceId: resolvedWorkspaceId, title, payload }; +} + +async function treeCommand(request, workspaceId, action, documentId, extra = {}) { + return await requestJson(request, BASE_URL, "/api/tree/commands", { + method: "POST", + data: { action, workspaceId, documentId, ...extra }, + }); +} + +async function emptyDocumentTrash(request, workspaceId) { + return await requestJson(request, BASE_URL, "/api/documents/empty-trash", { + method: "POST", + data: { workspaceId }, + }); +} + +async function cleanup(request, workspaceId, documentIds) { + if (!workspaceId) return; + for (const documentId of documentIds.filter(Boolean)) { + await treeCommand(request, workspaceId, "archive", documentId).catch(() => null); + } + await emptyDocumentTrash(request, workspaceId).catch(() => null); +} + +async function installTreeEventRecorder(page, label, requests) { + await page.addInitScript(() => { + window.__MNOTE_TASK449_TREE_EVENTS__ = []; + const record = (name, event) => { + const detail = event && event.detail ? event.detail : {}; + const payload = detail.payload || detail || {}; + let raw = ""; + try { + raw = JSON.stringify(payload); + } catch { + raw = ""; + } + window.__MNOTE_TASK449_TREE_EVENTS__.push({ + name, + at: Date.now(), + revision: detail.revision || payload.revision || payload.cursor || "", + kind: payload.kind || "", + op: payload && payload.data && payload.data.op ? payload.data.op : payload.op || "", + raw: raw.slice(0, 2400), + }); + }; + window.addEventListener("tree:snapshot", (event) => record("tree:snapshot", event)); + window.addEventListener("tree:delta", (event) => record("tree:delta", event)); + window.addEventListener("tree:resync", (event) => record("tree:resync", event)); + }); + page.on("request", (request) => { + const url = request.url(); + if (url.includes("/api/tree/events")) { + requests.push({ label, type: "request", method: request.method(), url, at: Date.now() }); + } + }); + page.on("requestfailed", (request) => { + const url = request.url(); + if (url.includes("/api/tree/events")) { + requests.push({ + label, + type: "requestfailed", + method: request.method(), + url, + failure: request.failure()?.errorText || "", + at: Date.now(), + }); + } + }); +} + +function recordNavigation(page, label, records) { + page.on("framenavigated", (frame) => { + if (frame === page.mainFrame()) { + records.push({ label, url: frame.url(), at: Date.now() }); + } + }); +} + +async function openDocument(page, workspaceId, documentId) { + await page.goto(`${BASE_URL}/documents/${encodeURIComponent(documentId)}?workspaceId=${encodeURIComponent(workspaceId)}`, { + waitUntil: "commit", + timeout: UI_TIMEOUT_MS, + }); + await page.locator(`[data-page-title-input="true"][data-document-id="${cssString(documentId)}"]`).first().waitFor({ + state: "visible", + timeout: UI_TIMEOUT_MS, + }); +} + +async function openFileTree(page, workspaceId, documentId) { + await openDocument(page, workspaceId, documentId); + await page.evaluate(() => { + const visible = (node) => + node instanceof HTMLElement && + !node.hidden && + getComputedStyle(node).display !== "none" && + getComputedStyle(node).visibility !== "hidden" && + node.getClientRects().length > 0; + const fileRoot = document.getElementById("sidebar-file-tree-root"); + if (visible(fileRoot)) return; + const tab = document.querySelector('[data-mnote-sidebar-tree-tab="filetree"], button[aria-label="文件"]'); + if (tab instanceof HTMLElement) tab.click(); + }); + await page.locator("#sidebar-file-tree-root").waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); +} + +async function waitForRows(page, documentIds) { + await page.waitForFunction( + (ids) => + ids.every( + (id) => + document.querySelector(`#sidebar-tree-root .tree-row[data-shell-mode="page"][data-node-id="${CSS.escape(id)}"]`) && + document.querySelector(`#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-row-id="${CSS.escape(`doc:${id}`)}"]`), + ), + documentIds, + { timeout: UI_TIMEOUT_MS }, + ); +} + +async function waitForReconnectRecoveryWithRows(page, documentIds, minSnapshotCount) { + await page.waitForFunction( + ({ ids, count }) => { + const events = Array.isArray(window.__MNOTE_TASK449_TREE_EVENTS__) ? window.__MNOTE_TASK449_TREE_EVENTS__ : []; + const snapshotCount = events.filter((event) => event && event.name === "tree:snapshot").length; + const hasRecoveryEvent = snapshotCount >= count || events.some((event) => event && event.name === "tree:resync"); + if (!hasRecoveryEvent) return false; + const applied = document.documentElement.getAttribute("data-mnote-tree-live-applied") || ""; + const status = document.documentElement.getAttribute("data-mnote-tree-live-status") || ""; + const error = document.documentElement.getAttribute("data-mnote-tree-live-apply-error") || ""; + if (!["snapshot", "resync"].includes(applied) || status !== "connected" || error) return false; + return ids.every( + (id) => + document.querySelector(`#sidebar-tree-root .tree-row[data-shell-mode="page"][data-node-id="${CSS.escape(id)}"]`) && + document.querySelector(`#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-row-id="${CSS.escape(`doc:${id}`)}"]`), + ); + }, + { ids: documentIds, count: minSnapshotCount }, + { timeout: UI_TIMEOUT_MS }, + ); +} + +async function readState(page, rootDocumentId) { + return await page.evaluate((rootId) => { + const readDirectChildren = (mode) => { + const rootSelector = + mode === "filetree" + ? `#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-row-id="doc:${CSS.escape(rootId)}"]` + : `#sidebar-tree-root .tree-row[data-shell-mode="page"][data-node-id="${CSS.escape(rootId)}"]`; + const rootRow = document.querySelector(rootSelector); + const list = rootRow?.closest(".tree-node")?.querySelector(":scope > .tree-children"); + if (!list) return []; + return Array.from(list.querySelectorAll(":scope > .tree-node > .tree-row")).map((row) => ({ + documentId: row instanceof HTMLElement ? row.dataset.documentId || row.dataset.docId || row.dataset.nodeId || "" : "", + rowId: row instanceof HTMLElement ? row.dataset.rowId || "" : "", + title: row instanceof HTMLElement ? row.querySelector(".tree-link-title")?.textContent?.trim() || "" : "", + })); + }; + return { + url: window.location.href, + liveStatus: document.documentElement.getAttribute("data-mnote-tree-live-status") || "", + liveApplied: document.documentElement.getAttribute("data-mnote-tree-live-applied") || "", + liveRevision: document.documentElement.getAttribute("data-mnote-tree-live-revision") || "", + liveError: document.documentElement.getAttribute("data-mnote-tree-live-apply-error") || "", + pageOrder: readDirectChildren("page"), + fileTreeOrder: readDirectChildren("filetree"), + treeEvents: window.__MNOTE_TASK449_TREE_EVENTS__ || [], + }; + }, rootDocumentId); +} + +(async () => { + const stamp = Date.now(); + const email = `mnote.stage8.reconnect.${stamp}@example.com`; + const prefix = `TEST-10REVIEW-08-RECONNECT-${stamp}`; + const result = { + ok: false, + task: TASK, + baseUrl: BASE_URL, + authBaseUrl: AUTH_BASE_URL, + email, + prefix, + fixture: {}, + navigationEvents: [], + treeEventRequests: [], + states: {}, + screenshots: { + bDocument: SCREENSHOT_B_DOCUMENT, + bFileTree: SCREENSHOT_B_FILETREE, + }, + }; + + const browser = await chromium.launch({ headless: process.env.HEADFUL !== "1" }); + const contextA = await browser.newContext(); + const contextB = await browser.newContext(); + const documentB = await contextB.newPage(); + const fileTreeB = await contextB.newPage(); + const requestA = contextA.request; + let workspaceId = ""; + const cleanupIds = []; + + for (const page of [documentB, fileTreeB]) { + await installTreeEventRecorder(page, page === documentB ? "B-document" : "B-filetree", result.treeEventRequests); + recordNavigation(page, page === documentB ? "B-document" : "B-filetree", result.navigationEvents); + } + + try { + for (const requestContext of [requestA, contextB.request]) { + await authenticate(requestContext, email, `stage8-reconnect-${stamp}`); + } + + const root = await createPage(requestA, null, `${prefix}-root`); + workspaceId = root.workspaceId; + cleanupIds.push(root.documentId); + const childA = await createPage(requestA, workspaceId, `${prefix}-a`, root.documentId); + cleanupIds.push(childA.documentId); + result.fixture = { + workspaceId, + rootId: root.documentId, + initialChildId: childA.documentId, + }; + + await openDocument(documentB, workspaceId, root.documentId); + await openFileTree(fileTreeB, workspaceId, root.documentId); + await waitForRows(documentB, [childA.documentId]); + await waitForRows(fileTreeB, [childA.documentId]); + await Promise.all([ + documentB.waitForFunction(() => document.documentElement.getAttribute("data-mnote-tree-live-status") === "connected", null, { timeout: UI_TIMEOUT_MS }), + fileTreeB.waitForFunction(() => document.documentElement.getAttribute("data-mnote-tree-live-status") === "connected", null, { timeout: UI_TIMEOUT_MS }), + ]); + + result.states.beforeDisconnect = { + document: await readState(documentB, root.documentId), + fileTree: await readState(fileTreeB, root.documentId), + }; + const snapshotCounts = { + document: + result.states.beforeDisconnect.document.treeEvents.filter((event) => event.name === "tree:snapshot").length, + fileTree: + result.states.beforeDisconnect.fileTree.treeEvents.filter((event) => event.name === "tree:snapshot").length, + }; + + await contextB.setOffline(true); + await Promise.all([documentB.waitForTimeout(500), fileTreeB.waitForTimeout(500)]); + result.states.offline = { + document: await readState(documentB, root.documentId), + fileTree: await readState(fileTreeB, root.documentId), + }; + + const navigationStart = result.navigationEvents.length; + const childB = await createPage(requestA, workspaceId, `${prefix}-b`, root.documentId); + const childC = await createPage(requestA, workspaceId, `${prefix}-c`, root.documentId); + cleanupIds.push(childB.documentId, childC.documentId); + result.fixture.recoveredChildIds = [childB.documentId, childC.documentId]; + + assert( + !(await documentB.locator(`#sidebar-tree-root .tree-row[data-shell-mode="page"][data-node-id="${cssString(childB.documentId)}"]`).count()), + "B 文档页离线期间不应提前看到 childB", + ); + assert( + !(await fileTreeB.locator(`#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-row-id="${cssString(`doc:${childB.documentId}`)}"]`).count()), + "B File Tree 离线期间不应提前看到 childB", + ); + + await contextB.setOffline(false); + await waitForReconnectRecoveryWithRows(documentB, [childB.documentId, childC.documentId], snapshotCounts.document + 1); + await waitForReconnectRecoveryWithRows(fileTreeB, [childB.documentId, childC.documentId], snapshotCounts.fileTree + 1); + await documentB.waitForTimeout(500); + await fileTreeB.waitForTimeout(500); + + result.states.afterReconnect = { + document: await readState(documentB, root.documentId), + fileTree: await readState(fileTreeB, root.documentId), + navigationEvents: result.navigationEvents.slice(navigationStart), + }; + + assert( + ["snapshot", "resync"].includes(result.states.afterReconnect.document.liveApplied), + `B 文档页应通过重连 snapshot/resync 应用最新树快照: ${result.states.afterReconnect.document.liveApplied}`, + ); + assert( + ["snapshot", "resync"].includes(result.states.afterReconnect.fileTree.liveApplied), + `B File Tree 应通过重连 snapshot/resync 应用最新树快照: ${result.states.afterReconnect.fileTree.liveApplied}`, + ); + assert(!result.states.afterReconnect.document.liveError, `B 文档页存在 live apply error: ${result.states.afterReconnect.document.liveError}`); + assert(!result.states.afterReconnect.fileTree.liveError, `B File Tree 存在 live apply error: ${result.states.afterReconnect.fileTree.liveError}`); + assert.equal(result.states.afterReconnect.navigationEvents.length, 0, `B 浏览器发生了刷新/导航: ${JSON.stringify(result.states.afterReconnect.navigationEvents)}`); + + await documentB.screenshot({ path: SCREENSHOT_B_DOCUMENT, fullPage: true }); + await fileTreeB.screenshot({ path: SCREENSHOT_B_FILETREE, fullPage: true }); + result.ok = true; + } catch (error) { + result.error = error instanceof Error ? error.stack || error.message : String(error); + result.failure = { + document: await readState(documentB, result.fixture.rootId || "").catch(() => null), + fileTree: await readState(fileTreeB, result.fixture.rootId || "").catch(() => null), + }; + process.exitCode = 1; + } finally { + await contextB.setOffline(false).catch(() => undefined); + await cleanup(requestA, workspaceId, cleanupIds).catch((error) => { + result.cleanupError = error instanceof Error ? error.message : String(error); + }); + await browser.close().catch(() => undefined); + await writeResult(result); + } +})(); diff --git a/wolai-frontend/convex/http.ts b/wolai-frontend/convex/http.ts index b4df88f1..b5a47f9a 100644 --- a/wolai-frontend/convex/http.ts +++ b/wolai-frontend/convex/http.ts @@ -1,5 +1,7 @@ import { httpRouter } from "convex/server"; import { auth } from "./auth"; +import { api } from "./_generated/api"; +import { httpAction } from "./_generated/server"; // 说明: // - Convex Auth 需要通过 HTTP Routes 提供回调/授权等端点(即使你只用密码登录,也建议保持该配置)。 @@ -9,5 +11,13 @@ const http = httpRouter(); auth.addHttpRoutes(http); -export default http; +http.route({ + path: "/ping", + method: "POST", + handler: httpAction(async (ctx) => { + const result = await ctx.runQuery(api.ping.ping, {}); + return Response.json(result); + }), +}); +export default http; diff --git a/wolai-frontend/next-dev-codex.err b/wolai-frontend/next-dev-codex.err deleted file mode 100644 index 39a2be96..00000000 --- a/wolai-frontend/next-dev-codex.err +++ /dev/null @@ -1,445 +0,0 @@ - -> wolai-frontend@0.1.0 dev /mnt/Data1T/mnote/wolai-frontend -> node scripts/dev-server.js -p 3000 - -[baseline-browser-mapping] The data in this module is over two months old. To ensure accurate Baseline data, please update: `npm i baseline-browser-mapping@latest -D` - ⚠ The "middleware" file convention is deprecated. Please use "proxy" instead. Learn more: https://nextjs.org/docs/messages/middleware-to-proxy -[dev-server] ready http://0.0.0.0:3000 (ONLYOFFICE ws via /onlyoffice-server -> http://127.0.0.1:8082; Convex ws via /convex -> http://127.0.0.1:3210) - GET / 307 in 1911ms (compile: 1670ms, proxy.ts: 30ms, render: 211ms) - ○ Compiling /documents/[id] ... - GET /documents/8337ceec-e236-41d7-a2eb-86838f46c858 200 in 4.1s (compile: 3.6s, proxy.ts: 9ms, render: 536ms) - ⚠ Cross origin request detected from 127.0.0.1 to /_next/* resource. In a future major version of Next.js, you will need to explicitly configure "allowedDevOrigins" in next.config to allow this. -Read more: https://nextjs.org/docs/app/api-reference/config/next-config-js/allowedDevOrigins - GET /api/tree/shell?workspaceId=8f317132-506f-4399-99dc-d22318427248&mode=page&channel=sidebar-page-tree-shell&host=sidebar-page-tree-shell&activeDocumentId=8337ceec-e236-41d7-a2eb-86838f46c858 200 in 366ms (compile: 319ms, proxy.ts: 8ms, render: 40ms) - GET /api/backend/health 200 in 98ms (compile: 81ms, proxy.ts: 11ms, render: 6ms) - GET /api/mnote-web/stream?workspaceId=8f317132-506f-4399-99dc-d22318427248 200 in 924ms (compile: 186ms, proxy.ts: 13ms, render: 725ms) - GET /api/references/backlinks?workspaceId=8f317132-506f-4399-99dc-d22318427248&pageId=8337ceec-e236-41d7-a2eb-86838f46c858 200 in 146ms (compile: 122ms, proxy.ts: 8ms, render: 16ms) - GET /api/leptos-tiptap-runtime/manifest.json 200 in 719ms (compile: 695ms, proxy.ts: 14ms, render: 9ms) - GET /api/leptos-tiptap-runtime/manifest.json 200 in 724ms (compile: 698ms, proxy.ts: 15ms, render: 11ms) - GET /api/leptos-tiptap-runtime/mnote-leptos-tiptap-spike-island.js 200 in 97ms (compile: 86ms, proxy.ts: 6ms, render: 6ms) - GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_blockquote.js 200 in 47ms (compile: 21ms, proxy.ts: 13ms, render: 13ms) - GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/bridge_runtime.js 200 in 50ms (compile: 19ms, proxy.ts: 14ms, render: 17ms) - GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_bold.js 200 in 49ms (compile: 21ms, proxy.ts: 14ms, render: 14ms) - GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_bullet_list.js 200 in 49ms (compile: 19ms, proxy.ts: 17ms, render: 12ms) - GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_code.js 200 in 52ms (compile: 20ms, proxy.ts: 17ms, render: 14ms) - GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_code_block.js 200 in 55ms (compile: 20ms, proxy.ts: 18ms, render: 16ms) - GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_document.js 200 in 51ms (compile: 22ms, proxy.ts: 17ms, render: 13ms) - GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_dropcursor.js 200 in 52ms (compile: 23ms, proxy.ts: 15ms, render: 14ms) - GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_gapcursor.js 200 in 53ms (compile: 21ms, proxy.ts: 18ms, render: 13ms) - GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_hard_break.js 200 in 50ms (compile: 22ms, proxy.ts: 15ms, render: 13ms) - GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_heading.js 200 in 52ms (compile: 17ms, proxy.ts: 20ms, render: 14ms) - GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_highlight.js 200 in 52ms (compile: 19ms, proxy.ts: 19ms, render: 14ms) - GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_history.js 200 in 47ms (compile: 18ms, proxy.ts: 16ms, render: 13ms) - GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_horizontal_rule.js 200 in 46ms (compile: 19ms, proxy.ts: 15ms, render: 12ms) - GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_image.js 200 in 46ms (compile: 19ms, proxy.ts: 16ms, render: 11ms) - GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_italic.js 200 in 48ms (compile: 20ms, proxy.ts: 17ms, render: 11ms) - GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_link.js 200 in 43ms (compile: 20ms, proxy.ts: 14ms, render: 9ms) - GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_list_item.js 200 in 43ms (compile: 20ms, proxy.ts: 12ms, render: 11ms) - GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_ordered_list.js 200 in 45ms (compile: 16ms, proxy.ts: 13ms, render: 16ms) - GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_paragraph.js 200 in 45ms (compile: 17ms, proxy.ts: 13ms, render: 14ms) - GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_placeholder.js 200 in 44ms (compile: 17ms, proxy.ts: 14ms, render: 13ms) - GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_strike.js 200 in 42ms (compile: 20ms, proxy.ts: 13ms, render: 9ms) - GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_task_item.js 200 in 43ms (compile: 19ms, proxy.ts: 16ms, render: 8ms) - GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_task_list.js 200 in 41ms (compile: 19ms, proxy.ts: 15ms, render: 7ms) - GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_text.js 200 in 36ms (compile: 15ms, proxy.ts: 10ms, render: 12ms) - GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_text_align.js 200 in 36ms (compile: 14ms, proxy.ts: 11ms, render: 11ms) - GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_underline.js 200 in 36ms (compile: 15ms, proxy.ts: 11ms, render: 9ms) - GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_text_style.js 200 in 38ms (compile: 16ms, proxy.ts: 14ms, render: 8ms) - GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_youtube.js 200 in 37ms (compile: 16ms, proxy.ts: 14ms, render: 7ms) - GET /api/leptos-tiptap-runtime/mnote-leptos-tiptap-spike-island_bg.wasm 200 in 30ms (compile: 3ms, proxy.ts: 8ms, render: 19ms) - GET /api/leptos-tiptap-runtime/mnote-leptos-tiptap-spike-island_bg.wasm 200 in 60ms (compile: 4ms, proxy.ts: 10ms, render: 46ms) - GET /api/tree/shell?workspaceId=8f317132-506f-4399-99dc-d22318427248&mode=filetree&channel=sidebar-file-tree-shell&host=sidebar-file-tree-shell&activeDocumentId=8337ceec-e236-41d7-a2eb-86838f46c858 200 in 41ms (compile: 4ms, proxy.ts: 20ms, render: 18ms) - GET /auth 200 in 441ms (compile: 376ms, proxy.ts: 4ms, render: 60ms) - GET /documents/8337ceec-e236-41d7-a2eb-86838f46c858 200 in 511ms (compile: 114ms, proxy.ts: 19ms, render: 378ms) - GET /api/tree/shell?workspaceId=8f317132-506f-4399-99dc-d22318427248&mode=page&channel=sidebar-page-tree-shell&host=sidebar-page-tree-shell&activeDocumentId=8337ceec-e236-41d7-a2eb-86838f46c858 200 in 29ms (compile: 3ms, proxy.ts: 11ms, render: 15ms) - GET /api/backend/health 200 in 12ms (compile: 1739µs, proxy.ts: 7ms, render: 4ms) - GET /api/leptos-tiptap-runtime/manifest.json 200 in 111ms (compile: 89ms, proxy.ts: 11ms, render: 11ms) - GET /api/leptos-tiptap-runtime/manifest.json 200 in 110ms (compile: 91ms, proxy.ts: 10ms, render: 9ms) - GET /api/leptos-tiptap-runtime/mnote-leptos-tiptap-spike-island.js 200 in 11ms (compile: 3ms, proxy.ts: 6ms, render: 2ms) - GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_blockquote.js 200 in 39ms (compile: 15ms, proxy.ts: 12ms, render: 12ms) - GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/bridge_runtime.js 200 in 41ms (compile: 13ms, proxy.ts: 12ms, render: 16ms) - GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_bold.js 200 in 40ms (compile: 15ms, proxy.ts: 13ms, render: 12ms) - GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_bullet_list.js 200 in 38ms (compile: 18ms, proxy.ts: 13ms, render: 7ms) - GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_code.js 200 in 38ms (compile: 19ms, proxy.ts: 14ms, render: 6ms) - GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_code_block.js 200 in 36ms (compile: 14ms, proxy.ts: 10ms, render: 12ms) - GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_document.js 200 in 35ms (compile: 15ms, proxy.ts: 9ms, render: 11ms) - GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_dropcursor.js 200 in 34ms (compile: 15ms, proxy.ts: 10ms, render: 9ms) - GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_gapcursor.js 200 in 35ms (compile: 15ms, proxy.ts: 12ms, render: 7ms) - GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_hard_break.js 200 in 35ms (compile: 16ms, proxy.ts: 13ms, render: 6ms) - GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_heading.js 200 in 57ms (compile: 17ms, proxy.ts: 13ms, render: 26ms) - GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_highlight.js 200 in 56ms (compile: 15ms, proxy.ts: 16ms, render: 25ms) - GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_history.js 200 in 56ms (compile: 16ms, proxy.ts: 16ms, render: 23ms) - GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_horizontal_rule.js 200 in 56ms (compile: 17ms, proxy.ts: 17ms, render: 22ms) - GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_image.js 200 in 56ms (compile: 30ms, proxy.ts: 20ms, render: 6ms) - GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_italic.js 200 in 33ms (compile: 12ms, proxy.ts: 11ms, render: 10ms) - GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_link.js 200 in 33ms (compile: 13ms, proxy.ts: 11ms, render: 9ms) - GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_list_item.js 200 in 35ms (compile: 14ms, proxy.ts: 11ms, render: 10ms) - GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_ordered_list.js 200 in 37ms (compile: 13ms, proxy.ts: 14ms, render: 10ms) - GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_paragraph.js 200 in 39ms (compile: 17ms, proxy.ts: 13ms, render: 9ms) - GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_placeholder.js 200 in 34ms (compile: 14ms, proxy.ts: 10ms, render: 9ms) - GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_strike.js 200 in 34ms (compile: 14ms, proxy.ts: 11ms, render: 8ms) - GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_task_item.js 200 in 35ms (compile: 15ms, proxy.ts: 12ms, render: 8ms) - GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_task_list.js 200 in 39ms (compile: 15ms, proxy.ts: 12ms, render: 12ms) - GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_text.js 200 in 37ms (compile: 17ms, proxy.ts: 10ms, render: 10ms) - GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_text_align.js 200 in 28ms (compile: 10ms, proxy.ts: 10ms, render: 8ms) - GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_text_style.js 200 in 31ms (compile: 11ms, proxy.ts: 10ms, render: 9ms) - GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_underline.js 200 in 30ms (compile: 11ms, proxy.ts: 12ms, render: 7ms) - GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_youtube.js 200 in 26ms (compile: 12ms, proxy.ts: 9ms, render: 5ms) - GET /api/mnote-web/stream?workspaceId=8f317132-506f-4399-99dc-d22318427248 200 in 719ms (compile: 1610µs, proxy.ts: 6ms, render: 711ms) - GET /api/leptos-tiptap-runtime/mnote-leptos-tiptap-spike-island_bg.wasm 200 in 76ms (compile: 3ms, proxy.ts: 9ms, render: 64ms) - GET /api/leptos-tiptap-runtime/mnote-leptos-tiptap-spike-island_bg.wasm 200 in 51ms (compile: 3ms, proxy.ts: 9ms, render: 40ms) - GET /api/references/backlinks?workspaceId=8f317132-506f-4399-99dc-d22318427248&pageId=8337ceec-e236-41d7-a2eb-86838f46c858 200 in 17ms (compile: 1686µs, proxy.ts: 7ms, render: 8ms) - GET /api/tree/shell?workspaceId=8f317132-506f-4399-99dc-d22318427248&mode=filetree&channel=sidebar-file-tree-shell&host=sidebar-file-tree-shell&activeDocumentId=8337ceec-e236-41d7-a2eb-86838f46c858 200 in 37ms (compile: 3ms, proxy.ts: 17ms, render: 17ms) - GET /documents/8337ceec-e236-41d7-a2eb-86838f46c858 200 in 483ms (compile: 111ms, proxy.ts: 18ms, render: 354ms) - GET /api/tree/shell?workspaceId=8f317132-506f-4399-99dc-d22318427248&mode=page&channel=sidebar-page-tree-shell&host=sidebar-page-tree-shell&activeDocumentId=8337ceec-e236-41d7-a2eb-86838f46c858 200 in 28ms (compile: 2ms, proxy.ts: 6ms, render: 20ms) - GET /api/backend/health 200 in 14ms (compile: 2ms, proxy.ts: 8ms, render: 3ms) - GET /api/leptos-tiptap-runtime/manifest.json 200 in 113ms (compile: 90ms, proxy.ts: 8ms, render: 15ms) - GET /api/leptos-tiptap-runtime/manifest.json 200 in 112ms (compile: 98ms, proxy.ts: 7ms, render: 7ms) - GET /api/leptos-tiptap-runtime/mnote-leptos-tiptap-spike-island.js 200 in 18ms (compile: 3ms, proxy.ts: 12ms, render: 3ms) - GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_blockquote.js 200 in 41ms (compile: 19ms, proxy.ts: 12ms, render: 9ms) - GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/bridge_runtime.js 200 in 44ms (compile: 18ms, proxy.ts: 12ms, render: 13ms) - GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_bold.js 200 in 41ms (compile: 18ms, proxy.ts: 13ms, render: 10ms) - GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_bullet_list.js 200 in 41ms (compile: 15ms, proxy.ts: 18ms, render: 7ms) - GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_code.js 200 in 41ms (compile: 16ms, proxy.ts: 19ms, render: 6ms) - GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_code_block.js 200 in 43ms (compile: 20ms, proxy.ts: 10ms, render: 13ms) - GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_document.js 200 in 42ms (compile: 19ms, proxy.ts: 12ms, render: 11ms) - GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_dropcursor.js 200 in 42ms (compile: 21ms, proxy.ts: 12ms, render: 10ms) - GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_gapcursor.js 200 in 43ms (compile: 20ms, proxy.ts: 14ms, render: 8ms) - GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_hard_break.js 200 in 48ms (compile: 21ms, proxy.ts: 15ms, render: 12ms) - GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_heading.js 200 in 34ms (compile: 13ms, proxy.ts: 10ms, render: 10ms) - GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_highlight.js 200 in 34ms (compile: 13ms, proxy.ts: 11ms, render: 9ms) - GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_history.js 200 in 40ms (compile: 13ms, proxy.ts: 13ms, render: 14ms) - GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_horizontal_rule.js 200 in 42ms (compile: 14ms, proxy.ts: 14ms, render: 14ms) - GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_image.js 200 in 39ms (compile: 16ms, proxy.ts: 11ms, render: 12ms) - GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_italic.js 200 in 38ms (compile: 13ms, proxy.ts: 13ms, render: 11ms) - GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_link.js 200 in 38ms (compile: 13ms, proxy.ts: 15ms, render: 10ms) - GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_list_item.js 200 in 34ms (compile: 13ms, proxy.ts: 13ms, render: 9ms) - GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_ordered_list.js 200 in 33ms (compile: 13ms, proxy.ts: 12ms, render: 7ms) - GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_paragraph.js 200 in 31ms (compile: 13ms, proxy.ts: 11ms, render: 7ms) - GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_placeholder.js 200 in 42ms (compile: 14ms, proxy.ts: 16ms, render: 13ms) - GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_strike.js 200 in 42ms (compile: 15ms, proxy.ts: 15ms, render: 12ms) - GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_task_item.js 200 in 42ms (compile: 16ms, proxy.ts: 15ms, render: 11ms) - GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_task_list.js 200 in 62ms (compile: 15ms, proxy.ts: 19ms, render: 28ms) - GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_text.js 200 in 61ms (compile: 16ms, proxy.ts: 16ms, render: 29ms) - GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_text_align.js 200 in 28ms (compile: 9ms, proxy.ts: 11ms, render: 8ms) - GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_text_style.js 200 in 28ms (compile: 10ms, proxy.ts: 11ms, render: 8ms) - GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_underline.js 200 in 29ms (compile: 10ms, proxy.ts: 11ms, render: 7ms) - GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_youtube.js 200 in 25ms (compile: 10ms, proxy.ts: 9ms, render: 5ms) - GET /api/mnote-web/stream?workspaceId=8f317132-506f-4399-99dc-d22318427248 200 in 739ms (compile: 1795µs, proxy.ts: 7ms, render: 731ms) - GET /api/leptos-tiptap-runtime/mnote-leptos-tiptap-spike-island_bg.wasm 200 in 80ms (compile: 2ms, proxy.ts: 7ms, render: 71ms) - GET /api/leptos-tiptap-runtime/mnote-leptos-tiptap-spike-island_bg.wasm 200 in 63ms (compile: 2ms, proxy.ts: 7ms, render: 54ms) - GET /api/references/backlinks?workspaceId=8f317132-506f-4399-99dc-d22318427248&pageId=8337ceec-e236-41d7-a2eb-86838f46c858 200 in 22ms (compile: 1535µs, proxy.ts: 6ms, render: 15ms) - GET /api/tree/shell?workspaceId=8f317132-506f-4399-99dc-d22318427248&mode=filetree&channel=sidebar-file-tree-shell&host=sidebar-file-tree-shell&activeDocumentId=8337ceec-e236-41d7-a2eb-86838f46c858 200 in 29ms (compile: 3ms, proxy.ts: 8ms, render: 18ms) - GET /documents/8337ceec-e236-41d7-a2eb-86838f46c858 200 in 481ms (compile: 111ms, proxy.ts: 19ms, render: 352ms) - GET /api/tree/shell?workspaceId=8f317132-506f-4399-99dc-d22318427248&mode=page&channel=sidebar-page-tree-shell&host=sidebar-page-tree-shell&activeDocumentId=8337ceec-e236-41d7-a2eb-86838f46c858 200 in 27ms (compile: 2ms, proxy.ts: 7ms, render: 18ms) - GET /api/backend/health 200 in 14ms (compile: 2ms, proxy.ts: 8ms, render: 3ms) - GET /api/leptos-tiptap-runtime/manifest.json 200 in 115ms (compile: 93ms, proxy.ts: 7ms, render: 15ms) - GET /api/leptos-tiptap-runtime/manifest.json 200 in 115ms (compile: 102ms, proxy.ts: 7ms, render: 6ms) - GET /api/leptos-tiptap-runtime/mnote-leptos-tiptap-spike-island.js 200 in 13ms (compile: 3ms, proxy.ts: 7ms, render: 3ms) - GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/bridge_runtime.js 200 in 42ms (compile: 16ms, proxy.ts: 14ms, render: 12ms) - GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_bold.js 200 in 37ms (compile: 16ms, proxy.ts: 11ms, render: 10ms) - GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_blockquote.js 200 in 43ms (compile: 17ms, proxy.ts: 17ms, render: 9ms) - GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_bullet_list.js 200 in 39ms (compile: 17ms, proxy.ts: 13ms, render: 8ms) - GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_code.js 200 in 45ms (compile: 17ms, proxy.ts: 13ms, render: 15ms) - GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_code_block.js 200 in 36ms (compile: 14ms, proxy.ts: 11ms, render: 11ms) - GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_dropcursor.js 200 in 35ms (compile: 14ms, proxy.ts: 12ms, render: 10ms) - GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_document.js 200 in 39ms (compile: 15ms, proxy.ts: 14ms, render: 10ms) - GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_gapcursor.js 200 in 40ms (compile: 16ms, proxy.ts: 14ms, render: 11ms) - GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_hard_break.js 200 in 43ms (compile: 17ms, proxy.ts: 13ms, render: 12ms) - GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_heading.js 200 in 45ms (compile: 21ms, proxy.ts: 14ms, render: 10ms) - GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_highlight.js 200 in 45ms (compile: 20ms, proxy.ts: 16ms, render: 9ms) - GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_history.js 200 in 43ms (compile: 21ms, proxy.ts: 14ms, render: 8ms) - GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_horizontal_rule.js 200 in 50ms (compile: 16ms, proxy.ts: 20ms, render: 14ms) - GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_image.js 200 in 45ms (compile: 17ms, proxy.ts: 15ms, render: 13ms) - GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_italic.js 200 in 40ms (compile: 14ms, proxy.ts: 14ms, render: 12ms) - GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_link.js 200 in 40ms (compile: 15ms, proxy.ts: 13ms, render: 11ms) - GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_list_item.js 200 in 42ms (compile: 15ms, proxy.ts: 14ms, render: 12ms) - GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_ordered_list.js 200 in 43ms (compile: 19ms, proxy.ts: 11ms, render: 13ms) - GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_paragraph.js 200 in 40ms (compile: 21ms, proxy.ts: 8ms, render: 12ms) - GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_placeholder.js 200 in 40ms (compile: 17ms, proxy.ts: 13ms, render: 10ms) - GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_strike.js 200 in 42ms (compile: 17ms, proxy.ts: 16ms, render: 10ms) - GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_task_item.js 200 in 45ms (compile: 17ms, proxy.ts: 17ms, render: 11ms) - GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_task_list.js 200 in 56ms (compile: 17ms, proxy.ts: 10ms, render: 29ms) - GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_text.js 200 in 57ms (compile: 18ms, proxy.ts: 9ms, render: 30ms) - GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_text_align.js 200 in 50ms (compile: 9ms, proxy.ts: 33ms, render: 8ms) - GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_text_style.js 200 in 49ms (compile: 10ms, proxy.ts: 31ms, render: 7ms) - GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_underline.js 200 in 32ms (compile: 11ms, proxy.ts: 14ms, render: 6ms) - GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_youtube.js 200 in 25ms (compile: 11ms, proxy.ts: 10ms, render: 5ms) - GET /api/mnote-web/stream?workspaceId=8f317132-506f-4399-99dc-d22318427248 200 in 738ms (compile: 1573µs, proxy.ts: 7ms, render: 730ms) - GET /api/leptos-tiptap-runtime/mnote-leptos-tiptap-spike-island_bg.wasm 200 in 62ms (compile: 3ms, proxy.ts: 7ms, render: 53ms) - GET /api/leptos-tiptap-runtime/mnote-leptos-tiptap-spike-island_bg.wasm 200 in 49ms (compile: 2ms, proxy.ts: 6ms, render: 40ms) - GET /api/references/backlinks?workspaceId=8f317132-506f-4399-99dc-d22318427248&pageId=8337ceec-e236-41d7-a2eb-86838f46c858 200 in 17ms (compile: 2ms, proxy.ts: 7ms, render: 8ms) - GET /api/tree/shell?workspaceId=8f317132-506f-4399-99dc-d22318427248&mode=filetree&channel=sidebar-file-tree-shell&host=sidebar-file-tree-shell&activeDocumentId=8337ceec-e236-41d7-a2eb-86838f46c858 200 in 40ms (compile: 3ms, proxy.ts: 16ms, render: 20ms) - ✓ Compiled in 80ms - GET /api/tree/shell?workspaceId=8f317132-506f-4399-99dc-d22318427248&mode=filetree&channel=sidebar-file-tree-shell&host=sidebar-file-tree-shell&activeDocumentId=8337ceec-e236-41d7-a2eb-86838f46c858 200 in 38ms (compile: 2ms, proxy.ts: 18ms, render: 18ms) - GET /api/tree/shell?workspaceId=8f317132-506f-4399-99dc-d22318427248&mode=filetree&channel=sidebar-file-tree-shell&host=sidebar-file-tree-shell&activeDocumentId=8337ceec-e236-41d7-a2eb-86838f46c858 200 in 24ms (compile: 1593µs, proxy.ts: 7ms, render: 16ms) - GET /documents/8337ceec-e236-41d7-a2eb-86838f46c858 200 in 747ms (compile: 183ms, proxy.ts: 17ms, render: 547ms) - GET /api/tree/shell?workspaceId=8f317132-506f-4399-99dc-d22318427248&mode=page&channel=sidebar-page-tree-shell&host=sidebar-page-tree-shell&activeDocumentId=8337ceec-e236-41d7-a2eb-86838f46c858 200 in 26ms (compile: 2ms, proxy.ts: 8ms, render: 16ms) - GET /api/backend/health 200 in 14ms (compile: 2ms, proxy.ts: 8ms, render: 4ms) - GET /api/leptos-tiptap-runtime/manifest.json 200 in 114ms (compile: 95ms, proxy.ts: 9ms, render: 10ms) - GET /api/leptos-tiptap-runtime/manifest.json 200 in 112ms (compile: 98ms, proxy.ts: 10ms, render: 5ms) - GET /api/leptos-tiptap-runtime/mnote-leptos-tiptap-spike-island.js 200 in 12ms (compile: 3ms, proxy.ts: 6ms, render: 2ms) - GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_blockquote.js 200 in 47ms (compile: 18ms, proxy.ts: 12ms, render: 17ms) - GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/bridge_runtime.js 200 in 49ms (compile: 16ms, proxy.ts: 11ms, render: 22ms) - GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_bullet_list.js 200 in 44ms (compile: 19ms, proxy.ts: 11ms, render: 15ms) - GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_bold.js 200 in 48ms (compile: 21ms, proxy.ts: 14ms, render: 12ms) - GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_code.js 200 in 46ms (compile: 22ms, proxy.ts: 13ms, render: 12ms) - GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_code_block.js 200 in 36ms (compile: 13ms, proxy.ts: 12ms, render: 11ms) - GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_document.js 200 in 34ms (compile: 14ms, proxy.ts: 11ms, render: 10ms) - GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_dropcursor.js 200 in 34ms (compile: 15ms, proxy.ts: 11ms, render: 9ms) - GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_gapcursor.js 200 in 35ms (compile: 14ms, proxy.ts: 14ms, render: 7ms) - GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_hard_break.js 200 in 33ms (compile: 14ms, proxy.ts: 13ms, render: 5ms) - GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_heading.js 200 in 39ms (compile: 18ms, proxy.ts: 10ms, render: 12ms) - GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_highlight.js 200 in 38ms (compile: 17ms, proxy.ts: 10ms, render: 11ms) - GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_history.js 200 in 38ms (compile: 15ms, proxy.ts: 13ms, render: 10ms) - GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_horizontal_rule.js 200 in 38ms (compile: 14ms, proxy.ts: 16ms, render: 8ms) - GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_image.js 200 in 39ms (compile: 15ms, proxy.ts: 17ms, render: 7ms) - GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_italic.js 200 in 39ms (compile: 13ms, proxy.ts: 12ms, render: 14ms) - GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_link.js 200 in 39ms (compile: 14ms, proxy.ts: 11ms, render: 14ms) - GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_list_item.js 200 in 39ms (compile: 15ms, proxy.ts: 12ms, render: 13ms) - GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_ordered_list.js 200 in 39ms (compile: 14ms, proxy.ts: 13ms, render: 12ms) - GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_paragraph.js 200 in 42ms (compile: 17ms, proxy.ts: 13ms, render: 11ms) - GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_placeholder.js 200 in 36ms (compile: 12ms, proxy.ts: 14ms, render: 10ms) - GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_strike.js 200 in 36ms (compile: 12ms, proxy.ts: 14ms, render: 9ms) - GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_task_item.js 200 in 36ms (compile: 12ms, proxy.ts: 15ms, render: 8ms) - GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_task_list.js 200 in 36ms (compile: 12ms, proxy.ts: 17ms, render: 7ms) - GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_text.js 200 in 32ms (compile: 13ms, proxy.ts: 13ms, render: 7ms) - GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_text_align.js 200 in 32ms (compile: 10ms, proxy.ts: 12ms, render: 10ms) - GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_text_style.js 200 in 32ms (compile: 11ms, proxy.ts: 12ms, render: 9ms) - GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_underline.js 200 in 30ms (compile: 10ms, proxy.ts: 12ms, render: 8ms) - GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_youtube.js 200 in 31ms (compile: 11ms, proxy.ts: 13ms, render: 6ms) - GET /api/mnote-web/stream?workspaceId=8f317132-506f-4399-99dc-d22318427248 200 in 726ms (compile: 1595µs, proxy.ts: 7ms, render: 718ms) - GET /api/leptos-tiptap-runtime/mnote-leptos-tiptap-spike-island_bg.wasm 200 in 27ms (compile: 2ms, proxy.ts: 6ms, render: 18ms) - GET /api/leptos-tiptap-runtime/mnote-leptos-tiptap-spike-island_bg.wasm 200 in 81ms (compile: 2ms, proxy.ts: 10ms, render: 69ms) - GET /api/references/backlinks?workspaceId=8f317132-506f-4399-99dc-d22318427248&pageId=8337ceec-e236-41d7-a2eb-86838f46c858 200 in 16ms (compile: 1736µs, proxy.ts: 7ms, render: 7ms) - GET /api/tree/shell?workspaceId=8f317132-506f-4399-99dc-d22318427248&mode=filetree&channel=sidebar-file-tree-shell&host=sidebar-file-tree-shell&activeDocumentId=8337ceec-e236-41d7-a2eb-86838f46c858 200 in 40ms (compile: 2ms, proxy.ts: 18ms, render: 19ms) - GET /documents/8337ceec-e236-41d7-a2eb-86838f46c858 200 in 509ms (compile: 114ms, proxy.ts: 39ms, render: 357ms) - GET /api/tree/shell?workspaceId=8f317132-506f-4399-99dc-d22318427248&mode=page&channel=sidebar-page-tree-shell&host=sidebar-page-tree-shell&activeDocumentId=8337ceec-e236-41d7-a2eb-86838f46c858 200 in 21ms (compile: 1697µs, proxy.ts: 7ms, render: 12ms) - GET /api/backend/health 200 in 11ms (compile: 1606µs, proxy.ts: 5ms, render: 4ms) - GET /api/leptos-tiptap-runtime/manifest.json 200 in 122ms (compile: 99ms, proxy.ts: 14ms, render: 9ms) - GET /api/leptos-tiptap-runtime/manifest.json 200 in 115ms (compile: 102ms, proxy.ts: 9ms, render: 3ms) - GET /api/leptos-tiptap-runtime/mnote-leptos-tiptap-spike-island.js 200 in 13ms (compile: 3ms, proxy.ts: 6ms, render: 3ms) - GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_bullet_list.js 200 in 38ms (compile: 16ms, proxy.ts: 14ms, render: 8ms) - GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/bridge_runtime.js 200 in 42ms (compile: 17ms, proxy.ts: 13ms, render: 11ms) - GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_blockquote.js 200 in 42ms (compile: 17ms, proxy.ts: 16ms, render: 9ms) - GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_bold.js 200 in 40ms (compile: 16ms, proxy.ts: 16ms, render: 8ms) - GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_code.js 200 in 41ms (compile: 16ms, proxy.ts: 19ms, render: 7ms) - GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_code_block.js 200 in 29ms (compile: 15ms, proxy.ts: 6ms, render: 8ms) - GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_dropcursor.js 200 in 38ms (compile: 19ms, proxy.ts: 11ms, render: 8ms) - GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_document.js 200 in 38ms (compile: 20ms, proxy.ts: 11ms, render: 7ms) - GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_hard_break.js 200 in 38ms (compile: 18ms, proxy.ts: 14ms, render: 6ms) - GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_gapcursor.js 200 in 38ms (compile: 17ms, proxy.ts: 17ms, render: 4ms) - GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_history.js 200 in 35ms (compile: 12ms, proxy.ts: 13ms, render: 11ms) - GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_heading.js 200 in 35ms (compile: 13ms, proxy.ts: 13ms, render: 9ms) - GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_highlight.js 200 in 35ms (compile: 14ms, proxy.ts: 13ms, render: 8ms) - GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_image.js 200 in 35ms (compile: 14ms, proxy.ts: 13ms, render: 8ms) - GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_horizontal_rule.js 200 in 35ms (compile: 14ms, proxy.ts: 16ms, render: 5ms) - GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_link.js 200 in 39ms (compile: 11ms, proxy.ts: 18ms, render: 11ms) - GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_italic.js 200 in 39ms (compile: 12ms, proxy.ts: 18ms, render: 10ms) - GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_paragraph.js 200 in 39ms (compile: 12ms, proxy.ts: 18ms, render: 9ms) - GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_ordered_list.js 200 in 39ms (compile: 13ms, proxy.ts: 18ms, render: 8ms) - GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_list_item.js 200 in 40ms (compile: 14ms, proxy.ts: 18ms, render: 7ms) - GET /api/mnote-web/stream?workspaceId=8f317132-506f-4399-99dc-d22318427248 200 in 703ms (compile: 1547µs, proxy.ts: 6ms, render: 695ms) - GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_placeholder.js 200 in 49ms (compile: 20ms, proxy.ts: 15ms, render: 14ms) - GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_strike.js 200 in 49ms (compile: 21ms, proxy.ts: 15ms, render: 13ms) - GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_text.js 200 in 49ms (compile: 22ms, proxy.ts: 16ms, render: 11ms) - GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_task_list.js 200 in 49ms (compile: 23ms, proxy.ts: 16ms, render: 10ms) - GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_task_item.js 200 in 49ms (compile: 23ms, proxy.ts: 17ms, render: 10ms) - GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_text_align.js 200 in 50ms (compile: 18ms, proxy.ts: 24ms, render: 8ms) - GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_text_style.js 200 in 111ms (compile: 95ms, proxy.ts: 8ms, render: 9ms) - GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_underline.js 200 in 112ms (compile: 97ms, proxy.ts: 8ms, render: 7ms) - GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_youtube.js 200 in 112ms (compile: 98ms, proxy.ts: 8ms, render: 6ms) - GET /api/leptos-tiptap-runtime/mnote-leptos-tiptap-spike-island_bg.wasm 200 in 62ms (compile: 3ms, proxy.ts: 6ms, render: 53ms) - GET /api/leptos-tiptap-runtime/mnote-leptos-tiptap-spike-island_bg.wasm 200 in 68ms (compile: 3ms, proxy.ts: 6ms, render: 59ms) - GET /api/references/backlinks?workspaceId=8f317132-506f-4399-99dc-d22318427248&pageId=8337ceec-e236-41d7-a2eb-86838f46c858 200 in 23ms (compile: 1505µs, proxy.ts: 7ms, render: 15ms) - GET / 307 in 85ms (compile: 2ms, proxy.ts: 7ms, render: 77ms) - GET /documents/8337ceec-e236-41d7-a2eb-86838f46c858 200 in 451ms (compile: 118ms, proxy.ts: 6ms, render: 327ms) - GET /api/tree/shell?workspaceId=8f317132-506f-4399-99dc-d22318427248&mode=page&channel=sidebar-page-tree-shell&host=sidebar-page-tree-shell&activeDocumentId=8337ceec-e236-41d7-a2eb-86838f46c858 200 in 19ms (compile: 1817µs, proxy.ts: 6ms, render: 12ms) - GET /api/backend/health 200 in 11ms (compile: 1466µs, proxy.ts: 6ms, render: 3ms) - GET /api/mnote-web/stream?workspaceId=8f317132-506f-4399-99dc-d22318427248 200 in 290ms (compile: 2ms, proxy.ts: 4ms, render: 284ms) - GET /api/leptos-tiptap-runtime/manifest.json 200 in 107ms (compile: 91ms, proxy.ts: 10ms, render: 6ms) - GET /api/leptos-tiptap-runtime/manifest.json 200 in 107ms (compile: 93ms, proxy.ts: 10ms, render: 5ms) - GET /api/leptos-tiptap-runtime/mnote-leptos-tiptap-spike-island.js 200 in 13ms (compile: 3ms, proxy.ts: 7ms, render: 3ms) - GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_blockquote.js 200 in 23ms (compile: 9ms, proxy.ts: 9ms, render: 5ms) - GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/bridge_runtime.js 200 in 25ms (compile: 8ms, proxy.ts: 9ms, render: 8ms) - GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_bold.js 200 in 24ms (compile: 9ms, proxy.ts: 10ms, render: 5ms) - GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_bullet_list.js 200 in 25ms (compile: 7ms, proxy.ts: 12ms, render: 6ms) - GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_code.js 200 in 23ms (compile: 7ms, proxy.ts: 11ms, render: 5ms) - GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_code_block.js 200 in 18ms (compile: 5ms, proxy.ts: 11ms, render: 2ms) - GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_dropcursor.js 200 in 21ms (compile: 6ms, proxy.ts: 8ms, render: 7ms) - GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_document.js 200 in 21ms (compile: 7ms, proxy.ts: 8ms, render: 6ms) - GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_gapcursor.js 200 in 21ms (compile: 8ms, proxy.ts: 8ms, render: 4ms) - GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_heading.js 200 in 24ms (compile: 8ms, proxy.ts: 10ms, render: 6ms) - GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_hard_break.js 200 in 24ms (compile: 10ms, proxy.ts: 9ms, render: 5ms) - GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_highlight.js 200 in 23ms (compile: 10ms, proxy.ts: 9ms, render: 4ms) - GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_history.js 200 in 21ms (compile: 7ms, proxy.ts: 9ms, render: 6ms) - GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_image.js 200 in 21ms (compile: 7ms, proxy.ts: 9ms, render: 5ms) - GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_horizontal_rule.js 200 in 21ms (compile: 7ms, proxy.ts: 10ms, render: 4ms) - GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_italic.js 200 in 117ms (compile: 96ms, proxy.ts: 9ms, render: 13ms) - GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_link.js 200 in 117ms (compile: 97ms, proxy.ts: 9ms, render: 11ms) - GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_list_item.js 200 in 118ms (compile: 98ms, proxy.ts: 9ms, render: 10ms) - GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_paragraph.js 200 in 46ms (compile: 6ms, proxy.ts: 33ms, render: 6ms) - GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_ordered_list.js 200 in 46ms (compile: 7ms, proxy.ts: 33ms, render: 5ms) - GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_placeholder.js 200 in 44ms (compile: 8ms, proxy.ts: 32ms, render: 4ms) - GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_task_item.js 200 in 21ms (compile: 7ms, proxy.ts: 7ms, render: 7ms) - GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_strike.js 200 in 21ms (compile: 8ms, proxy.ts: 8ms, render: 5ms) - GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_task_list.js 200 in 21ms (compile: 8ms, proxy.ts: 9ms, render: 4ms) - GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_text_align.js 200 in 19ms (compile: 7ms, proxy.ts: 7ms, render: 6ms) - GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_text.js 200 in 19ms (compile: 7ms, proxy.ts: 7ms, render: 5ms) - GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_text_style.js 200 in 20ms (compile: 8ms, proxy.ts: 8ms, render: 4ms) - GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_underline.js 200 in 16ms (compile: 5ms, proxy.ts: 7ms, render: 4ms) - GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_youtube.js 200 in 16ms (compile: 5ms, proxy.ts: 9ms, render: 3ms) - GET /api/leptos-tiptap-runtime/mnote-leptos-tiptap-spike-island_bg.wasm 200 in 57ms (compile: 2ms, proxy.ts: 6ms, render: 49ms) - GET /api/leptos-tiptap-runtime/mnote-leptos-tiptap-spike-island_bg.wasm 200 in 54ms (compile: 3ms, proxy.ts: 6ms, render: 45ms) - GET /api/references/backlinks?workspaceId=8f317132-506f-4399-99dc-d22318427248&pageId=8337ceec-e236-41d7-a2eb-86838f46c858 200 in 17ms (compile: 1539µs, proxy.ts: 7ms, render: 8ms) - GET /api/tree/shell?workspaceId=8f317132-506f-4399-99dc-d22318427248&mode=filetree&channel=sidebar-file-tree-shell&host=sidebar-file-tree-shell&activeDocumentId=8337ceec-e236-41d7-a2eb-86838f46c858 200 in 23ms (compile: 1655µs, proxy.ts: 7ms, render: 14ms) - GET /documents/ced55414-84b5-4d88-8af6-9a2d54f42b36 200 in 283ms (compile: 106ms, proxy.ts: 7ms, render: 170ms) - GET /api/tree/shell?workspaceId=8f317132-506f-4399-99dc-d22318427248&mode=filetree&channel=sidebar-file-tree-shell&host=sidebar-file-tree-shell&activeDocumentId=ced55414-84b5-4d88-8af6-9a2d54f42b36 200 in 34ms (compile: 2ms, proxy.ts: 8ms, render: 24ms) - GET /api/leptos-tiptap-runtime/manifest.json 200 in 105ms (compile: 89ms, proxy.ts: 5ms, render: 10ms) - GET /api/leptos-tiptap-runtime/manifest.json 200 in 104ms (compile: 95ms, proxy.ts: 6ms, render: 4ms) - GET /api/references/backlinks?workspaceId=8f317132-506f-4399-99dc-d22318427248&pageId=ced55414-84b5-4d88-8af6-9a2d54f42b36 200 in 18ms (compile: 1665µs, proxy.ts: 6ms, render: 10ms) - GET /documents/dba2659d-b9fd-4a3b-a28a-448756ea7768 200 in 282ms (compile: 110ms, proxy.ts: 6ms, render: 166ms) - GET /api/tree/shell?workspaceId=8f317132-506f-4399-99dc-d22318427248&mode=filetree&channel=sidebar-file-tree-shell&host=sidebar-file-tree-shell&activeDocumentId=dba2659d-b9fd-4a3b-a28a-448756ea7768 200 in 33ms (compile: 2ms, proxy.ts: 8ms, render: 23ms) - GET /api/leptos-tiptap-runtime/manifest.json 200 in 111ms (compile: 96ms, proxy.ts: 8ms, render: 7ms) - GET /api/leptos-tiptap-runtime/manifest.json 200 in 111ms (compile: 97ms, proxy.ts: 8ms, render: 5ms) - GET /api/references/backlinks?workspaceId=8f317132-506f-4399-99dc-d22318427248&pageId=dba2659d-b9fd-4a3b-a28a-448756ea7768 200 in 19ms (compile: 1920µs, proxy.ts: 7ms, render: 11ms) - GET /documents/0afdf760-a9aa-43e1-8b76-400d84b96b2a 200 in 279ms (compile: 111ms, proxy.ts: 6ms, render: 162ms) - GET /api/tree/shell?workspaceId=8f317132-506f-4399-99dc-d22318427248&mode=filetree&channel=sidebar-file-tree-shell&host=sidebar-file-tree-shell&activeDocumentId=0afdf760-a9aa-43e1-8b76-400d84b96b2a 200 in 34ms (compile: 2ms, proxy.ts: 10ms, render: 22ms) - GET /api/leptos-tiptap-runtime/manifest.json 200 in 106ms (compile: 92ms, proxy.ts: 7ms, render: 8ms) - GET /api/leptos-tiptap-runtime/manifest.json 200 in 107ms (compile: 95ms, proxy.ts: 7ms, render: 5ms) - GET /api/references/backlinks?workspaceId=8f317132-506f-4399-99dc-d22318427248&pageId=0afdf760-a9aa-43e1-8b76-400d84b96b2a 200 in 19ms (compile: 1664µs, proxy.ts: 6ms, render: 10ms) - GET /api/tree/shell?workspaceId=8f317132-506f-4399-99dc-d22318427248&mode=page&channel=sidebar-page-tree-shell&host=sidebar-page-tree-shell&activeDocumentId=0afdf760-a9aa-43e1-8b76-400d84b96b2a 200 in 21ms (compile: 1667µs, proxy.ts: 6ms, render: 13ms) - GET /documents/7575b06a-16a9-48d1-94fc-2b7532da75ca?preview=sidebar 200 in 450ms (compile: 106ms, proxy.ts: 7ms, render: 337ms) - GET /api/tree/shell?workspaceId=8f317132-506f-4399-99dc-d22318427248&mode=page&channel=sidebar-page-tree-shell&host=sidebar-page-tree-shell&activeDocumentId=7575b06a-16a9-48d1-94fc-2b7532da75ca 200 in 18ms (compile: 1688µs, proxy.ts: 6ms, render: 10ms) - GET /api/backend/health 200 in 13ms (compile: 1891µs, proxy.ts: 8ms, render: 4ms) - GET /api/mnote-web/stream?workspaceId=8f317132-506f-4399-99dc-d22318427248 200 in 303ms (compile: 1360µs, proxy.ts: 6ms, render: 296ms) - GET /api/leptos-tiptap-runtime/manifest.json 200 in 106ms (compile: 92ms, proxy.ts: 7ms, render: 6ms) - GET /api/leptos-tiptap-runtime/manifest.json 200 in 106ms (compile: 94ms, proxy.ts: 7ms, render: 5ms) - GET /api/leptos-tiptap-runtime/mnote-leptos-tiptap-spike-island.js 200 in 13ms (compile: 3ms, proxy.ts: 7ms, render: 3ms) - GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/bridge_runtime.js 200 in 20ms (compile: 6ms, proxy.ts: 7ms, render: 8ms) - GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_blockquote.js 200 in 21ms (compile: 10ms, proxy.ts: 8ms, render: 3ms) - GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_bold.js 200 in 17ms (compile: 4ms, proxy.ts: 8ms, render: 5ms) - GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_bullet_list.js 200 in 17ms (compile: 5ms, proxy.ts: 8ms, render: 3ms) - GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_code.js 200 in 13ms (compile: 5ms, proxy.ts: 6ms, render: 2ms) - GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_code_block.js 200 in 346ms (compile: 127ms, proxy.ts: 31ms, render: 188ms) - GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_document.js 200 in 413ms (compile: 292ms, proxy.ts: 18ms, render: 103ms) - GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_gapcursor.js 200 in 410ms (compile: 300ms, proxy.ts: 16ms, render: 94ms) - GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_dropcursor.js 200 in 412ms (compile: 163ms, proxy.ts: 195ms, render: 54ms) - GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_hard_break.js 200 in 410ms (compile: 164ms, proxy.ts: 193ms, render: 53ms) - GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_heading.js 200 in 110ms (compile: 12ms, proxy.ts: 93ms, render: 5ms) - GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_highlight.js 200 in 21ms (compile: 9ms, proxy.ts: 9ms, render: 4ms) - GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_history.js 200 in 20ms (compile: 6ms, proxy.ts: 12ms, render: 3ms) - GET /documents/6700d014-d576-4279-8d76-4787c9156989?preview=sidebar 200 in 662ms (compile: 139ms, proxy.ts: 6ms, render: 517ms) - GET /api/tree/shell?workspaceId=8f317132-506f-4399-99dc-d22318427248&mode=page&channel=sidebar-page-tree-shell&host=sidebar-page-tree-shell&activeDocumentId=6700d014-d576-4279-8d76-4787c9156989 200 in 128ms (compile: 8ms, proxy.ts: 15ms, render: 105ms) - GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_italic.js 200 in 132ms (compile: 102ms, proxy.ts: 15ms, render: 15ms) - GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_horizontal_rule.js 200 in 132ms (compile: 105ms, proxy.ts: 15ms, render: 13ms) - GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_image.js 200 in 134ms (compile: 106ms, proxy.ts: 16ms, render: 12ms) - GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_link.js 200 in 133ms (compile: 109ms, proxy.ts: 17ms, render: 7ms) - GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_list_item.js 200 in 127ms (compile: 108ms, proxy.ts: 14ms, render: 5ms) - GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_paragraph.js 200 in 42ms (compile: 13ms, proxy.ts: 17ms, render: 11ms) - GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_ordered_list.js 200 in 42ms (compile: 14ms, proxy.ts: 17ms, render: 10ms) - GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_strike.js 200 in 42ms (compile: 14ms, proxy.ts: 19ms, render: 9ms) - GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_placeholder.js 200 in 42ms (compile: 14ms, proxy.ts: 21ms, render: 8ms) - GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_task_item.js 200 in 38ms (compile: 14ms, proxy.ts: 17ms, render: 7ms) - GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_task_list.js 200 in 38ms (compile: 15ms, proxy.ts: 18ms, render: 6ms) - GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_text.js 200 in 42ms (compile: 19ms, proxy.ts: 12ms, render: 12ms) - GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_underline.js 200 in 42ms (compile: 20ms, proxy.ts: 12ms, render: 11ms) - GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_text_style.js 200 in 42ms (compile: 19ms, proxy.ts: 13ms, render: 10ms) - GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_text_align.js 200 in 43ms (compile: 20ms, proxy.ts: 15ms, render: 9ms) - GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_youtube.js 200 in 43ms (compile: 17ms, proxy.ts: 19ms, render: 7ms) - GET /api/references/backlinks?workspaceId=8f317132-506f-4399-99dc-d22318427248&pageId=7575b06a-16a9-48d1-94fc-2b7532da75ca 200 in 44ms (compile: 4ms, proxy.ts: 20ms, render: 19ms) - GET /api/backend/health 200 in 20ms (compile: 5ms, proxy.ts: 8ms, render: 8ms) - GET /api/leptos-tiptap-runtime/mnote-leptos-tiptap-spike-island_bg.wasm 200 in 320ms (compile: 12ms, proxy.ts: 16ms, render: 292ms) - GET /api/mnote-web/stream?workspaceId=8f317132-506f-4399-99dc-d22318427248 200 in 335ms (compile: 6ms, proxy.ts: 8ms, render: 321ms) - GET /api/leptos-tiptap-runtime/manifest.json 200 in 119ms (compile: 95ms, proxy.ts: 14ms, render: 10ms) - GET /api/leptos-tiptap-runtime/manifest.json 200 in 119ms (compile: 96ms, proxy.ts: 14ms, render: 10ms) - GET /api/leptos-tiptap-runtime/mnote-leptos-tiptap-spike-island.js 200 in 13ms (compile: 3ms, proxy.ts: 7ms, render: 2ms) - GET /api/leptos-tiptap-runtime/mnote-leptos-tiptap-spike-island_bg.wasm 200 in 183ms (compile: 93ms, proxy.ts: 15ms, render: 75ms) - GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_blockquote.js 200 in 35ms (compile: 11ms, proxy.ts: 12ms, render: 13ms) - GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_bold.js 200 in 35ms (compile: 13ms, proxy.ts: 12ms, render: 10ms) - GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/bridge_runtime.js 200 in 36ms (compile: 12ms, proxy.ts: 12ms, render: 12ms) - GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_bullet_list.js 200 in 35ms (compile: 13ms, proxy.ts: 13ms, render: 10ms) - GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_code.js 200 in 36ms (compile: 16ms, proxy.ts: 14ms, render: 5ms) - GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_code_block.js 200 in 36ms (compile: 15ms, proxy.ts: 10ms, render: 11ms) - GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_dropcursor.js 200 in 35ms (compile: 16ms, proxy.ts: 10ms, render: 10ms) - GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_document.js 200 in 35ms (compile: 15ms, proxy.ts: 11ms, render: 9ms) - GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_gapcursor.js 200 in 36ms (compile: 15ms, proxy.ts: 12ms, render: 8ms) - GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_hard_break.js 200 in 36ms (compile: 16ms, proxy.ts: 13ms, render: 7ms) - GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_heading.js 200 in 32ms (compile: 16ms, proxy.ts: 13ms, render: 3ms) - GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_history.js 200 in 41ms (compile: 14ms, proxy.ts: 16ms, render: 11ms) - GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_highlight.js 200 in 40ms (compile: 15ms, proxy.ts: 16ms, render: 10ms) - GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_horizontal_rule.js 200 in 41ms (compile: 13ms, proxy.ts: 18ms, render: 9ms) - GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_link.js 200 in 36ms (compile: 14ms, proxy.ts: 14ms, render: 8ms) - GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_italic.js 200 in 36ms (compile: 15ms, proxy.ts: 14ms, render: 7ms) - GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_image.js 200 in 36ms (compile: 15ms, proxy.ts: 15ms, render: 6ms) - GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_list_item.js 200 in 41ms (compile: 13ms, proxy.ts: 18ms, render: 11ms) - GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_placeholder.js 200 in 41ms (compile: 13ms, proxy.ts: 18ms, render: 10ms) - GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_ordered_list.js 200 in 41ms (compile: 14ms, proxy.ts: 18ms, render: 9ms) - GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_paragraph.js 200 in 41ms (compile: 14ms, proxy.ts: 18ms, render: 8ms) - GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_task_item.js 200 in 41ms (compile: 14ms, proxy.ts: 20ms, render: 7ms) - GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_strike.js 200 in 41ms (compile: 15ms, proxy.ts: 21ms, render: 5ms) - GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_task_list.js 200 in 130ms (compile: 105ms, proxy.ts: 11ms, render: 14ms) - GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_text_style.js 200 in 128ms (compile: 104ms, proxy.ts: 12ms, render: 12ms) - GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_text_align.js 200 in 129ms (compile: 104ms, proxy.ts: 14ms, render: 10ms) - GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_underline.js 200 in 128ms (compile: 104ms, proxy.ts: 15ms, render: 9ms) - GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_text.js 200 in 130ms (compile: 105ms, proxy.ts: 18ms, render: 7ms) - GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_youtube.js 200 in 129ms (compile: 105ms, proxy.ts: 18ms, render: 6ms) - GET /api/references/backlinks?workspaceId=8f317132-506f-4399-99dc-d22318427248&pageId=6700d014-d576-4279-8d76-4787c9156989 200 in 21ms (compile: 2ms, proxy.ts: 7ms, render: 11ms) - GET /api/leptos-tiptap-runtime/mnote-leptos-tiptap-spike-island_bg.wasm 200 in 66ms (compile: 5ms, proxy.ts: 7ms, render: 54ms) - GET /api/leptos-tiptap-runtime/mnote-leptos-tiptap-spike-island_bg.wasm 200 in 64ms (compile: 6ms, proxy.ts: 9ms, render: 49ms) - GET /documents/cbebb4af-a158-43c5-a55f-6a2a2d18d491?preview=sidebar 200 in 454ms (compile: 111ms, proxy.ts: 6ms, render: 337ms) - GET /api/tree/shell?workspaceId=8f317132-506f-4399-99dc-d22318427248&mode=page&channel=sidebar-page-tree-shell&host=sidebar-page-tree-shell&activeDocumentId=cbebb4af-a158-43c5-a55f-6a2a2d18d491 200 in 21ms (compile: 1850µs, proxy.ts: 6ms, render: 14ms) - GET /api/backend/health 200 in 10ms (compile: 1522µs, proxy.ts: 6ms, render: 3ms) - GET /api/leptos-tiptap-runtime/manifest.json 200 in 109ms (compile: 95ms, proxy.ts: 7ms, render: 8ms) - GET /api/leptos-tiptap-runtime/manifest.json 200 in 110ms (compile: 98ms, proxy.ts: 8ms, render: 5ms) - GET /api/leptos-tiptap-runtime/mnote-leptos-tiptap-spike-island.js 200 in 12ms (compile: 3ms, proxy.ts: 7ms, render: 2ms) - GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_blockquote.js 200 in 43ms (compile: 13ms, proxy.ts: 19ms, render: 11ms) - GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_bold.js 200 in 43ms (compile: 15ms, proxy.ts: 20ms, render: 8ms) - GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/bridge_runtime.js 200 in 44ms (compile: 15ms, proxy.ts: 19ms, render: 10ms) - GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_bullet_list.js 200 in 44ms (compile: 16ms, proxy.ts: 20ms, render: 7ms) - GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_code.js 200 in 44ms (compile: 14ms, proxy.ts: 24ms, render: 5ms) - GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_document.js 200 in 43ms (compile: 15ms, proxy.ts: 18ms, render: 10ms) - GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_code_block.js 200 in 43ms (compile: 15ms, proxy.ts: 18ms, render: 9ms) - GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_dropcursor.js 200 in 43ms (compile: 16ms, proxy.ts: 18ms, render: 8ms) - GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_gapcursor.js 200 in 35ms (compile: 12ms, proxy.ts: 17ms, render: 6ms) - GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_hard_break.js 200 in 35ms (compile: 13ms, proxy.ts: 17ms, render: 5ms) - GET /api/mnote-web/stream?workspaceId=8f317132-506f-4399-99dc-d22318427248 200 in 688ms (compile: 1358µs, proxy.ts: 5ms, render: 681ms) - GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_heading.js 200 in 42ms (compile: 17ms, proxy.ts: 13ms, render: 12ms) - GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_image.js 200 in 42ms (compile: 18ms, proxy.ts: 13ms, render: 11ms) - GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_highlight.js 200 in 42ms (compile: 19ms, proxy.ts: 13ms, render: 10ms) - GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_history.js 200 in 43ms (compile: 18ms, proxy.ts: 16ms, render: 9ms) - GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_horizontal_rule.js 200 in 43ms (compile: 18ms, proxy.ts: 18ms, render: 7ms) - GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_italic.js 200 in 39ms (compile: 18ms, proxy.ts: 14ms, render: 6ms) - GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_list_item.js 200 in 42ms (compile: 14ms, proxy.ts: 12ms, render: 16ms) - GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_link.js 200 in 42ms (compile: 15ms, proxy.ts: 12ms, render: 15ms) - GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_ordered_list.js 200 in 42ms (compile: 16ms, proxy.ts: 12ms, render: 14ms) - GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_paragraph.js 200 in 41ms (compile: 16ms, proxy.ts: 13ms, render: 12ms) - GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_strike.js 200 in 43ms (compile: 17ms, proxy.ts: 15ms, render: 11ms) - GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_placeholder.js 200 in 42ms (compile: 19ms, proxy.ts: 17ms, render: 6ms) - GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_task_item.js 200 in 129ms (compile: 98ms, proxy.ts: 12ms, render: 19ms) - GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_text_align.js 200 in 129ms (compile: 100ms, proxy.ts: 12ms, render: 17ms) - GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_text.js 200 in 129ms (compile: 101ms, proxy.ts: 12ms, render: 16ms) - GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_task_list.js 200 in 129ms (compile: 101ms, proxy.ts: 13ms, render: 15ms) - GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_text_style.js 200 in 130ms (compile: 103ms, proxy.ts: 15ms, render: 11ms) - GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_underline.js 200 in 130ms (compile: 104ms, proxy.ts: 16ms, render: 10ms) - GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_youtube.js 200 in 15ms (compile: 6ms, proxy.ts: 6ms, render: 3ms) - GET /api/references/backlinks?workspaceId=8f317132-506f-4399-99dc-d22318427248&pageId=cbebb4af-a158-43c5-a55f-6a2a2d18d491 200 in 21ms (compile: 2ms, proxy.ts: 6ms, render: 12ms) - GET /api/leptos-tiptap-runtime/mnote-leptos-tiptap-spike-island_bg.wasm 200 in 54ms (compile: 3ms, proxy.ts: 5ms, render: 47ms) - GET /api/leptos-tiptap-runtime/mnote-leptos-tiptap-spike-island_bg.wasm 200 in 87ms (compile: 3ms, proxy.ts: 7ms, render: 77ms) - GET /api/tree/shell?workspaceId=8f317132-506f-4399-99dc-d22318427248&mode=filetree&channel=sidebar-file-tree-shell&host=sidebar-file-tree-shell&activeDocumentId=0afdf760-a9aa-43e1-8b76-400d84b96b2a 200 in 22ms (compile: 1659µs, proxy.ts: 7ms, render: 14ms) - GET /documents/7575b06a-16a9-48d1-94fc-2b7532da75ca 200 in 270ms (compile: 110ms, proxy.ts: 5ms, render: 155ms) - GET /api/tree/shell?workspaceId=8f317132-506f-4399-99dc-d22318427248&mode=filetree&channel=sidebar-file-tree-shell&host=sidebar-file-tree-shell&activeDocumentId=7575b06a-16a9-48d1-94fc-2b7532da75ca 200 in 24ms (compile: 1557µs, proxy.ts: 6ms, render: 16ms) - GET /api/leptos-tiptap-runtime/manifest.json 200 in 112ms (compile: 95ms, proxy.ts: 6ms, render: 10ms) - GET /api/leptos-tiptap-runtime/manifest.json 200 in 11ms (compile: 2ms, proxy.ts: 6ms, render: 3ms) - GET /api/references/backlinks?workspaceId=8f317132-506f-4399-99dc-d22318427248&pageId=7575b06a-16a9-48d1-94fc-2b7532da75ca 200 in 15ms (compile: 1603µs, proxy.ts: 6ms, render: 7ms) - GET /documents/6700d014-d576-4279-8d76-4787c9156989 200 in 285ms (compile: 115ms, proxy.ts: 7ms, render: 163ms) - GET /api/tree/shell?workspaceId=8f317132-506f-4399-99dc-d22318427248&mode=filetree&channel=sidebar-file-tree-shell&host=sidebar-file-tree-shell&activeDocumentId=6700d014-d576-4279-8d76-4787c9156989 200 in 38ms (compile: 2ms, proxy.ts: 20ms, render: 15ms) - GET /api/leptos-tiptap-runtime/manifest.json 200 in 107ms (compile: 93ms, proxy.ts: 7ms, render: 6ms) - GET /api/leptos-tiptap-runtime/manifest.json 200 in 107ms (compile: 94ms, proxy.ts: 8ms, render: 5ms) - GET /api/references/backlinks?workspaceId=8f317132-506f-4399-99dc-d22318427248&pageId=6700d014-d576-4279-8d76-4787c9156989 200 in 16ms (compile: 3ms, proxy.ts: 6ms, render: 8ms) - GET /documents/11181c74-1333-41d1-ac84-9b8e027131db 200 in 295ms (compile: 114ms, proxy.ts: 18ms, render: 163ms) - GET /api/tree/shell?workspaceId=8f317132-506f-4399-99dc-d22318427248&mode=filetree&channel=sidebar-file-tree-shell&host=sidebar-file-tree-shell&activeDocumentId=11181c74-1333-41d1-ac84-9b8e027131db 200 in 32ms (compile: 1970µs, proxy.ts: 8ms, render: 22ms) - GET /api/leptos-tiptap-runtime/manifest.json 200 in 113ms (compile: 99ms, proxy.ts: 7ms, render: 7ms) - GET /api/leptos-tiptap-runtime/manifest.json 200 in 114ms (compile: 101ms, proxy.ts: 9ms, render: 4ms) - GET /api/references/backlinks?workspaceId=8f317132-506f-4399-99dc-d22318427248&pageId=11181c74-1333-41d1-ac84-9b8e027131db 200 in 18ms (compile: 1650µs, proxy.ts: 6ms, render: 11ms) - GET /api/tree/shell?workspaceId=8f317132-506f-4399-99dc-d22318427248&mode=page&channel=sidebar-page-tree-shell&host=sidebar-page-tree-shell&activeDocumentId=11181c74-1333-41d1-ac84-9b8e027131db 200 in 20ms (compile: 1814µs, proxy.ts: 6ms, render: 12ms) diff --git a/wolai-frontend/src/components/editor/DocumentAiAgentPanel.runtime.tsx b/wolai-frontend/src/components/editor/DocumentAiAgentPanel.runtime.tsx index 3c0727ab..66e23edc 100644 --- a/wolai-frontend/src/components/editor/DocumentAiAgentPanel.runtime.tsx +++ b/wolai-frontend/src/components/editor/DocumentAiAgentPanel.runtime.tsx @@ -926,6 +926,24 @@ export function DocumentAiAgentPanelRuntime({ const message = error instanceof Error ? error.message : "AI 页面正文保存失败"; setToolLogs((prev) => [...prev, { type: "error", message }]); }); + + // ── Phase B:将 blockDelta 推送给 leptos-tiptap 编辑器 ── + if (result && typeof result === "object") { + const resultRecord = result as Record; + const blockDelta = resultRecord.blockDelta; + if (blockDelta && typeof blockDelta === "object") { + try { + window.dispatchEvent( + new CustomEvent("mnote:editor:block-delta", { + detail: blockDelta, + bubbles: true, + }), + ); + } catch (dispatchError) { + console.debug("editor blockDelta dispatch failed", dispatchError); + } + } + } } catch { // ignore }