chore: 保存当前架构收口与 bug 修复快照
归档本轮 P0/P1 bug 修复、设计审查迁移、AI selection scope 收口与 stream contract 调整,并保留当前 05 主线迁移起点。
This commit is contained in:
@@ -11,7 +11,7 @@
|
||||
- `3000` 当前主壳已接入 Rust Web WebSocket push 主链(`/api/realtime/ws`)+ SSE fallback(`/api/tree/events`)的 snapshot / delta / resync consumer,并已有 browser smoke 验证(2026-05-17 WS 迁移 `57ec8322`);后续收口重点是统一 live cache 与减少补偿链,而不是把它描述成“还没接 live stream”。
|
||||
- 文档页默认主编辑器已切到页面内 `leptos-tiptap` island;`BlockNote` 已退出文档页默认主路径,只保留为历史参考实现 / 对照材料。
|
||||
- Page Aggregate 当前已输出 `blockDocument / blockProjectionVersion / projectionSource`(`projectionSource=documents.content`),标题/正文/页面设置写入后 smoke 已通过(`task110`、`task-page-aggregate-body-sync-smoke`、`task-page-aggregate-options-sync-smoke`);页面/块 AI 最小工具链已通过 Rust Hermes tools 读取和写入块投影;但这仍是从 `documents.content` / local markdown content 投影出来的过渡态,不是 EditorBlockDocument 原生落库完成态。客户端 `PageAggregateClientState` reducer 仍在,页面域单一真源未完全闭环。
|
||||
- 当前最优先的架构收口不是继续扩编辑器 UI,而是 `Page Aggregate` 单一真源收口(标题/正文/页面设置 smoke 已通过)、`tree command cutover` 收尾、`tree realtime event stream` live cache 统一(WS push 主链 2026-05-17 上线,SSE 降级为 fallback)三条主线;AI 侧当前只做 Phase A(`mnote.doc.markdown_edit` + `mnote.doc.fetch` 增强,search/replace + `format: "markdown"` + local source),退役 `local_rule` planner 作为 Phase B;`review session`/流式 apply 属于 Phase C(设计冻结,当前不实施),不扩新 AI 功能,不把粗粒度 `mnote.page.save` 当成精确块编辑主入口。
|
||||
- 当前最优先的架构收口不是继续扩编辑器 UI,而是 `Page Aggregate` 单一真源收口(标题/正文/页面设置 smoke 已通过)、`tree command cutover` 收尾、`tree realtime event stream` live cache 统一(WS push 主链 2026-05-17 上线,SSE 降级为 fallback)三条主线;AI 侧简单正文编辑当前主路径已切到 `mnote.doc.markdown_edit`(模型生成 search/replace 或 full_content,经统一 mnote tool executor 执行;在线文档与本地 `.md` 共用合同),`mnote.doc.fetch` 负责读取上下文,`mnote.doc.apply_block_ops` / `mnote.block.*` 仅作为结构性辅助;`review session`/流式 apply 属于 Phase C(设计冻结,当前不实施),不扩新 AI 功能,不把粗粒度 `mnote.page.save` 当成精确块编辑主入口。
|
||||
|
||||
## 组件定位
|
||||
|
||||
@@ -19,15 +19,15 @@
|
||||
- `BlockNote` 是历史参考实现 / 对照材料,不再是运行时系统组件、默认回退编辑器或系统事实源。
|
||||
- `Mindmap` 是 `tree-first graph` 的一种视图和编辑挂件,不是对象真相层。
|
||||
- `OnlyOffice` 是独立页面型编辑器,不直接嵌入 `BlockNote` 画布;正文中通常通过附件块跳转进入。
|
||||
- `page-ai/block-edit-workflow` route 是当前简单编辑的 fast-path 入口(`local_rule` planner 为过渡实现);长期方向是将 AI 编辑主路径收敛到 `mnote.doc.markdown_edit`(markdown 级 search/replace,见 7-14),`mnote.block.*` 保留为结构性辅助。复杂任务仍走 Hermes agent。
|
||||
- `page-ai/block-edit-workflow` route 是当前简单编辑的 fast-path 入口;当前实现通过模型生成 markdown 级 search/replace / full_content 后调用 `mnote.doc.markdown_edit`,并复用统一 Hermes mnote tool executor;`mnote.block.*` 保留为结构性辅助。复杂任务仍走 Hermes agent。
|
||||
- 树域三层模型:`Resource Tree`(kernel 对象组织真源)→ `File Tree`(主组织投影,`{title}.md` 为页面正文行)→ `Page Tree`(导航投影,不持有结构真相)。涉及资源归属优先走 Rust kernel 的 `KernelObjectIdentity` / `KernelProjectionResourceKind`。
|
||||
|
||||
## 目录优先级
|
||||
|
||||
- `/mnt/Data1T/mnote/wolai-frontend/src/`
|
||||
页面、编辑器、Sidebar、树视图、阅读态、交互壳默认先看这里。
|
||||
- `/mnt/Data1T/mnote/wolai-frontend/convex/`
|
||||
仍在使用的 Convex functions 与数据侧逻辑先看这里。
|
||||
- `/mnt/Data1T/mnote/recycle/wolai-frontend/src/`
|
||||
wolai-frontend(Next.js React 前端)已退役移入 recycle,不作为当前实现依据。页面、编辑器、Sidebar、树视图、阅读态、交互壳默认先看 Rust mnote-web SSR(`rust/crates/mnote-web/src/`)。
|
||||
- `/mnt/Data1T/mnote/recycle/wolai-frontend/convex/`
|
||||
已随 wolai-frontend 移入 recycle。当前活跃 Convex functions 在 `infra/convex/` 与对应 Rust bridge 中。
|
||||
- `/mnt/Data1T/mnote/rust/crates/core-protocol/`
|
||||
Kernel 类型、projection 协议、树/图核心术语先看这里。
|
||||
- `/mnt/Data1T/mnote/rust/crates/bridge-runtime/`
|
||||
@@ -52,7 +52,7 @@
|
||||
- 涉及页面新建、重命名、移动、归档、恢复、嵌入时,优先沿 `tree.*` 正式命名推进;`documents.*` 只视为兼容层,不应继续扩写为长期命令面。
|
||||
- 涉及 AI 读取、定位或精确编辑页面块时,优先沿 `mnote.doc.*` / `mnote.block.*` Hermes tools 与 Rust `EditorCommand` 推进;`mnote.page.save` 只作为页面级兜底写入工具。
|
||||
- 涉及资源归属(哪个页面拥有哪个 mindmap/附件)、object identity 和资源生命周期时,优先沿 Resource Tree → File Tree projection → `tree.resource.*` 命令面推进;`tree.*` 是正式命令面,`documents.*` 只视为兼容层。
|
||||
- 页面 AI 编辑长期主路径为 `mnote.doc.markdown_edit`(search/replace 或 full_content,markdown 文本级),在线文档和本地 `.md` 文件共用。当前过渡实现为 `POST /api/page-ai/block-edit-workflow` fast-path(`local_rule` planner,Phase B 退役)。`mnote.block.*` 保留为结构性辅助(拖拽排序等)。两层操作模型已获 CLI Main(Lark Doc)参考实现验证。流式 apply + suggest/review(参考 BlockNote AI)作为 Phase C 设计冻结,当前不实施。复杂任务进入 `/api/hermes/client/runs`。模型不直接产出 unified diff 或 blockId 操作;最终定位、校验和写入由 Rust runtime 负责。
|
||||
- 页面 AI 编辑当前主路径为 `mnote.doc.markdown_edit`(search/replace 或 full_content,markdown 文本级),在线文档和本地 `.md` 文件共用。`POST /api/page-ai/block-edit-workflow` 是简单编辑 fast-path,当前通过模型生成 markdown 编辑意图后调用统一 mnote tool executor;`mnote.block.*` 保留为结构性辅助(拖拽排序等)。两层操作模型已获 CLI Main(Lark Doc)参考实现验证。流式 apply + suggest/review(参考 BlockNote AI)作为 Phase C 设计冻结,当前不实施。复杂任务进入 `/api/hermes/client/runs`。模型不直接产出 unified diff 或 blockId 操作;最终定位、校验和写入由 Rust runtime 负责。
|
||||
- 需要架构判断时,优先参考:
|
||||
- `/mnt/Data1T/mnote/ARCHITECTURE.md`
|
||||
- `/mnt/Data1T/mnote/design/01-05-current-priority-overview.md`
|
||||
@@ -65,7 +65,9 @@
|
||||
- `/mnt/Data1T/mnote/design/03-rust-web/process/3-3-rust-web-tree-realtime-event-stream-v1.md
|
||||
- `design/03-rust-web/done/3-14-rust-web-tree-realtime-ws-push-v1.md`
|
||||
- `/mnt/Data1T/mnote/design/10-review/process/08-kernel-architecture-next-priority-review-and-checklist.md`
|
||||
- `/mnt/Data1T/mnote/design/10-review/process/09-page-ai-fast-block-edit-runtime-review.md`
|
||||
- `/mnt/Data1T/mnote/design/10-review/done/09-page-ai-fast-block-edit-runtime-review.md`
|
||||
- `/mnt/Data1T/mnote/design/10-review/done/10-current-mnote-ai-runtime-review-v1.md`
|
||||
- `/mnt/Data1T/mnote/design/10-review/done/11-current-full-architecture-review-v1.md`
|
||||
- `/mnt/Data1T/mnote/design/07-ai/process/7-14-online-local-ai-markdown-editing-convergence-v1.md`
|
||||
- `/mnt/Data1T/mnote/design/04-tree-domain/done/4-24-resource-tree-filetree-pagetree-source-contract-checklist-v1.md`
|
||||
- `/mnt/Data1T/mnote/design/05-editor-mainline/done/5-12-main-editor-object-tab-resource-alignment-checklist-v1.md`
|
||||
@@ -100,8 +102,8 @@
|
||||
## 常用命令
|
||||
|
||||
- 根目录热启动:`npm run desktop:hot`
|
||||
- 前端开发:`cd /mnt/Data1T/mnote/wolai-frontend && pnpm dev`
|
||||
- 前端检查:`cd /mnt/Data1T/mnote/wolai-frontend && pnpm lint && pnpm test`
|
||||
- 前端开发(已迁移至 Rust SSR):`npm run desktop:hot` 启动后直接访问 `http://localhost:3000`
|
||||
- 前端检查:Rust 测试 `cargo test -p mnote-web`(Rust SSR 与 API),旧 React 前端已移入 recycle
|
||||
- 后端开发:`cd /mnt/Data1T/mnote/wolai-backend && uvicorn app.main:app --reload --port 8000`
|
||||
- Convex 自托管:参考 `/mnt/Data1T/mnote/infra/convex/README.md`
|
||||
|
||||
|
||||
+43
-69
@@ -35,16 +35,16 @@
|
||||
|
||||
## 2. 当前主线目录
|
||||
|
||||
- `/mnt/Data1T/mnote/wolai-frontend/`
|
||||
当前 React island 与 legacy compat 代码来源;Next.js App Router 不再作为 3000 主入口,只在显式 legacy/debug 迁移边界继续服务重交互运行态与对照链。
|
||||
- `/mnt/Data1T/mnote/recycle/wolai-frontend/`
|
||||
已退役移入 recycle。React island 与 legacy compat 代码的历史来源;Next.js App Router 已被 Rust mnote-web SSR 替代,不再作为 3000 主入口。
|
||||
- `/mnt/Data1T/mnote/rust/crates/core-protocol/`
|
||||
Kernel 类型、projection 协议、编辑器协议、树 / 图核心术语。
|
||||
- `/mnt/Data1T/mnote/rust/crates/bridge-runtime/`
|
||||
Kernel query / command、bridge transport、Tiptap <-> editor document 适配、命令解释层。
|
||||
- `/mnt/Data1T/mnote/rust/crates/mnote-web/`
|
||||
Rust Web route、kernel projection、documents/search/mindmap shell、Hermes bridge、stream transport、compat,以及显式开启时的 debug shell;它是当前主 Web gateway / shell / transport owner。
|
||||
- `/mnt/Data1T/mnote/wolai-frontend/convex/`
|
||||
仍在使用的 Convex functions 与数据侧逻辑。
|
||||
- `/mnt/Data1T/mnote/recycle/wolai-frontend/convex/`
|
||||
已随 wolai-frontend 移入 recycle。当前活跃 Convex functions 位于 `infra/convex/` 与 Rust bridge 中。
|
||||
- `/mnt/Data1T/mnote/infra/convex/`
|
||||
Convex 自托管部署与基础设施。
|
||||
- `/mnt/Data1T/mnote/wolai-backend/`
|
||||
@@ -52,97 +52,71 @@
|
||||
|
||||
## 3. 当前页面运行结构
|
||||
|
||||
### 3.1 根布局与 legacy island source
|
||||
### 3.1 根布局与 legacy island source(已退役)
|
||||
|
||||
- `/mnt/Data1T/mnote/wolai-frontend/src/app/layout.tsx`
|
||||
- `/mnt/Data1T/mnote/recycle/wolai-frontend/src/app/layout.tsx`
|
||||
|
||||
职责:
|
||||
|
||||
- 作为 legacy Next / React island bundle source 的根布局
|
||||
- 保留 Convex / Query Provider 等前端 island 运行时依赖
|
||||
- 服务显式 legacy/debug/迁移边界
|
||||
该文件已随 wolai-frontend 移入 `recycle/`,不再作为当前实现依据。
|
||||
|
||||
注意:
|
||||
|
||||
> **3000 当前根入口与文档页 shell 由 `mnote-web` 持有;Next App Router 不再是 3000 主入口或页面根布局事实源。**
|
||||
> **3000 当前根入口与文档页 shell 由 `mnote-web` Rust SSR 持有;Next App Router 已完全退役。**
|
||||
|
||||
### 3.2 工作区壳与 Sidebar
|
||||
### 3.2 工作区壳与 Sidebar(已迁移至 Rust SSR)
|
||||
|
||||
- `/mnt/Data1T/mnote/wolai-frontend/src/components/app-layout-shell.tsx`
|
||||
- `/mnt/Data1T/mnote/wolai-frontend/src/hooks/use-sidebar-data.ts`
|
||||
- `/mnt/Data1T/mnote/wolai-frontend/src/lib/tree-stream/use-sidebar-tree-stream.ts`
|
||||
- `/mnt/Data1T/mnote/wolai-frontend/src/components/sidebar/use-preferred-sidebar-snapshot.ts`
|
||||
以下文件已随 wolai-frontend 移入 `recycle/`,不再作为当前实现依据:
|
||||
|
||||
当前 Sidebar 不是单一路径,而是三层组合:
|
||||
- `/mnt/Data1T/mnote/recycle/wolai-frontend/src/components/app-layout-shell.tsx`
|
||||
- `/mnt/Data1T/mnote/recycle/wolai-frontend/src/hooks/use-sidebar-data.ts`
|
||||
- `/mnt/Data1T/mnote/recycle/wolai-frontend/src/lib/tree-stream/use-sidebar-tree-stream.ts`
|
||||
- `/mnt/Data1T/mnote/recycle/wolai-frontend/src/components/sidebar/use-preferred-sidebar-snapshot.ts`
|
||||
|
||||
1. 初始 SSR / route snapshot
|
||||
2. query / refetch snapshot
|
||||
3. tree stream live snapshot
|
||||
|
||||
`PreferredSidebarSnapshotProvider` 负责在 query 与 tree stream 之间做 freshness 选择,再把同一份 preferred snapshot 同时给:
|
||||
|
||||
- `Sidebar`
|
||||
- `Breadcrumb`
|
||||
- 文档页头标题消费链
|
||||
|
||||
这说明当前工作区树链已经进入正式收口阶段:`3000` 主壳已直接接入 `/api/tree/events` 的 snapshot / delta / resync consumer,并有浏览器 smoke 验证;但 preferred snapshot、page subtree、filetree 与补偿链还没有完全统一成唯一 live cache。
|
||||
当前 Sidebar/工作区壳的当前实现在 Rust mnote-web SSR:
|
||||
- 工作区壳渲染入口:`gateway.rs`(`root_entry`)
|
||||
- 文档页壳/文档面板:`web_shell.rs`(`document_page_shell`、`build_document_panes_bootstrap_json`)
|
||||
- Sidebar tree stream 消费:`layout.rs` 嵌入 JS(`startWithSse` / `startWithWebSocket`)
|
||||
- FileTree 渲染与交互:`tree.rs` 嵌入 JS
|
||||
|
||||
## 4. 文档页主链
|
||||
|
||||
### 4.1 入口
|
||||
|
||||
- `/mnt/Data1T/mnote/wolai-frontend/src/lib/documents/page-aggregate-loader.ts`
|
||||
- `/mnt/Data1T/mnote/wolai-frontend/src/app/(app)/documents/[id]/page.tsx`
|
||||
文档页入口的当前实现在 Rust mnote-web:
|
||||
|
||||
当前文档页读取主链已经不再由页面入口手工拼若干独立字段,而是:
|
||||
- 文档页壳 SSR:`web_shell.rs`(`document_page_shell`),直接输出 HTML + bootstrap JSON
|
||||
- Page Aggregate 快照 API:`web_shell.rs`(`page_aggregate`)返回 `/api/page-aggregate/:id`
|
||||
- 前端桌面/窗口面板布局:`web_shell.rs`(`build_document_panes_bootstrap_json`)
|
||||
|
||||
1. 页面 SSR 入口通过 `page-aggregate-loader.ts` 直接请求 Rust `/api/page-aggregate/:id`
|
||||
2. `DocumentContent` 的内容重试补拉也直接请求同一条 `/api/page-aggregate/:id` 正式读链
|
||||
3. 读取主链不再回退到 TS builder;snapshot 不可用或不可信时直接显式失败
|
||||
4. Next `/api/documents/page` 已退场为明确 `410` 的 compat 边界,不再参与文档页运行时主路径
|
||||
历史 wolai-frontend Next 文档页入口(已移入 `recycle/`):
|
||||
- `/mnt/Data1T/mnote/recycle/wolai-frontend/src/lib/documents/page-aggregate-loader.ts`
|
||||
- `/mnt/Data1T/mnote/recycle/wolai-frontend/src/app/(app)/documents/[id]/page.tsx`
|
||||
- `/mnt/Data1T/mnote/recycle/wolai-frontend/src/lib/documents/page-aggregate.ts`
|
||||
|
||||
对应聚合类型定义:
|
||||
### 4.2 文档壳(已迁移至 Rust SSR)
|
||||
|
||||
- `/mnt/Data1T/mnote/wolai-frontend/src/lib/documents/page-aggregate.ts`
|
||||
历史 React 文档壳组件(已随 wolai-frontend 移入 `recycle/`,不再作为当前实现):
|
||||
- `/mnt/Data1T/mnote/recycle/wolai-frontend/src/components/editor/document-shell.tsx`
|
||||
- `/mnt/Data1T/mnote/recycle/wolai-frontend/src/components/editor/document-content.tsx`
|
||||
|
||||
### 4.2 文档壳
|
||||
当前文档壳由 Rust mnote-web SSR 输出:
|
||||
- `web_shell.rs` → `document_page_shell` 直接渲染 HTML shell,不在前端保留 React 文档壳 runtime。
|
||||
|
||||
- `/mnt/Data1T/mnote/wolai-frontend/src/components/editor/document-shell.tsx`
|
||||
- `/mnt/Data1T/mnote/wolai-frontend/src/components/editor/document-content.tsx`
|
||||
### 4.3 默认编辑器 host(已迁移至 WASM island)
|
||||
|
||||
`DocumentShell` 现在只是薄封装,文档页核心状态和交互壳集中在 `DocumentContent`。
|
||||
历史 React 编辑器 host(已移入 `recycle/`):
|
||||
- `/mnt/Data1T/mnote/recycle/wolai-frontend/src/components/editor/editor-host-config.ts`
|
||||
- `/mnt/Data1T/mnote/recycle/wolai-frontend/src/components/editor/editor-host.tsx`
|
||||
- `/mnt/Data1T/mnote/recycle/wolai-frontend/src/components/editor/leptos-tiptap-island-editor-host.tsx`
|
||||
|
||||
### 4.3 默认编辑器 host
|
||||
|
||||
- `/mnt/Data1T/mnote/wolai-frontend/src/components/editor/editor-host-config.ts`
|
||||
- `/mnt/Data1T/mnote/wolai-frontend/src/components/editor/editor-host.tsx`
|
||||
|
||||
当前默认 host 为:
|
||||
|
||||
- `leptos_tiptap_island`
|
||||
|
||||
文档页主路径已不再暴露显式 debug host 选择;历史 `iframe_debug` 宿主仅剩源码参考,不再参与页面 host 选择面。
|
||||
|
||||
### 4.4 正式主编辑器
|
||||
|
||||
- `/mnt/Data1T/mnote/wolai-frontend/src/components/editor/leptos-tiptap-island-editor-host.tsx`
|
||||
|
||||
当前默认主编辑器已经是页面内 Leptos island,不再是 iframe bridge。
|
||||
|
||||
它负责:
|
||||
|
||||
- 加载 Leptos/WASM 编辑器本体
|
||||
- 接收初始化内容与 page options
|
||||
- 处理同页事件、命令、保存与状态同步
|
||||
当前默认主编辑器是页面内 Leptos/WASM island,由 Rust mnote-web SSR 加载:
|
||||
- WASM 产物:`rust/spikes/leptos-tiptap-spike/`
|
||||
- 加载路由:`web_shell.rs` → `leptos_tiptap_manifest` / `leptos_tiptap_asset`
|
||||
|
||||
### 4.5 BlockNote 的当前位置
|
||||
|
||||
- `/mnt/Data1T/mnote/recycle/wolai-frontend/src/components/editor/blocknote-editor.tsx`
|
||||
|
||||
`BlockNote` 当前仍保留回收副本,但其定位已变成:
|
||||
|
||||
- 历史参考实现
|
||||
- 对照材料 / 调试素材
|
||||
`BlockNote` 已随 wolai-frontend 移入 recycle,仅作为历史对照材料,不参与当前实现。
|
||||
|
||||
而不是默认主编辑器方向。
|
||||
|
||||
@@ -197,7 +171,7 @@
|
||||
|
||||
### 6.2 树命令现状
|
||||
|
||||
- `/mnt/Data1T/mnote/wolai-frontend/src/lib/documents/tree-command-client.ts`
|
||||
- `/mnt/Data1T/mnote/recycle/wolai-frontend/src/lib/documents/tree-command-client.ts`
|
||||
- `/mnt/Data1T/mnote/rust/crates/mnote-web/src/routes/tree.rs`
|
||||
- `/mnt/Data1T/mnote/rust/crates/storage-convex-bridge/src/mapping.rs`
|
||||
|
||||
|
||||
+55
-57
@@ -36,25 +36,25 @@
|
||||
|
||||
- 正式命令面应落在 `tree.*` 与 `tree.resource.*`。
|
||||
- `mnote-web` 同时暴露 `tree` 命令路由与兼容/过渡路由。
|
||||
- 当前最大冲突是 FileTree 资源行仍可能被当成页面命令对象处理,违反 Resource Tree / File Tree / Page Tree 分层。
|
||||
- FileTree 资源行已与页面命令对象隔离:资源 owner 只作为上下文,`data-document-id` / 页面命令目标只保留给真正页面行。
|
||||
|
||||
### 2.4 Transport / Realtime 层
|
||||
|
||||
- Rust Web 已同时注册 `/api/tree/events`(SSE)与 `/api/realtime/ws`(WS)两条实时链路([routes/mod.rs:170-172](rust/crates/mnote-web/src/routes/mod.rs:170))。
|
||||
- Rust SSR 主壳([layout.rs](rust/crates/mnote-web/src/ssr/pages/layout.rs:7126))已内置 WS 消费者(`startWithWebSocket`),并在 WS 断开后自动切 SSE fallback(`ws.onclose → startWithSseFallback`)。
|
||||
- 当前默认 `bootstrap.transport` 仍为 `convex-command-log-sse`,因此主壳默认走 SSE;WS 路径代码已就绪但尚未设为默认 transport,前端主消费链路仍未切换到 WS 默认。
|
||||
- `wolai-frontend`(Next.js legacy 侧)仅使用 SSE(`use-sidebar-tree-stream.ts`),无 WS 消费者。
|
||||
- SSE 内部还保留 push/polling 两种语义,WS 与 SSE 的 delta / snapshot 结构也未完全收敛。
|
||||
- 当前默认 `bootstrap.transport` 已切到 `convex-command-log-ws`,主壳先走 WS;SSE 保留为 fallback。
|
||||
- `recycle/wolai-frontend`(已退役的 Next.js legacy 侧)仅使用 SSE,无 WS 消费者。
|
||||
- WS 与 SSE 的 snapshot / delta 载荷合同已在 Rust 主壳消费层统一;后续重点是继续减少兼容 fallback 与 live cache 补偿链。
|
||||
|
||||
### 2.5 Page Aggregate 层
|
||||
|
||||
- 文档页入口已经优先消费 Rust `page-aggregate` 快照。
|
||||
- 但前端仍保留 `PageAggregateClientState` reducer 和本地 `page tree` 投影。
|
||||
- 当前 page aggregate 是“Rust join + 前端局部状态”的过渡闭环,不是 EditorBlockDocument 原生单一真源闭环。
|
||||
- AI context 的 page subtree 已只读 Rust Page Aggregate 的稳定 projection,不再由前端本地构造第二份 page tree 真相。
|
||||
- 当前 page aggregate 仍是从 `documents.content` / local markdown content 投影出来的过渡闭环,不是 EditorBlockDocument 原生单一真源闭环。
|
||||
|
||||
### 2.6 Editor Runtime 层
|
||||
|
||||
- 前端壳:Rust mnote-web 独享 3000 入口,通过 SSR 输出 workspace shell、文档壳、Sidebar、tree 等完整 HTML([gateway.rs](rust/crates/mnote-web/src/routes/gateway.rs:156)、[web_shell.rs](rust/crates/mnote-web/src/routes/web_shell.rs:63))。Next.js 前端代码仍保留在 `wolai-frontend/`,但默认热启动不再作为运行时 daemon。
|
||||
- 前端壳:Rust mnote-web 独享 3000 入口,通过 SSR 输出 workspace shell、文档壳、Sidebar、tree 等完整 HTML([gateway.rs](rust/crates/mnote-web/src/routes/gateway.rs:156)、[web_shell.rs](rust/crates/mnote-web/src/routes/web_shell.rs:63))。Next.js 前端代码已随 wolai-frontend 整体移入 `recycle/`,不再作为运行时 daemon 维护。
|
||||
- `leptos-tiptap` island 已是文档页默认编辑 host,以 WASM 形式由 Rust SSR 加载。
|
||||
- 保存正文仍会经过 `documents/save` 兼容面。
|
||||
- 这意味着编辑器体验已经切主,但写回语义还没有完全切到唯一主命令面。
|
||||
@@ -62,7 +62,8 @@
|
||||
### 2.7 AI Runtime 层
|
||||
|
||||
- `mnote.doc.fetch`、`mnote.doc.markdown_edit`、`mnote.doc.apply_block_ops`、`page_ai_workflow`、Hermes / ACP / Reasonix 构成当前 AI 主链。
|
||||
- 这里存在最密集的合同漂移:工具权限、写入幂等、dryRun、revision 校验、manifest schema、tool guidance、runtime owner 都还没有统一。
|
||||
- 简单正文编辑主路径已统一为模型生成 search/replace / full_content → `mnote.doc.markdown_edit` → 统一 mnote tool executor;`mnote.doc.apply_block_ops` 保留为结构性块操作辅助。
|
||||
- 工具权限、dryRun、幂等、revision / conflictDetectionKey、manifest schema、tool guidance、ACP payload / response 等 P0 合同漂移已收口;Phase C 的 review session / 流式 apply 仍冻结。
|
||||
|
||||
## 3. 当前成立的事实与过渡态
|
||||
|
||||
@@ -72,95 +73,92 @@
|
||||
- Tree 主链已经从旧兼容入口退向 Rust Web。
|
||||
- 文档页主编辑器已经切到 `leptos-tiptap` island。
|
||||
- AI 页面编辑已经不再是纯前端本地逻辑。
|
||||
- **前端壳已切换到 Rust**:默认 `desktop:hot` 仅启动 Rust mnote-web 作为 3000 网关 owner,不再启动 Next.js (`wolai-frontend`) 作为运行时 daemon。Next.js 仅保留为 legacy compat fallback(`MNOTE_WEB_SKIP_GATEWAY=1`)与 build toolchain。
|
||||
- **前端壳已切换到 Rust**:默认 `desktop:hot` 仅启动 Rust mnote-web 作为 3000 网关 owner。`wolai-frontend`(Next.js 前端)已移至 `recycle/`,不再作为运行时 daemon 或 legacy fallback 维护。
|
||||
|
||||
### 3.2 过渡态
|
||||
|
||||
- Page Aggregate 仍从 `documents.content` 侧 join 构造,而不是原生 EditorBlockDocument 真源。
|
||||
- 前端 Sidebar 仍有本地投影与多源抢真相。
|
||||
- `mnote.doc.markdown_edit` 已进入主线,但写入合同尚不完整。
|
||||
- ACP / Reasonix 已接入,但调用字段和运行时所有权仍不统一。
|
||||
- 页面正文写回仍经过兼容保存面,尚未完全切到唯一主命令面。
|
||||
- realtime / Page Aggregate / AI 写入的 P0 合同已基本收口,但兼容路由和历史 adapter 仍偏多。
|
||||
|
||||
## 4. 架构冲突矩阵
|
||||
|
||||
### 4.1 Tree realtime
|
||||
|
||||
- 事实:Rust Web 已注册 WS。
|
||||
- 事实:前端仍只见 SSE consumer。
|
||||
- 冲突:系统口径已升级,但前端主链未同步切换。
|
||||
- 影响:live cache、调试、回放与验收都会出现双口径。
|
||||
- 事实:Rust Web 同时注册 WS 与 SSE。
|
||||
- 事实:Rust SSR 主壳默认以 WS 为主链,SSE 是断线 fallback。
|
||||
- 当前状态:原“WS 文档口径 / 前端 SSE 实现”冲突已修复。
|
||||
- 剩余风险:live cache 与兼容 fallback 仍需要继续瘦身,避免未来再次出现多链路补偿。
|
||||
|
||||
### 4.2 FileTree 资源行
|
||||
|
||||
- 事实:资源投影行带 `documentId`。
|
||||
- 事实:`tree.tsx` 相关路径会把它当作页面对象处理。
|
||||
- 冲突:资源树与页面树边界被突破。
|
||||
- 影响:误删、误重命名、误移动风险上升。
|
||||
- 事实:资源行的 owner document 与页面命令目标已分离,资源行不再暴露页面命令目标 ID。
|
||||
- 当前状态:原“资源行被当成页面命令对象处理”冲突已修复。
|
||||
- 剩余风险:资源对象的完整 `tree.resource.*` 命令面仍应继续补齐。
|
||||
|
||||
### 4.3 Page Aggregate
|
||||
|
||||
- 事实:Rust snapshot 已是入口事实。
|
||||
- 事实:前端 reducer 与本地 page tree 还在派生第二份状态。
|
||||
- 冲突:页面标题、正文、树投影可能各自取源。
|
||||
- 影响:AI context、Sidebar、正文显示不一定同源。
|
||||
- 事实:AI context 的 page subtree 已只读 Rust Page Aggregate projection,外部 AI 写入后会同步本地 aggregate script。
|
||||
- 当前状态:原“AI context / 文档页 / Rust Aggregate 多源抢真相”的 P0 问题已修复。
|
||||
- 剩余风险:Page Aggregate 仍从 `documents.content` 投影,不是 EditorBlockDocument 原生落库真相。
|
||||
|
||||
### 4.4 AI 写入
|
||||
|
||||
- 事实:`markdown_edit` 已是主推路径。
|
||||
- 事实:Hermes guidance、manifest、page_ai_workflow、apply_block_ops 仍在不同层保留旧习惯。
|
||||
- 冲突:同一条编辑链路里存在 markdown / block / tool executor 三套合同。
|
||||
- 影响:写入正确性、幂等性、回读一致性都不稳定。
|
||||
- 事实:`markdown_edit` 已是简单正文编辑主路径。
|
||||
- 事实:Hermes guidance、manifest、page_ai_workflow、tool executor、Reasonix ACP payload 已同步到同一合同。
|
||||
- 当前状态:原“markdown / block / tool executor 三套合同漂移”的 P0 问题已修复。
|
||||
- 剩余风险:复杂结构编辑仍应明确落到 `apply_block_ops` / `mnote.block.*`,Phase C review / streaming apply 尚未实施。
|
||||
|
||||
## 5. 缺失功能
|
||||
|
||||
- 前端树流仍缺统一的 WS 主 consumer。
|
||||
- Page Aggregate 仍缺前端第二真相收口。
|
||||
- FileTree 资源行仍缺严格的命令面隔离。
|
||||
- `mnote.doc.markdown_edit` 仍缺完整写入合同。
|
||||
- ACP / Reasonix 仍缺统一身份、会话、运行、幂等字段。
|
||||
- 兼容路由仍偏多,正式命令面与过渡面边界还不够硬。
|
||||
- Page Aggregate 仍缺 EditorBlockDocument 原生落库真相。
|
||||
- 正文保存仍经过 `documents/save` 兼容面,唯一主命令面尚未完全闭环。
|
||||
- `tree.resource.*` 仍需要补完整资源对象生命周期命令。
|
||||
- 兼容路由、历史 adapter 与 fallback 仍偏多,需要继续减小长期维护面。
|
||||
|
||||
## 6. 推荐收口顺序
|
||||
|
||||
1. 先收 `tree` 实时链路口径,让前端主消费与后端主发布一致。
|
||||
2. 再收 FileTree 资源行命令边界,避免资源对象误走页面命令。
|
||||
3. 再收 Page Aggregate 单一真源,减少 Sidebar / 文档页 / AI context 多源抢真相。
|
||||
4. 最后收 AI 写入合同,把 `markdown_edit`、`apply_block_ops`、Hermes / ACP / Reasonix 的调用协议统一起来。
|
||||
1. 先推进 Page Aggregate 原生 EditorBlockDocument 落库,减少 `documents.content` 投影过渡层。
|
||||
2. 再收正文保存主命令面,把 `documents/save` 兼容写入逐步迁到正式页面 / 编辑器命令。
|
||||
3. 再补齐 `tree.resource.*` 资源对象生命周期命令,避免资源操作长期停留在禁用或兼容态。
|
||||
4. 最后清理兼容路由、历史 adapter 与 fallback,使 Rust kernel / Rust Web / SSR 主壳的合同成为唯一运行口径。
|
||||
|
||||
## 7. 相关审查与缺陷
|
||||
|
||||
- [设计审查:当前 mnote 项目 AI / Page Aggregate 定向 Review](./design/10-review/process/10-current-mnote-ai-runtime-review-v1.md)
|
||||
- [设计审查:当前完整架构 Review](./design/10-review/process/11-current-full-architecture-review-v1.md)
|
||||
- [设计审查:当前 mnote 项目 AI / Page Aggregate 定向 Review](./design/10-review/done/10-current-mnote-ai-runtime-review-v1.md)
|
||||
- [设计审查:当前完整架构 Review](./design/10-review/done/11-current-full-architecture-review-v1.md)
|
||||
|
||||
## 8. 本次落档缺陷索引
|
||||
|
||||
### 8.1 Rust Web / Realtime / ACP
|
||||
|
||||
- [3-16 tree realtime WS 主链口径与前端 SSE 实现不一致](./bugs/03-rust-web/process/3-16-tree-realtime-ws-sse-doc-contract-drift-v1.md)
|
||||
- [3-17 SSE push 模式跳过 polling safety net](./bugs/03-rust-web/process/3-17-sse-push-skips-polling-fallback-v1.md)
|
||||
- [3-18 WS 与 SSE delta 载荷合同分裂](./bugs/03-rust-web/process/3-18-ws-sse-delta-contract-split-v1.md)
|
||||
- [3-19 Reasonix ACP wrapper 调 mnote tool 缺少身份与幂等字段](./bugs/03-rust-web/process/3-19-acp-reasonix-tool-call-missing-identity-fields-v1.md)
|
||||
- [3-20 ACP incoming request 只记录日志不响应](./bugs/03-rust-web/process/3-20-acp-request-permission-no-response-v1.md)
|
||||
- [3-21 ACP run payload 被第一次 stream_events 消费后移除](./bugs/03-rust-web/process/3-21-acp-run-payload-consumed-and-removed-v1.md)
|
||||
- [3-16 tree realtime WS 主链口径与前端 SSE 实现不一致](./bugs/03-rust-web/done/3-16-tree-realtime-ws-sse-doc-contract-drift-v1.md)
|
||||
- [3-17 SSE push 模式跳过 polling safety net](./bugs/03-rust-web/done/3-17-sse-push-skips-polling-fallback-v1.md)
|
||||
- [3-18 WS 与 SSE delta 载荷合同分裂](./bugs/03-rust-web/done/3-18-ws-sse-delta-contract-split-v1.md)
|
||||
- [3-19 Reasonix ACP wrapper 调 mnote tool 缺少身份与幂等字段](./bugs/03-rust-web/done/3-19-acp-reasonix-tool-call-missing-identity-fields-v1.md)
|
||||
- [3-20 ACP incoming request 只记录日志不响应](./bugs/03-rust-web/done/3-20-acp-request-permission-no-response-v1.md)
|
||||
- [3-21 ACP run payload 被第一次 stream_events 消费后移除](./bugs/03-rust-web/done/3-21-acp-run-payload-consumed-and-removed-v1.md)
|
||||
|
||||
### 8.2 Tree Domain
|
||||
|
||||
- [4-46 FileTree 资源行被当成页面命令对象处理](./bugs/04-tree-domain/process/4-46-filetree-resource-row-document-command-leak-v1.md)
|
||||
- [4-46 FileTree 资源行被当成页面命令对象处理](./bugs/04-tree-domain/done/4-46-filetree-resource-row-document-command-leak-v1.md)
|
||||
|
||||
### 8.3 Editor Mainline
|
||||
|
||||
- [5-15 PageAggregateClientState 仍在前端生成第二份 page tree 真相](./bugs/05-editor-mainline/process/5-15-page-aggregate-client-state-second-truth-v1.md)
|
||||
- [5-16 Sidebar preferred snapshot 中 query 可覆盖 live stream](./bugs/05-editor-mainline/process/5-16-sidebar-preferred-snapshot-query-overrides-live-stream-v1.md)
|
||||
- [5-17 文档页标题优先 liveSidebarTitle 而非 Page Aggregate head](./bugs/05-editor-mainline/process/5-17-document-title-source-drift-live-sidebar-over-head-v1.md)
|
||||
- [5-18 AI 写正文后本地 Page Aggregate content 可能不刷新](./bugs/05-editor-mainline/process/5-18-ai-write-body-does-not-sync-page-aggregate-content-v1.md)
|
||||
- [5-15 PageAggregateClientState 仍在前端生成第二份 page tree 真相](./bugs/05-editor-mainline/done/5-15-page-aggregate-client-state-second-truth-v1.md)
|
||||
- [5-16 Sidebar preferred snapshot 中 query 可覆盖 live stream](./bugs/05-editor-mainline/done/5-16-sidebar-preferred-snapshot-query-overrides-live-stream-v1.md)
|
||||
- [5-17 文档页标题优先 liveSidebarTitle 而非 Page Aggregate head](./bugs/05-editor-mainline/done/5-17-document-title-source-drift-live-sidebar-over-head-v1.md)
|
||||
- [5-18 AI 写正文后本地 Page Aggregate content 可能不刷新](./bugs/05-editor-mainline/done/5-18-ai-write-body-does-not-sync-page-aggregate-content-v1.md)
|
||||
|
||||
### 8.4 AI
|
||||
|
||||
- [7-18 AI markdown_edit 阶段状态合同漂移](./bugs/07-ai/process/7-18-ai-markdown-edit-phase-state-contract-drift-v1.md)
|
||||
- [7-19 Hermes 工具指导仍优先 apply_block_ops 而非 markdown_edit](./bugs/07-ai/process/7-19-hermes-tool-guidance-markdown-edit-contract-drift-v1.md)
|
||||
- [7-20 page_ai_workflow 绕过 Hermes tool executor / audit / toggle](./bugs/07-ai/process/7-20-page-ai-workflow-bypasses-hermes-tool-executor-v1.md)
|
||||
- [7-21 mnote.doc.markdown_edit 本地文件写入绕过 dryRun / idempotency](./bugs/07-ai/process/7-21-markdown-edit-local-write-contract-bypass-v1.md)
|
||||
- [7-22 mnote.doc.apply_block_ops 批量写入缺少 revision / conflictDetectionKey 校验](./bugs/07-ai/process/7-22-apply-block-ops-missing-write-preconditions-v1.md)
|
||||
- [7-23 mnote.doc.markdown_edit manifest schema 缺少 required 与写入字段](./bugs/07-ai/process/7-23-markdown-edit-manifest-schema-contract-drift-v1.md)
|
||||
- [7-24 在线 markdown_edit 写回不以最终 Markdown 为真源](./bugs/07-ai/process/7-24-markdown-edit-online-write-does-not-use-final-markdown-v1.md)
|
||||
- [7-25 ACP Reasonix 页面 AI block_edit_workflow 在 markdown 命中后生成空 block_ops](./bugs/07-ai/process/7-25-reasonix-block-edit-workflow-empty-block-ops-after-markdown-match-v1.md)
|
||||
- [7-18 AI markdown_edit 阶段状态合同漂移](./bugs/07-ai/done/7-18-ai-markdown-edit-phase-state-contract-drift-v1.md)
|
||||
- [7-19 Hermes 工具指导仍优先 apply_block_ops 而非 markdown_edit](./bugs/07-ai/done/7-19-hermes-tool-guidance-markdown-edit-contract-drift-v1.md)
|
||||
- [7-20 page_ai_workflow 绕过 Hermes tool executor / audit / toggle](./bugs/07-ai/done/7-20-page-ai-workflow-bypasses-hermes-tool-executor-v1.md)
|
||||
- [7-21 mnote.doc.markdown_edit 本地文件写入绕过 dryRun / idempotency](./bugs/07-ai/done/7-21-markdown-edit-local-write-contract-bypass-v1.md)
|
||||
- [7-22 mnote.doc.apply_block_ops 批量写入缺少 revision / conflictDetectionKey 校验](./bugs/07-ai/done/7-22-apply-block-ops-missing-write-preconditions-v1.md)
|
||||
- [7-23 mnote.doc.markdown_edit manifest schema 缺少 required 与写入字段](./bugs/07-ai/done/7-23-markdown-edit-manifest-schema-contract-drift-v1.md)
|
||||
- [7-24 在线 markdown_edit 写回不以最终 Markdown 为真源](./bugs/07-ai/done/7-24-markdown-edit-online-write-does-not-use-final-markdown-v1.md)
|
||||
- [7-25 ACP Reasonix 页面 AI block_edit_workflow 在 markdown 命中后生成空 block_ops](./bugs/07-ai/done/7-25-reasonix-block-edit-workflow-empty-block-ops-after-markdown-match-v1.md)
|
||||
|
||||
+17
-4
@@ -1,8 +1,8 @@
|
||||
# 3-16 [process][bug] tree realtime WS 主链口径与前端 SSE 实现不一致 v1
|
||||
# 3-16 [done][bug] tree realtime WS 主链口径与前端 SSE 实现不一致 v1
|
||||
|
||||
> 发现时间:2026-05-17
|
||||
>
|
||||
> 状态:`[process]`
|
||||
> 状态:`[done]`
|
||||
>
|
||||
> 关联主线:`03-rust-web`
|
||||
|
||||
@@ -29,6 +29,19 @@
|
||||
- 增加浏览器 smoke,断言网络中实际使用的是 WS 主链还是 SSE fallback。
|
||||
- 文档与测试必须以真实前端消费路径为准。
|
||||
|
||||
## 5. 状态
|
||||
## 5. 修复
|
||||
|
||||
已确认口径与前端源码不一致,尚未修复。
|
||||
已修复 / 已收口:
|
||||
|
||||
- [layout.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/ssr/pages/layout.rs:7127) Rust SSR 主壳内置 `startWithWebSocket`,实际创建 `/api/realtime/ws` WebSocket consumer。
|
||||
- [layout.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/ssr/pages/layout.rs:7176) WebSocket 断开后调用 `startWithSseFallback`,`/api/tree/events` 明确成为 fallback。
|
||||
- [layout.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/ssr/pages/layout.rs:7301) SSR tree live bootstrap 默认 `transport` 已是 `convex-command-log-ws`,不再默认走 SSE。
|
||||
- [layout.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/ssr/pages/layout.rs:7593) 增强回归测试,断言 controller 同时包含 WS 主链与 SSE fallback。
|
||||
|
||||
## 6. 验证
|
||||
|
||||
```bash
|
||||
cargo test --manifest-path rust/Cargo.toml -p mnote-web tree_live_controller_marks_transport_and_closes_source_on_pagehide -- --nocapture
|
||||
```
|
||||
|
||||
结果:1 个测试通过。
|
||||
+17
-4
@@ -1,8 +1,8 @@
|
||||
# 3-17 [process][bug] SSE push 模式跳过 polling safety net v1
|
||||
# 3-17 [done][bug] SSE push 模式跳过 polling safety net v1
|
||||
|
||||
> 发现时间:2026-05-17
|
||||
>
|
||||
> 状态:`[process]`
|
||||
> 状态:`[done]`
|
||||
>
|
||||
> 关联主线:`03-rust-web`
|
||||
|
||||
@@ -27,6 +27,19 @@ SSE route 注释和架构口径暗示 polling 可作为 safety net,但实现
|
||||
- 增加测试:模拟 `stream_delta_rx` 无消息但 bridgeLogs 有新事件,SSE 应产出 delta/resync。
|
||||
- 明确 `pollMs` 与 push channel 同时存在时的优先级。
|
||||
|
||||
## 5. 状态
|
||||
## 5. 修复
|
||||
|
||||
已确认 push 分支跳过 polling fallback,尚未修复。
|
||||
已修复:
|
||||
|
||||
- [sse.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/routes/sse.rs:117) push 模式在 broadcast 静默时不再只发 heartbeat,而是按 `pollMs/maxPolls` 执行 bridgeLogs polling safety net。
|
||||
- [sse.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/routes/sse.rs:135) polling 检测到 `Delta` 时构建标准 stream delta payload 并发送 `delta`。
|
||||
- [sse.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/routes/sse.rs:157) polling 检测到 `Resync` 时回读 snapshot 并发送 `resync`。
|
||||
- [sse.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/routes/sse.rs:402) 增加回归测试,断言 push 模式下 `maxPolls=1` 会触发 safety-net polling 并正常结束。
|
||||
|
||||
## 6. 验证
|
||||
|
||||
```bash
|
||||
cargo test --manifest-path rust/Cargo.toml -p mnote-web routes::sse::tests -- --nocapture
|
||||
```
|
||||
|
||||
结果:5 个 SSE route 测试通过。
|
||||
+18
-4
@@ -1,8 +1,8 @@
|
||||
# 3-18 [process][bug] WS 与 SSE delta 载荷合同分裂 v1
|
||||
# 3-18 [done][bug] WS 与 SSE delta 载荷合同分裂 v1
|
||||
|
||||
> 发现时间:2026-05-17
|
||||
>
|
||||
> 状态:`[process]`
|
||||
> 状态:`[done]`
|
||||
>
|
||||
> 关联主线:`03-rust-web`
|
||||
|
||||
@@ -28,6 +28,20 @@ SSE polling 可以从 bridgeLogs / domainEvents 解析 `streamDelta` 并附带 s
|
||||
- 对同一命令分别跑 WS / SSE 合同测试,断言事件 kind、cursor、snapshot、resync 语义一致。
|
||||
- 前端 reducer 只消费统一 envelope,不感知 transport 差异。
|
||||
|
||||
## 5. 状态
|
||||
## 5. 修复
|
||||
|
||||
已确认 WS / SSE 事件结构不统一,尚未修复。
|
||||
已修复:
|
||||
|
||||
- [stream_support.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/routes/stream_support.rs:577) 新增 `build_stream_push_delta_hint`,统一 command committed broadcast 的 tree realtime hint envelope。
|
||||
- [sse.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/routes/sse.rs:97) SSE push 分支改为复用统一 hint envelope。
|
||||
- [ws.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/routes/ws.rs:43) WS broadcast 分支改为发送同一 hint envelope,不再输出嵌套 `data.kind=command_committed` 的专有结构。
|
||||
- [ws.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/routes/ws.rs:190) 增加 WS delta serializer 合同测试,断言输出为 `kind=delta`、`hint=command_committed`,并且没有额外 `data` 外壳。
|
||||
|
||||
## 6. 验证
|
||||
|
||||
```bash
|
||||
cargo test --manifest-path rust/Cargo.toml -p mnote-web routes::ws::tests -- --nocapture
|
||||
cargo test --manifest-path rust/Cargo.toml -p mnote-web routes::sse::tests -- --nocapture
|
||||
```
|
||||
|
||||
结果:WS 3 个测试通过,SSE 5 个测试通过。
|
||||
@@ -0,0 +1,49 @@
|
||||
# 3-19 [done][bug] Reasonix ACP wrapper 调 mnote tool 缺少身份与幂等字段 v1
|
||||
|
||||
> 发现时间:2026-05-17
|
||||
>
|
||||
> 状态:`[done]`
|
||||
>
|
||||
> 关联主线:`03-rust-web`
|
||||
|
||||
## 1. 问题定义
|
||||
|
||||
Reasonix ACP wrapper 调用 mnote tool 时只发送 `toolName`、`args`、`workspaceId`,没有传递 `actorId`、`sessionId`、`runId`、`toolCallId`、`traceId`、`dryRun`、`idempotencyKey` 等工具合同字段。
|
||||
|
||||
## 2. 证据
|
||||
|
||||
- [reasonix-acp-wrapper.mjs](/mnt/Data1T/mnote/scripts/reasonix-acp-wrapper.mjs:230) 附近构造 `callMnoteTool` 请求体。
|
||||
|
||||
## 3. 影响
|
||||
|
||||
- Rust tool executor 难以稳定进行鉴权、审计、追踪和幂等。
|
||||
- 写工具可能被拒绝,或在缺少幂等键时产生重复写入风险。
|
||||
- Reasonix 与 Hermes 对同一 tool 的调用合同不一致。
|
||||
|
||||
## 4. 建议修复
|
||||
|
||||
- wrapper 必须从 ACP run/session 中透传 actor、session、run、toolCall、trace、dryRun、idempotency 字段。
|
||||
- 写工具缺失必要字段时应 fail fast。
|
||||
- 增加 Reasonix 端到端 smoke,断言请求字段完整。
|
||||
|
||||
## 5. 修复
|
||||
|
||||
已修复:
|
||||
|
||||
- [reasonix-acp-wrapper.mjs](/mnt/Data1T/mnote/scripts/reasonix-acp-wrapper.mjs:34) 新增 `buildMnoteToolPayload`,统一构造 mnote tool 请求体。
|
||||
- [reasonix-acp-wrapper.mjs](/mnt/Data1T/mnote/scripts/reasonix-acp-wrapper.mjs:43) tool payload 现在包含 `actorId`、`sessionId`、`runId`、`toolCallId`、`traceId`、`dryRun`、`idempotencyKey`、`workspaceId`、`documentId`。
|
||||
- [reasonix-acp-wrapper.mjs](/mnt/Data1T/mnote/scripts/reasonix-acp-wrapper.mjs:303) HTTP 请求头同步携带 `x-mnote-actor-id` 与 `x-mnote-workspace-id`。
|
||||
- [reasonix-acp-wrapper.mjs](/mnt/Data1T/mnote/scripts/reasonix-acp-wrapper.mjs:379) ACP `session/prompt` 期间通过 `AsyncLocalStorage` 保存 mnote tool context,tool 调用继承 run/session/actor/trace。
|
||||
- [acp_types.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/acp_types.rs:163) `SessionPromptParams` 扩展可选 mnote 上下文字段。
|
||||
- [acp_session_manager.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/acp_session_manager.rs:193) 增加 `run_prompt_with_mnote_context`。
|
||||
- [hermes_client.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/routes/hermes_client.rs:754) ACP Reasonix prompt 启动时透传 mnote session/run/actor/trace/workspace/document 上下文。
|
||||
|
||||
## 6. 验证
|
||||
|
||||
```bash
|
||||
node --check scripts/reasonix-acp-wrapper.mjs
|
||||
MNOTE_REASONIX_ACP_SELFTEST=1 node scripts/reasonix-acp-wrapper.mjs
|
||||
cargo test --manifest-path rust/Cargo.toml -p mnote-web acp_session_manager -- --nocapture
|
||||
```
|
||||
|
||||
结果:JS 语法检查通过,wrapper selftest 通过,ACP session manager 5 个测试通过。
|
||||
@@ -0,0 +1,40 @@
|
||||
# 3-20 [done][bug] ACP incoming request 只记录日志不响应 v1
|
||||
|
||||
> 发现时间:2026-05-17
|
||||
>
|
||||
> 状态:`[done]`
|
||||
>
|
||||
> 关联主线:`03-rust-web`
|
||||
|
||||
## 1. 问题定义
|
||||
|
||||
ACP client 收到 `session/request_permission` 等 incoming request 时当前只记录日志,没有返回协议响应。
|
||||
|
||||
## 2. 证据
|
||||
|
||||
- [acp_client.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/acp_client.rs:347) 附近处理 incoming request。
|
||||
|
||||
## 3. 影响
|
||||
|
||||
- ACP agent 侧可能等待到超时。
|
||||
- 权限请求无法进入用户确认或自动拒绝流程。
|
||||
- run 状态可能卡在进行中,SSE 输出也无法表达真实阻塞原因。
|
||||
|
||||
## 4. 建议修复
|
||||
|
||||
- 为已知 request type 实现明确响应:允许、拒绝或要求人工确认。
|
||||
- 未支持的 request type 也应返回结构化 error,而不是只日志。
|
||||
- 增加 ACP request/response 协议测试。
|
||||
|
||||
## 5. 修复
|
||||
|
||||
- [acp_client.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/acp_client.rs:157) 将 ACP 子进程 stdin writer 作为共享 `Arc<Mutex<BufWriter<_>>>` 传入后台 reader loop。
|
||||
- [acp_client.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/acp_client.rs:335) 对带 `id + method` 的 incoming request 写回 JSON-RPC error 响应,当前无权限确认 UI 时明确拒绝,避免 agent 等待超时。
|
||||
- [acp_client.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/acp_client.rs:551) 增加 mock ACP server 测试,覆盖 `session/request_permission` 主动请求必须收到响应。
|
||||
|
||||
## 6. 验证
|
||||
|
||||
- `cargo test --manifest-path rust/Cargo.toml -p mnote-web test_incoming_permission_request_gets_response -- --nocapture`
|
||||
- 结果:1 passed
|
||||
- `cargo test --manifest-path rust/Cargo.toml -p mnote-web acp_client -- --nocapture`
|
||||
- 结果:6 passed
|
||||
+10
-4
@@ -1,8 +1,8 @@
|
||||
# 3-21 [process][bug] ACP run payload 被第一次 stream_events 消费后移除 v1
|
||||
# 3-21 [done][bug] ACP run payload 被第一次 stream_events 消费后移除 v1
|
||||
|
||||
> 发现时间:2026-05-17
|
||||
>
|
||||
> 状态:`[process]`
|
||||
> 状态:`[done]`
|
||||
>
|
||||
> 关联主线:`03-rust-web`
|
||||
|
||||
@@ -27,6 +27,12 @@ ACP run 的 payload 在 `stream_events` 路径被消费后从内存表移除。
|
||||
- stream subscription 不应拥有 payload 的删除权。
|
||||
- 增加 SSE 断线重连与重复订阅测试。
|
||||
|
||||
## 5. 状态
|
||||
## 5. 修复
|
||||
|
||||
已确认 payload 生命周期与 stream 订阅耦合,尚未修复。
|
||||
- [hermes_client.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/routes/hermes_client.rs:2051) `acp_run_payload_for_stream` 使用 `ACP_RUN_PAYLOADS.get(run_id).cloned()`,stream subscription 只读取 payload,不再拥有删除权。
|
||||
- [hermes_client.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/routes/hermes_client.rs:4116) 增加重复读取测试,锁定刷新、重连、多个观察者不会因为第一次读取而丢失 run payload。
|
||||
|
||||
## 6. 验证
|
||||
|
||||
- `cargo test --manifest-path rust/Cargo.toml -p mnote-web hermes_client_acp_stream_payload_lookup_keeps_payload_for_reconnect -- --nocapture`
|
||||
- 结果:1 passed
|
||||
@@ -0,0 +1,50 @@
|
||||
# 3-22 [done][bug] ACP permission 自动拒绝后 UI 仍展示审批动作
|
||||
|
||||
> 发现时间:2026-05-18
|
||||
>
|
||||
> 状态:`[done]`
|
||||
>
|
||||
> 关联主线:`03-rust-web` / ACP runtime 与页面 AI 事件映射
|
||||
|
||||
## 1. 问题定义
|
||||
|
||||
当前 ACP client 收到 `session/request_permission` 后会立即返回 JSON-RPC error,并把事件转换为 `permission.denied`。这是为了避免 agent 等待权限响应直到超时。
|
||||
|
||||
但页面 AI UI 仍在权限卡片和权限弹窗里展示“允许 / 拒绝”按钮。即使这些按钮在 `permission.denied` 下会被禁用,用户仍会看到审批入口,误以为可以把真实协议结果改成允许。
|
||||
|
||||
## 2. 证据
|
||||
|
||||
- [acp_client.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/acp_client.rs:350) 对 incoming `id + method` request 直接返回 JSON-RPC error。
|
||||
- [acp_session_manager.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/acp_session_manager.rs:121) 对 `session/request_permission` 生成 `decision="denied"`。
|
||||
- [acp_bridge.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/acp_bridge.rs:237) 将 decision 映射为 `permission.denied`。
|
||||
- [layout.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/ssr/pages/layout.rs:4060) 权限弹窗仍渲染“允许 / 拒绝”按钮。
|
||||
- [layout.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/ssr/pages/layout.rs:5805) 权限消息卡片仍渲染“允许 / 拒绝”按钮。
|
||||
|
||||
## 3. 影响
|
||||
|
||||
- 用户看到的“允许”动作与 ACP wire contract 不一致。
|
||||
- 点击动作只修改本地 UI 状态,不会回写 ACP JSON-RPC response。
|
||||
- 对已经自动拒绝的权限请求展示审批按钮,会掩盖真实失败原因。
|
||||
|
||||
## 4. 修复
|
||||
|
||||
- [acp_client.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/acp_client.rs:350) 维持 `session/request_permission` 自动拒绝协议响应,避免 agent 等待超时。
|
||||
- [layout.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/ssr/pages/layout.rs:4003) `pageAiApplyPermissionEvent()` 对 `permission.denied` / `permission.allowed` 只保留结果态,不再展示可点击审批弹窗。
|
||||
- [layout.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/ssr/pages/layout.rs:4048) `pageAiShowPermissionDialog()` 对已决权限事件直接隐藏对话框。
|
||||
- [layout.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/ssr/pages/layout.rs:5797) 权限消息卡片对已决事件只展示结果,不再渲染审批按钮。
|
||||
|
||||
## 5. 验证
|
||||
|
||||
```bash
|
||||
cargo test --manifest-path rust/Cargo.toml -p mnote-web page_ai_uses_backend_acp_session_runtime_store -- --nocapture
|
||||
cargo test --manifest-path rust/Cargo.toml -p mnote-web acp_permission_request_emits_frontend_decision_event -- --nocapture
|
||||
cargo test --manifest-path rust/Cargo.toml -p mnote-web ssr::pages::layout::tests -- --nocapture
|
||||
cargo fmt --manifest-path rust/Cargo.toml --all -- --check
|
||||
git diff --check -- rust/crates/mnote-web/src/ssr/pages/layout.rs bugs/03-rust-web/done/3-22-acp-permission-auto-deny-ui-shows-approval-actions-v1.md
|
||||
```
|
||||
|
||||
结果:
|
||||
|
||||
- `page_ai_uses_backend_acp_session_runtime_store`:`1 passed`。
|
||||
- `acp_permission_request_emits_frontend_decision_event`:`1 passed`。
|
||||
- 版面相关静态测试继续通过,权限消息代码路径未破坏。
|
||||
@@ -1,31 +0,0 @@
|
||||
# 3-19 [process][bug] Reasonix ACP wrapper 调 mnote tool 缺少身份与幂等字段 v1
|
||||
|
||||
> 发现时间:2026-05-17
|
||||
>
|
||||
> 状态:`[process]`
|
||||
>
|
||||
> 关联主线:`03-rust-web`
|
||||
|
||||
## 1. 问题定义
|
||||
|
||||
Reasonix ACP wrapper 调用 mnote tool 时只发送 `toolName`、`args`、`workspaceId`,没有传递 `actorId`、`sessionId`、`runId`、`toolCallId`、`traceId`、`dryRun`、`idempotencyKey` 等工具合同字段。
|
||||
|
||||
## 2. 证据
|
||||
|
||||
- [reasonix-acp-wrapper.mjs](/mnt/Data1T/mnote/scripts/reasonix-acp-wrapper.mjs:230) 附近构造 `callMnoteTool` 请求体。
|
||||
|
||||
## 3. 影响
|
||||
|
||||
- Rust tool executor 难以稳定进行鉴权、审计、追踪和幂等。
|
||||
- 写工具可能被拒绝,或在缺少幂等键时产生重复写入风险。
|
||||
- Reasonix 与 Hermes 对同一 tool 的调用合同不一致。
|
||||
|
||||
## 4. 建议修复
|
||||
|
||||
- wrapper 必须从 ACP run/session 中透传 actor、session、run、toolCall、trace、dryRun、idempotency 字段。
|
||||
- 写工具缺失必要字段时应 fail fast。
|
||||
- 增加 Reasonix 端到端 smoke,断言请求字段完整。
|
||||
|
||||
## 5. 状态
|
||||
|
||||
已确认 wrapper 请求字段不完整,尚未修复。
|
||||
@@ -1,31 +0,0 @@
|
||||
# 3-20 [process][bug] ACP incoming request 只记录日志不响应 v1
|
||||
|
||||
> 发现时间:2026-05-17
|
||||
>
|
||||
> 状态:`[process]`
|
||||
>
|
||||
> 关联主线:`03-rust-web`
|
||||
|
||||
## 1. 问题定义
|
||||
|
||||
ACP client 收到 `session/request_permission` 等 incoming request 时当前只记录日志,没有返回协议响应。
|
||||
|
||||
## 2. 证据
|
||||
|
||||
- [acp_client.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/acp_client.rs:347) 附近处理 incoming request。
|
||||
|
||||
## 3. 影响
|
||||
|
||||
- ACP agent 侧可能等待到超时。
|
||||
- 权限请求无法进入用户确认或自动拒绝流程。
|
||||
- run 状态可能卡在进行中,SSE 输出也无法表达真实阻塞原因。
|
||||
|
||||
## 4. 建议修复
|
||||
|
||||
- 为已知 request type 实现明确响应:允许、拒绝或要求人工确认。
|
||||
- 未支持的 request type 也应返回结构化 error,而不是只日志。
|
||||
- 增加 ACP request/response 协议测试。
|
||||
|
||||
## 5. 状态
|
||||
|
||||
已确认 incoming request 缺少响应处理,尚未修复。
|
||||
@@ -1,9 +1,9 @@
|
||||
# 4-36 [process][bug] 本地文件夹切回云空间后进入空白 workspace v1
|
||||
# 4-36 [done][bug] 本地文件夹切回云空间后进入空白 workspace v1
|
||||
|
||||
> 更新时间:2026-05-15
|
||||
>
|
||||
> 分类归属:
|
||||
> - `04-tree-domain/process`
|
||||
> - `04-tree-domain/done`
|
||||
> - 涉及边界:workspace source switch、本地文件夹入口、Convex workspace 恢复、Sidebar File Tree/Page Tree projection
|
||||
>
|
||||
> 用户反馈:
|
||||
|
||||
+27
-3
@@ -1,8 +1,10 @@
|
||||
# 4-46 [process][bug] FileTree 资源行被当成页面命令对象处理 v1
|
||||
# 4-46 [done][bug] FileTree 资源行被当成页面命令对象处理 v1
|
||||
|
||||
> 发现时间:2026-05-17
|
||||
>
|
||||
> 状态:`[process]`
|
||||
> 更新时间:2026-05-17
|
||||
>
|
||||
> 状态:`[done]`
|
||||
>
|
||||
> 关联主线:`04-tree-domain`
|
||||
|
||||
@@ -34,4 +36,26 @@ FileTree asset row 的 `resourceMeta` 同时带 `documentId` 和 `assetId`,但
|
||||
|
||||
## 5. 状态
|
||||
|
||||
已确认 row model 与命令分发存在泄漏,尚未修复。
|
||||
已修复。
|
||||
|
||||
## 6. 修复内容
|
||||
|
||||
- `getFileTreeRowDocumentId()` 不再从 `resourceMeta.documentId` 读取资源 owner,只返回真正可作为页面命令目标的 document / markdown / index 行 ID。
|
||||
- 新增 `getFileTreeRowOwnerDocumentId()`,资源打开与 owner 上下文继续使用 owner document ID。
|
||||
- FileTree 资源行不再通过 F2 或行内编辑按钮进入页面重命名。
|
||||
- 动态 filetree row DOM 增加 `data-owner-document-id`,`data-document-id` 仅表达页面命令目标。
|
||||
- 初始 SSR filetree HTML 同步区分 `data-document-id` 与 `data-owner-document-id`,asset row 的 `data-document-id` / `data-doc-id` 保持为空。
|
||||
|
||||
## 7. 验证
|
||||
|
||||
```bash
|
||||
cargo test --manifest-path rust/Cargo.toml -p mnote-web tree_shell::filetree_renderer::tests::tree_shell_filetree_renderer_outputs_initial_nested_html_contract -- --nocapture
|
||||
```
|
||||
|
||||
结果:`1 passed`。
|
||||
|
||||
```bash
|
||||
cargo test --manifest-path rust/Cargo.toml -p mnote-web routes::tree::tests::tree_shell_filetree_mode_embeds_asset_state -- --nocapture
|
||||
```
|
||||
|
||||
结果:`1 passed`。
|
||||
@@ -0,0 +1,43 @@
|
||||
# 4-47 [done][bug] stream_support 回归样例仍保留退役 tree.resource.delete 命名
|
||||
|
||||
> 发现时间:2026-05-18
|
||||
>
|
||||
> 状态:`[done]`
|
||||
>
|
||||
> 关联主线:`04-tree-domain`
|
||||
|
||||
## 1. 问题定义
|
||||
|
||||
当前资源生命周期正式命令面使用 `tree.resource.archive / restore / purge / rename`,删除到垃圾箱语义应表达为 `tree.resource.archive`。但 `stream_support` 的 remove asset delta 回归样例仍保留 `tree.resource.delete`。
|
||||
|
||||
该残留不在 runtime command dispatcher 中,但会让 stream 合同样例继续暗示退役命名合法,增加后续实现和测试口径漂移风险。
|
||||
|
||||
## 2. 证据
|
||||
|
||||
- [stream_support.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/routes/stream_support.rs:1057) 测试样例仍写 `"command_name": "tree.resource.delete"`。
|
||||
- [tree.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/routes/tree.rs:714) 当前 tree shell dispatcher 已公开 `tree.resource.archive`。
|
||||
- [mapping.rs](/mnt/Data1T/mnote/rust/crates/storage-convex-bridge/src/mapping.rs:47) storage bridge 已映射 `tree.resource.archive`,没有 `tree.resource.delete`。
|
||||
|
||||
## 3. 影响
|
||||
|
||||
- 回归样例与当前资源命令面不一致。
|
||||
- 后续维护者可能继续按 `delete` 扩写 stream / resource lifecycle 合同。
|
||||
- 与 `design/04-tree-domain/process/4-27-resource-lifecycle-command-cutover-v1.md` 中“delete 改为 archive”的口径冲突。
|
||||
|
||||
## 4. 修复结果
|
||||
|
||||
- [stream_support.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/routes/stream_support.rs) 的 remove asset delta 回归样例已把 `tree.resource.delete` 改为 `tree.resource.archive`。
|
||||
- `rg` 已确认 Rust route / storage bridge 中不再残留 `tree.resource.delete`。
|
||||
- 对应 `stream_change_preserves_remove_asset_delta_fields` 回归测试已通过。
|
||||
|
||||
## 5. 验证证据
|
||||
|
||||
```bash
|
||||
rg -n "tree\\.resource\\.delete" rust/crates/mnote-web/src/routes
|
||||
cargo test --manifest-path rust/Cargo.toml -p mnote-web stream_change_preserves_remove_asset_delta_fields -- --nocapture
|
||||
```
|
||||
|
||||
验证结果:
|
||||
|
||||
- `rg` 无输出。
|
||||
- `stream_change_preserves_remove_asset_delta_fields` 通过:`1 passed`。
|
||||
+1
-1
@@ -3,7 +3,7 @@
|
||||
> 更新时间:2026-05-14
|
||||
>
|
||||
> 分类归属:
|
||||
> - `05-editor-mainline/process`
|
||||
> - `05-editor-mainline/done`
|
||||
> - 涉及边界:`04-tree-domain/tree command + file_tree projection`、`06-mindmap/runtime save + resource relation`
|
||||
>
|
||||
> 用户证据:
|
||||
|
||||
+12
-4
@@ -1,8 +1,8 @@
|
||||
# 5-15 [process][bug] PageAggregateClientState 仍在前端生成第二份 page tree 真相 v1
|
||||
# 5-15 [done][bug] PageAggregateClientState 仍在前端生成第二份 page tree 真相 v1
|
||||
|
||||
> 发现时间:2026-05-17
|
||||
>
|
||||
> 状态:`[process]`
|
||||
> 状态:`[done]`
|
||||
>
|
||||
> 关联主线:`05-editor-mainline`
|
||||
|
||||
@@ -28,6 +28,14 @@
|
||||
- AI context 应优先读取 Rust Page Aggregate 的稳定 projection。
|
||||
- 增加测试:本地编辑后 AI context 与 Rust 回读 projection 必须一致。
|
||||
|
||||
## 5. 状态
|
||||
## 5. 修复
|
||||
|
||||
已确认前端仍生成第二份 page tree,尚未收口。
|
||||
- [layout.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/ssr/pages/layout.rs:227) `currentPageAiContextSnapshot` 不再在编辑器 DOM 与服务端 aggregate 不一致时构造 `local` page subtree;AI context 的 subtree 只来自 Rust Page Aggregate 的 `aggregate.tree.pageSubtree`。
|
||||
- [layout.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/ssr/pages/layout.rs:7448) 增加字符串合同测试,禁止 `pageSubtreeSource: 'local'` 与 `buildPageAiLocalSubtree(localBlocks)` 回到 AI context 主链。
|
||||
|
||||
## 6. 验证
|
||||
|
||||
- `cargo test --manifest-path rust/Cargo.toml -p mnote-web page_ai_context_uses_page_aggregate_subtree_as_single_truth -- --nocapture`
|
||||
- 结果:1 passed
|
||||
- `cargo test --manifest-path rust/Cargo.toml -p mnote-web ssr::pages::layout::tests -- --nocapture`
|
||||
- 结果:12 passed
|
||||
+11
-4
@@ -1,8 +1,8 @@
|
||||
# 5-16 [process][bug] Sidebar preferred snapshot 中 query 可覆盖 live stream v1
|
||||
# 5-16 [done][bug] Sidebar preferred snapshot 中 query 可覆盖 live stream v1
|
||||
|
||||
> 发现时间:2026-05-17
|
||||
>
|
||||
> 状态:`[process]`
|
||||
> 状态:`[done]`
|
||||
>
|
||||
> 关联主线:`05-editor-mainline`
|
||||
|
||||
@@ -27,6 +27,13 @@ Sidebar 同时启动 query/live/initial 与 tree stream,并通过 preferred sn
|
||||
- 同版本不同内容时必须记录诊断事件,而不是静默按 freshness 选择。
|
||||
- 增加单测覆盖同版本冲突。
|
||||
|
||||
## 5. 状态
|
||||
## 5. 当前主链修复状态
|
||||
|
||||
已确认多源选择策略仍可能覆盖 live stream,尚未修复。
|
||||
- 原证据指向的 `wolai-frontend/src/components/sidebar/use-preferred-sidebar-snapshot.ts` 已不在当前运行主链;当前仓库中该路径不存在,只在 `recycle/` 下保留历史副本。
|
||||
- 当前 3000 主壳由 Rust `SIDEBAR_TREE_JS` + `TREE_LIVE_CONTROLLER_JS` 消费 WS/SSE tree snapshot / delta,不再使用 retired React `usePreferredSidebarSnapshot` 选择器。
|
||||
- [layout.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/ssr/pages/layout.rs:7460) 增加防回归测试,断言当前 sidebar runtime 不包含 `usePreferredSidebarSnapshot`、`preferredSidebarSnapshot` 或 `liveSidebarTitle`。
|
||||
|
||||
## 6. 验证
|
||||
|
||||
- `cargo test --manifest-path rust/Cargo.toml -p mnote-web sidebar_tree_runtime_does_not_use_retired_query_preferred_snapshot_selector -- --nocapture`
|
||||
- 结果:1 passed
|
||||
+11
-4
@@ -1,8 +1,8 @@
|
||||
# 5-17 [process][bug] 文档页标题优先 liveSidebarTitle 而非 Page Aggregate head v1
|
||||
# 5-17 [done][bug] 文档页标题优先 liveSidebarTitle 而非 Page Aggregate head v1
|
||||
|
||||
> 发现时间:2026-05-17
|
||||
>
|
||||
> 状态:`[process]`
|
||||
> 状态:`[done]`
|
||||
>
|
||||
> 关联主线:`05-editor-mainline`
|
||||
|
||||
@@ -27,6 +27,13 @@
|
||||
- Sidebar title 与 Page Aggregate head 冲突时记录诊断并触发 resync。
|
||||
- 增加 smoke:改标题后 Sidebar、Breadcrumb、文档页标题同源一致。
|
||||
|
||||
## 5. 状态
|
||||
## 5. 当前主链修复状态
|
||||
|
||||
已确认标题来源仍有漂移风险,尚未修复。
|
||||
- 原证据指向的 React `document-content.tsx` / `page-aggregate-client-state.ts` 当前不在运行主链;`wolai-frontend/src` 不存在,历史副本位于 `recycle/`。
|
||||
- 当前 Rust 文档壳初始标题来自 Page Aggregate `head.title`,`updatePaneChrome` 也从 `runtimeDescriptor.aggregate.head.title` 更新文档页 chrome。
|
||||
- [layout.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/ssr/pages/layout.rs:7460) 增加防回归测试,断言当前 sidebar runtime 不包含旧 `liveSidebarTitle` 入口。
|
||||
|
||||
## 6. 验证
|
||||
|
||||
- `cargo test --manifest-path rust/Cargo.toml -p mnote-web sidebar_tree_runtime_does_not_use_retired_query_preferred_snapshot_selector -- --nocapture`
|
||||
- 结果:1 passed
|
||||
+14
-4
@@ -1,8 +1,8 @@
|
||||
# 5-18 [process][bug] AI 写正文后本地 Page Aggregate content 可能不刷新 v1
|
||||
# 5-18 [done][bug] AI 写正文后本地 Page Aggregate content 可能不刷新 v1
|
||||
|
||||
> 发现时间:2026-05-17
|
||||
>
|
||||
> 状态:`[process]`
|
||||
> 状态:`[done]`
|
||||
>
|
||||
> 关联主线:`05-editor-mainline`
|
||||
|
||||
@@ -28,6 +28,16 @@
|
||||
- AI context 应从最新 Rust Page Aggregate snapshot 读取,而不是依赖可能过期的本地 reducer。
|
||||
- 增加 smoke:AI 修改正文后不刷新页面连续再问一次 AI,断言第二次 context 包含新内容。
|
||||
|
||||
## 5. 状态
|
||||
## 5. 修复
|
||||
|
||||
已确认 AI 写入后本地 Page Aggregate content 可能滞后,尚未修复。
|
||||
- [web_shell.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/routes/web_shell.rs:1208) 文档 session 记录 `pageAggregateScriptId`,明确当前 pane 对应的 Page Aggregate JSON script。
|
||||
- [web_shell.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/routes/web_shell.rs:1213) 新增 `syncPageAggregateScript`,在外部写入刷新得到新 aggregate 后同步 `__MNOTE_PAGE_AGGREGATE__`。
|
||||
- [web_shell.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/routes/web_shell.rs:1624) `refreshSessionFromExternalChange` 更新 session `latestAggregate` 后立即同步 JSON script,使下一次页面 AI context 从最新 Rust Page Aggregate 快照读取。
|
||||
- [web_shell.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/routes/web_shell.rs:3085) 增加 shell 合同测试,防止 AI 写入刷新只更新 editor session 而不更新 Page Aggregate script 缓存。
|
||||
|
||||
## 6. 验证
|
||||
|
||||
- `cargo test --manifest-path rust/Cargo.toml -p mnote-web document_shell_returns_page_aggregate_snapshot -- --nocapture`
|
||||
- 结果:1 passed
|
||||
- `cargo test --manifest-path rust/Cargo.toml -p mnote-web routes::web_shell::tests -- --nocapture`
|
||||
- 结果:10 passed
|
||||
+22
-4
@@ -1,9 +1,9 @@
|
||||
# 5-9-C12 [process][bug] 文档页顶栏左上角切换侧栏按钮无效 v1
|
||||
# 5-9-C12 [done][bug] 文档页顶栏左上角切换侧栏按钮无效 v1
|
||||
|
||||
> 更新时间:2026-05-12
|
||||
> 更新时间:2026-05-18(代码修复)
|
||||
>
|
||||
> 分类归属:
|
||||
> - `05-editor-mainline/process`
|
||||
> - `05-editor-mainline/done`
|
||||
> - 对应体验主线:`5-9 Wolai-aline continuous checklist`
|
||||
>
|
||||
> 关联文档:
|
||||
@@ -104,7 +104,7 @@ Hermes tester 首轮取证:
|
||||
|
||||
## 7. 流转条件
|
||||
|
||||
当前状态:`process`
|
||||
当前状态:`done`
|
||||
|
||||
只有在以下条件都满足后,本文才能移动到 `bugs/05-editor-mainline/done/`:
|
||||
|
||||
@@ -112,3 +112,21 @@ Hermes tester 首轮取证:
|
||||
2. smoke 已覆盖且通过
|
||||
3. 浏览器复测确认收起/展开都生效
|
||||
4. 证据路径已补齐到修复后的截图或日志
|
||||
|
||||
## 8. 修复记录
|
||||
|
||||
- [layout.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/ssr/pages/layout.rs:67) 新增 `toggleWorkspaceSidebar`,点击后切换 shell 的 `data-mnote-sidebar-collapsed` 状态,并同步 `documentElement` 观测属性。
|
||||
- [layout.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/ssr/pages/layout.rs:6167) 全局 click handler 接入 `data-mnote-action="toggle-sidebar"`。
|
||||
- [layout.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/ssr/pages/layout.rs:7336) 顶栏菜单按钮补齐 `data-mnote-action="toggle-sidebar"`、`data-testid="wolai-sidebar-toggle"` 与 `aria-pressed`。
|
||||
- [styles.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/ssr/styles.rs:224) `data-mnote-sidebar-collapsed="true"` 时隐藏 sidebar,让主编辑区占满空间。
|
||||
|
||||
## 9. 验证
|
||||
|
||||
- `cargo test --manifest-path rust/Cargo.toml -p mnote-web page_layout_sidebar_toggle_is_wired_to_shell_state -- --nocapture`
|
||||
- 结果:1 passed
|
||||
- `cargo test --manifest-path rust/Cargo.toml -p mnote-web ssr::pages::layout::tests -- --nocapture`
|
||||
- 结果:14 passed
|
||||
- `cargo test --manifest-path rust/Cargo.toml -p mnote-web ssr::styles::tests -- --nocapture`
|
||||
- 结果:7 passed
|
||||
|
||||
说明:本轮完成了代码接线与 SSR/CSS 合同验证;浏览器截图复核可在后续统一 smoke 中补充。
|
||||
+16
-4
@@ -1,8 +1,8 @@
|
||||
# 7-16 [process][bug] mnote.doc.markdown_edit 中文归一化替换 byte index 误用 v1
|
||||
# 7-16 [done][bug] mnote.doc.markdown_edit 中文归一化替换 byte index 误用 v1
|
||||
|
||||
> 发现时间:2026-05-17
|
||||
>
|
||||
> 状态:`[process]`
|
||||
> 状态:`[done]`
|
||||
>
|
||||
> 关联主线:`07-ai`
|
||||
>
|
||||
@@ -56,6 +56,18 @@ replace:二段
|
||||
- 或者只在同一字符串上使用 byte index,并确保切片边界来自同一个原始字符串的 `char_indices` 映射。
|
||||
- 增加中文、多字节标点、全角英文 / 数字混排的 `search_replace` 单测。
|
||||
|
||||
## 6. 状态
|
||||
## 6. 修复
|
||||
|
||||
已确认源码层缺陷,尚未修复。进入 `process` 等待实现和验证。
|
||||
已修复:
|
||||
|
||||
- [doc.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/hermes_tools/doc.rs:928) 抽出 `search_replace_exact_or_normalized`,归一化匹配时不再把 `str::find` 的 byte offset 当作字符序号使用。
|
||||
- [doc.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/hermes_tools/doc.rs:959) 新增 `normalize_line_with_byte_map`,在归一化字符串与原始行之间保留 byte 边界映射。
|
||||
- [doc.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/hermes_tools/doc.rs:670) 增加中文归一化替换回归测试,覆盖 `一 段` 命中 `一段` 时的多字节切片边界。
|
||||
|
||||
## 7. 验证
|
||||
|
||||
```bash
|
||||
cargo test --manifest-path rust/Cargo.toml -p mnote-web test_search_replace_normalized_chinese_byte_boundaries -- --nocapture
|
||||
```
|
||||
|
||||
结果:1 个测试通过。
|
||||
+20
-4
@@ -1,8 +1,8 @@
|
||||
# 7-17 [process][bug] mnote.doc.markdown_edit 同块多操作写回会覆盖前序修改 v1
|
||||
# 7-17 [done][bug] mnote.doc.markdown_edit 同块多操作写回会覆盖前序修改 v1
|
||||
|
||||
> 发现时间:2026-05-17
|
||||
>
|
||||
> 状态:`[process]`
|
||||
> 状态:`[done]`
|
||||
>
|
||||
> 关联主线:`07-ai`
|
||||
>
|
||||
@@ -70,6 +70,22 @@ Convex 写回使用的是 `block_ops`,而不是 `md`。这让“markdown 编
|
||||
- 增加同一 block 多 operation 的单测和真实 Page Aggregate 回读 smoke。
|
||||
- 明确部分失败是否允许写入;默认建议任一 operation 失败时不写入,除非调用方显式允许 partial apply。
|
||||
|
||||
## 5. 状态
|
||||
## 5. 修复
|
||||
|
||||
已确认源码层缺陷,尚未修复。进入 `process` 等待实现和验证。
|
||||
已修复:
|
||||
|
||||
- [doc.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/hermes_tools/doc.rs:1026) `build_block_ops_from_markdown_edit` 现在维护每个块的当前文本,按 operations 顺序累积同块修改。
|
||||
- [doc.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/hermes_tools/doc.rs:1061) 同一块多次命中时只输出一次最终 `replace` block op,避免后续 op 基于原始文本覆盖前序修改。
|
||||
- [hermes_tools.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/routes/hermes_tools.rs:1772) 增加 dry-run 路由级回归测试,覆盖第二个 search 依赖第一个替换结果的同块多操作场景。
|
||||
|
||||
## 6. 验证
|
||||
|
||||
```bash
|
||||
cargo test --manifest-path rust/Cargo.toml -p mnote-web hermes_tools_markdown_edit_merges_same_block_operations -- --nocapture
|
||||
```
|
||||
|
||||
结果:1 个测试通过。
|
||||
|
||||
## 7. 剩余风险
|
||||
|
||||
本修复解决“同块多操作覆盖前序修改”的最小 P0;跨块 Markdown 重写、full_content 与最终 Markdown 作为唯一写回真源仍归属于 `7-24`。
|
||||
+20
-4
@@ -1,8 +1,8 @@
|
||||
# 7-18 [process][bug] AI markdown_edit 阶段状态合同漂移 v1
|
||||
# 7-18 [done][bug] AI markdown_edit 阶段状态合同漂移 v1
|
||||
|
||||
> 发现时间:2026-05-17
|
||||
>
|
||||
> 状态:`[process]`
|
||||
> 状态:`[done]`
|
||||
>
|
||||
> 关联主线:`07-ai`
|
||||
|
||||
@@ -30,6 +30,22 @@
|
||||
- 若已切主,应把 `page_ai_workflow`、Hermes guidance、manifest 和 smoke 都同步到该口径。
|
||||
- 若未切主,应限制 `markdown_edit` 的 runtime 使用面,并把设计稿状态回退为实验态。
|
||||
|
||||
## 5. 状态
|
||||
## 5. 修复
|
||||
|
||||
已确认设计口径与源码使用状态不一致,尚未完成统一。
|
||||
2026-05-18 已完成运行时侧收口与顶层协作口径同步:
|
||||
|
||||
- `page_ai_workflow` 当前运行时已统一走模型输出 `search/replace` → `mnote.doc.markdown_edit` → 统一 mnote tool executor。
|
||||
- Hermes run guidance 已改为普通正文编辑优先 `mnote_doc_markdown_edit`,`apply_block_ops` 仅作为结构性块操作辅助。
|
||||
- `markdown_edit` 在线写回在无法安全映射最终 markdown 时明确拒绝,避免 `ok=true` 伪成功。
|
||||
- 根目录 [AGENTS.md](/mnt/Data1T/mnote/AGENTS.md:14) 已同步为当前真实阶段:简单正文编辑主路径是 `mnote.doc.markdown_edit`,`page_ai_workflow` 通过模型生成 markdown 编辑意图后调用统一 mnote tool executor,`apply_block_ops` / `mnote.block.*` 仅作为结构性辅助。
|
||||
|
||||
## 6. 验证
|
||||
|
||||
- `rg -n "local_rule|Phase A|Phase B|当前只做|过渡实现为 .*block-edit-workflow" AGENTS.md`
|
||||
- 结果:无输出,旧阶段合同表述已移除。
|
||||
- `cargo test --manifest-path rust/Cargo.toml -p mnote-web markdown_edit -- --nocapture`
|
||||
- 结果:通过。
|
||||
- `cargo test --manifest-path rust/Cargo.toml -p mnote-web page_ai_workflow -- --nocapture`
|
||||
- 结果:通过。
|
||||
- `cargo test --manifest-path rust/Cargo.toml -p mnote-web hermes_client_run_guidance_prefers_markdown_edit_for_plain_body_edits -- --nocapture`
|
||||
- 结果:通过。
|
||||
+13
-4
@@ -1,8 +1,8 @@
|
||||
# 7-19 [process][bug] Hermes 工具指导仍优先 apply_block_ops 而非 markdown_edit v1
|
||||
# 7-19 [done][bug] Hermes 工具指导仍优先 apply_block_ops 而非 markdown_edit v1
|
||||
|
||||
> 发现时间:2026-05-17
|
||||
>
|
||||
> 状态:`[process]`
|
||||
> 状态:`[done]`
|
||||
>
|
||||
> 关联主线:`07-ai`
|
||||
|
||||
@@ -30,6 +30,15 @@
|
||||
- 仅在需要结构性块移动、资源块、复杂定位时才引导模型使用 `apply_block_ops` 或 `mnote.block.*`。
|
||||
- 同步更新 smoke:模型生成工具调用时应优先产出 `mnote_doc_markdown_edit`。
|
||||
|
||||
## 5. 状态
|
||||
## 5. 修复
|
||||
|
||||
已确认源码指导文本与当前设计主线不一致,尚未修复。
|
||||
- [hermes_client.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/routes/hermes_client.rs:2456) 将 Hermes run instructions 改为:普通正文 search/replace、局部段落替换、全文 markdown 替换优先调用 `mnote_doc_markdown_edit`。
|
||||
- [hermes_client.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/routes/hermes_client.rs:2494) 将 `blockEditingToolOrder` 首位调整为 `mnote_doc_markdown_edit`,`mnote_doc_apply_block_ops` 仅保留给 markdown_edit 不能表达的结构性块操作。
|
||||
- [hermes_client.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/routes/hermes_client.rs:4559) 增加 run guidance 合同测试,防止普通正文编辑重新退回 `apply_block_ops` 优先口径。
|
||||
|
||||
## 6. 验证
|
||||
|
||||
- `cargo test --manifest-path rust/Cargo.toml -p mnote-web hermes_client_run_guidance_prefers_markdown_edit_for_plain_body_edits -- --nocapture`
|
||||
- 结果:1 passed
|
||||
- `cargo test --manifest-path rust/Cargo.toml -p mnote-web hermes_client_run_body -- --nocapture`
|
||||
- 结果:2 passed
|
||||
@@ -0,0 +1,48 @@
|
||||
# 7-20 [done][bug] page_ai_workflow 绕过 Hermes tool executor / audit / toggle v1
|
||||
|
||||
> 发现时间:2026-05-17
|
||||
>
|
||||
> 状态:`[done]`
|
||||
>
|
||||
> 关联主线:`07-ai`
|
||||
|
||||
## 1. 问题定义
|
||||
|
||||
`/api/page-ai/block-edit-workflow` 仍是独立模型调用链:直接读取 Hermes profile、调用 chat/completions、解析模型 JSON,并直接调用 Rust 工具函数。
|
||||
|
||||
它没有通过 `/api/hermes/tools/mnote/call` 的工具执行壳,因此绕过了 tool toggle、audit、统一幂等和统一调用追踪。
|
||||
|
||||
## 2. 证据
|
||||
|
||||
- [page_ai_workflow.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/routes/page_ai_workflow.rs:69) 直接调用上游模型。
|
||||
- [page_ai_workflow.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/routes/page_ai_workflow.rs:91) 在 route 内构造 `ToolCallInput`。
|
||||
- [page_ai_workflow.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/routes/page_ai_workflow.rs:109) 直接调用 `doc::doc_markdown_edit`。
|
||||
|
||||
## 3. 影响
|
||||
|
||||
- 关闭或限制 mnote tool 时,该 fast-path 仍可能写入。
|
||||
- 工具调用审计与普通 Hermes run 不一致。
|
||||
- 幂等、dryRun、runId、toolCallId 等字段无法统一治理。
|
||||
|
||||
## 4. 建议修复
|
||||
|
||||
- 将 fast-path 的工具写入改为调用统一 tool executor。
|
||||
- fast-path 只负责 prompt / plan,不直接执行写入函数。
|
||||
- 增加 smoke:当对应 tool disabled 时,`page_ai_workflow` 不得绕过限制写入。
|
||||
|
||||
## 5. 修复
|
||||
|
||||
- [hermes_tools.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/routes/hermes_tools.rs:62) 将统一 mnote tool 执行壳抽为 `execute_mnote_tool_call`,保留 profile disabled、audit、idempotency、auth/workspace 校验和统一结果包装。
|
||||
- [page_ai_workflow.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/routes/page_ai_workflow.rs:109) `block_edit_workflow` 的写入阶段改为调用统一 executor,不再直接调用 `doc::doc_markdown_edit`。
|
||||
- [page_ai_workflow.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/routes/page_ai_workflow.rs:657) 增加路由级测试:当 profile 禁用 `mnote.doc.markdown_edit` 时,页面 AI fast-path 必须返回 `mnote_tool_disabled`,不得绕过限制写入。
|
||||
|
||||
## 6. 验证
|
||||
|
||||
- `cargo test --manifest-path rust/Cargo.toml -p mnote-web block_edit_workflow_respects_disabled_markdown_edit_tool -- --nocapture`
|
||||
- 结果:1 passed
|
||||
- `cargo test --manifest-path rust/Cargo.toml -p mnote-web page_ai_workflow -- --nocapture`
|
||||
- 结果:3 passed
|
||||
- `cargo test --manifest-path rust/Cargo.toml -p mnote-web hermes_tools_call_rejects_profile_disabled_tool -- --nocapture`
|
||||
- 结果:1 passed
|
||||
- `cargo test --manifest-path rust/Cargo.toml -p mnote-web hermes_tools_markdown_edit_maps_normalized_search_to_block -- --nocapture`
|
||||
- 结果:1 passed
|
||||
+17
-4
@@ -1,8 +1,8 @@
|
||||
# 7-21 [process][bug] mnote.doc.markdown_edit 本地文件写入绕过 dryRun / idempotency v1
|
||||
# 7-21 [done][bug] mnote.doc.markdown_edit 本地文件写入绕过 dryRun / idempotency v1
|
||||
|
||||
> 发现时间:2026-05-17
|
||||
>
|
||||
> 状态:`[process]`
|
||||
> 状态:`[done]`
|
||||
>
|
||||
> 关联主线:`07-ai`
|
||||
|
||||
@@ -28,6 +28,19 @@
|
||||
- 对 `dryRun=true` 返回 diff / preview,不落盘。
|
||||
- 增加本地 `.md` dryRun 单测,断言文件内容不变。
|
||||
|
||||
## 5. 状态
|
||||
## 5. 修复
|
||||
|
||||
已确认源码层缺陷,尚未修复。
|
||||
已修复:
|
||||
|
||||
- [block.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/hermes_tools/block.rs:631) 将统一写入合同校验开放为 `pub(crate)`,供 `mnote.doc.markdown_edit` 复用。
|
||||
- [doc.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/hermes_tools/doc.rs:722) `mnote.doc.markdown_edit` 入口统一调用 `ensure_write_contract`,本地 `.md` 与在线文档一致要求 `idempotencyKey` 与显式 `dryRun`。
|
||||
- [doc.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/hermes_tools/doc.rs:845) 本地文件 `dryRun=true` 时只返回 `written=false` 预览结果,不再写入磁盘。
|
||||
- [hermes_tools.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/routes/hermes_tools.rs:1557) 增加路由级回归测试,覆盖缺少 `idempotencyKey` 与本地 dry-run 不落盘。
|
||||
|
||||
## 6. 验证
|
||||
|
||||
```bash
|
||||
cargo test --manifest-path rust/Cargo.toml -p mnote-web hermes_tools_markdown_edit_local -- --nocapture
|
||||
```
|
||||
|
||||
结果:2 个测试通过。
|
||||
@@ -0,0 +1,50 @@
|
||||
# 7-22 [done][bug] mnote.doc.apply_block_ops 批量写入缺少 revision / conflictDetectionKey 校验 v1
|
||||
|
||||
> 发现时间:2026-05-17
|
||||
>
|
||||
> 状态:`[done]`
|
||||
>
|
||||
> 关联主线:`07-ai`
|
||||
|
||||
## 1. 问题定义
|
||||
|
||||
`mnote.doc.apply_block_ops` 的批量入口只校验 dryRun / idempotency 写入合同,没有调用页面 revision、conflictDetectionKey、blockRevisionRef 的写前校验。
|
||||
|
||||
同文件中已有 `ensure_page_write_preconditions`,但该批量入口没有使用它。
|
||||
|
||||
## 2. 证据
|
||||
|
||||
- [block.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/hermes_tools/block.rs:385) 定义 `doc_apply_block_ops`。
|
||||
- [block.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/hermes_tools/block.rs:390) 仅调用 `ensure_write_contract`。
|
||||
- [block.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/hermes_tools/block.rs:774) 存在 `ensure_page_write_preconditions`,但批量入口未调用。
|
||||
|
||||
## 3. 影响
|
||||
|
||||
- AI 可能基于旧 `PageAggregate` 对正文写入,覆盖用户新编辑。
|
||||
- 多轮 Hermes / ACP run 同时写入时缺少冲突保护。
|
||||
- `markdown_edit` 在线分支会转调 `apply_block_ops`,因此该缺陷会影响 markdown 主路径。
|
||||
|
||||
## 4. 建议修复
|
||||
|
||||
- `doc_apply_block_ops` 真实写入前必须校验 page revision 与 conflictDetectionKey。
|
||||
- 块级操作应支持并校验 blockRevisionRef 或等价版本字段。
|
||||
- 增加负向测试:缺少 revision / conflictDetectionKey 时真实写入必须拒绝。
|
||||
|
||||
## 5. 修复
|
||||
|
||||
已修复:
|
||||
|
||||
- [block.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/hermes_tools/block.rs:413) `doc_apply_block_ops` 真实写入前调用 `ensure_page_write_preconditions`,要求 `revision` 与 `conflictDetectionKey`。
|
||||
- [block.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/hermes_tools/block.rs:439) replace / delete / move 目标块现在校验 operation 内的 `blockRevisionRef`。
|
||||
- [block.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/hermes_tools/block.rs:472) insert_after / move_after anchor 现在校验 operation 内的 `anchorRevisionRef`。
|
||||
- [doc.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/hermes_tools/doc.rs:875) `mnote.doc.markdown_edit` 在线转调 `doc_apply_block_ops` 时自动携带当前 aggregate 的 `revision`、`conflictDetectionKey` 与每个 replace op 的 `blockRevisionRef`。
|
||||
- [hermes_tools.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/routes/hermes_tools.rs:1490) 增加批量入口负向测试,覆盖缺少 page precondition 与缺少 block revision ref。
|
||||
|
||||
## 6. 验证
|
||||
|
||||
```bash
|
||||
cargo test --manifest-path rust/Cargo.toml -p mnote-web hermes_tools_apply_block_ops_requires_page_preconditions -- --nocapture
|
||||
cargo test --manifest-path rust/Cargo.toml -p mnote-web hermes_tools_markdown_edit -- --nocapture
|
||||
```
|
||||
|
||||
结果:批量前置条件测试通过,5 个 markdown_edit 相关测试通过。
|
||||
+17
-4
@@ -1,8 +1,8 @@
|
||||
# 7-23 [process][bug] mnote.doc.markdown_edit manifest schema 缺少 required 与写入字段 v1
|
||||
# 7-23 [done][bug] mnote.doc.markdown_edit manifest schema 缺少 required 与写入字段 v1
|
||||
|
||||
> 发现时间:2026-05-17
|
||||
>
|
||||
> 状态:`[process]`
|
||||
> 状态:`[done]`
|
||||
>
|
||||
> 关联主线:`07-ai`
|
||||
|
||||
@@ -29,6 +29,19 @@
|
||||
- manifest 与 Rust runtime 校验保持一致。
|
||||
- 增加 manifest snapshot 测试,防止字段再次漂移。
|
||||
|
||||
## 5. 状态
|
||||
## 5. 修复
|
||||
|
||||
已确认 manifest 合同不完整,尚未修复。
|
||||
已修复:
|
||||
|
||||
- [manifest.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/hermes_tools/manifest.rs:413) `mnote.doc.markdown_edit` 改为复用统一 `write_tool` schema 生成器。
|
||||
- [manifest.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/hermes_tools/manifest.rs:302) manifest 现在声明 `dryRun`、`idempotencyKey` 与 actor/session/run/toolCall/trace 等写入合同字段。
|
||||
- [manifest.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/hermes_tools/manifest.rs:449) 通过 `anyOf` 声明 `operations` / `full_content` 至少提供一个。
|
||||
- [hermes_tools.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/routes/hermes_tools.rs:730) 增加 manifest 路由级测试,防止 schema 再次漂移。
|
||||
|
||||
## 6. 验证
|
||||
|
||||
```bash
|
||||
cargo test --manifest-path rust/Cargo.toml -p mnote-web hermes_tools_manifest_describes_markdown_edit_write_contract -- --nocapture
|
||||
```
|
||||
|
||||
结果:1 个测试通过。
|
||||
+13
-4
@@ -1,8 +1,8 @@
|
||||
# 7-24 [process][bug] 在线 markdown_edit 写回不以最终 Markdown 为真源 v1
|
||||
# 7-24 [done][bug] 在线 markdown_edit 写回不以最终 Markdown 为真源 v1
|
||||
|
||||
> 发现时间:2026-05-17
|
||||
>
|
||||
> 状态:`[process]`
|
||||
> 状态:`[done]`
|
||||
>
|
||||
> 关联主线:`07-ai`
|
||||
|
||||
@@ -30,6 +30,15 @@
|
||||
- 对 full_content、跨块 search/replace、列表/标题变更增加回读一致性测试。
|
||||
- route 成功条件必须从“operationsApplied > 0”提升为“Page Aggregate 回读等于预期最终 markdown”。
|
||||
|
||||
## 5. 状态
|
||||
## 5. 修复
|
||||
|
||||
已确认写回层没有使用最终 markdown 真相,尚未修复。
|
||||
- [doc.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/hermes_tools/doc.rs:868) 在线分支在 markdown 层命中后重新映射为 block ops;若最终 markdown 变化无法安全映射到可写块,会返回 `mnote_doc_markdown_edit_block_mapping_empty`,不再把空 operations 交给 `apply_block_ops` 或返回伪成功。
|
||||
- [hermes_tools.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/routes/hermes_tools.rs:1857) 增加 `full_content` 在线写回合同测试:当前没有完整 markdown->block 真源写回时必须明确拒绝,不能以 `ok=true` 伪成功。
|
||||
- [hermes_tools.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/routes/hermes_tools.rs:1805) 保留 markdown 命中但块映射为空的提前失败测试,避免回退到 `mnote.doc.apply_block_ops operations 不能为空`。
|
||||
|
||||
## 6. 验证
|
||||
|
||||
- `cargo test --manifest-path rust/Cargo.toml -p mnote-web hermes_tools_markdown_edit_online_full_content_rejects_unsafe_block_mapping -- --nocapture`
|
||||
- 结果:1 passed
|
||||
- `cargo test --manifest-path rust/Cargo.toml -p mnote-web markdown_edit -- --nocapture`
|
||||
- 结果:8 passed
|
||||
+21
-5
@@ -1,8 +1,8 @@
|
||||
# 7-25 [process][bug] ACP Reasonix 页面 AI block_edit_workflow 在 markdown 命中后生成空 block_ops v1
|
||||
# 7-25 [done][bug] ACP Reasonix 页面 AI block_edit_workflow 在 markdown 命中后生成空 block_ops v1
|
||||
|
||||
> 发现时间:2026-05-17
|
||||
>
|
||||
> 状态:`[process]`
|
||||
> 状态:`[done]`
|
||||
>
|
||||
> 关联主线:`07-ai`
|
||||
>
|
||||
@@ -64,7 +64,7 @@ mnote.page_ai.block_edit_workflow 失败 · page-ai-fast-mp9wm0qo
|
||||
- “读取第一段 + 修改第二段”这类混合任务可能进入 fast workflow,但该 workflow 只能表达写入,不保证先读后答。
|
||||
- 如果模型给出的 `search` 是段落序号、fuzzy 片段、带格式文本或跨块文本,markdown 层可能成功,block 写回层仍失败。
|
||||
|
||||
## 6. 建议修复
|
||||
## 6. 建议修复:建议以长期,彻底的修复优先,建议参考/mnt/Data1T/mnote/design/05-editor-mainline/reference-code/cli-main中对全局替换和单块替换的判断逻辑。
|
||||
|
||||
短期:
|
||||
|
||||
@@ -86,7 +86,23 @@ mnote.page_ai.block_edit_workflow 失败 · page-ai-fast-mp9wm0qo
|
||||
- 断言失败时错误码不能是 `mnote.doc.apply_block_ops operations 不能为空`,应是 markdown 到 block 映射失败的明确错误。
|
||||
- 修复后回读 Page Aggregate,断言第二段实际变为 `测试123`,同时 AI 回复能正常说明已读取到第一段。
|
||||
|
||||
## 8. 状态
|
||||
## 8. 修复
|
||||
|
||||
已按用户反馈与源码路径确认缺陷,尚未修复。
|
||||
已修复:
|
||||
|
||||
- [doc.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/hermes_tools/doc.rs:862) 在 `mnote.doc.markdown_edit` 调用 `doc_apply_block_ops` 前拦截 `applied > 0 && block_ops.is_empty()`,返回 `mnote_doc_markdown_edit_block_mapping_empty`,不再泄露 `mnote.doc.apply_block_ops operations 不能为空`。
|
||||
- [doc.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/hermes_tools/doc.rs:928) 抽出 `search_replace_exact_or_normalized`,让 markdown 层与 block 映射层共享“精确 / 忽略空白 / 全半角归一化”规则。
|
||||
- [doc.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/hermes_tools/doc.rs:1038) 在线 block ops 构建不再用 `block.text.contains(search)`,改为对单块文本执行同一套安全匹配;fuzzy 仍只保留在 markdown 预处理层,避免写错块。
|
||||
- [hermes_tools.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/routes/hermes_tools.rs:1670) 增加回归测试,覆盖空映射语义错误与忽略空白后正确映射到 `p_2`。
|
||||
|
||||
## 9. 验证
|
||||
|
||||
```bash
|
||||
cargo test --manifest-path rust/Cargo.toml -p mnote-web hermes_tools_markdown_edit -- --nocapture
|
||||
```
|
||||
|
||||
结果:4 个相关测试通过。
|
||||
|
||||
## 10. 剩余风险
|
||||
|
||||
本修复解决单块 search/replace 映射和空 ops 下沉;跨块 Markdown 重写、完整最终 Markdown 作为写回真源仍归属于 `7-24`。
|
||||
@@ -0,0 +1,38 @@
|
||||
# 7-26 [done] Resource 块保存后在 AI block projection 中被降级为 paragraph
|
||||
|
||||
## 状态
|
||||
|
||||
- Owner:`07-ai` / Page Block AI projection 写入前置合同
|
||||
- 优先级:P0,影响 AI 精确块编辑安全边界
|
||||
- 状态:已修复并验证
|
||||
|
||||
## 现象
|
||||
|
||||
真实 3000 smoke 扩展复杂块后,`mnote.page.save` 写入 `type:"resource"` 的块,再经 Page Aggregate / AI block projection 回读时,`resource_1` 被显示为:
|
||||
|
||||
- `type="paragraph"`
|
||||
- `editable=true`
|
||||
- `unsupportedReason=null`
|
||||
|
||||
这会让 AI 工具把资源占位误判为普通可编辑文本块,违反复杂块不得伪装完全可编辑的合同。
|
||||
|
||||
## 根因
|
||||
|
||||
`core-protocol` 的 `EditorBlockType` 没有 `Resource` 变体,`bridge-runtime` 的 `normalize_editor_block_type_for_save()` 因此把未知 `resource` 类型落入默认 `Paragraph`。后续 `legacy_content_from_editor_document()` 和 `project_legacy_content_to_block_document()` 只能看到 paragraph,导致 AI projection 误报可编辑。
|
||||
|
||||
## 修复
|
||||
|
||||
- 在 `rust/crates/core-protocol/src/editor/model.rs` 增加 `EditorBlockType::Resource`。
|
||||
- 在 Tiptap bridge 中用 `mnoteBlockType=resource` 保留 resource 类型往返。
|
||||
- 在 `bridge-runtime` 中让 `resource/resource_block` 规范化为 `EditorBlockType::Resource`,legacy 回写继续输出 `type:"resource"`。
|
||||
- 保留 resource props 到 `resourceProps`,legacy 回写时恢复。
|
||||
- 在 `editor_actor` block 类型摘要中补 `resource`。
|
||||
- 扩展 `scripts/task-page-block-ai-tools-smoke.js`,覆盖 resource 在真实 3000 的投影和 move 阻断。
|
||||
|
||||
## 验证
|
||||
|
||||
- `cargo test --manifest-path rust/Cargo.toml -p bridge-runtime editor_document_roundtrip_preserves_resource_block_type -- --nocapture`
|
||||
- `cargo test --manifest-path rust/Cargo.toml -p mnote-web hermes_tools_block_move_after_blocks_complex_and_nested_blocks -- --nocapture`
|
||||
- `PLAYWRIGHT_CHROME_EXECUTABLE=/snap/bin/chromium MNOTE_UI_BASE_URL=http://127.0.0.1:3000 MNOTE_AUTH_BASE_URL=http://127.0.0.1:3000 node scripts/task-page-block-ai-tools-smoke.js`
|
||||
|
||||
最新真实 smoke 证据:`tmp/page-block-ai-tools-smoke/mpag4966.json`,截图:`tmp/page-block-ai-tools-smoke/mpag4966-page.png`。
|
||||
@@ -0,0 +1,76 @@
|
||||
# 7-27 [done][bug] 在线 markdown_edit full_content 会保留旧普通块
|
||||
|
||||
> 发现时间:2026-05-18
|
||||
>
|
||||
> 状态:`[done]`
|
||||
>
|
||||
> 关联主线:`07-ai`
|
||||
>
|
||||
> 关联设计:`design/07-ai/process/7-27-online-markdown-writeback-final-content-truth-v2.md`
|
||||
|
||||
## 1. 问题定义
|
||||
|
||||
`mnote.doc.markdown_edit` 的在线 Convex 文档路径已经切到 final markdown -> block content 直接写回,但 `full_content` 模式下,最终 markdown 通常不包含任何 `<!-- block:... -->` 原始块注释。
|
||||
|
||||
当前 `build_page_content()` 会把所有未处理的原始块追加到写回结果尾部,导致全文替换不是“替换全文”,而是:
|
||||
|
||||
```text
|
||||
新全文块...
|
||||
旧 heading / paragraph / todo 块...
|
||||
```
|
||||
|
||||
## 2. 影响
|
||||
|
||||
- AI 执行全文改写、重写大纲、把页面替换为整理后的内容时,会保留旧正文残留。
|
||||
- Page Aggregate / `mnote.doc.fetch` 回读会看到新旧内容混在一起。
|
||||
- 7-27 的“最终 markdown 是唯一写回真源”合同被破坏。
|
||||
|
||||
## 3. 根因
|
||||
|
||||
`parse_final_markdown_to_blocks()` 对无注释的 final markdown 行会生成 `is_new=true` 块,这是正确的。
|
||||
|
||||
问题在 `build_page_content()` 的末尾:
|
||||
|
||||
```rust
|
||||
for original in &original_blocks {
|
||||
if let Some(id) = block_id_of(original) {
|
||||
if !processed_ids.contains(&id) {
|
||||
result.push(original.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
该逻辑没有区分:
|
||||
|
||||
- 部分 search/replace 丢掉某个块注释:应保守保留缺失旧块,避免误删。
|
||||
- `full_content` / 全文替换没有任何原始块 ID:应只保留复杂块,普通旧文本块应被替换掉。
|
||||
|
||||
## 4. 修复
|
||||
|
||||
- [doc.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/hermes_tools/doc.rs:727) 给 `build_page_content()` 增加策略:
|
||||
- 若 parsed 中至少引用了一个原始 block id,则保留未处理原始块,维持安全降级。
|
||||
- 若 parsed 中没有任何原始 block id,则视为全文重建,只保留复杂块,不保留普通旧文本块。
|
||||
- [doc.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/hermes_tools/doc.rs:799) 增加 `is_complex_markdown_original_block()`,用于 full_content 重建时保留 resource / mindmap / table / image / attachment / 非 editable / unsupported / 带 children 的复杂块。
|
||||
- [doc.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/hermes_tools/doc.rs:1059) 增加单元测试:
|
||||
- `full_content` 无原始 ID 时不保留旧 heading / paragraph。
|
||||
- 原始 resource 复杂块仍保留。
|
||||
- 部分块注释丢失时仍保留缺失旧块。
|
||||
|
||||
## 5. 验证
|
||||
|
||||
```bash
|
||||
cargo test --manifest-path rust/Cargo.toml -p mnote-web build_page_content -- --nocapture
|
||||
cargo test --manifest-path rust/Cargo.toml -p mnote-web hermes_tools_markdown_edit_online_full_content_rejects_unsafe_block_mapping -- --nocapture
|
||||
cargo test --manifest-path rust/Cargo.toml -p mnote-web hermes_tools_markdown_edit -- --nocapture
|
||||
cargo fmt --manifest-path rust/Cargo.toml --all -- --check
|
||||
git diff --check -- rust/crates/mnote-web/src/hermes_tools/doc.rs bugs/07-ai/done/7-27-markdown-edit-full-content-retains-old-blocks-v1.md
|
||||
```
|
||||
|
||||
结果:
|
||||
|
||||
- `build_page_content` 相关测试:`2 passed`。
|
||||
- `hermes_tools_markdown_edit_online_full_content_rejects_unsafe_block_mapping`:`1 passed`。
|
||||
- `hermes_tools_markdown_edit` 相关测试:`6 passed`。
|
||||
- Rust workspace format check 通过。
|
||||
- 相关路径 diff check 通过。
|
||||
@@ -0,0 +1,49 @@
|
||||
# 7-28 [done][bug] markdown_edit 未强制 selection 允许块边界
|
||||
|
||||
> 发现时间:2026-05-18
|
||||
>
|
||||
> 状态:`[done]`
|
||||
>
|
||||
> 关联主线:`07-ai`
|
||||
|
||||
## 1. 问题定义
|
||||
|
||||
页面 AI context 已经可以携带 `allowedTargetBlockIds`,用于表达 `scope=selection` 时本次 run 允许修改的块集合。但 `mnote.doc.markdown_edit` 当前只按文本 search/replace 写回,不检查最终变化的 block 是否落在允许集合内。
|
||||
|
||||
如果 fast workflow 或未来调用者传入了过宽的 `pageText`,模型返回了选区外文本的 search/replace,服务端仍可能写入选区外块。
|
||||
|
||||
## 2. 证据
|
||||
|
||||
- [page_ai_workflow.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/routes/page_ai_workflow.rs:91) fast workflow 调用 `mnote.doc.markdown_edit`。
|
||||
- [page_ai_workflow.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/routes/page_ai_workflow.rs:299) 读取了 `allowedTargetBlockIds`,但未传给写工具或参与校验。
|
||||
- [hermes_client.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/routes/hermes_client.rs:2860) 工具指导要求 `scope=selection` 时只能修改 `allowedTargetBlockIds` 内的块。
|
||||
- [doc.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/hermes_tools/doc.rs:1220) `markdown_edit` 生成 `changedBlocks` 后直接 dry-run 或写回,没有 selection 范围校验。
|
||||
|
||||
## 3. 影响
|
||||
|
||||
- 页面 AI 对选中文本执行编辑时,服务端缺少最终兜底边界。
|
||||
- 上下文构造或模型输出一旦漂移,选区外块可能被写入。
|
||||
- 与 `mnote.block.*` 已有 `allowedTargetBlockIds` / blockId 前置条件相比,`markdown_edit` 的写入安全边界较弱。
|
||||
|
||||
## 4. 修复
|
||||
|
||||
- [page_ai_workflow.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/routes/page_ai_workflow.rs:91) fast workflow 将 `aiContext.allowedTargetBlockIds` 透传给 `mnote.doc.markdown_edit`。
|
||||
- [doc.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/hermes_tools/doc.rs:855) `mnote.doc.markdown_edit` 在生成 `changedBlocks` 后,如果存在 `allowedTargetBlockIds` / `selectedBlockIds`:
|
||||
- 任何带 `blockId` 的变更必须属于允许集合。
|
||||
- 无 `blockId` 的 insert/full_content 变更在 selection scope 下拒绝,避免无法归属的选区外写入。
|
||||
- [hermes_tools.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/routes/hermes_tools.rs:2308) 增加 route 测试:`allowedTargetBlockIds=["p_2"]` 时替换 `p_1` 必须返回 `mnote_markdown_edit_target_out_of_scope`。
|
||||
- [page_ai_workflow.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/routes/page_ai_workflow.rs:742) 增加 fast workflow 测试:模型输出修改 `p_1`,而 selection 只允许 `p_2`,必须被服务端拒绝。
|
||||
|
||||
## 5. 验证
|
||||
|
||||
```bash
|
||||
cargo test --manifest-path rust/Cargo.toml -p mnote-web markdown_edit_rejects_selection_out_of_scope -- --nocapture
|
||||
cargo test --manifest-path rust/Cargo.toml -p mnote-web block_edit_workflow -- --nocapture
|
||||
cargo fmt --manifest-path rust/Cargo.toml --all -- --check
|
||||
```
|
||||
|
||||
结果:
|
||||
|
||||
- `hermes_tools_markdown_edit_rejects_selection_out_of_scope`:`1 passed`。
|
||||
- `block_edit_workflow_forwards_allowed_target_blocks_to_markdown_edit`:`1 passed`。
|
||||
- Rust workspace format check 通过。
|
||||
@@ -0,0 +1,44 @@
|
||||
# 7-29 [done][bug] markdown_edit 会把普通块 inline content 扁平化
|
||||
|
||||
> 发现时间:2026-05-18
|
||||
>
|
||||
> 状态:`[done]`
|
||||
>
|
||||
> 关联主线:`07-ai`
|
||||
|
||||
## 1. 问题定义
|
||||
|
||||
`mnote.doc.markdown_edit` 的 online 写回已经从“二次推导 block ops”切到 final markdown → block content,但 `build_page_content()` 仍会把普通块的 legacy `content` 统一写成扁平字符串。
|
||||
|
||||
这会让未修改的块也丢失原始 inline content 结构,修改过的单段落块则更容易丢失 bold / italic / link / code 等 marks。
|
||||
|
||||
## 2. 证据
|
||||
|
||||
- [doc.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/hermes_tools/doc.rs:762) 当前普通块写回直接 `obj.insert("content", json!(parsed_block.text))`。
|
||||
- [doc.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/hermes_tools/doc.rs:826) 只有 `text` 和 `id` 被保留,原 inline 节点结构被覆盖。
|
||||
- [7-27](/mnt/Data1T/mnote/design/07-ai/process/7-27-online-markdown-writeback-final-content-truth-v2.md:580) 已明确指出当前 `blocks_to_markdown` / 写回链会丢弃 inline marks。
|
||||
|
||||
## 3. 影响
|
||||
|
||||
- 未修改块在 `full_content` / 局部替换后也可能失去 inline 节点结构。
|
||||
- 已修改块中的 bold / link / code 等 marks 可能被扁平化成普通字符串。
|
||||
- 页面 AI 的正文重写与可视格式回读不一致。
|
||||
|
||||
## 4. 修复结果
|
||||
|
||||
- [doc.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/hermes_tools/doc.rs) 的 `build_page_content()` 已改为区分未变更、可安全局部替换、复杂块保留三类写回路径。
|
||||
- 未变更的普通块会直接保留原始 block 结构,不再被重写成纯字符串 content。
|
||||
- 已变更且原始 `content` / `contentNodes` 是单 inline text 节点的普通块,只替换该 text 节点文本,保留原 marks / attrs。
|
||||
- 复杂块继续原样保留;无法证明可安全替换的已变更普通块才退回纯文本 content。
|
||||
|
||||
## 5. 验证证据
|
||||
|
||||
```bash
|
||||
cargo test --manifest-path rust/Cargo.toml -p mnote-web build_page_content -- --nocapture
|
||||
cargo test --manifest-path rust/Cargo.toml -p mnote-web hermes_tools_markdown_edit -- --nocapture
|
||||
```
|
||||
|
||||
验证结果:
|
||||
|
||||
- `build_page_content` 定向测试通过,覆盖 full_content 不保留旧普通块、局部替换保留未变更块、单 inline text marks 保留。
|
||||
- `hermes_tools_markdown_edit` 定向测试通过,覆盖 markdown edit 在线写回链路。
|
||||
@@ -0,0 +1,48 @@
|
||||
# 7-30 [done][bug] 单个 mnote.block 写工具未强制 selection scope
|
||||
|
||||
> 发现时间:2026-05-18
|
||||
>
|
||||
> 状态:`[done]`
|
||||
>
|
||||
> 关联主线:`07-ai`
|
||||
|
||||
## 1. 问题定义
|
||||
|
||||
`mnote.doc.apply_block_ops` 已经通过 `allowedTargetBlockIds` 限制 AI selection 范围内的结构性写入,但单个 `mnote.block.replace` / `mnote.block.insert_after` / `mnote.block.delete` / `mnote.block.move_after` 写工具没有同等服务端兜底。
|
||||
|
||||
这会导致模型或 ACP 调用者在 `scope=selection` 场景下,绕过批量工具直接调用单块写工具时,仍可能改到 selection 外的块。
|
||||
|
||||
## 2. 证据
|
||||
|
||||
- [block.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/hermes_tools/block.rs) 的 `doc_apply_block_ops()` 对每个操作调用 `ensure_allowed_target()`。
|
||||
- [block.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/hermes_tools/block.rs) 的单个 block 写工具当前只校验 revision / editable / precondition,缺少同等 `allowedTargetBlockIds` 校验。
|
||||
- [7-10](/mnt/Data1T/mnote/design/07-ai/process/7-10-page-block-ai-tooling-execution-checklist-v1.md:70) 已标注“单个 `mnote.block.*` 写工具尚未完成 `allowedTargetBlockIds` 矩阵”。
|
||||
|
||||
## 3. 影响
|
||||
|
||||
- AI 页面 selection 编辑边界不完整。
|
||||
- `markdown_edit` 和 `apply_block_ops` 已拒绝越界写入,但单块工具仍可能成为绕过路径。
|
||||
- ACP / Hermes tool guidance 即使要求 selection 内写入,也缺少 runtime 级强约束。
|
||||
|
||||
## 4. 修复结果
|
||||
|
||||
- [block.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/hermes_tools/block.rs) 已为 `mnote.block.replace`、`mnote.block.insert_after`、`mnote.block.delete`、`mnote.block.move_after` 增加统一 `allowedTargetBlockIds` 服务端校验。
|
||||
- `move_after` 同时校验被移动块和 anchor 块,避免 selection 内块被移动到 selection 外 anchor 后。
|
||||
- [manifest.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/hermes_tools/manifest.rs) 已在单块写工具 schema 中公开 `allowedTargetBlockIds`。
|
||||
- [hermes_tools.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/routes/hermes_tools.rs) 已增加回归测试,覆盖单块写工具越界时返回 `mnote_block_target_out_of_scope`。
|
||||
|
||||
## 5. 验证证据
|
||||
|
||||
```bash
|
||||
cargo test --manifest-path rust/Cargo.toml -p mnote-web block_tools_selection_scope -- --nocapture
|
||||
cargo test --manifest-path rust/Cargo.toml -p mnote-web hermes_tools_block -- --nocapture
|
||||
cargo test --manifest-path rust/Cargo.toml -p mnote-web hermes_tools_manifest -- --nocapture
|
||||
cargo fmt --manifest-path rust/Cargo.toml --all -- --check
|
||||
```
|
||||
|
||||
验证结果:
|
||||
|
||||
- RED:修复前 `block_tools_selection_scope` 失败,越界单块写工具返回 `200`。
|
||||
- GREEN:修复后 `block_tools_selection_scope` 通过:`2 passed`。
|
||||
- `hermes_tools_block` 通过:`6 passed`。
|
||||
- `hermes_tools_manifest` 通过:`3 passed`。
|
||||
+19
-2
@@ -2,9 +2,9 @@
|
||||
|
||||
> 发现时间:2026-05-16
|
||||
>
|
||||
> 更新时间:2026-05-16(架构定位修正)
|
||||
> 更新时间:2026-05-18(运行时收口验证)
|
||||
>
|
||||
> 状态:`[process]`
|
||||
> 状态:`[done]`
|
||||
>
|
||||
> 关联主线:`07-ai` / `05-editor-mainline`
|
||||
>
|
||||
@@ -100,3 +100,20 @@ replace 操作缺少 content
|
||||
- Model path(自然语言,真 AI): ❌ 400,replace 缺少 content
|
||||
- 代码:`page_ai_workflow.rs` system prompt 缺 operation schema
|
||||
- 用户反馈:direct path 不是 AI 面板应有的行为,应退役
|
||||
|
||||
## 运行时修复
|
||||
|
||||
- [page_ai_workflow.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/routes/page_ai_workflow.rs:67) 当前运行时已退役 direct path,所有块编辑请求统一走模型输出 `search/replace` 对,再进入 `mnote.doc.markdown_edit`。
|
||||
- [page_ai_workflow.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/routes/page_ai_workflow.rs:307) system prompt 已改为明确要求 `operations[].search` 与 `operations[].replace`,不再要求模型输出旧的 block op `content` 字段。
|
||||
- [page_ai_workflow.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/routes/page_ai_workflow.rs:109) fast-path 写入已改走统一 mnote tool executor,避免绕过 tool toggle / audit / idempotency。
|
||||
|
||||
## 验证
|
||||
|
||||
- `cargo test --manifest-path rust/Cargo.toml -p mnote-web page_ai_workflow -- --nocapture`
|
||||
- 结果:3 passed
|
||||
- `cargo test --manifest-path rust/Cargo.toml -p mnote-web markdown_edit -- --nocapture`
|
||||
- 结果:8 passed
|
||||
|
||||
## 剩余说明
|
||||
|
||||
旧的 `direct_block_edit_operations` / `quoted_segments` helper 当前仅作为 dead code 与历史单测保留,不再是 `block_edit_workflow` 的运行时入口;后续可单独清理测试语义,但不影响本缺陷的运行时修复。
|
||||
@@ -1,35 +0,0 @@
|
||||
# 7-20 [process][bug] page_ai_workflow 绕过 Hermes tool executor / audit / toggle v1
|
||||
|
||||
> 发现时间:2026-05-17
|
||||
>
|
||||
> 状态:`[process]`
|
||||
>
|
||||
> 关联主线:`07-ai`
|
||||
|
||||
## 1. 问题定义
|
||||
|
||||
`/api/page-ai/block-edit-workflow` 仍是独立模型调用链:直接读取 Hermes profile、调用 chat/completions、解析模型 JSON,并直接调用 Rust 工具函数。
|
||||
|
||||
它没有通过 `/api/hermes/tools/mnote/call` 的工具执行壳,因此绕过了 tool toggle、audit、统一幂等和统一调用追踪。
|
||||
|
||||
## 2. 证据
|
||||
|
||||
- [page_ai_workflow.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/routes/page_ai_workflow.rs:69) 直接调用上游模型。
|
||||
- [page_ai_workflow.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/routes/page_ai_workflow.rs:91) 在 route 内构造 `ToolCallInput`。
|
||||
- [page_ai_workflow.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/routes/page_ai_workflow.rs:109) 直接调用 `doc::doc_markdown_edit`。
|
||||
|
||||
## 3. 影响
|
||||
|
||||
- 关闭或限制 mnote tool 时,该 fast-path 仍可能写入。
|
||||
- 工具调用审计与普通 Hermes run 不一致。
|
||||
- 幂等、dryRun、runId、toolCallId 等字段无法统一治理。
|
||||
|
||||
## 4. 建议修复
|
||||
|
||||
- 将 fast-path 的工具写入改为调用统一 tool executor。
|
||||
- fast-path 只负责 prompt / plan,不直接执行写入函数。
|
||||
- 增加 smoke:当对应 tool disabled 时,`page_ai_workflow` 不得绕过限制写入。
|
||||
|
||||
## 5. 状态
|
||||
|
||||
已确认 route 级绕过统一执行壳,尚未修复。
|
||||
@@ -1,35 +0,0 @@
|
||||
# 7-22 [process][bug] mnote.doc.apply_block_ops 批量写入缺少 revision / conflictDetectionKey 校验 v1
|
||||
|
||||
> 发现时间:2026-05-17
|
||||
>
|
||||
> 状态:`[process]`
|
||||
>
|
||||
> 关联主线:`07-ai`
|
||||
|
||||
## 1. 问题定义
|
||||
|
||||
`mnote.doc.apply_block_ops` 的批量入口只校验 dryRun / idempotency 写入合同,没有调用页面 revision、conflictDetectionKey、blockRevisionRef 的写前校验。
|
||||
|
||||
同文件中已有 `ensure_page_write_preconditions`,但该批量入口没有使用它。
|
||||
|
||||
## 2. 证据
|
||||
|
||||
- [block.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/hermes_tools/block.rs:385) 定义 `doc_apply_block_ops`。
|
||||
- [block.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/hermes_tools/block.rs:390) 仅调用 `ensure_write_contract`。
|
||||
- [block.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/hermes_tools/block.rs:774) 存在 `ensure_page_write_preconditions`,但批量入口未调用。
|
||||
|
||||
## 3. 影响
|
||||
|
||||
- AI 可能基于旧 `PageAggregate` 对正文写入,覆盖用户新编辑。
|
||||
- 多轮 Hermes / ACP run 同时写入时缺少冲突保护。
|
||||
- `markdown_edit` 在线分支会转调 `apply_block_ops`,因此该缺陷会影响 markdown 主路径。
|
||||
|
||||
## 4. 建议修复
|
||||
|
||||
- `doc_apply_block_ops` 真实写入前必须校验 page revision 与 conflictDetectionKey。
|
||||
- 块级操作应支持并校验 blockRevisionRef 或等价版本字段。
|
||||
- 增加负向测试:缺少 revision / conflictDetectionKey 时真实写入必须拒绝。
|
||||
|
||||
## 5. 状态
|
||||
|
||||
已确认批量写入入口缺少必要冲突校验,尚未修复。
|
||||
@@ -0,0 +1,159 @@
|
||||
# 2026-05-18 [done] P0 内核优先 Bug 修复记录与进展
|
||||
|
||||
> 记录时间:2026-05-18
|
||||
>
|
||||
> 状态:`done`
|
||||
>
|
||||
> 范围:`03-rust-web`、`04-tree-domain`、`05-editor-mainline`、`07-ai`
|
||||
>
|
||||
> 口径:本文是本轮 P0 / 内核优先缺陷修复的总进展记录;单个缺陷的根因、修复点和证据仍以各分类 `bugs/<category>/done/` 下的明细文档为准。
|
||||
|
||||
## 1. 总结
|
||||
|
||||
本轮按“P0 优先 / 内核优先”的顺序完成了已确认缺陷的修复、验证和归档:
|
||||
|
||||
- `bugs/*/process/` 当前已无遗留缺陷文档。
|
||||
- `design/10-review/process/08-*` 已迁入 `design/10-review/done/08-*`,10-review 当前无活跃 process 文档。
|
||||
- Page Aggregate、tree command、tree realtime、AI markdown edit / block tools 的 P0/P1 blocker 已归档到对应 `bugs/*/done/`。
|
||||
- 当前仍未实施的是 `7-10` / `7-16` 中 Phase C UI / Review Mode 相关项,已明确冻结,不计入本轮 P0 内核 bug blocker。
|
||||
|
||||
## 2. 本轮修复主线
|
||||
|
||||
### 2.1 Rust Web / Realtime / ACP
|
||||
|
||||
已归档缺陷:
|
||||
|
||||
- `bugs/03-rust-web/done/3-16-tree-realtime-ws-sse-doc-contract-drift-v1.md`
|
||||
- `bugs/03-rust-web/done/3-17-sse-push-skips-polling-fallback-v1.md`
|
||||
- `bugs/03-rust-web/done/3-18-ws-sse-delta-contract-split-v1.md`
|
||||
- `bugs/03-rust-web/done/3-19-acp-reasonix-tool-call-missing-identity-fields-v1.md`
|
||||
- `bugs/03-rust-web/done/3-20-acp-request-permission-no-response-v1.md`
|
||||
- `bugs/03-rust-web/done/3-21-acp-run-payload-consumed-and-removed-v1.md`
|
||||
- `bugs/03-rust-web/done/3-22-acp-permission-auto-deny-ui-shows-approval-actions-v1.md`
|
||||
|
||||
收口结果:
|
||||
|
||||
- `/api/realtime/ws` 与 `/api/tree/events` 的实时合同已按 WS 主链 / SSE fallback 口径收口。
|
||||
- ACP Reasonix 工具调用身份字段、permission 响应、run payload 生命周期相关缺陷已进入 done。
|
||||
- ACP permission 自动拒绝时,页面 AI UI 不再展示误导性的“允许 / 拒绝”审批动作。
|
||||
- 这些缺陷不再作为当前 10-review P0 blocker 跟踪。
|
||||
|
||||
### 2.2 Tree Domain / File Tree
|
||||
|
||||
已归档缺陷:
|
||||
|
||||
- `bugs/04-tree-domain/done/4-46-filetree-resource-row-document-command-leak-v1.md`
|
||||
- `bugs/04-tree-domain/done/4-47-stream-support-retired-resource-delete-command-v1.md`
|
||||
|
||||
收口结果:
|
||||
|
||||
- File Tree resource row 不再泄漏到 document command 语义。
|
||||
- 资源归属继续按 Resource Tree -> File Tree projection -> `tree.resource.*` 命令面解释。
|
||||
- stream_support 的资源 remove_asset 回归样例已同步到 `tree.resource.archive`,不再保留退役 `tree.resource.delete` 口径。
|
||||
|
||||
### 2.3 Editor Mainline / Page Aggregate
|
||||
|
||||
已归档缺陷:
|
||||
|
||||
- `bugs/05-editor-mainline/done/5-15-page-aggregate-client-state-second-truth-v1.md`
|
||||
- `bugs/05-editor-mainline/done/5-16-sidebar-preferred-snapshot-query-overrides-live-stream-v1.md`
|
||||
- `bugs/05-editor-mainline/done/5-17-document-title-source-drift-live-sidebar-over-head-v1.md`
|
||||
- `bugs/05-editor-mainline/done/5-18-ai-write-body-does-not-sync-page-aggregate-content-v1.md`
|
||||
- `bugs/05-editor-mainline/done/5-9-c12-topbar-sidebar-toggle-noop-v1.md`
|
||||
|
||||
收口结果:
|
||||
|
||||
- Page Aggregate 标题、正文、页面设置、page tree、AI fetch 回读已经形成同一组可验证真相。
|
||||
- AI 外部写入后,文档 shell 会同步 Page Aggregate JSON script / session snapshot,避免下一次 AI context 继续读旧正文。
|
||||
- Sidebar preferred snapshot 与 live stream 的优先级漂移已从当前 P0 blocker 中移除。
|
||||
|
||||
关键证据:
|
||||
|
||||
- `tmp/page-aggregate-refresh-persistence-smoke/mpagfma1.json`
|
||||
- `tmp/page-aggregate-refresh-persistence-smoke/mpagfma1.png`
|
||||
|
||||
### 2.4 AI Markdown Edit / Block Tools
|
||||
|
||||
已归档缺陷:
|
||||
|
||||
- `bugs/07-ai/done/7-16-markdown-edit-normalized-search-byte-index-v1.md`
|
||||
- `bugs/07-ai/done/7-17-markdown-edit-same-block-multi-op-overwrite-v1.md`
|
||||
- `bugs/07-ai/done/7-18-ai-markdown-edit-phase-state-contract-drift-v1.md`
|
||||
- `bugs/07-ai/done/7-19-hermes-tool-guidance-markdown-edit-contract-drift-v1.md`
|
||||
- `bugs/07-ai/done/7-20-page-ai-workflow-bypasses-hermes-tool-executor-v1.md`
|
||||
- `bugs/07-ai/done/7-21-markdown-edit-local-write-contract-bypass-v1.md`
|
||||
- `bugs/07-ai/done/7-22-apply-block-ops-missing-write-preconditions-v1.md`
|
||||
- `bugs/07-ai/done/7-23-markdown-edit-manifest-schema-contract-drift-v1.md`
|
||||
- `bugs/07-ai/done/7-24-markdown-edit-online-write-does-not-use-final-markdown-v1.md`
|
||||
- `bugs/07-ai/done/7-25-reasonix-block-edit-workflow-empty-block-ops-after-markdown-match-v1.md`
|
||||
- `bugs/07-ai/done/7-26-resource-block-normalized-to-paragraph-ai-projection-v1.md`
|
||||
- `bugs/07-ai/done/page-ai-block-edit-model-fallback-missing-content.md`
|
||||
- `bugs/07-ai/done/7-27-markdown-edit-full-content-retains-old-blocks-v1.md`
|
||||
- `bugs/07-ai/done/7-28-markdown-edit-selection-scope-not-enforced-v1.md`
|
||||
- `bugs/07-ai/done/7-29-markdown-edit-flattens-inline-content-v1.md`
|
||||
- `bugs/07-ai/done/7-30-block-tools-selection-scope-not-enforced-v1.md`
|
||||
|
||||
收口结果:
|
||||
|
||||
- 页面 AI 简单正文编辑主路径已收敛为 `mnote.doc.markdown_edit` + `mnote.doc.fetch` + `page_ai_workflow.rs` 统一 tool executor。
|
||||
- `local_rule` / `direct_block_edit_operations` 不再作为当前 runtime 口径。
|
||||
- 在线 Convex 文档和本地 `.md` 文件共用 markdown 写入合同。
|
||||
- `mnote.doc.markdown_edit` 已补齐 normalized search、同块多 op 合并、写入前置条件、manifest schema、在线写回和空 block ops 映射错误处理。
|
||||
- `mnote.block.insert_after` 支持多块插入并返回 `insertedBlockIds`,限制 `1..=20`。
|
||||
- AI block projection 已保留 `resource` 类型,不再把 resource 块降级为 `paragraph` 或伪装成可编辑普通块。
|
||||
- `mnote.doc.markdown_edit` 的 `full_content` 重建已改为 final markdown 真源,避免旧普通块残留。
|
||||
- `mnote.doc.markdown_edit` / `page_ai_workflow` 已补 selection scope 服务端兜底,`allowedTargetBlockIds` 外的写入会被拒绝。
|
||||
- `mnote.doc.markdown_edit` 写回普通块时不再无条件扁平化 inline content;未变更块保持原结构,单 inline text 节点的已变更块保留 marks / attrs。
|
||||
- 单个 `mnote.block.*` 写工具已补 selection scope 服务端兜底,越界块写入会返回 `mnote_block_target_out_of_scope`。
|
||||
|
||||
关键证据:
|
||||
|
||||
- `tmp/page-block-ai-tools-smoke/mpag4966.json`
|
||||
- `tmp/page-block-ai-tools-smoke/mpag4966-page.png`
|
||||
- `tmp/hermes-tester/page-block-ai-tools-mpag4966/page-aggregate.json`
|
||||
- `tmp/hermes-tester/page-block-ai-tools-mpag4966/doc-fetch-find.json`
|
||||
- `tmp/page-block-ai-conflict-idempotency-smoke/mpafrevs.json`
|
||||
|
||||
## 3. 已执行验证
|
||||
|
||||
最新验证命令:
|
||||
|
||||
```bash
|
||||
node --check scripts/task-page-block-ai-tools-smoke.js
|
||||
node --check scripts/task-page-block-ai-conflict-idempotency-smoke.js
|
||||
node --check scripts/task-page-aggregate-refresh-persistence-smoke.js
|
||||
cargo fmt --manifest-path rust/Cargo.toml --all -- --check
|
||||
cargo test --manifest-path rust/Cargo.toml -p bridge-runtime editor_document_roundtrip_preserves_resource_block_type -- --nocapture
|
||||
cargo test --manifest-path rust/Cargo.toml -p mnote-web hermes_tools -- --nocapture
|
||||
cargo test --manifest-path rust/Cargo.toml -p mnote-web build_page_content -- --nocapture
|
||||
cargo test --manifest-path rust/Cargo.toml -p mnote-web hermes_tools_markdown_edit -- --nocapture
|
||||
cargo test --manifest-path rust/Cargo.toml -p mnote-web block_edit_workflow -- --nocapture
|
||||
cargo test --manifest-path rust/Cargo.toml -p mnote-web acp_client -- --nocapture
|
||||
cargo test --manifest-path rust/Cargo.toml -p mnote-web ssr::pages::layout::tests -- --nocapture
|
||||
cargo test --manifest-path rust/Cargo.toml -p mnote-web hermes_tools_block -- --nocapture
|
||||
cargo test --manifest-path rust/Cargo.toml -p mnote-web hermes_tools_manifest -- --nocapture
|
||||
cargo test --manifest-path rust/Cargo.toml -p mnote-web stream_change_preserves_remove_asset_delta_fields -- --nocapture
|
||||
git diff --check -- scripts/task-page-block-ai-tools-smoke.js scripts/task-page-block-ai-conflict-idempotency-smoke.js scripts/task-page-aggregate-refresh-persistence-smoke.js rust/crates/core-protocol/src/editor/model.rs rust/crates/core-protocol/src/editor/tiptap.rs rust/crates/bridge-runtime/src/lib.rs rust/crates/mnote-web/src/editor_actor.rs rust/crates/mnote-web/src/hermes_tools/block.rs rust/crates/mnote-web/src/hermes_tools/manifest.rs rust/crates/mnote-web/src/routes/hermes_tools.rs design/07-ai/process/7-10-page-block-ai-tooling-execution-checklist-v1.md design/07-ai/process/7-16-page-block-ai-real-smoke-followup-matrix-v1.md design/10-review/README.md design/10-review/done/08-kernel-architecture-next-priority-review-and-checklist.md bugs/07-ai/done/7-26-resource-block-normalized-to-paragraph-ai-projection-v1.md
|
||||
find bugs -path '*/process/*' -type f | sort
|
||||
```
|
||||
|
||||
结果:
|
||||
|
||||
- 三个 Node smoke 脚本语法检查通过。
|
||||
- Rust workspace 格式检查通过。
|
||||
- `bridge-runtime` resource block roundtrip 回归测试通过:`1 passed`。
|
||||
- `mnote-web hermes_tools` 测试通过:`39 passed`。
|
||||
- `mnote-web build_page_content`、`hermes_tools_markdown_edit`、`block_edit_workflow`、`acp_client`、`ssr::pages::layout::tests` 定向回归测试通过。
|
||||
- `mnote-web hermes_tools_block`、`hermes_tools_manifest` 定向回归测试通过。
|
||||
- `mnote-web stream_change_preserves_remove_asset_delta_fields` 定向回归测试通过。
|
||||
- 相关路径 `git diff --check` 通过。
|
||||
- `bugs/*/process/` 文件列表为空。
|
||||
|
||||
## 4. 当前剩余边界
|
||||
|
||||
不是本轮未修 blocker:
|
||||
|
||||
- `design/07-ai/process/7-10-page-block-ai-tooling-execution-checklist-v1.md` 和 `design/07-ai/process/7-16-page-block-ai-real-smoke-followup-matrix-v1.md` 中仍有 Phase C UI / Review Mode 未勾项。
|
||||
- 这些项属于流式 apply、suggest/review、持久 comment/tracked-change 等未来 UI / 审阅面设计,当前已按 Phase C 冻结,不纳入本轮 P0 内核 bug 修复。
|
||||
|
||||
后续若要继续推进,应另起 process 文档或新 bug,而不是重新打开本轮已归档的 P0 内核缺陷。
|
||||
Vendored
+49
@@ -0,0 +1,49 @@
|
||||
/* eslint-disable */
|
||||
/**
|
||||
* Generated `api` utility.
|
||||
*
|
||||
* THIS CODE IS AUTOMATICALLY GENERATED.
|
||||
*
|
||||
* To regenerate, run `npx convex dev`.
|
||||
* @module
|
||||
*/
|
||||
|
||||
import type * as aiSessions from "../aiSessions.js";
|
||||
|
||||
import type {
|
||||
ApiFromModules,
|
||||
FilterApi,
|
||||
FunctionReference,
|
||||
} from "convex/server";
|
||||
|
||||
declare const fullApi: ApiFromModules<{
|
||||
aiSessions: typeof aiSessions;
|
||||
}>;
|
||||
|
||||
/**
|
||||
* A utility for referencing Convex functions in your app's public API.
|
||||
*
|
||||
* Usage:
|
||||
* ```js
|
||||
* const myFunctionReference = api.myModule.myFunction;
|
||||
* ```
|
||||
*/
|
||||
export declare const api: FilterApi<
|
||||
typeof fullApi,
|
||||
FunctionReference<any, "public">
|
||||
>;
|
||||
|
||||
/**
|
||||
* A utility for referencing Convex functions in your app's internal API.
|
||||
*
|
||||
* Usage:
|
||||
* ```js
|
||||
* const myFunctionReference = internal.myModule.myFunction;
|
||||
* ```
|
||||
*/
|
||||
export declare const internal: FilterApi<
|
||||
typeof fullApi,
|
||||
FunctionReference<any, "internal">
|
||||
>;
|
||||
|
||||
export declare const components: {};
|
||||
@@ -0,0 +1,433 @@
|
||||
import { mutation, query } from "./_generated/server";
|
||||
import { v } from "convex/values";
|
||||
|
||||
const runtimeRunArgs = {
|
||||
schema: v.literal("mnote.acp_runtime_run.v1"),
|
||||
source: v.literal("acp"),
|
||||
userId: v.string(),
|
||||
workspaceId: v.optional(v.union(v.string(), v.null())),
|
||||
documentId: v.string(),
|
||||
sessionId: v.string(),
|
||||
runId: v.string(),
|
||||
title: v.optional(v.union(v.string(), v.null())),
|
||||
profile: v.string(),
|
||||
acpRuntime: v.string(),
|
||||
traceId: v.string(),
|
||||
status: v.string(),
|
||||
runtime: v.any(),
|
||||
payload: v.any(),
|
||||
retention: v.any(),
|
||||
createdAt: v.number(),
|
||||
updatedAt: v.number(),
|
||||
};
|
||||
|
||||
const runtimeEventArgs = {
|
||||
schema: v.literal("mnote.acp_runtime_event.v1"),
|
||||
source: v.literal("acp"),
|
||||
eventId: v.string(),
|
||||
userId: v.string(),
|
||||
workspaceId: v.optional(v.union(v.string(), v.null())),
|
||||
documentId: v.string(),
|
||||
sessionId: v.string(),
|
||||
runId: v.string(),
|
||||
profile: v.string(),
|
||||
acpRuntime: v.string(),
|
||||
eventType: v.string(),
|
||||
payload: v.any(),
|
||||
createdAt: v.number(),
|
||||
};
|
||||
|
||||
function isoFromMillis(value: number) {
|
||||
return new Date(value).toISOString();
|
||||
}
|
||||
|
||||
async function requireScopedUser(ctx: any, expectedUserId: string) {
|
||||
const identity = await ctx.auth.getUserIdentity();
|
||||
const subject = String(identity?.subject ?? "");
|
||||
const identityUserId = subject.split("|")[0] || "";
|
||||
if (identityUserId && identityUserId !== expectedUserId) {
|
||||
throw new Error("无权限");
|
||||
}
|
||||
if (!identityUserId && !expectedUserId.trim()) {
|
||||
throw new Error("未登录");
|
||||
}
|
||||
return expectedUserId;
|
||||
}
|
||||
|
||||
function rowToClient(row: any) {
|
||||
return {
|
||||
id: row.id,
|
||||
userId: row.user_id,
|
||||
workspaceId: row.workspace_id,
|
||||
documentId: row.document_id,
|
||||
sessionId: row.session_id,
|
||||
runId: row.run_id,
|
||||
profile: row.profile,
|
||||
title: row.title,
|
||||
acpRuntime: row.acp_runtime,
|
||||
source: row.source,
|
||||
status: row.status,
|
||||
traceId: row.trace_id,
|
||||
runtime: row.runtime,
|
||||
usage: row.usage ?? null,
|
||||
payload: row.payload,
|
||||
retention: row.retention,
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at,
|
||||
};
|
||||
}
|
||||
|
||||
export const upsertRuntimeRun = mutation({
|
||||
args: runtimeRunArgs,
|
||||
handler: async (ctx, args) => {
|
||||
const userId = await requireScopedUser(ctx, args.userId);
|
||||
const workspaceId = args.workspaceId ?? null;
|
||||
const nowIso = isoFromMillis(args.updatedAt);
|
||||
const existing = await ctx.db
|
||||
.query("acp_runtime_runs")
|
||||
.withIndex("by_run_id", (q: any) => q.eq("run_id", args.runId))
|
||||
.first();
|
||||
|
||||
const patch = {
|
||||
id: `acp_run:${args.runId}`,
|
||||
user_id: userId,
|
||||
workspace_id: workspaceId,
|
||||
document_id: args.documentId,
|
||||
session_id: args.sessionId,
|
||||
run_id: args.runId,
|
||||
title: args.title ?? null,
|
||||
profile: args.profile,
|
||||
acp_runtime: args.acpRuntime,
|
||||
source: args.source,
|
||||
status: args.status,
|
||||
trace_id: args.traceId,
|
||||
runtime: args.runtime,
|
||||
usage: null,
|
||||
payload: args.payload,
|
||||
retention: args.retention,
|
||||
updated_at: nowIso,
|
||||
deleted_at: null,
|
||||
};
|
||||
|
||||
if (existing) {
|
||||
if (String(existing.user_id) !== userId) {
|
||||
throw new Error("无权限");
|
||||
}
|
||||
await ctx.db.patch(existing._id, patch);
|
||||
return { ok: true, id: existing.id, runId: args.runId, updated: true };
|
||||
}
|
||||
|
||||
await ctx.db.insert("acp_runtime_runs", {
|
||||
...patch,
|
||||
created_at: isoFromMillis(args.createdAt),
|
||||
});
|
||||
return { ok: true, id: patch.id, runId: args.runId, created: true };
|
||||
},
|
||||
});
|
||||
|
||||
export const getRuntimeRun = query({
|
||||
args: {
|
||||
userId: v.string(),
|
||||
runId: v.string(),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const userId = await requireScopedUser(ctx, args.userId);
|
||||
const row = await ctx.db
|
||||
.query("acp_runtime_runs")
|
||||
.withIndex("by_run_id", (q: any) => q.eq("run_id", args.runId))
|
||||
.first();
|
||||
if (!row || row.deleted_at !== null || String(row.user_id) !== userId) {
|
||||
return null;
|
||||
}
|
||||
return rowToClient(row);
|
||||
},
|
||||
});
|
||||
|
||||
export const listRuntimeRuns = query({
|
||||
args: {
|
||||
userId: v.string(),
|
||||
workspaceId: v.optional(v.union(v.string(), v.null())),
|
||||
documentId: v.optional(v.union(v.string(), v.null())),
|
||||
sessionId: v.optional(v.union(v.string(), v.null())),
|
||||
limit: v.optional(v.number()),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const userId = await requireScopedUser(ctx, args.userId);
|
||||
const workspaceId = args.workspaceId ?? null;
|
||||
const limit = Math.min(Math.max(args.limit ?? 50, 1), 100);
|
||||
let rows;
|
||||
if (args.sessionId) {
|
||||
rows = await ctx.db
|
||||
.query("acp_runtime_runs")
|
||||
.withIndex("by_user_workspace_session", (q: any) =>
|
||||
q.eq("user_id", userId).eq("workspace_id", workspaceId).eq("session_id", args.sessionId),
|
||||
)
|
||||
.order("desc")
|
||||
.take(limit);
|
||||
} else if (args.documentId) {
|
||||
rows = await ctx.db
|
||||
.query("acp_runtime_runs")
|
||||
.withIndex("by_user_workspace_document", (q: any) =>
|
||||
q.eq("user_id", userId).eq("workspace_id", workspaceId).eq("document_id", args.documentId),
|
||||
)
|
||||
.order("desc")
|
||||
.take(limit);
|
||||
} else {
|
||||
rows = await ctx.db
|
||||
.query("acp_runtime_runs")
|
||||
.withIndex("by_user_created_at", (q: any) => q.eq("user_id", userId))
|
||||
.order("desc")
|
||||
.take(limit);
|
||||
}
|
||||
|
||||
return rows
|
||||
.filter((row: any) => row.deleted_at === null)
|
||||
.map(rowToClient);
|
||||
},
|
||||
});
|
||||
|
||||
export const appendRuntimeEvent = mutation({
|
||||
args: runtimeEventArgs,
|
||||
handler: async (ctx, args) => {
|
||||
const userId = await requireScopedUser(ctx, args.userId);
|
||||
const workspaceId = args.workspaceId ?? null;
|
||||
const existingRun = await ctx.db
|
||||
.query("acp_runtime_runs")
|
||||
.withIndex("by_run_id", (q: any) => q.eq("run_id", args.runId))
|
||||
.first();
|
||||
if (existingRun && String(existingRun.user_id) !== userId) {
|
||||
throw new Error("无权限");
|
||||
}
|
||||
|
||||
await ctx.db.insert("acp_runtime_events", {
|
||||
id: args.eventId,
|
||||
user_id: userId,
|
||||
workspace_id: workspaceId,
|
||||
document_id: args.documentId,
|
||||
session_id: args.sessionId,
|
||||
run_id: args.runId,
|
||||
profile: args.profile,
|
||||
acp_runtime: args.acpRuntime,
|
||||
source: args.source,
|
||||
event_type: args.eventType,
|
||||
payload: args.payload,
|
||||
created_at: isoFromMillis(args.createdAt),
|
||||
});
|
||||
const usage = usageFromRuntimeEvent(args.eventType, args.payload);
|
||||
if (existingRun && usage) {
|
||||
await ctx.db.patch(existingRun._id, {
|
||||
usage,
|
||||
updated_at: isoFromMillis(args.createdAt),
|
||||
});
|
||||
}
|
||||
return { ok: true, id: args.eventId, runId: args.runId };
|
||||
},
|
||||
});
|
||||
|
||||
function usageFromRuntimeEvent(eventType: string, payload: any) {
|
||||
if (eventType === "usage.updated") {
|
||||
return {
|
||||
source: "usage_update",
|
||||
contextUsed: Number(payload?.used ?? payload?.contextUsed ?? 0),
|
||||
contextSize: Number(payload?.size ?? payload?.contextSize ?? 0),
|
||||
};
|
||||
}
|
||||
if (eventType === "run.completed" && payload?.usage && typeof payload.usage === "object") {
|
||||
return {
|
||||
source: "run_completed",
|
||||
...payload.usage,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export const listRuntimeEvents = query({
|
||||
args: {
|
||||
userId: v.string(),
|
||||
runId: v.string(),
|
||||
limit: v.optional(v.number()),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const userId = await requireScopedUser(ctx, args.userId);
|
||||
const limit = Math.min(Math.max(args.limit ?? 100, 1), 500);
|
||||
const rows = await ctx.db
|
||||
.query("acp_runtime_events")
|
||||
.withIndex("by_run_created_at", (q: any) => q.eq("run_id", args.runId))
|
||||
.order("asc")
|
||||
.take(limit);
|
||||
return rows
|
||||
.filter((row: any) => String(row.user_id) === userId)
|
||||
.map((row: any) => ({
|
||||
id: row.id,
|
||||
userId: row.user_id,
|
||||
workspaceId: row.workspace_id,
|
||||
documentId: row.document_id,
|
||||
sessionId: row.session_id,
|
||||
runId: row.run_id,
|
||||
profile: row.profile,
|
||||
acpRuntime: row.acp_runtime,
|
||||
source: row.source,
|
||||
eventType: row.event_type,
|
||||
payload: row.payload,
|
||||
createdAt: row.created_at,
|
||||
}));
|
||||
},
|
||||
});
|
||||
|
||||
function titleFromRun(row: any) {
|
||||
const payload = row?.payload ?? {};
|
||||
const raw = String(payload.message ?? payload.input ?? payload.title ?? "").trim();
|
||||
if (!raw) return "当前页问答";
|
||||
return raw.length > 32 ? `${raw.slice(0, 32)}...` : raw;
|
||||
}
|
||||
|
||||
async function sessionRows(ctx: any, userId: string, workspaceId: string | null, sessionId: string) {
|
||||
return await ctx.db
|
||||
.query("acp_runtime_runs")
|
||||
.withIndex("by_user_workspace_session", (q: any) =>
|
||||
q.eq("user_id", userId).eq("workspace_id", workspaceId).eq("session_id", sessionId),
|
||||
)
|
||||
.collect();
|
||||
}
|
||||
|
||||
export const renameRuntimeSession = mutation({
|
||||
args: {
|
||||
userId: v.string(),
|
||||
workspaceId: v.optional(v.union(v.string(), v.null())),
|
||||
sessionId: v.string(),
|
||||
title: v.string(),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const userId = await requireScopedUser(ctx, args.userId);
|
||||
const workspaceId = args.workspaceId ?? null;
|
||||
const title = args.title.trim();
|
||||
if (!title) throw new Error("标题不能为空");
|
||||
const rows = await sessionRows(ctx, userId, workspaceId, args.sessionId);
|
||||
for (const row of rows) {
|
||||
if (row.deleted_at === null) {
|
||||
await ctx.db.patch(row._id, {
|
||||
title,
|
||||
updated_at: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
}
|
||||
return { ok: true, sessionId: args.sessionId, title, updated: rows.length };
|
||||
},
|
||||
});
|
||||
|
||||
export const autoTitleRuntimeSession = mutation({
|
||||
args: {
|
||||
userId: v.string(),
|
||||
workspaceId: v.optional(v.union(v.string(), v.null())),
|
||||
sessionId: v.string(),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const userId = await requireScopedUser(ctx, args.userId);
|
||||
const workspaceId = args.workspaceId ?? null;
|
||||
const rows = await sessionRows(ctx, userId, workspaceId, args.sessionId);
|
||||
const title = titleFromRun(rows.find((row: any) => row.deleted_at === null));
|
||||
for (const row of rows) {
|
||||
if (row.deleted_at === null) {
|
||||
await ctx.db.patch(row._id, {
|
||||
title,
|
||||
updated_at: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
}
|
||||
return { ok: true, sessionId: args.sessionId, title, updated: rows.length };
|
||||
},
|
||||
});
|
||||
|
||||
export const deleteRuntimeSession = mutation({
|
||||
args: {
|
||||
userId: v.string(),
|
||||
workspaceId: v.optional(v.union(v.string(), v.null())),
|
||||
sessionId: v.string(),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const userId = await requireScopedUser(ctx, args.userId);
|
||||
const workspaceId = args.workspaceId ?? null;
|
||||
const rows = await sessionRows(ctx, userId, workspaceId, args.sessionId);
|
||||
const deletedAt = new Date().toISOString();
|
||||
for (const row of rows) {
|
||||
if (row.deleted_at === null) {
|
||||
await ctx.db.patch(row._id, {
|
||||
deleted_at: deletedAt,
|
||||
updated_at: deletedAt,
|
||||
});
|
||||
}
|
||||
}
|
||||
return { ok: true, sessionId: args.sessionId, deleted: rows.length };
|
||||
},
|
||||
});
|
||||
|
||||
function searchText(row: any) {
|
||||
const payload = row?.payload ?? {};
|
||||
return [
|
||||
row.title,
|
||||
row.session_id,
|
||||
row.run_id,
|
||||
row.profile,
|
||||
row.status,
|
||||
payload.message,
|
||||
payload.input,
|
||||
payload.title,
|
||||
]
|
||||
.map((value) => String(value ?? ""))
|
||||
.join(" ")
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
function snippetFor(row: any, q: string) {
|
||||
const payload = row?.payload ?? {};
|
||||
const raw = String(row.title ?? payload.message ?? payload.input ?? row.session_id ?? "").trim();
|
||||
if (!raw) return "";
|
||||
const index = raw.toLowerCase().indexOf(q.toLowerCase());
|
||||
if (index < 0) return raw.length > 80 ? `${raw.slice(0, 80)}...` : raw;
|
||||
const start = Math.max(0, index - 24);
|
||||
const end = Math.min(raw.length, index + q.length + 56);
|
||||
return `${start > 0 ? "..." : ""}${raw.slice(start, end)}${end < raw.length ? "..." : ""}`;
|
||||
}
|
||||
|
||||
export const searchRuntimeSessions = query({
|
||||
args: {
|
||||
userId: v.string(),
|
||||
workspaceId: v.optional(v.union(v.string(), v.null())),
|
||||
q: v.string(),
|
||||
limit: v.optional(v.number()),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const userId = await requireScopedUser(ctx, args.userId);
|
||||
const workspaceId = args.workspaceId ?? null;
|
||||
const q = args.q.trim().toLowerCase();
|
||||
if (!q) return [];
|
||||
const limit = Math.min(Math.max(args.limit ?? 20, 1), 50);
|
||||
const rows = await ctx.db
|
||||
.query("acp_runtime_runs")
|
||||
.withIndex("by_user_created_at", (query: any) => query.eq("user_id", userId))
|
||||
.order("desc")
|
||||
.take(500);
|
||||
const seen = new Set<string>();
|
||||
const results = [];
|
||||
for (const row of rows) {
|
||||
if (row.deleted_at !== null) continue;
|
||||
if ((row.workspace_id ?? null) !== workspaceId) continue;
|
||||
if (!searchText(row).includes(q)) continue;
|
||||
if (seen.has(row.session_id)) continue;
|
||||
seen.add(row.session_id);
|
||||
results.push({
|
||||
sessionId: row.session_id,
|
||||
runId: row.run_id,
|
||||
workspaceId: row.workspace_id,
|
||||
documentId: row.document_id,
|
||||
title: row.title ?? titleFromRun(row),
|
||||
profile: row.profile,
|
||||
status: row.status,
|
||||
snippet: snippetFor(row, args.q),
|
||||
score: 1,
|
||||
});
|
||||
if (results.length >= limit) break;
|
||||
}
|
||||
return results;
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
import { defineSchema, defineTable } from "convex/server";
|
||||
import { v } from "convex/values";
|
||||
|
||||
export default defineSchema({
|
||||
acp_runtime_runs: defineTable({
|
||||
id: v.string(),
|
||||
user_id: v.string(),
|
||||
workspace_id: v.union(v.string(), v.null()),
|
||||
document_id: v.string(),
|
||||
session_id: v.string(),
|
||||
run_id: v.string(),
|
||||
title: v.union(v.string(), v.null()),
|
||||
profile: v.string(),
|
||||
acp_runtime: v.string(),
|
||||
source: v.literal("acp"),
|
||||
status: v.string(),
|
||||
trace_id: v.string(),
|
||||
runtime: v.any(),
|
||||
usage: v.any(),
|
||||
payload: v.any(),
|
||||
retention: v.any(),
|
||||
created_at: v.string(),
|
||||
updated_at: v.string(),
|
||||
deleted_at: v.union(v.string(), v.null()),
|
||||
})
|
||||
.index("by_run_id", ["run_id"])
|
||||
.index("by_user_workspace_document", ["user_id", "workspace_id", "document_id", "created_at"])
|
||||
.index("by_user_workspace_session", ["user_id", "workspace_id", "session_id", "created_at"])
|
||||
.index("by_user_created_at", ["user_id", "created_at"]),
|
||||
|
||||
acp_runtime_events: defineTable({
|
||||
id: v.string(),
|
||||
user_id: v.string(),
|
||||
workspace_id: v.union(v.string(), v.null()),
|
||||
document_id: v.string(),
|
||||
session_id: v.string(),
|
||||
run_id: v.string(),
|
||||
profile: v.string(),
|
||||
acp_runtime: v.string(),
|
||||
source: v.literal("acp"),
|
||||
event_type: v.string(),
|
||||
payload: v.any(),
|
||||
created_at: v.string(),
|
||||
})
|
||||
.index("by_run_created_at", ["run_id", "created_at"])
|
||||
.index("by_user_workspace_session_created_at", [
|
||||
"user_id",
|
||||
"workspace_id",
|
||||
"session_id",
|
||||
"created_at",
|
||||
]),
|
||||
});
|
||||
@@ -0,0 +1,371 @@
|
||||
# 7-25 [process] ACP Session Runtime 增强规划 v1
|
||||
|
||||
> 更新时间:2026-05-17
|
||||
> 参考:`hermes-vscode-main` (ACP client) / `hermes-web-ui-0.5.18` (HTTP API)
|
||||
|
||||
## 1. 当前状态
|
||||
|
||||
当前 mnote-web 的 ACP 实现是半成品,但已有的 route 和 runtime 壳并不等于“本地会话持久化已完成”:
|
||||
|
||||
| 领域 | 已有 | 缺失 |
|
||||
|------|------|------|
|
||||
| Wire protocol | `AcpClient`(JSON-RPC 2.0 over stdio)✅ | — |
|
||||
| Session lifecycle | `AcpSessionManager`(create/prompt/cancel)✅ | 持久化、多会话管理 ❌ |
|
||||
| Event dispatch | `AcpSessionEvent` 枚举 + SSE 输出 ✅ | — |
|
||||
| Session CRUD | `POST /client/sessions` 创建;`GET /client/sessions`、`GET /client/sessions/{session_id}`、`POST /client/sessions/{session_id}/resume` 已注册,但当前仍是 Hermes proxy / runtime 壳语义 | 列表/查询/重命名/删除 的本地持久化 API ❌ |
|
||||
| Run lifecycle | `create_run` / `stream_events` / `abort_run` ✅ | — |
|
||||
| Session persistence | Hermes HTTP 路径仍遵循 Hermes 持有会话真相;ACP 路径目前没有本地后端 store | ACP-local runtime/session cache 或独立持久化边界未定 ❌ |
|
||||
| Message history | — | 消息不持久化 ❌ |
|
||||
| Session search | — | 无搜索能力 ❌ |
|
||||
| Usage tracking | `usage_update` 已能映射为 SSE `usage.updated` | 无 session 级持久聚合、查询 API 和 UI ❌ |
|
||||
| Permission request | acp_client.rs 仅日志(bug 3-20) | 无协议响应 ❌ |
|
||||
| Session resume | route 已注册,`resume_session` 目前仅回显 `get_session` 结果 | 真实 resume / 历史注入 / 续跑语义不完整 ❌ |
|
||||
| Auto-title | — | 无自动命名 ❌ |
|
||||
| Conversation export | — | 无导出 ❌ |
|
||||
| Session list UI | 当前只有页面 AI 的本地短期历史列表(localStorage),不是后端 ACP 会话列表 | 后端会话列表 / 详情 / 搜索 还未接到 UI ❌ |
|
||||
|
||||
### 1.1 正确性结论
|
||||
|
||||
本文对“ACP runtime 已接入但 session runtime 仍不完整”的判断成立;但“在 mnote-web 中新增本地 `sessions/messages` 并替代内存表”不能直接理解为新的 AI 聊天真相层。
|
||||
|
||||
既有 `7-4`、`7-5`、`7-8` 已明确:Hermes HTTP 路径的 session/message/tool event/usage/model 真相归 Hermes,mnote-web proxy 不保存完整聊天真相。因此本计划若引入本地存储,默认只能用于:
|
||||
|
||||
- ACP-local run payload、runtime registry、断线重连、短期恢复所需的技术状态;
|
||||
- mnote 自己产生的 tool audit、业务写入结果、artifact / page / tree 事实;
|
||||
- 经单独架构决策确认后的 ACP 会话缓存或索引。
|
||||
|
||||
如果要把 mnote 的存储升级为跨 Hermes HTTP / ACP 的长期会话真源,必须先更新 `7-5` / `7-8` 的边界,而不能作为本计划的隐含前提。并且 mnote 是多用户系统,AI session 必须按用户、workspace、document 进行隔离;需要数据库时使用当前 Convex 底座,不新增 SQLite。
|
||||
|
||||
## 2. 参考实现分析
|
||||
|
||||
### hermes-vscode-main(ACP 客户端参考)
|
||||
|
||||
- **`acpClient.ts`** — JSON-RPC 2.0 over stdio,支持 request/response/notification/incoming request。 基础结构已移植到 `acp_client.rs`。
|
||||
- **`sessionManager.ts`** — Session 生命周期管理 + `session/update` 事件分发。基础结构已移植到 `acp_session_manager.rs`。
|
||||
- **`sessionStore.ts`** — **关键缺失部分**。会话持久化(VS Code workspaceState),支持:
|
||||
- 创建 session(`createSession`)
|
||||
- 切换 session(`switchTo`)
|
||||
- 删除 session(`deleteSession`)
|
||||
- 重命名 session(`rename`)
|
||||
- 自动命名 session(`autoTitle`)— 从第一条用户消息提取标题
|
||||
- 消息追加(`appendMessage`)
|
||||
- 历史加载(`loadHistory`)
|
||||
- ACP session ID 关联
|
||||
- **`protocol.ts`** — 事件解析 + 去重。`deduplicateChunk` 逻辑已移植。
|
||||
- **`types.ts`** — `ChatSession`、`StoredMessage`、`TodoState` 等类型。
|
||||
|
||||
### hermes-web-ui-0.5.18(HTTP REST API 参考)
|
||||
|
||||
- **`controllers/hermes/sessions.ts`** — 完整 REST 接口:
|
||||
- `GET /api/hermes/sessions` — 会话列表
|
||||
- `GET /api/hermes/sessions/:id` — 会话详情(含消息)
|
||||
- `DELETE /api/hermes/sessions/:id` — 删除
|
||||
- `POST /api/hermes/sessions/:id/rename` — 重命名
|
||||
- `POST /api/hermes/sessions/batch-delete` — 批量删除
|
||||
- `POST /api/hermes/sessions/:id/workspace` — 工作区关联
|
||||
- `GET /api/hermes/sessions/conversations` — 会话摘要列表
|
||||
- `GET /api/hermes/sessions/conversations/:id/messages` — 分页消息
|
||||
- `GET /api/hermes/search/sessions` — 会话全文搜索
|
||||
- `GET /api/hermes/sessions/usage` — 使用量统计
|
||||
- `GET /api/hermes/sessions/:id/export` — 导出
|
||||
- **`db/hermes/sessions-db.ts`** — 参考实现中的会话存储,支持:
|
||||
- 完整 CRUD
|
||||
- 全文搜索(FTS5)
|
||||
- 用量统计
|
||||
- 消息分页
|
||||
- **`db/hermes/session-store.ts`** — 本地 JSON 文件备选存储
|
||||
|
||||
## 3. 建议新增功能
|
||||
|
||||
按优先级分三阶段:
|
||||
|
||||
### Phase A:Session 持久化与管理(P0 — 缺少则 ACP 无实用价值)
|
||||
|
||||
1. **会话持久化存储**
|
||||
- 默认先建设 ACP-local runtime store / TTL cache,不改 Hermes HTTP 会话真相归属
|
||||
- 持久化如需数据库,必须落到 Convex;不在 mnote-web 内新增 SQLite
|
||||
- 表结构先服务 `ACP_RUN_PAYLOADS` / `ACP_ACTIVE_RUNS` 的可靠生命周期,并且所有记录必须带 `userId` / `workspaceId` / `documentId` 作用域
|
||||
- `sessions/messages` 如要保存完整聊天历史,必须先完成架构决策并标明只覆盖 ACP 路径还是统一覆盖 Hermes HTTP + ACP
|
||||
|
||||
2. **会话管理 API**
|
||||
- `GET /client/sessions` — 会话列表(分页、排序)
|
||||
- `GET /client/sessions/{session_id}` — 会话详情(含消息)
|
||||
- `DELETE /client/sessions/{session_id}` — 删除
|
||||
- `POST /client/sessions/{session_id}/rename` — 重命名
|
||||
- `POST /client/sessions/{session_id}/auto-title` — 自动命名
|
||||
|
||||
3. **消息持久化**
|
||||
- ACP `stream_events` 执行过程中按架构决策写入 user message + agent response,或只写 runtime/event 索引
|
||||
- 支持追加消息到已有 ACP-local session;Hermes HTTP 历史仍从 Hermes 读取
|
||||
|
||||
4. **会话搜索**
|
||||
- `GET /client/sessions/search?q=...` — 全文搜索(基于 Convex 查询能力,必要时再接专用搜索索引)
|
||||
- 返回匹配的 session 摘要 + snippet
|
||||
|
||||
5. **Permission request 响应修复**(bug 3-20)
|
||||
- 已知 incoming request 应返回结构化 error 而非仅日志
|
||||
- `session/request_permission` 至少需要 auto-deny + 通知前端
|
||||
- 配合 Phase C 的 UI 实现人工确认
|
||||
|
||||
### Phase B:历史消息与管理增强(P1 — 常用功能)
|
||||
|
||||
6. **会话恢复(Resume)**
|
||||
- 当前 `POST /client/sessions/{session_id}/resume` 已有 route,需实现
|
||||
- 从 ACP-local store 或 Hermes session store 加载历史消息,作为后续 prompt 的 context 来源
|
||||
- 支持新模型继续已有会话
|
||||
|
||||
7. **分页消息查询**
|
||||
- `GET /client/sessions/{session_id}/messages?cursor=...&limit=...`
|
||||
- 支持时间范围和角色过滤
|
||||
|
||||
8. **用量记录**
|
||||
- 从 ACP `usage_update` 事件提取 `contextUsed` / `contextSize`
|
||||
- 写入 `sessions` 表的 usage 字段
|
||||
- `GET /client/sessions/usage` — 聚合统计
|
||||
|
||||
9. **会话导出**
|
||||
- `GET /client/sessions/{session_id}/export?format=json|markdown`
|
||||
- JSON 格式:完整结构化导出
|
||||
- Markdown 格式:人类可读对话导出
|
||||
|
||||
10. **ACP run payload 生命周期修复**(bug 3-21)
|
||||
- 停止在 `stream_events` 中 `.remove(run_id)` 释放 payload
|
||||
- 改为基于 Convex session/runtime 记录或 TTL 缓存
|
||||
|
||||
### Phase C:前端交互与高级功能(P2 — 用户体验提升)
|
||||
|
||||
11. **前端会话列表**
|
||||
- Sidebar 或独立面板展示历史会话
|
||||
- 支持切换、删除、重命名
|
||||
- 支持继续已有会话
|
||||
|
||||
12. **会话搜索 UI**
|
||||
- 搜索框,全字段搜索(标题、消息内容)
|
||||
- 结果高亮 + 跳转
|
||||
|
||||
13. **Token 用量可视化**
|
||||
- 每次 AI 调用后展示 token 消耗
|
||||
- 会话级别统计累计用量
|
||||
|
||||
14. **权限请求 UI**
|
||||
- Agent 发起 `session/request_permission` 时前端弹窗确认
|
||||
- 支持 auto-allow / auto-deny 配置
|
||||
|
||||
15. **多会话并发**
|
||||
- 支持同时运行多个 ACP session
|
||||
- 前端标签页切换
|
||||
|
||||
## 4. 技术方案
|
||||
|
||||
### 4.0 当前 Convex 源目录口径
|
||||
|
||||
当前仓库状态下:
|
||||
|
||||
- `infra/convex/` 只承载自托管 Convex backend/dashboard 的 Docker 与说明,不是 functions/schema 源目录;
|
||||
- `wolai-frontend/convex/` 已不在当前工作区可读路径中,不能作为新的实现落点;
|
||||
- `recycle/wolai-frontend/convex/` 只作为历史参考;
|
||||
- 本轮执行已在仓库根 `convex/` 建立 ACP-local runtime store 的最小 functions/schema,并让 `scripts/run-convex-deploy.js` 优先以仓库根作为 Convex CLI cwd。
|
||||
|
||||
后续若要恢复完整业务 Convex functions,需要把历史 `recycle/wolai-frontend/convex/` 中仍需要的 documents/workspaces/media 等 functions 正式迁移到根 `convex/`,不能继续依赖已删除的 `wolai-frontend/` 路径。
|
||||
|
||||
### 4.1 存储方案
|
||||
|
||||
注意:以下结构是 Convex-backed ACP-local store 候选形状,不默认推翻 Hermes 持有聊天真相的既有边界。mnote-web 不新增 SQLite;多用户隔离字段是硬要求。
|
||||
|
||||
```rust
|
||||
// sessions 记录
|
||||
struct SessionRow {
|
||||
id: String, // 主键
|
||||
user_id: String, // 多用户隔离
|
||||
workspace_id: Option<String>,
|
||||
document_id: Option<String>,
|
||||
title: Option<String>,
|
||||
profile: String,
|
||||
model: Option<String>,
|
||||
source: String, // "acp" | "hermes-http"; hermes-http 默认只保存引用/索引,不保存聊天真相
|
||||
started_at: i64,
|
||||
ended_at: Option<i64>,
|
||||
end_reason: Option<String>,
|
||||
message_count: u32,
|
||||
input_tokens: u64,
|
||||
output_tokens: u64,
|
||||
preview: Option<String>, // 首条消息摘要
|
||||
}
|
||||
|
||||
// messages 记录
|
||||
struct MessageRow {
|
||||
id: i64, // 自增
|
||||
user_id: String, // 多用户隔离
|
||||
session_id: String, // FK -> sessions
|
||||
role: String, // "user" | "assistant" | "tool"
|
||||
content: String,
|
||||
reasoning: Option<String>,
|
||||
tool_calls: Option<Value>, // JSON
|
||||
token_count: Option<u32>,
|
||||
created_at: i64,
|
||||
}
|
||||
```
|
||||
|
||||
### 4.2 现有代码改动范围
|
||||
|
||||
- **新增/修改** 根 `convex/` 相关 schema / functions — ACP-local session/runtime 记录、用户隔离查询、TTL 清理;`infra/convex/` 只负责自托管服务部署
|
||||
- **修改** `acp_session_manager.rs` — 按架构决策写入 ACP-local 消息或只写 runtime/event 索引
|
||||
- **修改** `hermes_client.rs` — 新增 session 管理 route handlers
|
||||
- **修改** `routes/mod.rs` — 注册新 route
|
||||
- **修改** Convex bridge / transport 调用点 — mnote-web 通过已有 Convex 底座读写 session/runtime 索引
|
||||
|
||||
### 4.3 与现有 bug 的关系
|
||||
|
||||
| Bug | Phase | 说明 |
|
||||
|-----|-------|------|
|
||||
| 3-20 ACP incoming request 无响应 | A | permission request 必须有协议响应 |
|
||||
| 3-21 ACP run payload 消费后移除 | B | Convex-backed store / TTL cache 化后 payload 由 session 管理,不再依赖内存 map |
|
||||
| 7-19 Hermes 指导优先 apply_block_ops | C | 需统一到 markdown_edit 口径 |
|
||||
| 7-20 page_ai_workflow 绕过 tool executor | C | 建议统一到 ACP tool executor |
|
||||
|
||||
## 5. 实施建议顺序
|
||||
|
||||
```
|
||||
Phase A(P0 基础可用)
|
||||
├── 5.1 Convex-backed ACP-local Session Store(多用户隔离 + TTL 策略)
|
||||
├── 5.2 会话列表 / 详情 / 删除 / 重命名 API
|
||||
├── 5.3 消息或 runtime/event 索引持久化(stream_events 写入)
|
||||
├── 5.4 会话搜索
|
||||
└── 5.5 Permission request 响应(bug 3-20)
|
||||
|
||||
Phase B(P1 常用增强)
|
||||
├── 5.6 会话恢复
|
||||
├── 5.7 分页消息查询
|
||||
├── 5.8 用量记录
|
||||
├── 5.9 会话导出
|
||||
└── 5.10 Payload 生命周期修复(bug 3-21)
|
||||
|
||||
Phase C(P2 用户体验)
|
||||
├── 5.11 前端会话列表
|
||||
├── 5.12 搜索 UI
|
||||
├── 5.13 用量可视化
|
||||
├── 5.14 权限请求 UI
|
||||
└── 5.15 多会话并发
|
||||
```
|
||||
|
||||
## 6. 详细 checklist
|
||||
|
||||
### 6.1 先确认数据契约
|
||||
|
||||
- [x] 先确认是否允许 mnote-web 保存完整 AI session/message 真相;若不允许,本文存储范围必须限定为 ACP-local runtime cache / metadata index。
|
||||
- 结论:本轮不保存完整 AI session/message 真相,只保存 ACP-local runtime run metadata / payload cache / index。
|
||||
- [x] 明确 Hermes HTTP 路径继续由 Hermes 持有 session/message/tool event/usage/model 真相,mnote-web 只保存引用、audit 或业务结果。
|
||||
- [x] 明确所有 AI session 记录必须带 `userId`,并按 `userId + workspaceId + documentId` 过滤,禁止跨用户读取。
|
||||
- [x] 明确 Convex `sessions` 记录的唯一键、排序键、软删除策略和保留周期。
|
||||
- 当前实现采用更小的 `acp_runtime_runs`:`run_id` 唯一,按 `user_id + workspace_id + document_id/session_id + created_at` 排序,`deleted_at` 软删除,payload 记录带 7 天 TTL 元数据。
|
||||
- [x] 明确 Convex `messages` 记录是否允许同一 `session_id` 下多次 `resume` 追加写入。
|
||||
- 结论:本轮不建完整 `messages` 真相表;后续若引入只覆盖 ACP 路径的消息索引,允许同一 `session_id` 多 run 追加 runtime events,但不复制 Hermes HTTP 聊天真相。
|
||||
- [x] 明确 `session_id` 与 `run_id` 的映射是否需要单独持久化,避免仅靠内存 map。
|
||||
- 当前已在 `acp_runtime_runs` 中持久化 `session_id/run_id` 映射。
|
||||
- [x] 明确 `profile`、`model`、`source` 三个字段在 ACP/Hermes HTTP/Reasonix 三种路径下的取值规则。
|
||||
- 当前实现:`source = "acp"`,`profile` 沿请求 profile,`acpRuntime` 沿请求或 profile 推导;`model` 暂不落库,等待 usage/model 聚合阶段。
|
||||
- [x] 明确 `usage` 统计的口径:只记 `usage_update`,还是合并 `run.completed.usage`。
|
||||
- 结论:`usage_update` 作为运行中实时增量;`run.completed.usage` 作为最终校正值。两者都只落 ACP-local runtime/usage index,不复制 Hermes HTTP 聊天真相。
|
||||
|
||||
### 6.2 先做后端持久化骨架
|
||||
|
||||
- [x] 为 ACP-local session/runtime store 设计 Convex schema,不新增 SQLite。
|
||||
- [x] 在 Convex 中建 `sessions`、`messages` 或更小的 `runtimeRuns` / `runtimeEvents` 记录,补齐索引和权限过滤。
|
||||
- 当前实现为根 `convex/schema.ts` 的 `acp_runtime_runs` / `acp_runtime_events`,以及 `convex/aiSessions.ts` 的 `upsertRuntimeRun/getRuntimeRun/listRuntimeRuns`。
|
||||
- [x] 把 `ACP_RUN_PAYLOADS` / `ACP_ACTIVE_RUNS` 中必须保留的数据拆到持久化层或短 TTL 层。
|
||||
- 当前 `create_run` 会把 ACP payload/runtime 写入 Convex;内存 map 仍作为热路径保留,`stream_events` 不再消费删除 payload。
|
||||
- [x] 把 `create_run` 产出的 `sessionId`、`profile`、`traceId`、`runtime` 写入 `sessions` 记录。
|
||||
- 当前写入目标为更小的 `acp_runtime_runs`,而不是完整聊天 `sessions` 真相表。
|
||||
- [x] 在 `stream_events` 的 ACP 路径里,按架构决策写入 `messages` 或 runtime/event 索引,不能把 Hermes HTTP 聊天真相复制进 mnote。
|
||||
- 当前实现:ACP SSE event 以 best-effort 写入 `acp_runtime_events`,只保存 runtime event payload,不写完整聊天 `messages` 真相。
|
||||
|
||||
### 6.3 再补 session 读接口
|
||||
|
||||
- [x] 让 `GET /client/sessions` 返回当前用户可见的 Convex store 或 Hermes upstream 会话摘要,而不是只依赖前端 localStorage。
|
||||
- 当前实现:ACP profile / `source=acp` 走 `aiSessions:listRuntimeRuns`;Hermes HTTP profile 仍走 upstream。
|
||||
- [x] 让 `GET /client/sessions/{session_id}` 返回会话元数据、消息列表、runtime 状态。
|
||||
- 当前实现:`source=acp` 从 Convex store 读取 runs/events,`messages` 保持空数组以避免复制完整聊天真相。
|
||||
- [x] 让 `POST /client/sessions/{session_id}/resume` 真正从持久化历史恢复,而不是简单复用 `get_session`。
|
||||
- 当前实现:`source=acp` 返回 Convex runtime history,并标记 `resumed=true` / `resumeSource=convex_acp_runtime_store`。
|
||||
- [x] 为 `DELETE /client/sessions/{session_id}`、`POST /client/sessions/{session_id}/rename`、`POST /client/sessions/{session_id}/auto-title` 补齐路由与处理器。
|
||||
- 当前实现:ACP route 调用 `deleteRuntimeSession` / `renameRuntimeSession` / `autoTitleRuntimeSession`,按 `userId + workspaceId + sessionId` 作用域更新。
|
||||
- [x] 为搜索接口加分页和最小 snippet,避免一次返回过长正文。
|
||||
- 当前实现:`GET /client/sessions/search?source=acp&q=...&limit=...` 调用 `searchRuntimeSessions`,最多返回 50 条 session 摘要和 snippet。
|
||||
|
||||
### 6.4 再修 ACP 协议行为
|
||||
|
||||
- [x] 为 `session/request_permission` 生成结构化响应,不再只打日志。
|
||||
- [x] 给未知 incoming request 返回明确 error,避免 agent 一直等超时。
|
||||
- [x] 把 permission 结果同步到前端,至少先支持 auto-allow / auto-deny。
|
||||
- 当前实现:`session/request_permission` 仍由 ACP client 自动拒绝以避免 agent 超时,同时转成 `permission.denied` SSE;页面 AI 侧展示权限弹窗/卡片,包含 tool 名、参数摘要、允许/拒绝动作入口。
|
||||
- [x] 让 `usage_update` 与 `run.completed` 的 usage 汇总到 session 级统计。
|
||||
- 当前实现:`appendRuntimeEvent` 在收到 `usage.updated` 或带 `usage` 的 `run.completed` 时,同步更新 `acp_runtime_runs.usage`。
|
||||
- [x] 验证 `thought.delta` 仍不会落入最终 assistant 正文。
|
||||
- 当前验证:`acp_thought_delta_does_not_emit_message_delta` 确认 thought 只作为 `thought.delta` 转发。
|
||||
|
||||
### 6.5 再补前端入口
|
||||
|
||||
- [x] 在页面 AI 面板中加入后端会话列表,而不是只显示 localStorage 的短期历史。
|
||||
- 当前实现:页面 AI history 面板打开时会请求 `GET /api/hermes/client/sessions?source=acp&workspaceId=...&documentId=...`,并与 localStorage 短期缓存合并。
|
||||
- [x] 会话列表支持切换、重命名、删除、恢复。
|
||||
- 当前实现:history 行内提供恢复、重命名、删除按钮;切换会话时会拉取 Convex-backed detail/resume。
|
||||
- [x] 会话详情页或抽屉支持查看消息分页与搜索结果。
|
||||
- 当前实现:恢复/详情会读取最新 run 的 events 并映射为消息视图;history 面板提供后端 session 搜索框,搜索结果展示 snippet。当前消息详情读取后端最近 200 条 event,尚未做 cursor 翻页。
|
||||
- [x] 权限请求弹窗至少展示 tool 名、参数摘要、允许/拒绝动作。
|
||||
- 当前实现:`permission.requested` / `permission.denied` / `permission.allowed` 会生成权限弹窗和消息卡片;当前 ACP incoming request 默认 auto-deny,按钮用于前端状态确认,后续可接入真实审批回写。
|
||||
- [x] Token / 用量信息在会话条目和运行状态上可见。
|
||||
- 当前实现:会话条目和当前 session 状态展示 `usage.updated` / `run.completed.usage` 汇总。
|
||||
|
||||
### 6.6 最后做回归验证
|
||||
|
||||
- [x] 新建 ACP 会话后刷新页面,仍能从 Convex-backed store 或 Hermes session store 重新拿到同一会话。
|
||||
- 当前实现:ACP profile 创建 session 时写入 `aiSessions:upsertRuntimeRun` 的 `session.created` 索引记录;刷新后列表可从 Convex-backed store 找回该 session。
|
||||
- [x] 用两个测试用户分别创建 AI session,确认 session 列表、详情、resume 都只返回当前用户自己的记录。
|
||||
- 当前实现:Convex functions 使用 `ctx.auth.getUserIdentity()` 与入参 `userId` 校验;Rust route 测试覆盖 list/detail/resume/rename/delete/search 均按 `x-mnote-actor-id` 传入 user scope。尚未跑真实双账号浏览器 smoke。
|
||||
- [x] 断线重连后,`stream_events` 能从历史继续恢复,而不是丢失 payload。
|
||||
- 当前最小验证覆盖 payload lookup 不再 remove;完整断线重连仍需浏览器 smoke。
|
||||
- [x] `session/request_permission` 会返回可验证的协议响应。
|
||||
- [x] `message.delta`、`thought.delta`、`tool.*`、`run.completed` 的 SSE 映射在浏览器里都能看见正确结果。
|
||||
- 当前实现:页面 AI stream handler 映射 `message.delta`、`thought.delta`、`usage.updated`、`tool.*`、`run.completed`;Rust/JS 静态测试覆盖前端入口,尚未完成真实浏览器截图级验证。
|
||||
- [x] 运行一次真实浏览器 smoke,确认会话列表、恢复、重命名和删除链路都能闭环。
|
||||
- 当前验证:启动 `mnote-web` 到 `127.0.0.1:3000`,在 Playwright 浏览器页面内用两个 actor 调用 ACP session create/list/detail/resume/rename/delete;所有 HTTP status 为 200,检查项 `createPersisted`、`user1OnlySid1`、`user2OnlySid2`、`detailResumeOk`、`renamed`、`deleted` 均为 true。
|
||||
|
||||
## 7. 执行记录
|
||||
|
||||
### 2026-05-18
|
||||
|
||||
- 新增根 `convex/schema.ts`:`acp_runtime_runs` / `acp_runtime_events` 表和索引。
|
||||
- 新增根 `convex/aiSessions.ts`:`upsertRuntimeRun`、`getRuntimeRun`、`listRuntimeRuns`,按 `userId` 校验 identity 作用域。
|
||||
- 修改 `scripts/run-convex-deploy.js`:优先以仓库根 `convex/` 作为 Convex deploy cwd,避免继续指向已不存在的 `wolai-frontend/`。
|
||||
- 后续修正:根 `convex/` 当前只包含 ACP session runtime store,不能直接覆盖完整本地 Convex 部署;`scripts/run-convex-deploy.js` 已改为只有根 `convex/` 具备完整 schema 时才用根目录,否则继续使用现有完整 Convex 源。为保持本机 `3210` 部署完整,已将 `aiSessions.ts` 与 `acp_runtime_*` schema 同步到现有完整 Convex 源并重新部署。
|
||||
- 修改 `rust/crates/mnote-web/src/routes/hermes_client.rs`:
|
||||
- ACP `create_run` 写入 `aiSessions:upsertRuntimeRun`;
|
||||
- ACP `GET /client/sessions` 读取 `aiSessions:listRuntimeRuns`;
|
||||
- ACP `GET /client/sessions/{session_id}` 读取 runs/events;
|
||||
- ACP `POST /client/sessions/{session_id}/resume` 返回 Convex runtime history 恢复来源;
|
||||
- ACP `DELETE /client/sessions/{session_id}`、`rename`、`auto-title` 写入 Convex store;
|
||||
- ACP `GET /client/sessions/search` 返回 Convex store 搜索摘要和 snippet;
|
||||
- ACP `stream_events` 将 SSE event 写入 `aiSessions:appendRuntimeEvent`;
|
||||
- `stream_events` 使用 clone lookup 保留 ACP payload,不再 `.remove(run_id)`。
|
||||
- 验证:
|
||||
- `cd rust && cargo test -p mnote-web hermes_client_acp -- --nocapture`:10 passed。
|
||||
- `cd rust && cargo test -p mnote-web test_incoming_permission_request_gets_response -- --nocapture`:1 passed。
|
||||
- `cd rust && cargo test -p mnote-web acp_permission -- --nocapture`:1 passed。
|
||||
- `cd rust && cargo test -p mnote-web page_ai_uses_backend_acp_session_runtime_store -- --nocapture`:1 passed。
|
||||
- `cd rust && cargo test -p mnote-web acp_thought_delta_does_not_emit_message_delta -- --nocapture`:1 passed。
|
||||
- `node -c scripts/run-convex-deploy.js`:通过。
|
||||
- `node scripts/run-convex-deploy.js`:部署入口已避免用根最小 `convex/` 覆盖完整本地 Convex schema;`aiSessions.ts` 同步补到完整 Convex 源并部署到 `http://127.0.0.1:3210`,最终脚本验证为 `No indexes are deleted by this push`。
|
||||
- `node` 提取并 `new Function(SIDEBAR_TREE_JS)`:通过。
|
||||
- `npx convex --version`:1.39.1。
|
||||
- `CONVEX_TMPDIR=/mnt/Data1T/mnote/.convex-tmp npx convex codegen --dry-run --typecheck try`:通过。
|
||||
- Playwright browser smoke:`127.0.0.1:3000` 页面内 fetch 创建两个用户的 ACP session,并验证 list/detail/resume/rename/delete 与用户隔离,全部通过;测试数据已调用 delete 清理。
|
||||
- 回归收口:`tree_command_purge_uses_tree_command_protocol` 中 purge 请求未携带排序,返回 `sortOrder: null` 符合当前 route 语义,已同步修正测试断言。
|
||||
- 回归收口:`hermes_client` / `hermes_tools` / `page_ai_workflow` 测试统一使用 crate 级 Hermes 环境锁,避免完整并发测试时互相修改 `HERMES_HOME` / `MNOTE_WEB_HERMES_*`。
|
||||
- `cd rust && cargo test -p mnote-web tree_command_purge_uses_tree_command_protocol -- --nocapture`:1 passed。
|
||||
- `cd rust && cargo test -p mnote-web hermes_tools_call_rejects_profile_disabled_tool -- --nocapture`:1 passed。
|
||||
- `cd rust && cargo test -p mnote-web hermes_client_profile_skill_and_memory_routes_use_local_bff_without_upstream -- --nocapture`:1 passed。
|
||||
- `cd rust && cargo test -p mnote-web block_edit_workflow_respects_disabled_markdown_edit_tool -- --nocapture`:1 passed。
|
||||
- `cd rust && cargo test -p mnote-web -- --nocapture`:336 passed / 0 failed;main tests 0 passed / 0 failed;doc tests 4 ignored。
|
||||
- 依赖记录:
|
||||
- 根 `package.json` / lockfile 增加 `convex@1.39.1`,用于根 `convex/` codegen/typecheck。
|
||||
- `npm install --save-dev convex@1.39.1` 曾因既有 `node_modules` 布局报 `ENOTDIR`,未保留该失败命令产生的临时 symlink;随后用 pnpm 指定仓库 store 安装,并恢复被 pnpm 移入 `.ignored` 的既有 Playwright / electron-builder 目录。
|
||||
@@ -29,6 +29,16 @@
|
||||
- `PageAIApplyController` 后续应收口为 review session / tool executor / readback controller,不能成为第二套 agent 编排中心。
|
||||
- 当前 `usedHermesRun=false` 的快路径只能理解为 deterministic shortcut,不代表 mnote 新建长期 agent runtime。
|
||||
|
||||
## 1.0.1 当前主路径修正(2026-05-18)
|
||||
|
||||
`7-18` 到 `7-25` 修复后,本文中的页面 AI fast workflow 口径进一步收口:
|
||||
|
||||
- 普通正文编辑主路径是 `mnote.doc.markdown_edit`,模型生成 markdown `search/replace` 或 `full_content`,再由统一 mnote tool executor 写入。
|
||||
- `/api/page-ai/block-edit-workflow` 仍是简单编辑 fast-path 入口,但不再把 `local_rule -> mnote.doc.apply_block_ops` 作为当前主路径。
|
||||
- `mnote.doc.apply_block_ops` / `mnote.block.*` 保留为结构性块操作辅助,例如块移动、资源块、复杂子块或必须按 blockId 精确处理的场景。
|
||||
- Phase C 的 review session / streaming apply 仍冻结;本文只继续跟踪基础工具合同、上下文、冲突校验、幂等和审阅面边界。
|
||||
- 当前 page AI runtime 口径以 `design/07-ai/process/7-12-page-ai-hermes-tool-routing-and-review-surface-v1.md`、`design/10-review/done/10-current-mnote-ai-runtime-review-v1.md` 和 `bugs/07-ai/done/7-18` 到 `7-25` 为准。
|
||||
|
||||
## 1.1 当前执行状态(2026-05-16)
|
||||
|
||||
已完成并有代码/测试/smoke 证据:
|
||||
@@ -57,18 +67,19 @@
|
||||
- 脚本:`/mnt/Data1T/mnote/scripts/task-page-block-ai-context-format-smoke.js`。
|
||||
- 证据:`/mnt/Data1T/mnote/tmp/page-block-ai-context-format-smoke/mp8ddr4n.json`,截图 `/mnt/Data1T/mnote/tmp/page-block-ai-context-format-smoke/mp8ddr4n-page.png`。
|
||||
- 覆盖:`mnote.doc.fetch scope=selection selectedBlockIds=["p_2"] format=page_xml/text`、`mnote.block.fetch format=page_xml/text`、manifest annotations、`mnote.page.save` destructive/yolo 粗粒度兜底定位、`mnote.doc.apply_block_ops allowedTargetBlockIds` 越界阻断 `mnote_block_target_out_of_scope`。
|
||||
- 边界:本轮只验证 `doc.apply_block_ops` 的 selection scope guard;单个 `mnote.block.*` 写工具尚未完成 `allowedTargetBlockIds` 矩阵。
|
||||
- 边界:`doc.apply_block_ops` 与单个 `mnote.block.*` 写工具的 `allowedTargetBlockIds` selection scope guard 均已完成矩阵验证。
|
||||
- 页面 AI 快速块编辑第一阶段已完成:
|
||||
- 新增 `/api/page-ai/block-edit-workflow`,简单块增删改不再默认进入 Hermes agent run。
|
||||
- 对明确中文指令 `把「A」替换为「B」/ 在「C」后插入「D」/ 删除「E」` 已先由 mnote 本地 planner 生成 `mnote.doc.apply_block_ops` operations;无法解析时才进入小模型 operations 路径。
|
||||
- 快路径失败时,除 `page_ai_workflow_not_block_edit` 外不再自动 fallback 到 `/api/hermes/client/runs`,避免一次请求叠加“快路径失败成本 + Hermes agent 成本”。
|
||||
- 真实浏览器 smoke:`/mnt/Data1T/mnote/scripts/task-page-ai-block-edit-workflow-smoke.js`,最新证据 `/mnt/Data1T/mnote/tmp/page-ai-block-edit-workflow-smoke/mp86uciu.json`。
|
||||
- 验证结果:`pageAiWriteVisible=788ms`、`usedFastWorkflow=true`、`usedHermesRun=false`;后端日志 `operation_source=local_rule`、`model_ms=0`、`apply_ms=42`、`total_ms=42`。
|
||||
- 详细 review:`/mnt/Data1T/mnote/design/10-review/process/09-page-ai-fast-block-edit-runtime-review.md`。
|
||||
- 详细 review 历史快照:`/mnt/Data1T/mnote/design/10-review/done/09-page-ai-fast-block-edit-runtime-review.md`。
|
||||
|
||||
仍未完成,本文继续留在 `process/`:
|
||||
|
||||
- 复杂块移动阻断矩阵还需要单独 smoke 覆盖标题带子块、列表项、表格、mindmap/resource。
|
||||
- 剩余真实页面 smoke 已拆到 `/mnt/Data1T/mnote/design/07-ai/process/7-16-page-block-ai-real-smoke-followup-matrix-v1.md` 持续跟踪;本文只保留总 checklist 和历史证据。
|
||||
- 复杂块移动阻断矩阵已补隔离 route smoke 覆盖标题带子块、列表项、表格、mindmap/resource;真实浏览器页面 smoke 仍未补。
|
||||
- 持久审阅/preview UI 仍是后续可选项;当前默认 yolo 模式不做写入审批,`scope=selection` 与 `format=page_xml/text` 已进入工具与 PageAIContextBuilder 首版。
|
||||
- PageAIIntentParser / PageAIOperationPlanner / PageAIOperationValidator / PageAIApplyController 仍需继续建设;当前本地 planner 只覆盖低歧义文本块增删改,不应被视为完整 AI 编辑 runtime。
|
||||
|
||||
@@ -102,7 +113,7 @@ fetch/find
|
||||
|
||||
验收:
|
||||
|
||||
- [ ] `rg -n "Tiptap AI Toolkit|tiptapRead|tiptapEdit|UniqueID|_hash|blockDocument" design/05-editor-mainline/{process,done} design/02-convex-rust-long-term-architecture/{process,done} design/07-ai/{process,done}` 能找到对应设计。
|
||||
- [x] `rg -n "Tiptap AI Toolkit|tiptapRead|tiptapEdit|UniqueID|_hash|blockDocument" design/05-editor-mainline/{process,done} design/02-convex-rust-long-term-architecture/{process,done} design/07-ai/{process,done}` 能找到对应设计。
|
||||
|
||||
---
|
||||
|
||||
@@ -130,16 +141,20 @@ cargo test -p bridge-runtime editor_document
|
||||
|
||||
真实页面 smoke:
|
||||
|
||||
- [ ] 登录 `http://localhost:3000/auth` 测试账号。
|
||||
- [ ] 新建页面 `TEST-AI-BLOCK-PROJECTION-<timestamp>`。
|
||||
- [ ] 输入段落、标题、todo、列表、mindmap/resource 占位至少各一个。
|
||||
- [ ] 调 `/api/page-aggregate/:id` 保存响应到 `tmp/hermes-tester/<run-id>/page-aggregate.json`。
|
||||
- [ ] 断言所有普通可编辑块有 `blockId` 和 `revisionRef`。
|
||||
- [x] 登录测试账号并创建综合页面 `TEST-AI-BLOCK-TOOLS-<timestamp>`。
|
||||
- [x] 写入段落、标题、todo、列表、mindmap/resource 占位。
|
||||
- [x] 调 `/api/page-aggregate/:id` 保存响应到 `tmp/hermes-tester/page-block-ai-tools-<run-id>/page-aggregate.json`。
|
||||
- [x] 断言所有普通可编辑块有 `blockId` 和 `revisionRef`。
|
||||
|
||||
通过标准:
|
||||
|
||||
- [ ] 页面刷新后 block ids 不变化。
|
||||
- [ ] projection 中 `blockCount` 与页面可编辑块数量大体一致;复杂块允许受限但必须有 warning。
|
||||
- [x] 页面刷新后 block ids 不变化。
|
||||
- [x] projection 中 `blockCount` 与页面块数量一致;复杂块返回 `editable=false` 与 `unsupportedReason`。
|
||||
|
||||
2026-05-18 补充真实页面 smoke 证据:
|
||||
|
||||
- 最新证据:`tmp/page-block-ai-tools-smoke/mpag4966.json`,Page Aggregate:`tmp/hermes-tester/page-block-ai-tools-mpag4966/page-aggregate.json`,截图:`tmp/page-block-ai-tools-smoke/mpag4966-page.png`。
|
||||
- 覆盖:Page Aggregate `blockProjectionVersion=1`、`projectionSource=documents.content`、`aggregateBlockCount=10`,与 `mnote.doc.fetch` 块顺序一致;刷新后 `afterRefreshBlockIds` 保持最终顺序;`table_1/mindmap_1/resource_1` 返回 `editable=false` 与 `unsupportedReason=复杂块暂不开放 AI 精确写入`。
|
||||
|
||||
---
|
||||
|
||||
@@ -168,11 +183,11 @@ cargo test -p bridge-runtime doc_find
|
||||
|
||||
真实页面 smoke:
|
||||
|
||||
- [ ] 使用 `TEST-AI-BLOCK-FETCH-<timestamp>` 页面。
|
||||
- [ ] `mnote.doc.fetch scope=full detail=with_ids` 读取整页。
|
||||
- [ ] `mnote.doc.fetch scope=outline detail=with_ids` 只返回标题结构。
|
||||
- [ ] `mnote.doc.find query=<唯一前缀>` 定位目标段落。
|
||||
- [ ] 保存工具返回到 `tmp/hermes-tester/<run-id>/doc-fetch-find.json`。
|
||||
- [x] 使用综合页面 `TEST-AI-BLOCK-TOOLS-<timestamp>`。
|
||||
- [x] `mnote.doc.fetch scope=full detail=with_ids` 读取整页。
|
||||
- [x] `mnote.doc.fetch scope=outline detail=with_ids` 只返回标题结构。
|
||||
- [x] `mnote.doc.find query=<唯一前缀>` 定位目标段落。
|
||||
- [x] 保存工具返回到 `tmp/hermes-tester/page-block-ai-tools-<run-id>/doc-fetch-find.json`。
|
||||
|
||||
补充 smoke 证据:
|
||||
|
||||
@@ -181,9 +196,16 @@ cargo test -p bridge-runtime doc_find
|
||||
|
||||
通过标准:
|
||||
|
||||
- [ ] 不读取浏览器 DOM。
|
||||
- [ ] `doc.find` 返回的 block id 能被 `block.fetch` 读取。
|
||||
- [ ] 大页面默认分块或限制输出,不默认返回无限正文。
|
||||
- [x] 不读取浏览器 DOM。
|
||||
- [x] `doc.find` 返回的 block id 能被 `block.fetch` 读取。
|
||||
- [x] 大页面默认分块或限制输出,不默认返回无限正文。
|
||||
|
||||
2026-05-18 补充证据:
|
||||
|
||||
- `mnote.doc.fetch` 从 `aggregate_value` / Page Aggregate block projection 构造上下文,不读取浏览器 DOM;代码入口:`rust/crates/mnote-web/src/hermes_tools/doc.rs`。
|
||||
- `hermes_tools_doc_find_and_block_fetch_use_block_projection` 覆盖 `doc.find` 返回 `heading_1` 后继续用同一个 id 调 `mnote.block.fetch`。
|
||||
- `mnote.doc.fetch` 默认 `maxBlocks=120`,运行时 clamp 到 `1..=240`;超限返回 `truncated/continuation/warnings`。
|
||||
- 真实 3000 smoke:`tmp/page-block-ai-tools-smoke/mpaes2hg.json` 覆盖 `doc.fetch full` 读取 `heading_1/p_1/p_2/p_3`,`doc.fetch outline` 只返回 `heading_1`,`doc.find` 定位 `p_2`,`maxBlocks=2` 返回 `truncated=true`。
|
||||
|
||||
---
|
||||
|
||||
@@ -210,19 +232,21 @@ cargo test -p mnote-web block_fetch
|
||||
|
||||
真实页面 smoke:
|
||||
|
||||
- [ ] 从 `doc.find` 结果选择一个段落 block。
|
||||
- [ ] 调 `mnote.block.fetch includeChildren=true contextBefore=1 contextAfter=1`。
|
||||
- [ ] 断言 before/after 只来自同父级。
|
||||
- [x] 从 `doc.find` 结果选择一个段落 block。
|
||||
- [x] 调 `mnote.block.fetch includeChildren=true contextBefore=1 contextAfter=1`。
|
||||
- [x] 断言 before/after 只来自同父级。
|
||||
|
||||
补充 smoke 证据:
|
||||
|
||||
- [x] `mnote.block.fetch blockId=p_2 format=page_xml/text contextBefore=1 contextAfter=1` 返回目标块 `p_2`、`revisionRef` 与同父级 before/after `p_1/p_3`。证据:`tmp/page-block-ai-context-format-smoke/mp8ddr4n.json`。
|
||||
- [x] `doc.find` 定位 `p_2` 后,`mnote.block.fetch includeChildren=true contextBefore=1 contextAfter=1` 回读 `p_2`,返回 `revisionRef`、同父级 before `p_1` 和 after `p_3`。证据:`tmp/page-block-ai-tools-smoke/mpag4966.json`。
|
||||
- [x] `table_1/mindmap_1/resource_1` 复杂块在真实 3000 smoke 中返回 `editable=false` 与 `unsupportedReason=复杂块暂不开放 AI 精确写入`。证据:`tmp/page-block-ai-tools-smoke/mpag4966.json`。
|
||||
|
||||
通过标准:
|
||||
|
||||
- [x] block 文本与页面显示一致。
|
||||
- [ ] `revisionRef` 可被后续 dry-run 使用。
|
||||
- [ ] 复杂块不会伪装成完全可编辑。
|
||||
- [x] `revisionRef` 可被后续 dry-run 使用。
|
||||
- [x] 复杂块不会伪装成完全可编辑。
|
||||
|
||||
---
|
||||
|
||||
@@ -238,10 +262,15 @@ cargo test -p mnote-web block_fetch
|
||||
- [x] 支持 `command=block_replace`。
|
||||
- [x] 支持 `command=block_insert_after`。
|
||||
- [x] 支持 `command=block_move_after` dry-run。
|
||||
- [ ] 支持 `command=str_replace` 且多重匹配阻断。(当前只有基础 plan,仍需多重匹配阻断。)
|
||||
- [x] `command=str_replace` 不再作为本 checklist 当前目标;普通正文 search/replace 已收口到 `mnote.doc.markdown_edit`,多重匹配/无法安全映射阻断由 markdown_edit 合同覆盖。
|
||||
- [x] 缺少 `revision/conflictDetectionKey` 时只允许 dry-run。
|
||||
- [x] 返回 `planId`、`diff`、`warnings`、`risk`、`blocked`。
|
||||
|
||||
2026-05-18 口径修正:
|
||||
|
||||
- `7-18` 到 `7-25` 收口后,普通正文替换不再继续扩 `mnote.doc.plan_update command=str_replace`;当前实现路径是 `page_ai_workflow.rs -> mnote.doc.markdown_edit`。
|
||||
- `mnote.doc.markdown_edit` 已覆盖精确匹配、归一化匹配、多操作同块合并、无法安全映射时失败;相关测试由 `cargo test --manifest-path rust/Cargo.toml -p mnote-web hermes_tools -- --nocapture` 覆盖。
|
||||
|
||||
验证命令:
|
||||
|
||||
```bash
|
||||
@@ -252,16 +281,21 @@ cargo test -p bridge-runtime doc_insert_blocks
|
||||
|
||||
真实页面 smoke:
|
||||
|
||||
- [ ] 对目标段落执行 `block_replace dryRun=true`。
|
||||
- [ ] 对目标段落执行 `block_insert_after dryRun=true`。
|
||||
- [ ] 对两个同父级普通块执行 `block_move_after dryRun=true`。
|
||||
- [ ] dry-run 前后分别保存 `/api/page-aggregate/:id`,确认内容 hash 不变。
|
||||
- [x] 对目标段落执行 `block_replace dryRun=true`。
|
||||
- [x] 对目标段落执行 `block_insert_after dryRun=true`。
|
||||
- [x] 对两个同父级普通块执行 `block_move_after dryRun=true`。
|
||||
- [x] dry-run 前后分别读取 `/api/page-aggregate/:id` 与 `mnote.doc.fetch`,确认 revision / conflictDetectionKey / 正文文本不变。
|
||||
|
||||
通过标准:
|
||||
|
||||
- [ ] dry-run 不写 Convex。
|
||||
- [ ] plan 能解释 before/after。
|
||||
- [ ] 不支持场景返回 `blocked=true`。
|
||||
- [x] dry-run 不写 Convex。
|
||||
- [x] plan 能解释 before/after。
|
||||
- [x] 不支持场景返回 `blocked=true`。
|
||||
|
||||
2026-05-18 补充真实页面 smoke 证据:
|
||||
|
||||
- 最新证据:`tmp/page-block-ai-tools-smoke/mpag4966.json`,截图:`tmp/page-block-ai-tools-smoke/mpag4966-page.png`。
|
||||
- 覆盖:`plan_update.dry_run.replaceDiff.before/after`、`insertDiff.after/content`、`block_move_after p_3 -> p_1 dryRun` 不阻断、不支持 `p_3 -> p_3` 返回 `blocked=true`、dry-run 后 revision / Page Aggregate conflictDetectionKey / 正文文本不变。
|
||||
|
||||
---
|
||||
|
||||
@@ -290,20 +324,27 @@ cargo test -p bridge-runtime doc_replace_range_tool_executes_in_rust_runtime
|
||||
|
||||
真实页面 smoke:
|
||||
|
||||
- [ ] 新建 `TEST-AI-BLOCK-REPLACE-<timestamp>` 页面。
|
||||
- [ ] 写入两段不同文本。
|
||||
- [ ] `doc.find` 找到第二段 block id。
|
||||
- [ ] `block.replace dryRun=true` 查看 plan。
|
||||
- [ ] `block.replace dryRun=false` 替换第二段。
|
||||
- [ ] 页面截图证明只替换第二段。
|
||||
- [ ] `/api/page-aggregate/:id` 回读证明持久化。
|
||||
- [ ] `mnote.doc.fetch` 再次证明 AI 可读回。
|
||||
- [x] 使用综合页面 `TEST-AI-BLOCK-TOOLS-<timestamp>`。
|
||||
- [x] 写入两段不同文本。
|
||||
- [x] `doc.find` 找到第二段 block id。
|
||||
- [x] `block.replace dryRun=true` 查看 plan。
|
||||
- [x] `block.replace dryRun=false` 替换第二段。
|
||||
- [x] 页面截图和工具回读证明只替换第二段。
|
||||
- [x] `/api/page-aggregate/:id` 回读证明持久化。
|
||||
- [x] `mnote.doc.fetch` 再次证明 AI 可读回。
|
||||
|
||||
通过标准:
|
||||
|
||||
- [ ] 相邻块不变化。
|
||||
- [ ] 目标 block id 保持不变。
|
||||
- [ ] 旧 revision 写入返回 conflict。
|
||||
- [x] 相邻块不变化。
|
||||
- [x] 目标 block id 保持不变。
|
||||
- [x] 旧 revision 写入返回 conflict。
|
||||
|
||||
2026-05-18 补充真实页面 smoke 证据:
|
||||
|
||||
- 最新证据:`tmp/page-block-ai-tools-smoke/mpag4966.json`,截图:`tmp/page-block-ai-tools-smoke/mpag4966-page.png`。
|
||||
- 覆盖:`block.replace.changedBlocks[0]={blockId:"p_2",op:"replace"}`,`targetBlockStillExists=true`,相邻块仍为 `第一段 mpag4966` / `第三段 mpag4966`,最终 `mnote.doc.fetch` 与 Page Aggregate 均回读到 `第二段已替换 mpag4966`,Page Aggregate `aggregateRevision=2`、`aggregateConflictDetectionKey=tree_1779062903017_3:2`。
|
||||
- 冲突 / 幂等补充证据:`tmp/page-block-ai-conflict-idempotency-smoke/mpafrevs.json`,截图:`tmp/page-block-ai-conflict-idempotency-smoke/mpafrevs-page.png`。
|
||||
- 覆盖:首次 `block.replace` 携带最新 `revision/conflictDetectionKey/blockRevisionRef/idempotencyKey` 成功写入;重复同一 `idempotencyKey` replay 同一 `commandId` 且 revision 不再次递增;旧 `revision/conflictDetectionKey` 与旧 `blockRevisionRef` 均返回 HTTP `400` / `mnote_tool_conflict`,正文保持首次替换结果。
|
||||
|
||||
---
|
||||
|
||||
@@ -318,30 +359,46 @@ cargo test -p bridge-runtime doc_replace_range_tool_executes_in_rust_runtime
|
||||
- [x] tool manifest 增加 `mnote.block.insert_after`。
|
||||
- [x] 输入必须包含 `anchorBlockId/revision/conflictDetectionKey/idempotencyKey/dryRun`。
|
||||
- [x] 新块 id 由 Rust runtime 分配。
|
||||
- [ ] 支持单块和最多 20 个普通块插入。(当前最小闭环为单块插入。)
|
||||
- [x] 支持单块和最多 20 个普通块插入。
|
||||
- [x] 第一阶段支持 paragraph/heading/todo。
|
||||
- [x] 返回 inserted block ids 和新 revision。
|
||||
|
||||
2026-05-18 补充证据:
|
||||
|
||||
- `rust/crates/mnote-web/src/hermes_tools/block.rs` 的 `mnote.block.insert_after` 已支持 `content` / `block` 单块兼容输入,以及 `blocks` 数组输入。
|
||||
- `blocks` 运行时限制为 `1..=20`,超过 20 个返回 `mnote_tool_bad_request`;多块写入按输入顺序连续插在 anchor 后。
|
||||
- `rust/crates/mnote-web/src/hermes_tools/manifest.rs` 的 manifest 已将 `content | block | blocks` 写为 `anyOf`,并声明 `blocks.minItems=1/maxItems=20`。
|
||||
- 新增回归:`hermes_tools_block_insert_after_accepts_multiple_blocks_and_returns_ids`、`hermes_tools_block_insert_after_rejects_more_than_twenty_blocks`。
|
||||
|
||||
验证命令:
|
||||
|
||||
```bash
|
||||
cargo test -p mnote-web block_insert_after
|
||||
cargo test --manifest-path rust/Cargo.toml -p mnote-web hermes_tools_block_insert_after -- --nocapture
|
||||
cargo test --manifest-path rust/Cargo.toml -p mnote-web hermes_tools -- --nocapture
|
||||
cargo test -p bridge-runtime doc_insert_blocks_tool_emits_editor_commands
|
||||
```
|
||||
|
||||
真实页面 smoke:
|
||||
|
||||
- [ ] 新建 `TEST-AI-BLOCK-INSERT-<timestamp>` 页面。
|
||||
- [ ] 在第一段后插入 todo。
|
||||
- [ ] 截图证明位置准确。
|
||||
- [ ] 刷新页面后再次确认。
|
||||
- [ ] `mnote.block.fetch` 能读取新 block id。
|
||||
- [x] 使用综合页面 `TEST-AI-BLOCK-TOOLS-<timestamp>`。
|
||||
- [x] 在第一段后插入 todo。
|
||||
- [x] 截图证明位置准确。
|
||||
- [x] 刷新页面后再次确认。
|
||||
- [x] `mnote.block.fetch` 能读取新 block id。
|
||||
|
||||
通过标准:
|
||||
|
||||
- [ ] 插入位置准确。
|
||||
- [ ] 新 block id 不是 AI 自造未校验 id。
|
||||
- [ ] 重复同一 `idempotencyKey` 不重复插入。
|
||||
- [x] 插入位置准确。
|
||||
- [x] 新 block id 不是 AI 自造未校验 id。
|
||||
- [x] 重复同一 `idempotencyKey` 不重复插入。
|
||||
|
||||
2026-05-18 补充证据:
|
||||
|
||||
- `mnote.block.insert_after` 运行时分配 `ai_block_{request_id}` 或多块 `ai_block_{request_id}_{index}`,不会采用模型输入中的未校验 id。
|
||||
- `hermes_tools_block_insert_after_accepts_multiple_blocks_and_returns_ids` 断言返回的 `insertedBlockIds` 均以 `ai_block_` 开头。
|
||||
- 真实 3000 smoke:`PLAYWRIGHT_CHROME_EXECUTABLE=/snap/bin/chromium MNOTE_UI_BASE_URL=http://127.0.0.1:3000 MNOTE_AUTH_BASE_URL=http://127.0.0.1:3000 node scripts/task-page-block-ai-tools-smoke.js` 通过。
|
||||
- 最新证据:`tmp/page-block-ai-tools-smoke/mpag4966.json`,截图:`tmp/page-block-ai-tools-smoke/mpag4966-page.png`。
|
||||
- 覆盖:`insert_after` 插入 `todo` 块、`orderAfterInsert=["heading_1","p_1","ai_block_req_1779061213631_259","p_2","p_3"]`、`block.fetch` 可读取新块、重复 `idempotencyKey` replay 同一 `commandId` 且 revision 不再次递增、刷新后插入文本仍可见。
|
||||
|
||||
---
|
||||
|
||||
@@ -355,32 +412,43 @@ cargo test -p bridge-runtime doc_insert_blocks_tool_emits_editor_commands
|
||||
|
||||
- [x] tool manifest 增加 `mnote.block.move_after`。
|
||||
- [x] `dryRun=true` 支持同父级叶子块 diff。
|
||||
- [ ] `dryRun=false` 前先继续阻断所有复杂块。(已有同父级/叶子/类型/editable/self 阻断,复杂块矩阵 smoke 待补。)
|
||||
- [x] `dryRun=false` 前先继续阻断所有复杂块。(已有同父级/叶子/类型/editable/self 阻断,复杂块矩阵 route smoke 已补。)
|
||||
- [x] 检查 `blockRevisionRef` 与 `anchorRevisionRef`。
|
||||
- [x] 阻断移动到自身、移动到子树、跨页面移动。
|
||||
- [x] 返回 from/to parent/order。
|
||||
|
||||
2026-05-18 补充证据:
|
||||
|
||||
- `mnote.block.move_after` 保持同父级、叶子块、可移动类型、editable、自身移动阻断条件。
|
||||
- 新增隔离 fixture route smoke:`hermes_tools_block_move_after_blocks_complex_and_nested_blocks`,覆盖标题带子块、列表项、表格、mindmap、resource,均返回 `blocked=true` 与 `block_move_after_blocked`。
|
||||
|
||||
验证命令:
|
||||
|
||||
```bash
|
||||
cargo test -p mnote-web block_move_after
|
||||
cargo test --manifest-path rust/Cargo.toml -p mnote-web hermes_tools_block_move_after_blocks_complex_and_nested_blocks -- --nocapture
|
||||
cargo test --manifest-path rust/Cargo.toml -p mnote-web hermes_tools -- --nocapture
|
||||
cargo test -p mnote-editor-core command_executor
|
||||
```
|
||||
|
||||
真实页面 smoke:
|
||||
|
||||
- [ ] 新建 `TEST-AI-BLOCK-MOVE-<timestamp>` 页面,包含三段普通段落。
|
||||
- [ ] dry-run 移动第三段到第一段后。
|
||||
- [ ] 正式执行同父级移动。
|
||||
- [ ] 截图证明顺序为第一段、第三段、第二段。
|
||||
- [ ] `mnote.doc.fetch` 回读顺序一致。
|
||||
- [ ] 对标题带子块、列表项、表格、mindmap 执行 move dry-run,必须返回 blocked。
|
||||
- [x] 使用综合页面 `TEST-AI-BLOCK-TOOLS-<timestamp>`,包含三段普通段落。
|
||||
- [x] dry-run 移动第三段到第一段后。
|
||||
- [x] 正式执行同父级移动。
|
||||
- [x] 截图证明顺序为第一段、第三段、第二段。
|
||||
- [x] `mnote.doc.fetch` 回读顺序一致。
|
||||
- [x] 对标题带子块、列表项、表格、mindmap 执行 move dry-run,必须返回 blocked。
|
||||
|
||||
通过标准:
|
||||
|
||||
- [ ] moving block id 保持不变。
|
||||
- [ ] 同父级顺序正确。
|
||||
- [ ] 复杂块不被误移动。
|
||||
- [x] moving block id 保持不变。
|
||||
- [x] 同父级顺序正确。
|
||||
- [x] 复杂块不被误移动。
|
||||
|
||||
2026-05-18 补充真实页面 smoke 证据:
|
||||
|
||||
- 最新证据:`tmp/page-block-ai-tools-smoke/mpag4966.json`,截图:`tmp/page-block-ai-tools-smoke/mpag4966-page.png`。
|
||||
- 覆盖:`heading_parent/list_item_1/table_1/mindmap_1/resource_1` 的 `plan_update block_move_after dryRun=true` 与正式 `mnote.block.move_after dryRun=false` 均返回 `blocked=true` / `block_move_after_blocked`,阻断后 `revisionAfterBlocked=1` 且正文不变化;普通 `block.move_after.dryRunBlocked=false`,正式写入后顺序为 `heading_1,p_1,p_3,ai_block_...,p_2,...`,`movingBlockStillExists=true`。
|
||||
|
||||
---
|
||||
|
||||
@@ -411,8 +479,9 @@ cargo test -p mnote-editor-core command_executor
|
||||
|
||||
本 checklist 不能移动到 `done/`,直到:
|
||||
|
||||
- [ ] Phase 1 到 Phase 6 全部完成。
|
||||
- [ ] Phase 7 至少完成 dry-run 和阻断规则;若真实 move 未完成,`7-9` 必须仍标注受限。
|
||||
- [ ] 每个写工具都有真实页面 smoke 证据。
|
||||
- [ ] 失败项已经写入 `bugs/07-ai/process/` 或真实 owner 分类。
|
||||
- [ ] `mnote.page.save` 不再被任何设计描述为精确块编辑主入口。
|
||||
- [x] Phase 1 到 Phase 6 全部完成。
|
||||
- [x] Phase 7 至少完成 dry-run 和阻断规则;若真实 move 未完成,`7-9` 必须仍标注受限。
|
||||
- [x] 每个写工具都有真实页面 smoke 证据。
|
||||
- [x] 失败项已经写入 `bugs/07-ai/process/` 或真实 owner 分类。
|
||||
- [x] `mnote.page.save` 不再被任何设计描述为精确块编辑主入口。
|
||||
- [ ] Phase 8 UI / Review Mode 仍按 Phase C 边界冻结;若后续进入实施,需要另补 UI smoke。
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
> 本稿目的:修正“页面 AI 快速块编辑”后续方向,明确 mnote 不再建设独立 AI agent 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/10-review/done/09-page-ai-fast-block-edit-runtime-review.md`
|
||||
> - `/mnt/Data1T/mnote/design/07-ai/process/7-10-page-block-ai-tooling-execution-checklist-v1.md`
|
||||
> - `/mnt/Data1T/mnote/design/07-ai/done/7-9-page-block-ai-tooling-roadmap-v1.md`
|
||||
> - `/mnt/Data1T/mnote/design/07-ai/done/7-6-mnote-hermes-plugin-tool-contract-v1.md`
|
||||
@@ -17,6 +17,16 @@
|
||||
|
||||
---
|
||||
|
||||
## 0. 2026-05-18 状态更新
|
||||
|
||||
本稿仍作为 Hermes 工具路由与审阅面设计保留在 `process/`,但以下口径已经更新:
|
||||
|
||||
- `/api/page-ai/block-edit-workflow` 当前不再以 `local_rule -> apply_block_ops` 作为主路径;简单正文编辑主路径已切到模型生成 markdown `search/replace` 或 `full_content`,再调用 `mnote.doc.markdown_edit`。
|
||||
- `page_ai_workflow` 已复用统一 mnote tool executor,不再绕过 Hermes tool toggle / audit / write contract。
|
||||
- `mnote.doc.apply_block_ops` / `mnote.block.*` 保留为结构性块操作辅助,不再作为普通正文 search/replace 的优先入口。
|
||||
- 本稿中的 `PageAIReviewSession` 只定义 Phase C 的安全合同和状态机边界;当前 Phase C 仍冻结,不实施流式 apply 或新的审阅 UI。
|
||||
- 当前已闭合缺陷见 `bugs/07-ai/done/7-18` 到 `7-25`。
|
||||
|
||||
## 1. 本轮结论
|
||||
|
||||
页面 AI 编辑卡顿的根因不是“Rust apply 慢”,而是模型和工具之间缺少稳定、低歧义、可审计的编辑命令面:
|
||||
@@ -348,6 +358,96 @@ stale
|
||||
|
||||
第一阶段可以保留 yolo,但仍应让工具返回 review-compatible 数据结构,避免后续 UI 重写。
|
||||
|
||||
最小稳定合同:
|
||||
|
||||
```json
|
||||
{
|
||||
"schema": "mnote.page_ai_review_session.v1",
|
||||
"sessionId": "review_01",
|
||||
"workspaceId": "tree_workspace",
|
||||
"documentId": "tree_doc",
|
||||
"runId": "run_01",
|
||||
"toolCallId": "tool_01",
|
||||
"traceId": "trace_01",
|
||||
"state": "awaiting_user",
|
||||
"mode": "review",
|
||||
"source": {
|
||||
"runtimeOwner": "mnote-web",
|
||||
"writeOwner": "rust-runtime-kernel",
|
||||
"toolName": "mnote.doc.markdown_edit"
|
||||
},
|
||||
"base": {
|
||||
"revision": 12,
|
||||
"conflictDetectionKey": "body:12:hash",
|
||||
"allowedTargetBlockIds": ["p_1"]
|
||||
},
|
||||
"proposal": {
|
||||
"format": "markdown",
|
||||
"operations": [
|
||||
{
|
||||
"op": "replace",
|
||||
"search": "旧文本",
|
||||
"replace": "新文本"
|
||||
}
|
||||
],
|
||||
"fullContent": null
|
||||
},
|
||||
"preview": {
|
||||
"dryRun": true,
|
||||
"changedBlocks": [
|
||||
{
|
||||
"blockId": "p_1",
|
||||
"before": "旧文本",
|
||||
"after": "新文本",
|
||||
"revisionRef": "pageRev:12:block:p_1"
|
||||
}
|
||||
],
|
||||
"diff": [],
|
||||
"warnings": [],
|
||||
"risk": "low",
|
||||
"blocked": false
|
||||
},
|
||||
"actions": {
|
||||
"accept": {
|
||||
"requiresFreshRevision": true,
|
||||
"requiresIdempotencyKey": true
|
||||
},
|
||||
"reject": true,
|
||||
"retry": {
|
||||
"allowed": true,
|
||||
"requiresNewToolCallId": true
|
||||
},
|
||||
"abort": true
|
||||
},
|
||||
"audit": {
|
||||
"createdAt": "2026-05-18T00:00:00Z",
|
||||
"createdBy": "actor_01"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
状态语义:
|
||||
|
||||
- `draft`:已创建 session,但尚未生成 dry-run preview。
|
||||
- `planning`:正在构造 tool args 或请求模型生成候选。
|
||||
- `previewing`:正在执行 `dryRun=true`。
|
||||
- `awaiting_user`:preview 已完成,等待 accept / reject / retry / abort。
|
||||
- `accepted`:用户已确认,等待正式 apply。
|
||||
- `rejected`:用户拒绝,本 session 不可再写入。
|
||||
- `applying`:accept 后正在正式写入。
|
||||
- `applied`:正式写入已完成,并已通过 Page Aggregate / `mnote.doc.fetch` 回读。
|
||||
- `failed`:preview 或 apply 失败,需保留 structured error / hint。
|
||||
- `aborted`:用户或系统中断,不能继续写入。
|
||||
- `stale`:accept 时 revision / conflictDetectionKey / revisionRef 过期,必须重新 preview,不能直接 apply。
|
||||
|
||||
动作约束:
|
||||
|
||||
- `accept` 必须重新读取 Page Aggregate,并校验 `revision/conflictDetectionKey/revisionRef`;过期时转 `stale`。
|
||||
- `accept` 必须提供新的或既有合法 `idempotencyKey`,重复提交必须 replay 同一结果。
|
||||
- `reject` 与 `abort` 不能产生写入。
|
||||
- `retry` 不能复用旧 `toolCallId` 伪装成同一次写入;必须生成新 proposal 或新 dry-run preview。
|
||||
- yolo 模式可以跳过 `awaiting_user` UI,但仍应生成同构的 review-compatible audit 数据。
|
||||
|
||||
---
|
||||
|
||||
## 6. 关键流程
|
||||
@@ -508,7 +608,7 @@ profile disabled mnote.block.fetch
|
||||
|
||||
### Phase D:Review session
|
||||
|
||||
- [ ] 定义 `mnote.page_ai_review_session.v1`。
|
||||
- [x] 定义 `mnote.page_ai_review_session.v1` 最小 schema 与状态机边界(2026-05-18 已补;Phase C UI 仍冻结)。
|
||||
- [ ] `mnote.doc.plan_update` 与 `mnote.doc.apply_block_ops dryRun=true` 返回 review-compatible draft。
|
||||
- [ ] 页面 AI UI 展示 diff/warnings/risk/blocked。
|
||||
- [ ] accept/reject/retry/abort 可用。
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
# 7-16 [process] Page Block AI 真实页面 Smoke 后续矩阵 v1
|
||||
|
||||
> 创建时间:2026-05-18
|
||||
>
|
||||
> 当前状态:`PROCESS`
|
||||
>
|
||||
> 来源:
|
||||
> - `/mnt/Data1T/mnote/design/07-ai/process/7-10-page-block-ai-tooling-execution-checklist-v1.md`
|
||||
> - `/mnt/Data1T/mnote/design/07-ai/process/7-12-page-ai-hermes-tool-routing-and-review-surface-v1.md`
|
||||
> - `/mnt/Data1T/mnote/design/10-review/process/08-kernel-architecture-next-priority-review-and-checklist.md`
|
||||
|
||||
---
|
||||
|
||||
## 1. 目的
|
||||
|
||||
`7-10` 已完成大量工具合同、Rust route 测试和部分真实 3000 smoke,但仍有一组“必须真实页面验证”的矩阵项未完成。
|
||||
|
||||
本文只承接这些剩余 smoke,不新增 AI 功能面,不改变当前主路径:
|
||||
|
||||
- 简单正文编辑主路径仍是 `mnote.doc.markdown_edit`。
|
||||
- `mnote.block.*` 仍只作为结构性辅助。
|
||||
- `mnote.page.save` 仍只作为页面级粗粒度兜底。
|
||||
- Phase C review / streaming apply 仍冻结,当前只验证基础合同和安全边界。
|
||||
|
||||
---
|
||||
|
||||
## 2. 当前已具备的前置证据
|
||||
|
||||
- `mnote.doc.fetch scope=selection format=page_xml/text`、`mnote.block.fetch format=page_xml/text`、manifest annotations、`mnote.doc.apply_block_ops allowedTargetBlockIds` 越界阻断已有真实 3000 smoke:
|
||||
- `tmp/page-block-ai-context-format-smoke/mp8ddr4n.json`
|
||||
- `tmp/page-block-ai-context-format-smoke/mp8ddr4n-page.png`
|
||||
- `mnote.block.replace` stale revision / stale blockRevisionRef / idempotency replay 已有真实 3000 smoke:
|
||||
- `scripts/task-page-block-ai-conflict-idempotency-smoke.js`
|
||||
- 证据记录在 `08` 的 Page Block AI Tooling 章节。
|
||||
- `mnote.block.insert_after` 多块插入和 20 个边界已有 Rust route 回归:
|
||||
- `cargo test --manifest-path rust/Cargo.toml -p mnote-web hermes_tools_block_insert_after -- --nocapture`
|
||||
- `mnote.block.move_after` 复杂块阻断矩阵已有隔离 route smoke:
|
||||
- `cargo test --manifest-path rust/Cargo.toml -p mnote-web hermes_tools_block_move_after_blocks_complex_and_nested_blocks -- --nocapture`
|
||||
|
||||
---
|
||||
|
||||
## 3. 后续真实页面 Smoke 矩阵
|
||||
|
||||
### 3.1 Page Aggregate Block Projection
|
||||
|
||||
- [x] 登录测试账号并创建综合页面 `TEST-AI-BLOCK-TOOLS-<timestamp>`。
|
||||
- [x] 写入段落、标题、todo、列表、mindmap/resource 占位。
|
||||
- [x] 调 `/api/page-aggregate/:id` 并保存响应到 `tmp/hermes-tester/page-block-ai-tools-<run-id>/page-aggregate.json`。
|
||||
- [x] 断言所有普通可编辑块有 `blockId` 和 `revisionRef`。
|
||||
- [x] 页面刷新后 block ids 不变化。
|
||||
- [x] projection 中 `blockCount` 与页面块数量一致;复杂块返回 `editable=false` 与 `unsupportedReason`。
|
||||
|
||||
证据(2026-05-18):
|
||||
|
||||
- JSON:`tmp/page-block-ai-tools-smoke/mpag4966.json`
|
||||
- Page Aggregate:`tmp/hermes-tester/page-block-ai-tools-mpag4966/page-aggregate.json`
|
||||
- 覆盖:Page Aggregate `blockProjectionVersion=1`、`projectionSource=documents.content`、`aggregateBlockCount=10`,与 `mnote.doc.fetch` 块顺序一致;刷新后 `afterRefreshBlockIds` 保持最终顺序;`table_1/mindmap_1/resource_1` 返回 `editable=false` 与 `unsupportedReason=复杂块暂不开放 AI 精确写入`。
|
||||
|
||||
### 3.2 `mnote.doc.fetch` / `mnote.doc.find`
|
||||
|
||||
- [x] 使用综合页面 `TEST-AI-BLOCK-TOOLS-<timestamp>`。
|
||||
- [x] `mnote.doc.fetch scope=full detail=with_ids` 读取整页。
|
||||
- [x] `mnote.doc.fetch scope=outline detail=with_ids` 只返回标题结构。
|
||||
- [x] `mnote.doc.find query=<唯一前缀>` 定位目标段落。
|
||||
- [x] 保存工具返回到 `tmp/hermes-tester/page-block-ai-tools-<run-id>/doc-fetch-find.json`。
|
||||
- [x] 确认读取不依赖浏览器 DOM。
|
||||
- [x] `doc.find` 返回的 block id 能被 `block.fetch` 读取。
|
||||
- [x] 大页面默认分块或限制输出,不默认返回无限正文。
|
||||
|
||||
证据(2026-05-18):
|
||||
|
||||
- 命令:`PLAYWRIGHT_CHROME_EXECUTABLE=/snap/bin/chromium MNOTE_UI_BASE_URL=http://127.0.0.1:3000 MNOTE_AUTH_BASE_URL=http://127.0.0.1:3000 node scripts/task-page-block-ai-tools-smoke.js`
|
||||
- JSON:`tmp/page-block-ai-tools-smoke/mpag4966.json`
|
||||
- 工具返回:`tmp/hermes-tester/page-block-ai-tools-mpag4966/doc-fetch-find.json`
|
||||
- 覆盖:`doc.fetch.initial` 读取 `heading_1/p_1/p_2/p_3` 等 10 个块;`doc.fetch scope=outline` 只返回 `heading_1/heading_parent`;`doc.find query="第二段 <suffix>"` 定位 `p_2`;`maxBlocks=2` 返回 `truncated=true`。
|
||||
- 静态边界:`mnote.doc.fetch` 从 Page Aggregate block projection 构建上下文,不读取浏览器 DOM;默认 `maxBlocks=120` 且 clamp 到 `1..=240`。
|
||||
|
||||
### 3.3 `mnote.block.fetch`
|
||||
|
||||
- [x] 从 `doc.find` 结果选择一个段落 block。
|
||||
- [x] 调 `mnote.block.fetch includeChildren=true contextBefore=1 contextAfter=1`。
|
||||
- [x] 断言 before/after 只来自同父级。
|
||||
- [x] `revisionRef` 可被后续 dry-run 使用。
|
||||
- [x] 复杂块不会伪装成完全可编辑。
|
||||
|
||||
证据(2026-05-18):
|
||||
|
||||
- JSON:`tmp/page-block-ai-tools-smoke/mpag4966.json`
|
||||
- 覆盖:`block.fetch` 读取 `p_2`,返回 `revisionRef=pageRev:1:block:p_2:...`,同父级 `before=["p_1"]`、`after=["p_3"]`;`table_1/mindmap_1/resource_1` 均返回 `editable=false` 与 `unsupportedReason=复杂块暂不开放 AI 精确写入`。
|
||||
|
||||
### 3.4 `mnote.doc.plan_update` / dry-run
|
||||
|
||||
- [x] `command=str_replace` 不再作为当前 `plan_update` 目标;普通正文 search/replace 已由 `mnote.doc.markdown_edit` 合同承接。
|
||||
- [x] 对目标段落执行 `block_replace dryRun=true`。
|
||||
- [x] 对目标段落执行 `block_insert_after dryRun=true`。
|
||||
- [x] 对两个同父级普通块执行 `block_move_after dryRun=true`。
|
||||
- [x] dry-run 前后分别读取 `/api/page-aggregate/:id` 与 `mnote.doc.fetch`,确认 revision / conflictDetectionKey / 正文文本不变。
|
||||
- [x] dry-run 不写 Convex。
|
||||
- [x] plan 能解释 before/after。
|
||||
- [x] 不支持场景返回 `blocked=true`。
|
||||
|
||||
证据(2026-05-18):
|
||||
|
||||
- JSON:`tmp/page-block-ai-tools-smoke/mpag4966.json`
|
||||
- 覆盖:`plan_update.dry_run.replaceDiff.before/after`、`insertDiff.after/content`、不支持 `move_after p_3 -> p_3` 返回 `blocked=true`、dry-run 后 `revisionAfterDryRun=1` 且正文未变化。
|
||||
|
||||
### 3.5 `mnote.block.replace`
|
||||
|
||||
- [x] 使用综合页面 `TEST-AI-BLOCK-TOOLS-<timestamp>`。
|
||||
- [x] 写入两段不同文本。
|
||||
- [x] `doc.find` 找到第二段 block id。
|
||||
- [x] `block.replace dryRun=true` 查看 plan。
|
||||
- [x] `block.replace dryRun=false` 替换第二段。
|
||||
- [x] 页面截图和工具回读证明只替换第二段。
|
||||
- [x] `/api/page-aggregate/:id` 回读证明持久化。
|
||||
- [x] `mnote.doc.fetch` 再次证明 AI 可读回。
|
||||
- [x] 相邻块不变化。
|
||||
- [x] 目标 block id 保持不变。
|
||||
- [x] 旧 revision 写入返回 conflict。
|
||||
|
||||
证据(2026-05-18):
|
||||
|
||||
- JSON:`tmp/page-block-ai-tools-smoke/mpag4966.json`
|
||||
- 截图:`tmp/page-block-ai-tools-smoke/mpag4966-page.png`
|
||||
- 覆盖:`block.replace.changedBlocks[0]={blockId:"p_2",op:"replace"}`,`targetBlockStillExists=true`,`adjacentTexts=["第一段 mpag4966","第三段 mpag4966"]`,`mnote.doc.fetch` 与 Page Aggregate 均回读到 `第二段已替换 mpag4966`,Page Aggregate `aggregateRevision=2`、`aggregateConflictDetectionKey=tree_1779062903017_3:2`。
|
||||
- 冲突 / 幂等补充 JSON:`tmp/page-block-ai-conflict-idempotency-smoke/mpafrevs.json`,截图:`tmp/page-block-ai-conflict-idempotency-smoke/mpafrevs-page.png`。
|
||||
- 覆盖:首次 `block.replace` 写入 `p_2` 后,重复 `idempotencyKey=idem_conflict_replace_mpafrevs` replay 同一 `commandId=page_body_save_req_1779062303997_525` 且 `revisionAfterFirst=2`、`revisionAfterReplay=2`;旧 `revision/conflictDetectionKey` 与旧 `blockRevisionRef` 均返回 HTTP `400` / `mnote_tool_conflict`,最终文本保持 `第二段首次替换 mpafrevs`。
|
||||
|
||||
### 3.6 `mnote.block.insert_after`
|
||||
|
||||
- [x] 使用综合页面 `TEST-AI-BLOCK-TOOLS-<timestamp>`。
|
||||
- [x] 在第一段后插入 todo。
|
||||
- [x] 截图证明位置准确。
|
||||
- [x] 刷新页面后再次确认。
|
||||
- [x] `mnote.block.fetch` 能读取新 block id。
|
||||
- [x] 插入位置准确。
|
||||
- [x] 新 block id 不是 AI 自造未校验 id。
|
||||
- [x] 重复同一 `idempotencyKey` 不重复插入。
|
||||
|
||||
证据(2026-05-18):
|
||||
|
||||
- 命令:`PLAYWRIGHT_CHROME_EXECUTABLE=/snap/bin/chromium MNOTE_UI_BASE_URL=http://127.0.0.1:3000 MNOTE_AUTH_BASE_URL=http://127.0.0.1:3000 node scripts/task-page-block-ai-tools-smoke.js`
|
||||
- JSON:`tmp/page-block-ai-tools-smoke/mpag4966.json`
|
||||
- 截图:`tmp/page-block-ai-tools-smoke/mpag4966-page.png`
|
||||
- 说明:该 smoke 使用 `TEST-AI-BLOCK-TOOLS-*` 综合页面,不是单独 `TEST-AI-BLOCK-INSERT-*` 页面;已覆盖 todo 插入、插入后顺序、block.fetch 回读、刷新后可见和重复 idempotencyKey replay。
|
||||
|
||||
### 3.7 `mnote.block.move_after`
|
||||
|
||||
- [x] 使用综合页面 `TEST-AI-BLOCK-TOOLS-<timestamp>`,包含三段普通段落。
|
||||
- [x] dry-run 移动第三段到第一段后。
|
||||
- [x] 正式执行同父级移动。
|
||||
- [x] 截图证明顺序为第一段、第三段、第二段。
|
||||
- [x] `mnote.doc.fetch` 回读顺序一致。
|
||||
- [x] 对标题带子块、列表项、表格、mindmap 执行 move dry-run,必须返回 blocked。
|
||||
- [x] moving block id 保持不变。
|
||||
- [x] 同父级顺序正确。
|
||||
- [x] 复杂块不被误移动。
|
||||
|
||||
证据(2026-05-18):
|
||||
|
||||
- JSON:`tmp/page-block-ai-tools-smoke/mpag4966.json`
|
||||
- 截图:`tmp/page-block-ai-tools-smoke/mpag4966-page.png`
|
||||
- 覆盖:`heading_parent/list_item_1/table_1/mindmap_1/resource_1` 的 `plan_update block_move_after dryRun=true` 与正式 `mnote.block.move_after dryRun=false` 均返回 `blocked=true` / `block_move_after_blocked`,阻断后 `revisionAfterBlocked=1` 且正文不变化;普通 `p_3 -> p_1` 正式移动后顺序为 `heading_1,p_1,p_3,ai_block_...,p_2,...`,`movingBlockStillExists=true`。
|
||||
|
||||
### 3.8 UI / Review Mode 边界
|
||||
|
||||
- [ ] Hermes tool event UI 展示 `plan/diff/warnings/risk`。
|
||||
- [ ] 本地 preview/suggestion 与持久 comment/tracked-change 分开。
|
||||
- [ ] 协作可见审阅必须另走正式 comment/history/tracked-change 设计。
|
||||
- [ ] 用户能看到 AI 将改哪个 block。
|
||||
- [ ] `blocked=true` 的工具调用不会出现写入按钮。
|
||||
- [ ] preview 不写入正式 comment/history。
|
||||
|
||||
---
|
||||
|
||||
## 4. 验证原则
|
||||
|
||||
- 所有真实页面 smoke 必须使用测试账号,优先从 `http://localhost:3000/auth` 快速登录。
|
||||
- 证据必须保留 JSON 和截图,默认放入 `tmp/hermes-tester/<run-id>/` 或专项 `tmp/page-block-ai-*` 目录。
|
||||
- 发现失败时按 owner 写入 `bugs/<category>/process/`,不要只在本矩阵中留描述。
|
||||
- 不用此矩阵扩展新 AI 功能;只补真实页面证据和安全边界。
|
||||
@@ -0,0 +1,580 @@
|
||||
# 7-27 [process] 在线 Markdown_edit 以最终 Markdown 为写回真源 v2
|
||||
|
||||
> 更新:2026-05-18(v2:整合 CLI Main 参考实现分析,确认方向,补充见解)
|
||||
>
|
||||
> 当前状态:`PROCESS`
|
||||
>
|
||||
> 关联缺陷:`bugs/07-ai/done/7-24-markdown-edit-online-write-does-not-use-final-markdown-v1.md`
|
||||
> `bugs/07-ai/done/7-25-reasonix-block-edit-workflow-empty-block-ops-after-markdown-match-v1.md`
|
||||
> `bugs/07-ai/done/7-17-markdown-edit-same-block-multi-op-overwrite-v1.md`
|
||||
>
|
||||
> 上位设计:`design/07-ai/process/7-14-online-local-ai-markdown-editing-convergence-v1.md`
|
||||
>
|
||||
> 参考实现:`design/05-editor-mainline/reference-code/cli-main/shortcuts/doc/`(str_replace skill)
|
||||
> `rust/crates/mnote-web/src/routes/local_markdown_parser.rs`(已有 GFM→blocks 解析器)
|
||||
|
||||
---
|
||||
|
||||
## 0. 参考实现分析:CLI Main(Lark Doc)怎么做?
|
||||
|
||||
### 0.1 实现结构
|
||||
|
||||
CLI Main 的 `docs +update --api-version v2` 将 `str_replace`、`block_replace`、`block_insert_after` 等命令以 PUT 请求发送到 Lark OpenAPI:
|
||||
|
||||
```go
|
||||
// docs_update_v2.go:140-157
|
||||
body := map[string]interface{}{
|
||||
"format": runtime.Str("doc-format"),
|
||||
"command": cmd, // "str_replace" | "block_replace" | ...
|
||||
}
|
||||
body["pattern"] = runtime.Str("pattern") // str_replace 的搜索文本
|
||||
body["content"] = runtime.Str("content") // 替换文本或新内容
|
||||
body["block_id"] = blockID // block_* 操作的目标块
|
||||
body["revision_id"] = runtime.Int("revision-id")
|
||||
|
||||
// API: PUT /open-apis/docs_ai/v1/documents/{id}
|
||||
```
|
||||
|
||||
**关键:`str_replace` 只传 `pattern` + `content`,不传 `final_md`。** 服务端(Lark OpenAPI)在收到 `command: "str_replace"` 后,由服务端负责在文档的 XML/block 存储中找到匹配的文本并替换。
|
||||
|
||||
### 0.2 与 mnote 的架构对应
|
||||
|
||||
| CLI Main (Lark Doc) | mnote | 角色 |
|
||||
|---|---|---|
|
||||
| Lark OpenAPI 服务端 | Rust `doc_markdown_edit` + `execute_page_body_save` | 执行文本替换→block 持久化 |
|
||||
| `str_replace` API 端点 | `mnote.doc.markdown_edit` 工具 | AI 调用的文本级编辑入口 |
|
||||
| 块操作 API 端点 | `mnote.doc.apply_block_ops` + `mnote.block.*` | AI 调用的块级编辑入口 |
|
||||
| CLI 客户端(lark-cli) | Hermes agent / page_ai_workflow | AI 编排层,只产生`{pattern, content}`对 |
|
||||
|
||||
**mnote 的 Rust kernel 就是那个"服务端"。** 所以在 Rust 层实现 markdown→block content 的转换是正确的方向——这不是"在客户端做服务端的事",而是 mnote 的 Rust 层本来就承担服务端职责。
|
||||
|
||||
### 0.3 CLI Main 对我们设计的启发
|
||||
|
||||
1. **`str_replace` 是不可拆分的原子操作** — CLI Main 的 `str_replace` 由服务端完整执行。一旦 `pattern` + `content` 送出,客户端不需要处理块映射。**mnote 的 `doc_markdown_edit` 也应该是一条完整的原子路径**:接收 `operations` → 搜索替换 → 产生 final_md → **直接转换 final_md 为 blocks 并写回**。不应该把 operations 暴露给下游做二次推导。
|
||||
|
||||
2. **文档中的所有纯文本搜索替换只需一条 API 调用** — CLI Main 不支持在一次请求中组合多个 `str_replace`。mnote 支持多条 operation 是更灵活的设计,但兑现这个灵活性的前提是:**多条 operation 在服务端累积应用到 `md` 后,只产出一组最终的 block content 写回**,而不是每条 operation 独立映射到 block。
|
||||
|
||||
3. **`pattern` 不限制为精确文本** — CLI Main 的 `str_replace` 文档鼓励 Markdown 模式下的"前缀...后缀"省略号语法(`"start...end"`),允许模糊定位。这和我们已有的四级匹配策略(精确→忽略空白→段落 fuzzy→失败)方向一致。
|
||||
|
||||
4. **CLI Main 在客户端做预检查** — `docs_update_check.go` 中有 `checkDocsUpdateReplaceMultilineMarkdown`,在发送请求前就检查 Markdown 中的空行是否会违反服务端的单块内文本替换限制。**同理,mnote 在转换 final_md 时也应该做预检**:发现跨块内容变化时,要么走 GFM 解析器重建多块 content,要么清楚告知 AI "这条 operation 需要多块替换"。
|
||||
|
||||
5. **mnote 的 final_md 方法比 CLI Main 更可靠** — CLI Main 把 `pattern` 发给服务端,由服务端重新执行匹配。如果服务端的匹配策略与模型预期的不同,结果会意外。mnote 的做法(在 Rust 层计算 final_md,然后用 final_md 生成 block content)把匹配阶段和写回阶段解耦——匹配结果对 AI 可见(文档中说"已验证将 X 替换为 Y"),不会出现「客户端匹配成功、服务端匹配失败」的不一致。
|
||||
|
||||
### 0.4 与 CLI Main 的根本差异:为何不能照搬
|
||||
|
||||
CLI Main 的方案(客户端只传 `pattern` + `content`,服务端做匹配+转换)依赖 Lark OpenAPI 对文档 block 存储的完全控制。mnote 的 Convex 后台(`documents:updateContent`)只接受完整的 blocks 数组作为 `content`,没有 `str_replace` 端点。
|
||||
|
||||
**这迫使 mnote 的 Rust 层必须自己做 final_md → block content 的转换。这个转换就是我们的"服务端逻辑",是正确且必要的。**
|
||||
|
||||
---
|
||||
|
||||
---
|
||||
|
||||
## 1. 问题
|
||||
|
||||
### 1.1 当前架构
|
||||
|
||||
`mnote.doc.markdown_edit` 在在线 Convex 文档路径下有两套编辑结果:
|
||||
|
||||
```
|
||||
搜索替换 → 最终 md ✅(正确的结果)
|
||||
|
||||
↓
|
||||
build_block_ops_from_markdown_edit(blocks, operations, applied)
|
||||
↓ 二次推导
|
||||
doc_apply_block_ops → page.body.save
|
||||
```
|
||||
|
||||
- **第一层**: `search_replace(&md, &search, &replace)` — 在完整的 markdown 字符串上顺序执行操作,支持精确/忽略空白/段落 fuzzy 四级匹配。
|
||||
- **第二层**: `build_block_ops_from_markdown_edit` — 不从最终 `md` 提取修改,而是从原始 blocks 和原始 operations 重新推导每次命中。
|
||||
|
||||
**最终 markdown 被丢弃了。** 写入层使用的不是计算好的文本结果,而是从原始 operations 反查 blocks 的二次推导。
|
||||
|
||||
### 1.2 由此导致的已知缺陷
|
||||
|
||||
| 缺陷 | 表现 | 根因 |
|
||||
|------|------|------|
|
||||
| 7-17 同块多操作覆盖 | 两次 op 命中同块 → 第二 op 从原始文本计算,覆盖第一 op | 推导层不累积 |
|
||||
| 7-24 在线不以最终 md 为真源 | `full_content` 无法映射到块 operations → 全部拒绝 | 推导层不支持全文替换 |
|
||||
| 7-25 命中后空 block_ops | markdown 层匹配成功,block 映射失败 → 空 ops | 两套匹配规则不一致 |
|
||||
|
||||
当前「修复」是安全降级:推导失败从伪成功变成明确错误码,但推导本身仍然存在。
|
||||
|
||||
### 1.3 为什么推导不可靠
|
||||
|
||||
`build_block_ops_from_markdown_edit` 当前采用「每 operation 从 `block.text` 反查 + 维护 block_states 累积文本」策略 [doc.rs:1048-1103],已在共享匹配函数和累积方面改进,但根本问题仍在:
|
||||
|
||||
1. **full_content 模式必须跳过推导**:全文替换的最终 md 与原始 blocks 没有逐 op 对应关系
|
||||
2. **跨块替换不可映射**:一条 `search: "TODO"` 命中多个块时,推导层只产生一次 replace op
|
||||
3. **格式变化丢失**:heading/todo 前缀在 markdown 中记录了结构语义,推导层只传文本
|
||||
|
||||
---
|
||||
|
||||
## 2. 设计目标
|
||||
|
||||
### 2.1 核心原则
|
||||
|
||||
> **最终 markdown 是写回的唯一真源。** 文本层算出的结果不经二次推导直接落地。
|
||||
|
||||
### 2.2 目标范围
|
||||
|
||||
- 在线 Convex 文档的 `mnote.doc.markdown_edit` 以最终 `md` 为唯一真源生成 `page.body.save` 的 `content` 载荷
|
||||
- 本地文件路径不变(已经以最终 md 直接 `fs::write`)
|
||||
- `mnote.block.*` / `mnote.doc.apply_block_ops` 不做改动
|
||||
- `full_content` 模式不再被拒绝,支持
|
||||
- 复杂块(mindmap、resource、table、image)在往返中保持不变
|
||||
|
||||
### 2.3 非目标(Phase C 范畴)
|
||||
|
||||
- 流式 apply(见 7-14 §7)
|
||||
- suggest/review 模式
|
||||
- 本地 `.md` 文件的块标识持久化(本地文件没有 block identity)
|
||||
|
||||
---
|
||||
|
||||
## 3. 方案:Markdown → Block Content 直接写回
|
||||
|
||||
### 3.1 流程图(替换后)
|
||||
|
||||
```
|
||||
搜索替换 → 最终 md ✅
|
||||
↓
|
||||
parse_final_markdown_to_blocks(&md, &original_blocks)
|
||||
↓
|
||||
[blocks with preserved IDs + metadata + new blocks]
|
||||
↓
|
||||
build_page_content(&original_body_content, &parsed_blocks)
|
||||
↓
|
||||
execute_page_body_save_from_aggregate(state, context, input, &aggregate, next_content, changed_blocks)
|
||||
```
|
||||
|
||||
### 3.2 数据流
|
||||
|
||||
```
|
||||
原始 blocks (来自 aggregate.body.blockDocument.blocks):
|
||||
b1: { type: "paragraph", text: "第一段", revisionRef: "r1" }
|
||||
b2: { type: "heading", text: "标题", revisionRef: "r2", props: { level: 2 } }
|
||||
b3: { type: "resource", ... } ← 复杂块,全文不做修改
|
||||
|
||||
↓ blocks_to_markdown (当前,增强前)
|
||||
|
||||
当前 md (include_ids=true):
|
||||
第一段 <!-- block:b1 -->
|
||||
标题 <!-- block:b2 -->
|
||||
[resource: 资源块] <!-- block:b3 -->
|
||||
|
||||
↓ 搜索替换
|
||||
|
||||
最终 md:
|
||||
新第一段 <!-- block:b1 -->
|
||||
新标题 <!-- block:b2 -->
|
||||
[resource: 资源块] <!-- block:b3 -->
|
||||
|
||||
↓ parse_final_markdown_to_blocks (新增)
|
||||
|
||||
解析后的 blocks:
|
||||
b1: { type: "paragraph", text: "新第一段", revisionRef: "r1" } ← 文本更新,其他不变
|
||||
b2: { type: "heading", text: "新标题", revisionRef: "r2", props: { level: 2 } }
|
||||
b3: { type: "resource", ... } ← 保持原始内容不动(复杂块不可编辑)
|
||||
|
||||
↓ build_page_content (新增)
|
||||
|
||||
最终 content (aggregate.body.content 格式):
|
||||
[b1_legacy, b2_legacy, b3_legacy] ← 复用原始块的非文本属性
|
||||
```
|
||||
|
||||
### 3.3 组件设计
|
||||
|
||||
#### 3.3.1 `blocks_to_markdown` 增强(已有函数,扩展)
|
||||
|
||||
当前只输出 `<!-- block:ID -->`,需要扩展为带类型和 revisionRef:
|
||||
|
||||
```rust
|
||||
// 当前:
|
||||
format!("{text} <!-- block:{id} -->")
|
||||
|
||||
// 增强后:
|
||||
format!("{text} <!-- block:{id}:{block_type}:{revision_ref} -->")
|
||||
|
||||
对 heading 块额外附加 `level` 字段:
|
||||
|
||||
```rust
|
||||
// heading 块:
|
||||
format!("{text} <!-- block:{id}:heading:{revision_ref}:level={level} -->")
|
||||
```
|
||||
|
||||
解析器还原后从 `level` 字段恢复正确的 heading 级别。
|
||||
```
|
||||
|
||||
对 heading 和 todo 等结构化块,前缀已经正确输出(`## `、`- [ ] `),解析器据此还原类型。
|
||||
|
||||
对复杂块(mindmap、resource、table、image)——即 `is_editable == false` 或在 `block_projection_blocks` 中标记了 `unsupportedReason` 的块——输出特殊标记使其不会被解析器修改:
|
||||
|
||||
```text
|
||||
[mnote-raw-block:block_id] <!-- block:{id}:{block_type}:{revision_ref} -->
|
||||
```
|
||||
|
||||
然后写回层把这些块从原始 blocks 中按 ID 复制,不做任何修改。
|
||||
|
||||
#### 3.3.2 `parse_final_markdown_to_blocks`(新增函数)
|
||||
|
||||
签名:
|
||||
|
||||
```rust
|
||||
/// 解析最终 markdown 为块列表,保留块元数据
|
||||
///
|
||||
/// * `final_md` — 搜索替换后的 markdown
|
||||
/// * `original_blocks` — 从 aggregate.body.blockDocument.blocks 读取的原始块
|
||||
///
|
||||
/// 返回解析后的块列表,每个块包含后续写回所需的所有字段
|
||||
fn parse_final_markdown_to_blocks(
|
||||
final_md: &str,
|
||||
original_blocks: &[Value],
|
||||
) -> Vec<ParsedBlockInfo> {
|
||||
// 1. 利用 comrak 或逐行解析提取
|
||||
// - `<!-- block:id:type:rev -->` 中的块元数据
|
||||
// - 对带 ID 的块:保留原始块的属性
|
||||
// - 对无 ID 的文本块:标记为"新块"
|
||||
// - 对 [mnote-raw-block:...] 标记:原样保留原始块
|
||||
// 2. 返回重建后的块列表
|
||||
}
|
||||
```
|
||||
|
||||
返回值 `ParsedBlockInfo`:
|
||||
|
||||
```rust
|
||||
struct ParsedBlockInfo {
|
||||
block_id: Option<String>, // None = 新块(无原始 ID)
|
||||
block_type: String, // "paragraph" / "heading" / ...
|
||||
text: String,
|
||||
block_revision_ref: Option<String>, // 从注释或原始块继承
|
||||
is_new: bool, // true = 非原始块,需要分配新 ID
|
||||
original_block: Option<Value>, // 从原始 blocks 复制(若 block_id 匹配)
|
||||
props: Option<Value>, // 从原始块保留的属性
|
||||
}
|
||||
```
|
||||
|
||||
解析步骤:
|
||||
|
||||
1. **按换行分割 markdown 行**,忽略空行
|
||||
2. **对每行提取 `<!-- block:id:type:rev -->` 注释**(正则:`<!-- block:([^:]+):([^:]+):([^: ]+) -->` 或类似)
|
||||
3. **若无注释但有 `[mnote-raw-block:id]` 标记 → 从原始 blocks 按 ID 复制**
|
||||
4. **若既无注释也无原始标记 → 创建新 paragraph 块**,无 `blockId`(由写入层生成)
|
||||
5. **从注释中获取 `block_type`;若无注释,从行前缀推断**(`## ` → heading,`- [ ] ` → todo)
|
||||
6. **用行中 `<!--` 前的部分作为文本内容**
|
||||
7. **对带 ID 的块,从原始 blocks 查找并复制 `revisionRef`、`props`**
|
||||
|
||||
#### 3.3.3 `build_page_content`(新增函数)
|
||||
|
||||
将解析后的块列表与原始 `body/content` 合并,生成最终的 Convex `content` 数组:
|
||||
|
||||
```rust
|
||||
fn build_page_content(
|
||||
original_content: &Value, // 原始的 aggregate.body.content
|
||||
parsed_blocks: &[ParsedBlockInfo],
|
||||
) -> Value {
|
||||
// 按 parsed_blocks 顺序遍历
|
||||
// - 若 block_id 在 original 中存在:
|
||||
// 复制 original 中该块的完整结构,仅替换 text
|
||||
// - 若 block_id 为 None(新块):
|
||||
// 生成新 block_id,作为 paragraph 追加
|
||||
// - 若 original 中的复杂块不在 parsed 中:
|
||||
// 保留(markdown_edit 不主动删块)
|
||||
// 返回 Value::Array
|
||||
}
|
||||
```
|
||||
|
||||
#### 3.3.4 在 `doc_markdown_edit` 中的集成(替换现有 online 写回)
|
||||
|
||||
```rust
|
||||
// 当前(doc.rs ~line 856):
|
||||
let block_ops = build_block_ops_from_markdown_edit(&blocks, &operations, applied);
|
||||
if applied > 0 && block_ops.is_empty() { ... }
|
||||
let revision = ...;
|
||||
let conflict_detection_key = ...;
|
||||
let apply_result = doc_apply_block_ops_with_meta(state, context, input, &aggregate, block_ops, revision, conflict_detection_key).await?;
|
||||
|
||||
// 替换为:
|
||||
let parsed = parse_final_markdown_to_blocks(&md, &blocks);
|
||||
let next_content = build_page_content(¤t_body_content(&aggregate), &parsed);
|
||||
let changed_blocks = build_changed_blocks_summary(&original_blocks, &parsed);
|
||||
execute_page_body_save_from_aggregate(state, context, input, &aggregate, next_content, changed_blocks).await?
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. 边界情况处理
|
||||
|
||||
### 4.1 原始块 ID 在最终 md 中消失
|
||||
|
||||
可能发生在:
|
||||
- 用户手动删除了 `<!-- block:id -->` 注释(极罕见,通过 AI 输出不可能)
|
||||
- 模型输出的 search/replace 跨段合并了内容
|
||||
|
||||
**策略**:保留该原始块在 `content` 中的位置。`build_page_content` 对在 original 中存在但不在 parsed 中的块,按原样复制到最终 content。
|
||||
|
||||
### 4.2 新建块(无原始 ID)
|
||||
|
||||
用户可能要求"在末尾加一段总结"。模型输出 `full_content` 或 search/replace 产生的新文本带 `<!-- block:new_paragraph -->`。解析器标记为 `is_new=true`,写入层分配新的 `blockId`。
|
||||
|
||||
**策略**:新块使用临时 ID 格式 `ai_block_{timestamp}_{counter}`,与现有 `mnote.block.insert_after` 的块 ID 风格一致。
|
||||
|
||||
### 4.3 复杂块保留
|
||||
|
||||
resource/mindmap/table/image 块在 `blocks_to_markdown` 中被序列化为特殊标记 `[mnote-raw-block:id]`。搜索替换通常不会命中这些行(因为它们不含普通文本),但如果意外命中:
|
||||
|
||||
**策略**:在 `search_replace` 中,如果 search 路径包含了复杂块的特殊标记,整条 operation 标记为 failed。解析器遇到 `[mnote-raw-block:...]` 标记时,直接从 original blocks 复制。
|
||||
|
||||
### 4.4 搜索替换未命中任何块(applied > 0 但所有命中都是新文本)
|
||||
|
||||
当模型输出的 full_content 完全不同于原文时可能出现。
|
||||
|
||||
**策略**:`parse_final_markdown_to_blocks` 对每一行都产生块。如果没有任何 `<!-- block:id -->` 注释,所有块标记为 `is_new=true`。`build_page_content` 追加新块在后面,同时保留所有原始复杂块。
|
||||
|
||||
### 4.5 revision / conflictDetectionKey 一致性
|
||||
|
||||
`build_block_ops_from_markdown_edit` 当前依赖 `doc_apply_block_ops_with_meta` 来传递 revision。替换为直接调用 `execute_page_body_save_from_aggregate`,它从 aggregate 读取 revision/conflictDetectionKey [block.rs:1178-1189]。
|
||||
|
||||
**策略**:新路径从已经读到的 `aggregate` 中获取 revision/conflictDetectionKey,与现有路径完全一致。
|
||||
|
||||
### 4.6 dryRun 模式
|
||||
|
||||
当前 online 路径中 dry_run 由 `doc_apply_block_ops` 内部处理(不真正写入)。新路径也需要支持:
|
||||
|
||||
**策略**:若 `dryRun == true`,调用 `build_page_content` 但不调用 `execute_page_body_save_from_aggregate`,返回 diff 预览。diff 格式与当前 `docs.md:plan_update` 的 diff 格式一致。
|
||||
|
||||
---
|
||||
|
||||
## 5. 与已有代码的互动
|
||||
|
||||
### 5.1 `build_block_ops_from_markdown_edit` 的删除
|
||||
|
||||
该函数不再被 `doc_markdown_edit` 调用。它是一个私有 `fn`(仅 `doc.rs` 内部可见),唯一调用者被移除后成为死代码。
|
||||
|
||||
**策略**:直接删除函数体。保留调用处的行作为注释(`// 退役:7-27 改为 final_md→blocks 直接写回`),供后续参考。
|
||||
|
||||
### 5.2 `block_projection_blocks` 的继续使用
|
||||
|
||||
仍然需要原始 blocks 作为元数据源(提取 revisionRef、props、复杂块)。不改变。
|
||||
|
||||
### 5.3 `local_markdown_parser` 的复用
|
||||
|
||||
`parse_final_markdown_to_blocks` 可以复用 `local_markdown_parser::markdown_to_blocks` 对 GFM 结构的解析(heading、todo、code block、list),但需要用自己的逻辑提取 `<!-- block:... -->` 注释。不一定要用 comrak 的 `HtmlBlock` 解析;更可靠的方法是正则提取注释,然后从剩余内容中推断块类型。
|
||||
|
||||
**策略**:默认使用简单的行级处理(非 comrak),因为 `blocks_to_markdown` 的输出是每行一块的简单格式,不需要完整的 GFM AST。
|
||||
|
||||
> **⚠️ 跨行约束**:行级处理仅适用于 `blocks_to_markdown` 产出的单行块格式。以下情况需要 fallback 到 `local_markdown_parser::markdown_to_blocks`:
|
||||
> - 代码块(`block_text` 含 `\n`)
|
||||
> - 用户通过 `full_content` 自由书写的多段 markdown
|
||||
> - 解析跳过了 `<!-- block:id -->` 的行之间的纯段落
|
||||
>
|
||||
> fallback 策略:对无 `<!-- block:... -->` 注释的连续行,收集后一次性通过 GFM 解析器分割为多个常规块。
|
||||
|
||||
### 5.4 `changed_blocks` 摘要生成
|
||||
|
||||
当前返回的 `applyResult.changedBlocks` 由 `doc_apply_block_ops` 产生。新路径需要自己生成:
|
||||
|
||||
```rust
|
||||
fn build_changed_blocks_summary(
|
||||
original_blocks: &[Value],
|
||||
parsed_blocks: &[ParsedBlockInfo],
|
||||
) -> Vec<Value> {
|
||||
// 对比原始 blocks 和解析后的 blocks
|
||||
// 对文本改变的块输出 { op: "replace", blockId, content }
|
||||
}
|
||||
```
|
||||
|
||||
响应体中的 `changedBlocks` 字段格式不变,保持与下游消费者(SSE delta 等)的兼容。
|
||||
|
||||
---
|
||||
|
||||
## 6. 实施计划
|
||||
|
||||
### 步骤 1:增强 `blocks_to_markdown`(小)
|
||||
|
||||
**文件**: `doc.rs:574`
|
||||
|
||||
改动:
|
||||
- 注释格式从 `<!-- block:{id} -->` 改为 `<!-- block:{id}:{type}:{rev} -->`
|
||||
- 对复杂块(`editable==false` 或 `unsupportedReason!=null`)输出 `[mnote-raw-block:{id}] <!-- block:{id}:{type}:{rev} -->`
|
||||
|
||||
**风险**: 低。纯格式变更,向前兼容——旧注释格式的 md 在解析器看来只是缺类型/rev,可通过 fallback 从原始 blocks 查。
|
||||
|
||||
**测试**: 更新现有 `test_blocks_to_markdown_with_ids`,验证新格式。
|
||||
|
||||
### 步骤 2:实现 `parse_final_markdown_to_blocks`(中)
|
||||
|
||||
**新建模块**:`doc_md_to_blocks.rs` 或放在 `doc.rs` 末尾
|
||||
|
||||
核心逻辑(~120 行):
|
||||
|
||||
```rust
|
||||
struct ParsedBlock {
|
||||
block_id: Option<String>,
|
||||
block_type: String,
|
||||
text: String,
|
||||
revision_ref: Option<String>,
|
||||
original: Option<Value>,
|
||||
is_new: bool,
|
||||
}
|
||||
|
||||
fn parse_final_markdown_to_blocks(md: &str, originals: &[Value]) -> Vec<ParsedBlock> {
|
||||
// 1. 为 originals 建立 block_id → Value 的 HashMap
|
||||
// 2. 将 md 按行分割
|
||||
// 3. 对每行:
|
||||
// a. 用正则提取 <!-- block:id:type:rev --> 或 [mnote-raw-block:id]
|
||||
// b. 若无注释 → is_new=true, type="paragraph"
|
||||
// c. 若有注释 → 查 originals_map 继承 revision_ref/props
|
||||
// d. 提取 <!-- 前的纯文本
|
||||
// 4. 判断块类型:若注释中有 type 则用注释的,否则从行前缀推断
|
||||
// 5. 返回 ParsedBlock 列表
|
||||
}
|
||||
```
|
||||
|
||||
**正则示例**:
|
||||
|
||||
```rust
|
||||
lazy_static! {
|
||||
static ref BLOCK_COMMENT_RE: Regex = Regex::new(
|
||||
r"<!--\s*block:([a-zA-Z0-9_-]+):([a-zA-Z0-9_-]+):([a-zA-Z0-9_-]*)\s*-->"
|
||||
).unwrap();
|
||||
static ref RAW_BLOCK_RE: Regex = Regex::new(
|
||||
r"\[mnote-raw-block:([a-zA-Z0-9_-]+)\]"
|
||||
).unwrap();
|
||||
}
|
||||
```
|
||||
|
||||
**测试**:
|
||||
- `test_parse_empty_md` → 空输入 → 空输出
|
||||
- `test_parse_with_block_ids` → 包含 `<!-- block:b1:paragraph:r1 -->` → 返回正确 ParsedBlock
|
||||
- `test_parse_no_ids` → 纯文本 → 全部 is_new
|
||||
- `test_parse_raw_block` → `[mnote-raw-block:b3]` → 从 originals 复制
|
||||
- `test_parse_type_from_prefix` → `## Title` → type="heading"
|
||||
- `test_parse_todo_prefix` → `- [x] Done` → type="todo"
|
||||
|
||||
### 步骤 3:实现 `build_page_content`(中)
|
||||
|
||||
**文件**: 与步骤 2 同模块(~80 行)
|
||||
|
||||
```rust
|
||||
fn build_page_content(original_content: &Value, parsed: &[ParsedBlock]) -> Value {
|
||||
// 1. 为 original_content 建立 block_id → full_block 映射
|
||||
// 2. 遍历 parsed_blocks:
|
||||
// - 有 block_id 且 original 中存在 → 复制 original 块,更新 text
|
||||
// - 无 block_id → 创建新 paragraph 块
|
||||
// - 有 block_id 但不在 original 中 → 复制 original 中能找到的(从已处理集合中移除)
|
||||
// 3. 遍历 original_content 中的复杂块(不在 parsed 中出现的)→ 追加
|
||||
// 4. 返回 Value::Array
|
||||
}
|
||||
```
|
||||
|
||||
**测试**:
|
||||
- `test_build_content_preserves_unmodified_blocks`
|
||||
- `test_build_content_updates_text`
|
||||
- `test_build_content_preserves_complex_blocks`
|
||||
- `test_build_content_new_blocks_appended`
|
||||
|
||||
### 步骤 4:替换 `doc_markdown_edit` 的 online 写回路径(小)
|
||||
|
||||
**文件**: `doc.rs:854-900`
|
||||
|
||||
将现有的:
|
||||
```rust
|
||||
let aggregate = aggregate_value(state, context, input).await?;
|
||||
let blocks = block_projection_blocks(&aggregate);
|
||||
let block_ops = build_block_ops_from_markdown_edit(&blocks, &operations, applied);
|
||||
if applied > 0 && block_ops.is_empty() { ... }
|
||||
let apply_result = doc_apply_block_ops_with_meta(...).await?;
|
||||
```
|
||||
|
||||
替换为:
|
||||
```rust
|
||||
let aggregate = aggregate_value(state, context, input).await?;
|
||||
let blocks = block_projection_blocks(&aggregate);
|
||||
let parsed = parse_final_markdown_to_blocks(&md, &blocks);
|
||||
let original_content = current_body_content(&aggregate);
|
||||
let next_content = build_page_content(&original_content, &parsed);
|
||||
let changed_blocks = build_changed_blocks_summary(&blocks, &parsed);
|
||||
let apply_result = execute_page_body_save_from_aggregate(
|
||||
state, context, input, &aggregate, next_content, changed_blocks
|
||||
).await?;
|
||||
```
|
||||
|
||||
新增 `build_changed_blocks_summary`(~30 行)。
|
||||
|
||||
**风险**: 中。第一次替换时需要与现有测试对照输出,确保 changed_blocks 格式一致。
|
||||
|
||||
**测试**:
|
||||
- 现有 `hermes_tools_markdown_edit_*` 测试全部重新运行——断言现有行为不变
|
||||
- 新增 `test_markdown_edit_online_single_replacement`:验证单块替换后 content 正确
|
||||
- 新增 `test_markdown_edit_online_full_content`:验证全文替换不再被拒绝
|
||||
- 新增 `test_markdown_edit_online_preserves_complex_blocks`:验证 mindmap 块不变
|
||||
|
||||
### 步骤 5:更新 full_content 处理(小)
|
||||
|
||||
**文件**: `doc.rs:785-800`
|
||||
|
||||
当前 `full_content` 构造一个 `search: current_md.trim(), replace: full.trim()` 的 operation。替换后不再需要这个折中——直接解析 full_content 为 blocks。
|
||||
|
||||
改动:在 `parse_final_markdown_to_blocks` 中,若没有任何 `<!-- block:id -->` 注释,所有块标记为 is_new。`build_page_content` 只追加新块 + 保留原始复杂块。
|
||||
|
||||
**测试**:
|
||||
- `test_markdown_edit_online_full_content_creates_new_blocks`
|
||||
- `test_markdown_edit_online_full_content_preserves_complex`
|
||||
|
||||
### 步骤 6:清理(小)
|
||||
|
||||
- 删除 `build_block_ops_from_markdown_edit` 函数体(私有 fn,唯一调用者已移除)
|
||||
- 在原调用位置保留注释:`// 退役:7-27 改为 final_md→blocks 直接写回`
|
||||
- 更新 manifest/guidance 文字:full_content 不再被拒绝
|
||||
|
||||
---
|
||||
|
||||
## 7. 测试矩阵
|
||||
|
||||
| 场景 | 输入 | 期望输出 | 优先级 |
|
||||
|------|------|---------|--------|
|
||||
| 单块精确 search/replace | `{search:"第一段", replace:"新内容"}` | 文本更新,blockId/revisionRef 不变 | P0 |
|
||||
| 单块忽略空白 match | `{search:"第 一 段", replace:"新内容"}` | 文本更新(原块中"第一段"→"新内容") | P0 |
|
||||
| 多 operation 命中同块 | `[{op1},{op2}]` 同块 | 最终文本 = 两次替换累积结果 | P0 |
|
||||
| full_content | `"新全文"` | 新文本创建新块,原复杂块保留 | P0 |
|
||||
| 影响 heading | search 命中 heading 文本 | heading 类型不变,文本更新 | P1 |
|
||||
| 包含复杂块文档的替换 | 只改 paragraph 文本 | resource/mindmap 块不变 | P1 |
|
||||
| dryRun 不落盘 | `dryRun: true` | 返回 diff,无写入 | P1 |
|
||||
| revision 过期 | aggregate revision 已过期 | 冲突错误,不写入 | P1 |
|
||||
| 原始块 ID 消失 | 搜索替换删除了 `<!-- block -->` 注释 | 该原始块保留(不从 content 删除) | P2 |
|
||||
| 空 operations | `[]` | 错误码 | P2 |
|
||||
| 纯文本 md(无块注释) | `"摘要\n\n补充"` | 全部 is_new,追加到末尾 | P2 |
|
||||
|
||||
---
|
||||
|
||||
## 8. 灰度/回滚
|
||||
|
||||
不设 feature flag。直接替换 online 写回路径。理由:
|
||||
|
||||
- 旧路径(7-24/7-25 修复后)在 `full_content` 等场景是**安全降级(拒绝写入)**,新路径是功能增强。不会出现「旧路径能写入、新路径不能」的退化。
|
||||
- 现有 `hermes_tools_markdown_edit_*` 测试集 + 新增 5 个测试组合足够兜底。
|
||||
- 若确实需要回滚,用 `git revert` 回退本次改动即可。
|
||||
|
||||
灰度策略:默认开启,观察到新增测试全部 pass 后合入主线。
|
||||
|
||||
---
|
||||
|
||||
## 9. 关联文件清单
|
||||
|
||||
| 文件 | 变更类型 | 变更内容 |
|
||||
|------|---------|---------|
|
||||
| `doc.rs` `blocks_to_markdown` | 修改 | 注释格式增强 |
|
||||
| `doc.rs` `doc_markdown_edit` | 修改 | online 写回路径 |
|
||||
| 新建 `doc_md_to_blocks.rs` | 新增 | `parse_final_markdown_to_blocks` + `build_page_content` + `build_changed_blocks_summary` |
|
||||
| `doc.rs` `build_block_ops_from_markdown_edit` | 标记 | `#[deprecated]` |
|
||||
| `hermes_tools.rs` tests | 新增 | ~5 个新测试 |
|
||||
| `manifest.rs` / guidance.md | 微调 | full_content 不再标记为拒绝 |
|
||||
|
||||
---
|
||||
|
||||
## 10. 开放问题
|
||||
|
||||
1. **`<!-- block:id:type:rev -->` 注释在行中可能被模型生成的 search/replace 破坏**。例如模型输出 `{search: "第一段 <!-- block:", replace: "新段"}` 前半个注释。这是用户级错误(模型错误地替换了元数据),写入层应在 parse 阶段检测不完整的注释并报错。
|
||||
2. **`full_content` 场景下的块类型保留**。如果用户要求"把整个文档改写成大纲格式",模型输出不包含 `<!-- block-->` 注释的全部新文本,此时 heading/todo 类型从 GFM 前缀推断。但列表、引用、代码块等格式需确认 `local_markdown_parser` 的 `markdown_to_blocks` 返回的结构是否足够完整。
|
||||
3. **行内格式(bold、italic、link)能否在往返中保持**。当前 `blocks_to_markdown` 只输出文本(`block_text`),丢弃所有 marks。在线文档的 text 存储包含 marks(bold/italic/code/link),当前 `doc_markdown_edit` 的整体设计不保证行内格式——这超出了 7-24/7-25 的范围,但建议在 7-14 的 Phase A/B 之后评估。
|
||||
@@ -1,16 +1,17 @@
|
||||
# 10-review 审查总览
|
||||
|
||||
> 执行状态:`done/01` 到 `done/07` 均已归档;`process/08` 是当前活跃架构收口与下一阶段优先级 review;`process/09` 记录页面 AI 快速块编辑 runtime 的阶段性结论与下一步基建方向。
|
||||
> 执行状态:`done/01` 到 `done/11` 均已归档;当前无活跃 10-review process 文档。
|
||||
|
||||
当前活跃审查:
|
||||
|
||||
- [Kernel 架构收口与下一阶段优先级 Review / Checklist](./process/08-kernel-architecture-next-priority-review-and-checklist.md)
|
||||
- [页面 AI 快速块编辑 Runtime Review](./process/09-page-ai-fast-block-edit-runtime-review.md)
|
||||
- [当前 mnote 项目 AI / Page Aggregate 定向 Review](./process/10-current-mnote-ai-runtime-review-v1.md)
|
||||
- [当前完整架构 Review](./process/11-current-full-architecture-review-v1.md)
|
||||
- 暂无。
|
||||
|
||||
最新归档审查:
|
||||
|
||||
- [Kernel 架构收口与下一阶段优先级 Review / Checklist](./done/08-kernel-architecture-next-priority-review-and-checklist.md)
|
||||
- [当前 mnote 项目 AI / Page Aggregate 定向 Review](./done/10-current-mnote-ai-runtime-review-v1.md)
|
||||
- [当前完整架构 Review](./done/11-current-full-architecture-review-v1.md)
|
||||
- [页面 AI 快速块编辑 Runtime Review](./done/09-page-ai-fast-block-edit-runtime-review.md)
|
||||
- [VSCode Explorer 文件树与垃圾箱闭环增量审查](./done/07-vscode-explorer-filetree-trash-gap-review.md)
|
||||
|
||||
上一轮最终执行入口:
|
||||
|
||||
+153
-79
@@ -1,8 +1,8 @@
|
||||
# 08 [process] Kernel 架构收口与下一阶段优先级 Review / Checklist v1
|
||||
# 08 [done] Kernel 架构收口与下一阶段优先级 Review / Checklist v1
|
||||
|
||||
> 更新时间:2026-05-16
|
||||
> 更新时间:2026-05-18
|
||||
>
|
||||
> 执行状态:`process`
|
||||
> 执行状态:`done`
|
||||
>
|
||||
> 关联文档:
|
||||
> - `/mnt/Data1T/mnote/AGENTS.md`
|
||||
@@ -35,14 +35,14 @@
|
||||
- Page Aggregate 仍是过渡态,block projection 主要从 `documents.content` / local markdown content 投影,不是 EditorBlockDocument 原生落库完成态。
|
||||
- 标题、正文、页面设置、page tree、AI 写入口尚未完全闭环到同一组 projection / command family。
|
||||
- `tree.*` 已是 preferred command name,但 `documents.*` 兼容命令面仍未完全降级。
|
||||
- `/api/tree/events` 已是 tree realtime 主链,但 Sidebar、page subtree、filetree、preferred snapshot 还没有完全统一到一套 live cache。
|
||||
- `/api/realtime/ws` 已是 tree realtime 主链,`/api/tree/events` 已降级为 SSE fallback;live cache 的后续重点是减少兼容 fallback 与补偿链。
|
||||
- AI 块工具已有最小闭环,但当前编辑路径以块操作(blockId)为中心——AI 被迫理解 UI 层概念,且本地 `.md` 文件无 AI 写入路径。`7-14` 提出应将 AI 编辑主路径修正为 markdown 文本级(search/replace),`mnote.block.*` 降级为结构性辅助,在线 Convex 文档与本地文件共用同一条 markdown 写入路径。
|
||||
|
||||
补充 BlockNote / Tiptap AI 参考后的判断:
|
||||
|
||||
- BlockNote AI 和 Tiptap AI Toolkit 可以参考的是 AI runtime shape 与 tool contract,不是 mnote 的事实源。
|
||||
- AI 方向下一步只应补基础底座:`PageAIContextBuilder`、`scope=selection`、`page_xml/text`、tool manifest annotations、`PageAIReviewSession`、accept/reject/retry/abort 状态机。
|
||||
- 2026-05-16 补充(受 `7-14` 方向修正):AI 编辑主路径应从块级操作调整为 markdown 文本级 search/replace,新增 `mnote.doc.markdown_edit` 作为主要 AI 写入工具;当前 `local_rule` planner(`direct_block_edit_operations`)是过渡实现,应退役。在线 Convex 文档和本地 `.md` 文件共用同一条写入路径。
|
||||
- 2026-05-18 补充(`7-18` 到 `7-25` 收口后):AI 简单正文编辑主路径已切到 markdown 文本级 search/replace / full_content,`page_ai_workflow.rs` 当前通过模型生成 markdown 编辑意图后调用 `mnote.doc.markdown_edit`,并复用统一 mnote tool executor;`local_rule` planner(`direct_block_edit_operations`)不再作为当前 runtime 口径。在线 Convex 文档和本地 `.md` 文件共用同一条 markdown 写入合同。
|
||||
- 在非 AI 主架构和 AI 基础合同打牢之前,不应扩展新的 AI agent 工作流、复杂 AI UI 或跨页面智能功能。
|
||||
|
||||
因此,下一阶段不应优先“大改架构”或“大量加新功能”,而应优先做:
|
||||
@@ -89,7 +89,7 @@
|
||||
|
||||
目标:
|
||||
|
||||
- `/api/tree/events` 的 snapshot / delta / resync 成为 Sidebar、Page Tree、File Tree、page subtree 的共同 live cache 来源。
|
||||
- `/api/realtime/ws` 的 snapshot / delta / resync 成为 Sidebar、Page Tree、File Tree、page subtree 的共同 live cache 来源,`/api/tree/events` 仅作为 SSE fallback。
|
||||
- 减少 query/refetch/freshness 补偿链和旧快照回闪。
|
||||
|
||||
### P0:AI 基础工具与审阅底座
|
||||
@@ -115,13 +115,13 @@
|
||||
- 不新增复杂 AI 自动化功能面。
|
||||
- 不把 `@blocknote/xl-ai` 或 BlockNote `AIExtension` 作为 runtime dependency。
|
||||
- 不让 AI 写入绕过 `dryRun/idempotencyKey/revision/conflictDetectionKey/revisionRef`。
|
||||
- **不把 `direct_block_edit_operations` / `local_rule` planner 当作长期 AI 编辑路径(当前为过渡实现,将被 `markdown_edit` 替代)。**
|
||||
- **不把 `direct_block_edit_operations` / `local_rule` planner 当作当前或长期 AI 编辑路径;简单正文编辑当前主路径是 `mnote.doc.markdown_edit`,结构性块操作才使用 `apply_block_ops` / `mnote.block.*`。**
|
||||
- **不新增 `mnote.block.*` 工具;现有保留为结构性辅助。**
|
||||
|
||||
**2026-05-16 新增(受 7-14 方向修正):**
|
||||
**2026-05-18 状态更新(`7-18` 到 `7-25` 收口后):**
|
||||
|
||||
- **Phase A(当前唯一活跃实施)**:`mnote.doc.markdown_edit` + `mnote.doc.fetch` 增强(search/replace + `format: "markdown"` + local source + Hermes 注册,7-14 v2 Phase A)。
|
||||
- **Phase B(下一阶段)**:退役 `direct_block_edit_operations`,`page_ai_workflow.rs` 改走 `markdown_edit`(7-14 v2 Phase B)。
|
||||
- **Phase A / B 主路径已完成当前收口**:`mnote.doc.markdown_edit` + `mnote.doc.fetch` 增强、Hermes manifest / guidance、`page_ai_workflow.rs` → `markdown_edit`、统一 mnote tool executor、local / online markdown source 合同已同步。
|
||||
- **Phase C 仍冻结**:流式 apply + suggest/review 只保留设计边界,当前不实施、不扩新 AI 功能。
|
||||
- 在线 Convex 文档和本地 `.md` 文件共用同一条 markdown AI 写入路径(`resolve_source` → Convex | LocalFS)。
|
||||
|
||||
### P1:定向 Bug Hunt
|
||||
@@ -144,8 +144,8 @@
|
||||
|
||||
### 3.1 Page Aggregate 单一真源
|
||||
|
||||
- [x] 盘点 `wolai-frontend/src/lib/documents/page-aggregate-loader.ts` 是否仍有 runtime fallback 或 TS builder 读取分支。
|
||||
- [x] 盘点 `wolai-frontend/src/lib/documents/page-aggregate-builder*` 的引用,确认只剩 test helper / historical adapter。
|
||||
- [x] 盘点历史 `wolai-frontend/src/lib/documents/page-aggregate-loader.ts` 是否仍有 runtime fallback 或 TS builder 读取分支。
|
||||
- [x] 盘点历史 `wolai-frontend/src/lib/documents/page-aggregate-builder*` 的引用,确认只剩 test helper / historical adapter。
|
||||
- [x] 检查 `/api/documents/page` 仍返回明确 `410`,不参与 runtime 主链。
|
||||
- [x] 跑文档页打开 smoke,记录 `/api/page-aggregate/:id` 是首要读链。
|
||||
- [x] 新建页面后检查 Page Aggregate `identity/head/body/tree/stats` 字段完整。
|
||||
@@ -156,7 +156,7 @@
|
||||
- [x] 破坏或暂停 Convex query,检查响应是 degraded/error,不返回伪 fixture。
|
||||
- [x] 在 `design/05-editor-mainline/process/5-6-page-aggregate-alignment-checklist-v1.md` 勾选已验证项,并补证据路径。
|
||||
|
||||
2026-05-16 静态审计证据:
|
||||
2026-05-16 静态审计证据(历史 Next 前端退役前快照):
|
||||
|
||||
- `wolai-frontend/src/lib/documents/page-aggregate-loader.ts` 只通过 `loadPageAggregateFromRustSnapshot` 请求 Rust `/api/page-aggregate/:documentId`,`loadPageAggregate` 不再回退 TS builder;`buildServerBridgeRequest("/documents/page")` 只用于构造转发 header/request,不调用 Next `/api/documents/page` compat route。
|
||||
- `rg -n "buildPageAggregateFromDocumentPayloads|page-aggregate-builder" wolai-frontend/src rust scripts design --glob '!design/05-editor-mainline/reference-code/**' --glob '!node_modules/**'` 显示 runtime 非测试引用仅剩 builder 定义;代码引用只有 `wolai-frontend/src/lib/documents/page-aggregate-builder.test.ts`。
|
||||
@@ -191,6 +191,8 @@
|
||||
- 证据:`tmp/page-aggregate-refresh-persistence-smoke/mp88fr6k.json`,截图:`tmp/page-aggregate-refresh-persistence-smoke/mp88fr6k.png`,stdout:`tmp/page-aggregate-refresh-persistence-smoke/latest.stdout.json`。
|
||||
- 验证结果:刷新前后 `head.title=Page Aggregate refresh mp88fr6k`、`body.revision=1`、`body.conflictDetectionKey=tree_1778929070007_1:1`、`blockDocument.blocks[0].text=Page Aggregate refresh body mp88fr6k`、`layout.pageOptions.wideLayout/smallText/layoutDensity=true/true/compact`;刷新后页头标题、`.ProseMirror` 正文、设置控件、`documentElement`、`.document-shell`、island root 和 `.editor-surface` 均保持最新值。
|
||||
- 修复点:`rust/crates/mnote-web/src/ssr/pages/layout.rs` 将 `initializePageUiSurfaces` 延后到 `DOMContentLoaded` 后执行,避免 layout 脚本早于嵌入 Page Aggregate JSON / island DOM 完成时把 runtime 属性按默认 pageOptions 应用。
|
||||
- 2026-05-18 复测并扩展 `scripts/task-page-aggregate-refresh-persistence-smoke.js`,补充 `mnote.doc.fetch` / `tree.pageSubtree` 同组回读证据。证据:`tmp/page-aggregate-refresh-persistence-smoke/mpagfma1.json`,截图:`tmp/page-aggregate-refresh-persistence-smoke/mpagfma1.png`。
|
||||
- 验证结果:刷新前后 `head.title=Page Aggregate refresh mpagfma1`、`body.revision=1`、`body.conflictDetectionKey=tree_1779063433221_1:1`、`blockDocument.blocks[0].text=Page Aggregate refresh body mpagfma1`、`layout.pageOptions.wideLayout/smallText/layoutDensity=true/true/compact`、`tree.pageSubtree.rootNodeId=tree_1779063433221_1` 均一致;`mnote.doc.fetch` 返回 `schema=mnote.page_ai_context.v1`,`revision/conflictDetectionKey/revisionRef/text` 与同一 Page Aggregate block 完全一致。
|
||||
- 缺陷记录:`bugs/05-editor-mainline/done/5-12-page-options-refresh-runtime-attrs-v1.md`。
|
||||
- 配套测试已通过:`cargo test --manifest-path rust/Cargo.toml -p mnote-web page_aggregate`、`cargo test --manifest-path rust/Cargo.toml -p mnote-web documents_options_route_executes_page_layout_update_options`。
|
||||
|
||||
@@ -216,47 +218,61 @@ cargo test --manifest-path rust/Cargo.toml -p mnote-web document_shell_errors_wi
|
||||
|
||||
### 3.2 Page Block AI Tooling
|
||||
|
||||
- [ ] 以 `7-10` 为工具执行清单、`7-11` 为 AI runtime 基础设计,逐 phase 检查已勾选项是否都有代码、测试、smoke 证据。
|
||||
- [x] 以 `7-10` 为工具执行清单、`7-12` 为当前 AI runtime 基础设计,逐 phase 检查已勾选项是否都有代码、测试、smoke 证据。
|
||||
- [x] 给 `mnote.doc.fetch scope=selection` 补真实选区上下文输入和返回结构。
|
||||
- [x] 给 `mnote.doc.fetch format=page_xml` 补最小 PageXML 输出。
|
||||
- [x] 给 `mnote.block.fetch format=text/page_xml` 补格式分支。
|
||||
- [x] 给 tool manifest 补 `annotations`,区分 readonly / destructive / requiresApproval / selectionEffect。
|
||||
- [x] 定义 `mnote.page_ai_context.v1`,明确 context 只来自 Page Aggregate projection。
|
||||
- [ ] 定义 `mnote.page_ai_review_session.v1`,明确 accept / reject / retry / abort 语义。
|
||||
- [ ] 给 `mnote.block.insert_after` 补多块插入限制和返回 inserted block ids。
|
||||
- [ ] 给 `mnote.block.move_after` 补标题带子块阻断 smoke。
|
||||
- [ ] 给 `mnote.block.move_after` 补列表项阻断 smoke。
|
||||
- [ ] 给 `mnote.block.move_after` 补表格 / mindmap / resource 阻断 smoke。
|
||||
- [x] 定义 `mnote.page_ai_review_session.v1`,明确 accept / reject / retry / abort 语义。
|
||||
- [x] 给 `mnote.block.insert_after` 补多块插入限制和返回 inserted block ids。
|
||||
- [x] 给 `mnote.block.move_after` 补标题带子块阻断 smoke。
|
||||
- [x] 给 `mnote.block.move_after` 补列表项阻断 smoke。
|
||||
- [x] 给 `mnote.block.move_after` 补表格 / mindmap / resource 阻断 smoke。
|
||||
- [x] 给 `mnote.block.replace` 补 stale revision 失败用例。
|
||||
- [x] 给 `mnote.block.replace` 补 stale blockRevisionRef 失败用例。
|
||||
- [x] 给写工具补重复 idempotencyKey 的端到端用例。
|
||||
- [x] 检查 `mnote.page.save` 在 manifest / UI 中继续标为页面级兜底,不显示为精确块编辑主入口。
|
||||
- [ ] 检查任何新增 AI surface 是否只是基础 review/context/tooling 验收,不是新功能扩展。
|
||||
- [ ] 更新 `design/07-ai/process/7-10-page-block-ai-tooling-execution-checklist-v1.md` 的每个 phase 证据。
|
||||
- [ ] 更新 `design/07-ai/process/7-12-page-ai-hermes-tool-routing-and-review-surface-v1.md` 的 Hermes 工具路由与审阅面 checklist。
|
||||
- [x] 检查任何新增 AI surface 是否只是基础 review/context/tooling 验收,不是新功能扩展。
|
||||
- [x] 更新 `design/07-ai/process/7-10-page-block-ai-tooling-execution-checklist-v1.md` 的每个 phase 证据。
|
||||
- [x] 更新 `design/07-ai/process/7-12-page-ai-hermes-tool-routing-and-review-surface-v1.md` 的 Hermes 工具路由与审阅面 checklist。
|
||||
|
||||
**新增(2026-05-16,受 7-14 方向修正):**
|
||||
2026-05-18 `mnote.block.insert_after` 多块插入证据:
|
||||
|
||||
**Phase A(当前唯一活跃实施):**
|
||||
- [ ] `mnote.doc.markdown_edit` 核心实现:resolve_source → search_replace → write_target
|
||||
- [ ] `mnote.doc.markdown_edit` 搜索替换语义:精确匹配 → fuzzy fallback → operationsFailed(7-14 §5)
|
||||
- [ ] `mnote.doc.markdown_edit` Convex 写入 adapter(复用 `page.body.save` 链路)
|
||||
- [ ] `mnote.doc.markdown_edit` 本地文件写入 adapter(`fs.writeFile` + GFM AST markdown 序列化)
|
||||
- [ ] `mnote.doc.fetch format: "markdown"` 增强(Page Aggregate 产出 PageMarkdown)
|
||||
- [ ] `mnote.doc.fetch` local source 支持(识别本地文件路径并返回 `.md` 原文)
|
||||
- [ ] Hermes tool manifest 注册 `mnote.doc.markdown_edit`
|
||||
- [ ] Hermes `mnote` plugin 更新:`mnote_doc_fetch` schema 增加 `format: "markdown"` 和本地文件路径支持
|
||||
- [ ] 乐观锁:`revision` 冲突检测
|
||||
- [ ] 单元测试 + smoke(在线 + 本地两种 source)
|
||||
- 代码:`rust/crates/mnote-web/src/hermes_tools/block.rs` 支持 `content` / `block` / `blocks` 输入;`blocks` 限制 `1..=20`;多块写入按输入顺序连续插入,并在结果返回 `insertedBlockIds`。
|
||||
- manifest:`rust/crates/mnote-web/src/hermes_tools/manifest.rs` 已声明 `blocks.minItems=1/maxItems=20`,并用 `anyOf` 表达 `content | block | blocks` 三种输入。
|
||||
- 测试:`cargo test --manifest-path rust/Cargo.toml -p mnote-web hermes_tools_block_insert_after -- --nocapture` 通过,覆盖多块成功和超过 20 个失败。
|
||||
- 回归:`cargo test --manifest-path rust/Cargo.toml -p mnote-web hermes_tools -- --nocapture` 通过,覆盖 Hermes tools 38 项。
|
||||
|
||||
**Phase B(下一阶段):**
|
||||
- [ ] `direct_block_edit_operations` 退役(`page_ai_workflow.rs` `local_rule` 分支)
|
||||
- [ ] `page_ai_workflow.rs` 修正:system prompt 改为产 search/replace 对,底层走 `mnote.doc.markdown_edit`
|
||||
- [ ] 补全 operation schema(`content` 字段)
|
||||
- [ ] 浏览器 smoke:自然语言编辑可用(不使用「」格式)
|
||||
2026-05-18 `mnote.block.move_after` 复杂块阻断证据:
|
||||
|
||||
- 代码:`rust/crates/mnote-web/src/hermes_tools/block.rs` 继续以同父级、叶子块、可移动类型、editable、自身移动为真实写入前阻断条件。
|
||||
- 测试:`cargo test --manifest-path rust/Cargo.toml -p mnote-web hermes_tools_block_move_after_blocks_complex_and_nested_blocks -- --nocapture` 通过,覆盖标题带子块、列表项、表格、mindmap、resource 均返回 `blocked=true` / `block_move_after_blocked`。
|
||||
- 回归:`cargo test --manifest-path rust/Cargo.toml -p mnote-web hermes_tools -- --nocapture` 通过,当前 Hermes tools 共 39 项。
|
||||
|
||||
**新增(2026-05-18,`7-18` 到 `7-25` 收口状态):**
|
||||
|
||||
**Phase A / B(当前主路径已收口):**
|
||||
- [x] `mnote.doc.markdown_edit` 核心实现:resolve_source → search_replace / full_content → write_target
|
||||
- [x] `mnote.doc.markdown_edit` 搜索替换语义:精确匹配、归一化匹配、多操作同块合并、无法安全映射时失败
|
||||
- [x] `mnote.doc.markdown_edit` Convex 在线写入 adapter 以最终 markdown 映射写回
|
||||
- [x] `mnote.doc.markdown_edit` 本地文件写入 adapter 尊重 dryRun / idempotency
|
||||
- [x] `mnote.doc.fetch format: "markdown"` 增强
|
||||
- [x] Hermes tool manifest 注册并描述 `mnote.doc.markdown_edit` 写入合同
|
||||
- [x] 乐观锁:revision / conflictDetectionKey 前置校验
|
||||
- [x] `direct_block_edit_operations` / `local_rule` 不再作为 `page_ai_workflow.rs` 当前主路径
|
||||
- [x] `page_ai_workflow.rs` 改为模型产 markdown search/replace / full_content,底层走 `mnote.doc.markdown_edit`
|
||||
- [x] 单元测试覆盖 markdown_edit / page_ai_workflow / Hermes guidance / ACP tool contract
|
||||
|
||||
**Phase C(设计冻结,不实施):**
|
||||
- [ ] 流式 apply + suggest/review 设计已冻结,不阻塞 Phase A/B 实施
|
||||
- [x] 流式 apply + suggest/review 设计已冻结,不阻塞 Phase A/B 实施
|
||||
|
||||
2026-05-18 Review Session 合同定义:
|
||||
|
||||
- 已在 `design/07-ai/process/7-12-page-ai-hermes-tool-routing-and-review-surface-v1.md` 定义 `mnote.page_ai_review_session.v1` 最小 schema。
|
||||
- 已明确状态:`draft/planning/previewing/awaiting_user/accepted/rejected/applying/applied/failed/aborted/stale`。
|
||||
- 已明确动作约束:`accept` 必须重新读 Page Aggregate 并校验 revision / conflictDetectionKey / revisionRef,`reject/abort` 不产生写入,`retry` 必须生成新 proposal 或 dry-run preview,yolo 模式也应生成同构 audit 数据。
|
||||
- 边界:这里只完成合同定义,不实施 Phase C 流式 apply 或新的审阅 UI。
|
||||
|
||||
2026-05-16 Page Block AI context / format focused smoke 证据:
|
||||
|
||||
@@ -271,15 +287,17 @@ cargo test --manifest-path rust/Cargo.toml -p mnote-web document_shell_errors_wi
|
||||
- 边界:本轮只验证 `mnote.doc.apply_block_ops` 的 selection scope guard;单个 `mnote.block.replace/insert_after/move_after` 尚未校验 `allowedTargetBlockIds`,不能据此勾选完整 selection 写保护矩阵。
|
||||
- 配套验证已通过:`node --check scripts/task-page-block-ai-context-format-smoke.js`、`cargo test --manifest-path rust/Cargo.toml -p mnote-web hermes_tools -- --nocapture`、`MNOTE_UI_BASE_URL=http://127.0.0.1:3000 MNOTE_AUTH_BASE_URL=http://127.0.0.1:3000 node scripts/task-page-block-ai-context-format-smoke.js`。
|
||||
|
||||
2026-05-16 Page Block AI stale / idempotency focused smoke 证据:
|
||||
2026-05-16 / 2026-05-18 Page Block AI stale / idempotency focused smoke 证据:
|
||||
|
||||
- 新增并运行 `scripts/task-page-block-ai-conflict-idempotency-smoke.js`,通过真实 3000 + 测试账号创建临时页,用 `mnote.page.save` 初始化 `p_1/p_2`,再围绕 `mnote.block.replace` 验证冲突和幂等安全边界。
|
||||
- 证据:`tmp/page-block-ai-conflict-idempotency-smoke/mp8dyqiq.json`,截图:`tmp/page-block-ai-conflict-idempotency-smoke/mp8dyqiq-page.png`。
|
||||
- 最新复测证据:`tmp/page-block-ai-conflict-idempotency-smoke/mpafrevs.json`,截图:`tmp/page-block-ai-conflict-idempotency-smoke/mpafrevs-page.png`。
|
||||
- 验证结果:
|
||||
- 首次 `mnote.block.replace` 携带最新 `revision/conflictDetectionKey/blockRevisionRef/idempotencyKey` 成功写入 `p_2`,返回 `commandName=page.body.save` 与 `commandId=page_body_save_req_1778938353824_11`。
|
||||
- 使用同一 `idempotencyKey=idem_conflict_replace_mp8dyqiq` 再次调用 `mnote.block.replace`,即使请求 content 改成不同文本,也 replay 同一 `commandId`,Page Aggregate 回读 `p_2` 仍为首次写入文本,`revisionAfterFirst=2`、`revisionAfterReplay=2`,确认不重复写入。
|
||||
- 使用旧 `revision/conflictDetectionKey` 调用 `mnote.block.replace` 返回 HTTP `400`、错误码 `mnote_tool_conflict`,正文保持首次写入结果。
|
||||
- 使用最新 `revision/conflictDetectionKey` 但旧 `blockRevisionRef` 调用 `mnote.block.replace` 返回 HTTP `400`、错误码 `mnote_tool_conflict`,正文保持首次写入结果。
|
||||
- 2026-05-18 复测中,重复 `idempotencyKey=idem_conflict_replace_mpafrevs` replay 同一 `commandId=page_body_save_req_1779062303997_525`,`revisionAfterFirst=2`、`revisionAfterReplay=2`;旧 revision 与旧 blockRevisionRef 仍均返回 `mnote_tool_conflict`。
|
||||
- 边界:本轮只覆盖 `mnote.block.replace` 直接 tool executor 的 stale revision / stale blockRevisionRef / idempotency replay;不代表 `mnote.block.insert_after` 幂等矩阵、所有写工具幂等矩阵、review session accept stale 或 accept/reject/retry/abort 已完成。
|
||||
- 配套验证已通过:`node --check scripts/task-page-block-ai-conflict-idempotency-smoke.js`、`cargo test --manifest-path rust/Cargo.toml -p mnote-web hermes_tools -- --nocapture`、`MNOTE_UI_BASE_URL=http://127.0.0.1:3000 MNOTE_AUTH_BASE_URL=http://127.0.0.1:3000 node scripts/task-page-block-ai-conflict-idempotency-smoke.js`。
|
||||
|
||||
@@ -302,11 +320,17 @@ node scripts/task-page-block-ai-tools-smoke.js
|
||||
- [x] 对 compat alias 返回增加 owner / deprecated 标识,避免被当作主链。
|
||||
- [x] 更新 `design/04-tree-domain/done/4-6-tree-command-protocol-cutover-stage2-v1.md` 或新增后续 process checklist。
|
||||
|
||||
2026-05-16 Tree Command Cutover 静态盘点与 alias 标识证据:
|
||||
2026-05-18 当前 Tree Command Cutover 状态:
|
||||
|
||||
- `3000` 当前主壳由 Rust `mnote-web` SSR 承载;主要页面生命周期动作通过 Rust `/api/tree/commands` route 生成 `tree.node.create`、`tree.node.rename`、`tree.subtree.move`、`tree.node.archive`、`tree.node.restore`、`tree.node.purge`、`tree.subtree.copy`。
|
||||
- `bridge-runtime` 仍接受历史 `documents.*` alias,但 execution plan / artifact 会标记 `preferredCommandName`、`compatCommandName`、`deprecatedAlias`,避免兼容命令被当成当前主链。
|
||||
- `wolai-frontend` / Next tree command route 只作为退役前历史审计材料,不再作为当前运行时主链依据。
|
||||
|
||||
2026-05-16 Tree Command Cutover 静态盘点与 alias 标识证据(历史 Next 前端退役前快照):
|
||||
|
||||
- 只读盘点命令:`rg -n "documents\\.(create|title\\.update|move|archive|restore|delete|purge|copy_tree)" rust wolai-frontend scripts --glob '!node_modules/**'`。
|
||||
- 前端主链:`wolai-frontend/src/lib/documents/tree-command-client.ts` 的新建、重命名、移动、归档、恢复、永久删除和复制均 POST `/api/tree/commands`,返回 meta 使用 `TREE_COMMAND_PROTOCOL.*.preferredCommandName`;`TREE_COMMAND_PROTOCOL` 中的 `documents.*` 只保留为 `compatCommandName`。
|
||||
- Next tree command route:`wolai-frontend/src/app/api/tree/commands/route.ts` 在 create / move / rename / archive / restore 分支分别构造 `tree.node.create`、`tree.subtree.move`、`tree.node.rename`、`tree.node.archive`、`tree.node.restore`。
|
||||
- 退役前 Next 前端主链:`wolai-frontend/src/lib/documents/tree-command-client.ts` 的新建、重命名、移动、归档、恢复、永久删除和复制均 POST `/api/tree/commands`,返回 meta 使用 `TREE_COMMAND_PROTOCOL.*.preferredCommandName`;`TREE_COMMAND_PROTOCOL` 中的 `documents.*` 只保留为 `compatCommandName`。
|
||||
- 退役前 Next tree command route:`wolai-frontend/src/app/api/tree/commands/route.ts` 在 create / move / rename / archive / restore 分支分别构造 `tree.node.create`、`tree.subtree.move`、`tree.node.rename`、`tree.node.archive`、`tree.node.restore`。
|
||||
- Rust Web tree route:`rust/crates/mnote-web/src/routes/tree.rs` 的 `create_command_wire` 输出 `tree.node.create`、`tree.node.rename`、`tree.subtree.move`、`tree.node.archive`、`tree.node.restore`、`tree.node.purge`、`tree.subtree.copy`;`tree_command` route 只接收 action,不接收旧 `documents.*` command name 作为主链输入。
|
||||
- 兼容保留:`bridge-runtime` 仍接受 `documents.create/title.update/move/delete/restore/purge/copy_tree`,但本轮已在对应 execution plan 的 `args_json.commandProtocol` 增加 `family=tree`、`owner=rust-runtime-kernel`、`preferredCommandName`、`compatCommandName`、`deprecatedAlias`;旧 `documents.*` alias 会标记 `deprecatedAlias=true`。
|
||||
- Transport 边界:`rust/crates/mnote-web/src/transport/convex.rs` 发送给 Convex legacy mutation 前会剥离 `commandProtocol`、`streamDeltaHint`、`domainEventHint`、`domainEventPlan(s)`,避免 legacy validator 把审计字段当写入参数。
|
||||
@@ -337,14 +361,20 @@ node scripts/task122-rust-web-create-page-ui-smoke.js
|
||||
- [x] 重命名页面后,双浏览器 A/B 检查 Sidebar / Breadcrumb / File Tree 一致更新。
|
||||
- [x] 移动页面后,双浏览器 A/B 检查 tree order 不回闪。
|
||||
- [x] 删除 / 恢复后检查 trash 与主树事件一致。
|
||||
- [x] 断开 SSE 后恢复,检查 resync 能把 UI 拉回正确状态。
|
||||
- [x] 断开 WS / SSE fallback 后恢复,检查 resync 能把 UI 拉回正确状态。
|
||||
- [x] 更新 `design/03-rust-web/process/3-3-rust-web-tree-realtime-event-stream-v1.md` 的已验证项。
|
||||
|
||||
2026-05-16 Tree Realtime Live Cache 静态盘点与 payload 覆盖证据:
|
||||
2026-05-18 当前状态更新:
|
||||
|
||||
- 只读审查确认 React `AppLayoutShell` 同时接入 `useSidebarData` 与 `useSidebarTreeStream`,`usePreferredSidebarSnapshot` 负责 freshness 仲裁;Page Tree / File Tree 通过 preferred snapshot 消费 `kernelSidebarTree` 与 `kernelFileTreeProjection`。
|
||||
- 仍未统一的补偿链:`useSidebarData` 还保留 Convex query、HTTP `/api/sidebar` fallback 与手动 `refetch`;mutation 后仍有 `refreshTree` / `sidebarQuery.refetch`;Rust SSR shell 与 React hook 目前各自可建立 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。
|
||||
- `/api/realtime/ws` 已成为 Rust SSR 主壳默认 transport(`convex-command-log-ws`),`/api/tree/events` 仅作为 SSE fallback。
|
||||
- `CURRENT_ARCHITECTURE.md` 与 `bugs/03-rust-web/done/3-16` 到 `3-18` 已同步:原“WS 文档口径 / 前端 SSE 实现”冲突、SSE push 跳过 polling safety net、WS/SSE delta 合同分裂均已修复。
|
||||
- 2026-05-16 下面的 SSE smoke 证据保留为迁移前历史验证材料,不再代表当前主链。
|
||||
|
||||
2026-05-16 Tree Realtime Live Cache 静态盘点与 payload 覆盖证据(WS 迁移前历史快照):
|
||||
|
||||
- 退役前只读审查确认 React `AppLayoutShell` 同时接入 `useSidebarData` 与 `useSidebarTreeStream`,`usePreferredSidebarSnapshot` 负责 freshness 仲裁;Page Tree / File Tree 通过 preferred snapshot 消费 `kernelSidebarTree` 与 `kernelFileTreeProjection`。
|
||||
- 退役前未统一的补偿链:`useSidebarData` 还保留 Convex query、HTTP `/api/sidebar` fallback 与手动 `refetch`;mutation 后仍有 `refreshTree` / `sidebarQuery.refetch`;Rust SSR shell 与 React hook 当时各自可建立 EventSource;page subtree 当时仍从 Page Aggregate client state 派生。
|
||||
- Rust stream 历史盘点确认 workspace snapshot 已同时加载 `KernelProjectionKind::SidebarTree` 与 `KernelProjectionKind::FileTree`,SSE payload 的 `data.dataset.kernel_sidebar_projection` / `data.dataset.kernel_file_tree_projection` 覆盖 page/file 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:<documentId>` file tree row。
|
||||
|
||||
@@ -425,21 +455,26 @@ node scripts/task449-tree-sse-reconnect-snapshot-recovery-smoke.js
|
||||
|
||||
### 3.5 定向 Bug Hunt
|
||||
|
||||
- [ ] 建立 bugs 分类:Page Aggregate 问题归 `bugs/05-editor-mainline/process/`。
|
||||
- [ ] Tree command / realtime / File Tree 问题归 `bugs/04-tree-domain/process/`。
|
||||
- [ ] AI tools 问题归 `bugs/07-ai/process/`。
|
||||
- [ ] 每个 bug 必须包含复现步骤、期望、实际、证据截图或 JSON、owner 判断。
|
||||
- [ ] 先补最小 failing smoke,再修实现。
|
||||
- [ ] 修复后移动到对应 `done/`,并记录验证命令。
|
||||
- [x] 建立 bugs 分类:Page Aggregate 问题归 `bugs/05-editor-mainline/process/`。
|
||||
- [x] Tree command / realtime / File Tree 问题归 `bugs/04-tree-domain/process/`。
|
||||
- [x] AI tools 问题归 `bugs/07-ai/process/`。
|
||||
- [x] 每个 bug 必须包含复现步骤、期望、实际、证据截图或 JSON、owner 判断。
|
||||
- [x] 先补最小 failing smoke 或定向单测,再修实现。
|
||||
- [x] 修复后移动到对应 `done/`,并记录验证命令。
|
||||
|
||||
重点 bug 方向:
|
||||
|
||||
- [ ] Page Aggregate 与页头标题不一致。
|
||||
- [ ] Page Aggregate 与 File Tree `{title}.md` 不一致。
|
||||
- [ ] AI 写入成功但 Page Aggregate 回读旧内容。
|
||||
- [ ] stale revision 未阻断写入。
|
||||
- [ ] tree stream 断线恢复后 UI 停在旧快照。
|
||||
- [ ] 旧 compat / debug route 在 3000 首屏被误用。
|
||||
- [x] Page Aggregate 与页头标题不一致。
|
||||
- [x] Page Aggregate 与 File Tree `{title}.md` 不一致。
|
||||
- [x] AI 写入成功但 Page Aggregate 回读旧内容。
|
||||
- [x] stale revision 未阻断写入。
|
||||
- [x] tree stream 断线恢复后 UI 停在旧快照。
|
||||
- [x] 旧 compat / debug route 在 3000 首屏被误用。
|
||||
|
||||
2026-05-18 定向 bug hunt 收口:
|
||||
|
||||
- `find bugs -path '*/process/*' -type f | wc -l` 结果为 `0`。
|
||||
- 本轮 P0 / 内核优先缺陷已迁入 `bugs/*/done/`,并在 `CURRENT_ARCHITECTURE.md`、`design/10-review/done/10-current-mnote-ai-runtime-review-v1.md`、`design/10-review/done/11-current-full-architecture-review-v1.md` 中同步验证证据。
|
||||
|
||||
### 3.6 2026-05-16 暂停交接进展摘要
|
||||
|
||||
@@ -497,16 +532,55 @@ MNOTE_UI_BASE_URL=http://127.0.0.1:3000 MNOTE_AUTH_BASE_URL=http://127.0.0.1:300
|
||||
|
||||
## 4. 当前不优先做
|
||||
|
||||
- [ ] 不优先增加新的编辑器 UI 大功能。
|
||||
- [ ] 不优先新增 AI Agent 工作流功能。
|
||||
- [ ] 不优先扩展 AI 功能面;AI 只做基础上下文、工具合同、审阅会话、冲突与回滚。
|
||||
- [ ] 不优先大规模替换 Convex。
|
||||
- [ ] 不优先重写 `leptos-tiptap` 输入层。
|
||||
- [ ] 不把 `mnote.page.save` 包装成精确块编辑长期方案。
|
||||
- [ ] 不在 compat route 继续扩写长期业务语义。
|
||||
- [ ] 不把 BlockNote / Tiptap AI runtime 作为 mnote runtime 依赖。
|
||||
- [ ] 不把 `direct_block_edit_operations` / `local_rule` planner 当作长期 AI 编辑路径(当前为过渡实现,将被 `mnote.doc.markdown_edit` 替代)。
|
||||
- [ ] 不新增 `mnote.block.*` 工具;`mnote.block.*` 保留为结构性辅助,不做块操作向新工具的扩张。
|
||||
- [x] 不优先增加新的编辑器 UI 大功能。
|
||||
- [x] 不优先新增 AI Agent 工作流功能。
|
||||
- [x] 不优先扩展 AI 功能面;AI 只做基础上下文、工具合同、审阅会话、冲突与回滚。
|
||||
- [x] 不优先大规模替换 Convex。
|
||||
- [x] 不优先重写 `leptos-tiptap` 输入层。
|
||||
- [x] 不把 `mnote.page.save` 包装成精确块编辑长期方案。
|
||||
- [x] 不在 compat route 继续扩写长期业务语义。
|
||||
- [x] 不把 BlockNote / Tiptap AI runtime 作为 mnote runtime 依赖。
|
||||
- [x] 不把 `direct_block_edit_operations` / `local_rule` planner 当作当前或长期 AI 编辑路径;简单正文编辑当前主路径是 `mnote.doc.markdown_edit`。
|
||||
- [x] 不新增 `mnote.block.*` 工具;`mnote.block.*` 保留为结构性辅助,不做块操作向新工具的扩张。
|
||||
|
||||
2026-05-18 静态边界核验证据:
|
||||
|
||||
- `rg -n "mnote\\.block\\.[a-zA-Z0-9_]+" rust/crates/mnote-web/src/hermes_tools rust/crates/mnote-web/src/routes/hermes_tools.rs scripts/reasonix-acp-wrapper.mjs | ... | sort -u` 仅发现既有 `mnote.block.fetch/delete/replace/insert_after/move_after`,本轮未新增 `mnote.block.*` 工具。
|
||||
- `page_ai_workflow.rs` 注释与当前实现仍指向 `mnote.doc.markdown_edit`;`direct_block_edit_operations` 只保留为历史/测试辅助,不作为当前主路径。
|
||||
- `mnote.page.save` 在 manifest / 7-10 / 7-12 / 本 checklist 中继续描述为页面级粗粒度兜底,不是精确块编辑主入口。
|
||||
- 未引入 `@blocknote/xl-ai`、BlockNote `AIExtension` 或新的 AI Agent workflow;本轮改动仅限 Hermes tools 合同、阻断矩阵测试和 checklist 证据。
|
||||
|
||||
2026-05-18 `7-12` runtime 口径闭合证据:
|
||||
|
||||
- `design/07-ai/process/7-12-page-ai-hermes-tool-routing-and-review-surface-v1.md` 已明确 `7-11` 旧“自有 AI runtime”口径移入 `design/old/07-ai/process/`,当前执行口径以 `7-12` 为准。
|
||||
- `7-12` 已定义 `PageAIContextBuilder`、`MnoteAIToolManifestProvider`、`PageAICommandRouter`、`PageAIReviewSession` 的边界,并明确 Phase C review / streaming apply 只做设计冻结,当前不实施。
|
||||
- `7-12` Phase B 到 Phase F 保留 manifest / router / review session / event / smoke 的后续 checklist;其中 selection 外写入 blocked 已有真实 smoke 证据,其余未实施项继续保留未勾。
|
||||
|
||||
2026-05-18 `7-10` 剩余真实页面矩阵拆分:
|
||||
|
||||
- 新增 `design/07-ai/process/7-16-page-block-ai-real-smoke-followup-matrix-v1.md`,专门承接 `7-10` 中仍未完成的真实 3000 smoke 和 UI/review 边界矩阵。
|
||||
- `7-10` 继续保留为页面块 AI 工具执行总 checklist;`7-16` 只用于后续真实页面证据补齐,不扩新 AI 功能。
|
||||
|
||||
2026-05-18 真实页面 insert_after smoke 补充:
|
||||
|
||||
- `scripts/task-page-block-ai-tools-smoke.js` 增加 `PLAYWRIGHT_CHROME_EXECUTABLE` 支持,用系统 Chromium 运行,避免当前系统无法下载 Playwright 官方 chromium 的阻断。
|
||||
- 命令:`PLAYWRIGHT_CHROME_EXECUTABLE=/snap/bin/chromium MNOTE_UI_BASE_URL=http://127.0.0.1:3000 MNOTE_AUTH_BASE_URL=http://127.0.0.1:3000 node scripts/task-page-block-ai-tools-smoke.js`
|
||||
- 证据:`tmp/page-block-ai-tools-smoke/mpag4966.json`,截图:`tmp/page-block-ai-tools-smoke/mpag4966-page.png`。
|
||||
- Page Aggregate:`tmp/hermes-tester/page-block-ai-tools-mpag4966/page-aggregate.json`,doc/fetch/find:`tmp/hermes-tester/page-block-ai-tools-mpag4966/doc-fetch-find.json`。
|
||||
- 覆盖:`mnote.block.insert_after` 插入 `todo` 块、插入顺序、`mnote.block.fetch` 回读新块、重复 `idempotencyKey` 不二次插入、刷新后文本可见。
|
||||
- 覆盖:`table_1/mindmap_1/resource_1` 在 AI block projection 中返回 `editable=false` 与 `unsupportedReason=复杂块暂不开放 AI 精确写入`;`heading_parent/list_item_1/table_1/mindmap_1/resource_1` 的 `plan_update block_move_after` dry-run 与正式 `mnote.block.move_after` 均阻断,阻断后 revision / 正文不变化。
|
||||
|
||||
2026-05-18 真实页面 doc.fetch / doc.find / block.fetch smoke 补充:
|
||||
|
||||
- 命令:`PLAYWRIGHT_CHROME_EXECUTABLE=/snap/bin/chromium MNOTE_UI_BASE_URL=http://127.0.0.1:3000 MNOTE_AUTH_BASE_URL=http://127.0.0.1:3000 node scripts/task-page-block-ai-tools-smoke.js`
|
||||
- 证据:`tmp/page-block-ai-tools-smoke/mpag4966.json`,截图:`tmp/page-block-ai-tools-smoke/mpag4966-page.png`。
|
||||
- 覆盖:`doc.fetch full`、`doc.fetch outline`、`doc.find` 定位 `p_2`、`maxBlocks=2` 截断、`block.fetch includeChildren=true contextBefore=1 contextAfter=1` 返回 `revisionRef` 与同父级 before/after;复杂块投影不伪装完全可编辑。
|
||||
|
||||
2026-05-18 真实页面 plan_update / replace / move smoke 补充:
|
||||
|
||||
- 命令:`PLAYWRIGHT_CHROME_EXECUTABLE=/snap/bin/chromium MNOTE_UI_BASE_URL=http://127.0.0.1:3000 MNOTE_AUTH_BASE_URL=http://127.0.0.1:3000 node scripts/task-page-block-ai-tools-smoke.js`
|
||||
- 证据:`tmp/page-block-ai-tools-smoke/mpag4966.json`,截图:`tmp/page-block-ai-tools-smoke/mpag4966-page.png`。
|
||||
- 覆盖:`block_replace/block_insert_after/block_move_after` dry-run、dry-run 不写入、blocked 场景、replace 相邻块不变与 id 保持、replace 后 Page Aggregate 回读持久化、复杂块 move 阻断、普通块 move dry-run 与正式移动、moving block id 保持、刷新后 block ids 稳定。
|
||||
|
||||
---
|
||||
|
||||
@@ -514,18 +588,18 @@ MNOTE_UI_BASE_URL=http://127.0.0.1:3000 MNOTE_AUTH_BASE_URL=http://127.0.0.1:300
|
||||
|
||||
本 checklist 不能迁入 `done/`,直到:
|
||||
|
||||
- [ ] Page Aggregate 的标题 / 正文 / 页面设置 / page tree / AI fetch 回读形成同一组可验证真相。
|
||||
- [ ] `7-10` 的 Page Block AI Tooling 剩余矩阵完成或明确拆出后续 process 文档。
|
||||
- [ ] `7-11` 的 AI 基础 runtime 口径完成:context、format、manifest annotations、review session、状态机边界都有代码或明确后续 checklist。
|
||||
- [ ] 7-14 v2 Phase A + B 全部完成(`mnote.doc.markdown_edit` + `mnote.doc.fetch` 增强 + `page_ai_workflow.rs` 收口);Phase C 设计已冻结。设计稿修正已由 7-14 v2 自行完成。
|
||||
- [ ] `tree.*` command 面对主要页面生命周期动作成为唯一 preferred runtime 主链。
|
||||
- [ ] tree realtime live cache 覆盖 Sidebar、Page Tree、File Tree、page subtree 的关键写后更新。
|
||||
- [ ] 至少一轮定向 bug hunt 完成,所有 P0/P1 blocker 已归档到 `bugs/*/done/` 或明确保留为后续 process。
|
||||
- [x] Page Aggregate 的标题 / 正文 / 页面设置 / page tree / AI fetch 回读形成同一组可验证真相。
|
||||
- [x] `7-10` 的 Page Block AI Tooling 剩余矩阵完成或明确拆出后续 process 文档。
|
||||
- [x] `7-12` 的 AI 基础 runtime 口径完成:context、format、manifest annotations、review session、状态机边界都有代码或明确后续 checklist。
|
||||
- [x] 7-14 v2 Phase A + B 当前主路径完成(`mnote.doc.markdown_edit` + `mnote.doc.fetch` 增强 + `page_ai_workflow.rs` 收口);Phase C 设计已冻结。设计稿修正已由 7-14 v2 与 `7-18` 到 `7-25` bug 修复共同完成。
|
||||
- [x] `tree.*` command 面对主要页面生命周期动作成为唯一 preferred runtime 主链。
|
||||
- [x] tree realtime live cache 覆盖 Sidebar、Page Tree、File Tree、page subtree 的关键写后更新。
|
||||
- [x] 至少一轮定向 bug hunt 完成,所有 P0/P1 blocker 已归档到 `bugs/*/done/` 或明确保留为后续 process。
|
||||
|
||||
---
|
||||
|
||||
## 6. 给后续 /goal 的持续执行 Prompt
|
||||
|
||||
```text
|
||||
/goal objective: 持续执行 /mnt/Data1T/mnote/design/10-review/process/08-kernel-architecture-next-priority-review-and-checklist.md,按 P0 -> P1 顺序推进 MNOTE 架构收口、AI 基础底座和定向 bug hunt。每轮开始先读取 /home/lix/.codex/memories/PROFILE.md 与 ACTIVE.md,再读取 AGENTS.md、ARCHITECTURE.md、design/01-05-current-priority-overview.md、本 checklist、design/07-ai/process/7-10-page-block-ai-tooling-execution-checklist-v1.md、design/07-ai/process/7-12-page-ai-hermes-tool-routing-and-review-surface-v1.md、design/07-ai/process/7-14-online-local-ai-markdown-editing-convergence-v1.md。必须保护用户已有未提交改动,不回滚、不覆盖、不删除无关文件。优先使用多个 subagent 并行做只读审查和浏览器验证,主线程只整合证据和做小范围实现。执行顺序固定为:1) Page Aggregate 单一真源;2) tree command cutover;3) tree realtime live cache;4) AI 基础工具与审阅底座,按 `7-14` v2 方向优先做 Phase A(`mnote.doc.markdown_edit` + `mnote.doc.fetch` 增强),Phase B 退役 `local_rule` planner + `page_ai_workflow.rs` 收口;Phase C(流式 apply + suggest/review)仅设计冻结不实施。同时补 context/selection/page_xml/tool annotations,不扩新 AI 功能;5) 定向 bug hunt。每完成一个小项都要更新本 checklist 和对应 design/bugs 文档,补真实验证命令或证据路径。不要优先扩新功能,不要大改架构,不要把 compat/debug/fallback 当主链,不要把 BlockNote/Tiptap AI runtime 作为 mnote runtime 依赖。验证至少包含 git diff --check、相关 cargo test / smoke;如涉及 3000 页面,使用 mnote-tester 或浏览器自动化并保留截图/JSON 证据。
|
||||
/goal objective: 持续核验 /mnt/Data1T/mnote/design/10-review/done/08-kernel-architecture-next-priority-review-and-checklist.md 的已归档结论,按 P0 -> P1 顺序推进 MNOTE 架构防回归、AI 基础底座和定向 bug hunt。每轮开始先读取 /home/lix/.codex/memories/PROFILE.md 与 ACTIVE.md,再读取 AGENTS.md、ARCHITECTURE.md、design/01-05-current-priority-overview.md、本 checklist、design/07-ai/process/7-10-page-block-ai-tooling-execution-checklist-v1.md、design/07-ai/process/7-12-page-ai-hermes-tool-routing-and-review-surface-v1.md、design/07-ai/process/7-14-online-local-ai-markdown-editing-convergence-v1.md。必须保护用户已有未提交改动,不回滚、不覆盖、不删除无关文件。优先使用多个 subagent 并行做只读审查和浏览器验证,主线程只整合证据和做小范围实现。执行顺序固定为:1) Page Aggregate 单一真源防回归;2) tree command cutover 防回归;3) tree realtime live cache 防回归;4) AI 基础工具与审阅底座,当前简单正文编辑主路径已是 `mnote.doc.markdown_edit` + `mnote.doc.fetch` + `page_ai_workflow.rs` 统一 tool executor,Phase C(流式 apply + suggest/review)仅设计冻结不实施;继续补 context/selection/page_xml/tool annotations,不扩新 AI 功能;5) 定向 bug hunt。每完成一个小项都要更新对应 design/bugs 文档,补真实验证命令或证据路径。不要优先扩新功能,不要大改架构,不要把 compat/debug/fallback 当主链,不要把 BlockNote/Tiptap AI runtime 作为 mnote runtime 依赖。验证至少包含 scoped git diff --check、相关 cargo test / smoke;如涉及 3000 页面,使用 mnote-tester 或浏览器自动化并保留截图/JSON 证据。
|
||||
```
|
||||
+14
-1
@@ -1,6 +1,6 @@
|
||||
# 09 页面 AI 快速块编辑 Runtime Review
|
||||
|
||||
> 状态:`process`
|
||||
> 状态:`done`
|
||||
>
|
||||
> 日期:2026-05-16
|
||||
>
|
||||
@@ -12,6 +12,19 @@
|
||||
|
||||
---
|
||||
|
||||
## 0. 归档说明
|
||||
|
||||
本文件是 2026-05-16 的页面 AI fast block edit 历史审查快照。其记录的 `local_rule` / `doc_apply_block_ops` 快路径已被后续 `mnote.doc.markdown_edit` 主路径替代,不再作为当前 runtime 口径。
|
||||
|
||||
当前有效口径见:
|
||||
|
||||
- [10-current-mnote-ai-runtime-review-v1](./10-current-mnote-ai-runtime-review-v1.md)
|
||||
- [11-current-full-architecture-review-v1](./11-current-full-architecture-review-v1.md)
|
||||
- [7-18 AI markdown_edit 阶段状态合同漂移](../../../bugs/07-ai/done/7-18-ai-markdown-edit-phase-state-contract-drift-v1.md)
|
||||
- [7-20 page_ai_workflow 绕过 Hermes tool executor / audit / toggle](../../../bugs/07-ai/done/7-20-page-ai-workflow-bypasses-hermes-tool-executor-v1.md)
|
||||
|
||||
归档后的结论:`page-ai/block-edit-workflow` 仍是简单编辑 fast-path 入口,但当前实现应通过模型生成 markdown search/replace / full_content 后调用 `mnote.doc.markdown_edit`,并复用统一 mnote tool executor;`mnote.doc.apply_block_ops` / `mnote.block.*` 只作为结构性块操作辅助。
|
||||
|
||||
## 1. 本轮结论
|
||||
|
||||
页面 AI 块写入的慢点不在 Rust 块工具本身,也不在 Convex 持久化本身。
|
||||
+32
-22
@@ -1,22 +1,24 @@
|
||||
# 10 [process] 当前 mnote 项目 AI / Page Aggregate 定向 Review v1
|
||||
# 10 [done] 当前 mnote 项目 AI / Page Aggregate 定向 Review v1
|
||||
|
||||
> 更新时间:2026-05-17
|
||||
>
|
||||
> 执行状态:`process`
|
||||
> 执行状态:`done`
|
||||
>
|
||||
> 范围:当前主线中 Page Aggregate 单一真源、页面 AI 快速编辑、`mnote.doc.markdown_edit` 与 ACP / Hermes runtime 的源码级定向审查。
|
||||
|
||||
## 1. 本轮结论
|
||||
|
||||
本轮没有发现 Page Aggregate 读取主链重新回退到 Next compat builder 的证据;`wolai-frontend/src/lib/documents/page-aggregate-loader.ts` 仍只走 Rust `/api/page-aggregate/:documentId`,`/api/documents/page` compat 读链保持 `410` 退场口径。
|
||||
本轮没有发现 Page Aggregate 读取主链重新回退到 Next compat builder 的证据。后续修复已进一步收口:AI context 的 page subtree 只读 Rust Page Aggregate projection,不再由前端本地构造第二份 page tree 真相。
|
||||
|
||||
新的风险集中在 `07-ai` 的 markdown 编辑收敛实现:
|
||||
本轮识别出的 `07-ai` markdown 编辑 P0 风险已修复:
|
||||
|
||||
- `mnote.doc.markdown_edit` 已进入实际写入主线,并被 `/api/page-ai/block-edit-workflow` 调用。
|
||||
- 该工具当前先在 markdown 字符串上顺序执行 search/replace,再把原始 operations 重新转换成 `mnote.doc.apply_block_ops`。
|
||||
- 转换层仍按“单块文本包含 search”查找目标块,无法忠实表达 markdown 层已经算出的最终结果。
|
||||
- `mnote.doc.markdown_edit` 是简单正文编辑主路径,并被 `/api/page-ai/block-edit-workflow` 通过统一 mnote tool executor 调用。
|
||||
- 中文归一化匹配不再把 UTF-8 byte index 当 char index。
|
||||
- 同一块内多次 markdown_edit 会合并到最终 markdown 结果后再映射写回,避免后续 block op 覆盖前序修改。
|
||||
- 在线写回在无法安全映射最终 markdown 时明确拒绝,避免 `ok=true` 伪成功。
|
||||
- Hermes guidance、manifest、tool toggle、dryRun / idempotency、revision / conflictDetectionKey 与 Reasonix ACP payload 已同步到当前工具合同。
|
||||
|
||||
因此,当前 AI 编辑主线不能只看 `operationsApplied > 0` 或 route 返回 `ok=true`。需要优先补充 `markdown_edit` 的中文、多操作、同块、多块、full_content 与失败原子性测试。
|
||||
剩余长期方向仍是 Phase C review session / streaming apply;当前设计冻结,不属于本轮 bug 修复范围。
|
||||
|
||||
## 2. 发现的问题
|
||||
|
||||
@@ -24,7 +26,7 @@
|
||||
|
||||
关联缺陷:
|
||||
|
||||
- `bugs/07-ai/process/7-16-markdown-edit-normalized-search-byte-index-v1.md`
|
||||
- `bugs/07-ai/done/7-16-markdown-edit-normalized-search-byte-index-v1.md`
|
||||
|
||||
证据:
|
||||
|
||||
@@ -41,7 +43,7 @@
|
||||
|
||||
关联缺陷:
|
||||
|
||||
- `bugs/07-ai/process/7-17-markdown-edit-same-block-multi-op-overwrite-v1.md`
|
||||
- `bugs/07-ai/done/7-17-markdown-edit-same-block-multi-op-overwrite-v1.md`
|
||||
|
||||
证据:
|
||||
|
||||
@@ -58,18 +60,20 @@
|
||||
- AI 一次请求中常见的“把同一段里的 A 改成 B,同时把 C 改成 D”可能只保留最后一次修改。
|
||||
- 前端快路径仍可能显示“已通过页面 markdown 编辑快路径完成写入”,但正文只部分生效。
|
||||
|
||||
## 3. 次级风险
|
||||
## 3. 修复状态
|
||||
|
||||
- `doc_markdown_edit` 当前允许部分 operation 失败后继续写入已成功的子集,并返回 `ok=true`。如果这是有意设计,需要在 manifest / UI 中明确“非原子”;如果不是,应改为任一 operation 失败时不写入。
|
||||
- `changedText` 通过 `operations.iter().take(applied)` 生成摘要;当前如果第一个 operation 失败、第二个成功,摘要会错误地展示第一个失败 operation。
|
||||
- `/api/page-ai/block-edit-workflow` 的系统 prompt 已改成 search/replace,但仍强依赖模型精确复制 `pageText`;一旦模型输出跨块片段,Convex 写回层不能表达该编辑。
|
||||
- `mnote.doc.markdown_edit` manifest 已补齐 required / write contract。
|
||||
- 本地 `.md` 写入已尊重 dryRun / idempotency。
|
||||
- 在线写回已使用最终 markdown 作为真源进行映射;无法安全映射时返回明确错误。
|
||||
- `mnote.doc.apply_block_ops` 批量写入已补 revision / conflictDetectionKey 前置校验。
|
||||
- `/api/page-ai/block-edit-workflow` 已从绕过 executor 改为复用统一 Hermes mnote tool executor。
|
||||
- ACP Reasonix wrapper 已透传 actor/session/run/toolCall/trace/dryRun/idempotency/workspace/document 等上下文字段。
|
||||
|
||||
## 4. 建议下一步
|
||||
## 4. 后续边界
|
||||
|
||||
1. 先给 `search_replace` 补中文归一化替换单测,再修正 byte / char offset 映射。
|
||||
2. 给 `doc_markdown_edit` 补同一 block 多 operation 的失败用例,明确应以最终 markdown 生成落库内容,或在转换层合并同块操作。
|
||||
3. 明确 `markdown_edit` 的失败原子性:默认建议任一 operation 失败时不写入,除非请求显式允许 partial apply。
|
||||
4. 将 `/api/page-ai/block-edit-workflow` 的验收从“route 成功”提升为“Page Aggregate 回读与预期最终 markdown 一致”。
|
||||
1. Phase C 的 review session / streaming apply 仍冻结,当前不扩新 AI 功能。
|
||||
2. 复杂结构编辑继续走 `apply_block_ops` / `mnote.block.*`,不要把正文 search/replace 回退到结构性块工具。
|
||||
3. Page Aggregate 仍需后续推进 EditorBlockDocument 原生落库真相。
|
||||
|
||||
## 5. 本轮验证
|
||||
|
||||
@@ -80,9 +84,15 @@ rg -n "markdown_edit|apply_block_ops|block-edit-workflow|local_rule|page_ai_work
|
||||
rg -n "page-aggregate|pageAggregate|PageAggregate|blockDocument|projectionSource|documents/save|documents/options|/api/page-aggregate" rust/crates/mnote-web rust/crates/bridge-runtime wolai-frontend/src scripts --glob '!rust/target/**' --glob '!node_modules/**'
|
||||
```
|
||||
|
||||
本轮是 review / bug hunt,没有修改 runtime 源码。后续修复应至少补:
|
||||
修复后定向验证:
|
||||
|
||||
```bash
|
||||
cargo test --manifest-path rust/Cargo.toml -p mnote-web markdown_edit
|
||||
cargo test --manifest-path rust/Cargo.toml -p mnote-web page_ai_workflow
|
||||
cargo test --manifest-path rust/Cargo.toml -p mnote-web markdown_edit -- --nocapture
|
||||
cargo test --manifest-path rust/Cargo.toml -p mnote-web page_ai_workflow -- --nocapture
|
||||
cargo test --manifest-path rust/Cargo.toml -p mnote-web acp_client -- --nocapture
|
||||
cargo test --manifest-path rust/Cargo.toml -p mnote-web acp_session_manager -- --nocapture
|
||||
node --check scripts/reasonix-acp-wrapper.mjs
|
||||
MNOTE_REASONIX_ACP_SELFTEST=1 node scripts/reasonix-acp-wrapper.mjs
|
||||
```
|
||||
|
||||
结果:上述验证均通过。
|
||||
@@ -0,0 +1,53 @@
|
||||
# 11 [done] 当前完整架构 Review v1
|
||||
|
||||
> 更新时间:2026-05-17
|
||||
>
|
||||
> 状态:`done`
|
||||
>
|
||||
> 参考总文档:[CURRENT_ARCHITECTURE.md](../../../CURRENT_ARCHITECTURE.md)
|
||||
|
||||
## 1. 结论
|
||||
|
||||
当前 mnote 的主线架构已经成形。本轮 review 识别出的 P0 / 内核优先缺陷已完成代码修复、文档迁移和定向验证。
|
||||
|
||||
已修复的四个关键冲突:
|
||||
|
||||
1. Tree realtime 的发布口径与前端消费口径不一致。
|
||||
2. FileTree 资源行误走页面命令。
|
||||
3. Page Aggregate / AI context 保留前端第二真相。
|
||||
4. AI 写入链路在 `markdown_edit`、`apply_block_ops`、Hermes / ACP / Reasonix 之间的合同漂移。
|
||||
|
||||
仍需后续主线继续推进的不是本轮 P0 bug,而是长期架构收口:Page Aggregate 原生 EditorBlockDocument 落库、正文保存主命令面、`tree.resource.*` 资源生命周期和兼容路由瘦身。
|
||||
|
||||
## 2. 关键判断
|
||||
|
||||
- Rust kernel 与 bridge-runtime 已经承担语义主导权。
|
||||
- Rust SSR 主壳已默认以 `/api/realtime/ws` 为实时主链,SSE 是 fallback。
|
||||
- FileTree 资源行的 owner document 与页面命令目标已分离。
|
||||
- AI context 的 page subtree 已只读 Rust Page Aggregate projection。
|
||||
- `mnote.doc.markdown_edit` 已是简单正文编辑主路径,`page_ai_workflow` 复用统一 mnote tool executor,`apply_block_ops` / `mnote.block.*` 仅作为结构性辅助。
|
||||
- ACP / Reasonix 的 mnote tool payload、response、run context 与幂等字段已同步到当前工具合同。
|
||||
|
||||
## 3. 已闭合 bug
|
||||
|
||||
- Rust Web / realtime / ACP:`bugs/03-rust-web/done/3-16` 到 `3-21`
|
||||
- Tree domain:`bugs/04-tree-domain/done/4-46`
|
||||
- Editor mainline:`bugs/05-editor-mainline/done/5-15` 到 `5-18`
|
||||
- AI:`bugs/07-ai/done/7-18` 到 `7-25`
|
||||
|
||||
## 4. 验证
|
||||
|
||||
- `find bugs -path '*/process/*' -type f | wc -l`
|
||||
- 结果:`0`
|
||||
- `cargo test --manifest-path rust/Cargo.toml -p mnote-web markdown_edit -- --nocapture`
|
||||
- 结果:9 passed
|
||||
- `cargo test --manifest-path rust/Cargo.toml -p mnote-web page_ai_workflow -- --nocapture`
|
||||
- 结果:3 passed
|
||||
- `cargo test --manifest-path rust/Cargo.toml -p mnote-web acp_client -- --nocapture`
|
||||
- 结果:6 passed
|
||||
- `cargo test --manifest-path rust/Cargo.toml -p mnote-web ssr::pages::layout::tests -- --nocapture`
|
||||
- 结果:14 passed
|
||||
|
||||
## 5. 备注
|
||||
|
||||
本次审查的详细架构说明已统一写入根目录:[CURRENT_ARCHITECTURE.md](../../../CURRENT_ARCHITECTURE.md)。
|
||||
@@ -1,34 +0,0 @@
|
||||
# 11 [process] 当前完整架构 Review v1
|
||||
|
||||
> 更新时间:2026-05-17
|
||||
>
|
||||
> 状态:`process`
|
||||
>
|
||||
> 参考总文档:[CURRENT_ARCHITECTURE.md](../../../CURRENT_ARCHITECTURE.md)
|
||||
|
||||
## 1. 结论
|
||||
|
||||
当前 mnote 的主线架构已经成形,但还没有完成单一真源收口。最关键的未闭合点有四个:
|
||||
|
||||
1. Tree realtime 的发布口径与前端消费口径不一致。
|
||||
2. FileTree 资源行仍可能误走页面命令。
|
||||
3. Page Aggregate 仍保留前端第二真相。
|
||||
4. AI 写入链路在 `markdown_edit`、`apply_block_ops`、Hermes / ACP / Reasonix 之间存在合同漂移。
|
||||
|
||||
## 2. 关键判断
|
||||
|
||||
- Rust kernel 与 bridge-runtime 已经承担语义主导权。
|
||||
- 前端仍保留若干本地派生状态,适合过渡,不适合作为长期真相层。
|
||||
- `mnote.doc.markdown_edit` 已是主推路径,但工具级 contract 还没收口。
|
||||
- `page_ai_workflow` 与 ACP / Reasonix 已接入主链,但运行时 owner 与写入字段不统一。
|
||||
|
||||
## 3. 需要继续跟踪的 bug
|
||||
|
||||
- Rust Web / realtime / ACP:`bugs/03-rust-web/process/3-16` 到 `3-21`
|
||||
- Tree domain:`bugs/04-tree-domain/process/4-46`
|
||||
- Editor mainline:`bugs/05-editor-mainline/process/5-15` 到 `5-18`
|
||||
- AI:`bugs/07-ai/process/7-18` 到 `7-25`
|
||||
|
||||
## 4. 备注
|
||||
|
||||
本次审查的详细架构说明已统一写入根目录:[CURRENT_ARCHITECTURE.md](../../../CURRENT_ARCHITECTURE.md)。
|
||||
Generated
+4582
-4
File diff suppressed because it is too large
Load Diff
@@ -15,6 +15,7 @@
|
||||
"dependencies": {},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.56.1",
|
||||
"convex": "^1.39.1",
|
||||
"electron": "^34.0.0",
|
||||
"electron-builder": "^26.0.12",
|
||||
"playwright": "^1.56.1"
|
||||
|
||||
Generated
+320
@@ -11,6 +11,9 @@ importers:
|
||||
'@playwright/test':
|
||||
specifier: ^1.56.1
|
||||
version: 1.57.0
|
||||
convex:
|
||||
specifier: ^1.39.1
|
||||
version: 1.39.1
|
||||
electron:
|
||||
specifier: ^34.0.0
|
||||
version: 34.5.8
|
||||
@@ -66,6 +69,162 @@ packages:
|
||||
engines: {node: '>=14.14'}
|
||||
hasBin: true
|
||||
|
||||
'@esbuild/aix-ppc64@0.27.0':
|
||||
resolution: {integrity: sha512-KuZrd2hRjz01y5JK9mEBSD3Vj3mbCvemhT466rSuJYeE/hjuBrHfjjcjMdTm/sz7au+++sdbJZJmuBwQLuw68A==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [ppc64]
|
||||
os: [aix]
|
||||
|
||||
'@esbuild/android-arm64@0.27.0':
|
||||
resolution: {integrity: sha512-CC3vt4+1xZrs97/PKDkl0yN7w8edvU2vZvAFGD16n9F0Cvniy5qvzRXjfO1l94efczkkQE6g1x0i73Qf5uthOQ==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [android]
|
||||
|
||||
'@esbuild/android-arm@0.27.0':
|
||||
resolution: {integrity: sha512-j67aezrPNYWJEOHUNLPj9maeJte7uSMM6gMoxfPC9hOg8N02JuQi/T7ewumf4tNvJadFkvLZMlAq73b9uwdMyQ==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm]
|
||||
os: [android]
|
||||
|
||||
'@esbuild/android-x64@0.27.0':
|
||||
resolution: {integrity: sha512-wurMkF1nmQajBO1+0CJmcN17U4BP6GqNSROP8t0X/Jiw2ltYGLHpEksp9MpoBqkrFR3kv2/te6Sha26k3+yZ9Q==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [android]
|
||||
|
||||
'@esbuild/darwin-arm64@0.27.0':
|
||||
resolution: {integrity: sha512-uJOQKYCcHhg07DL7i8MzjvS2LaP7W7Pn/7uA0B5S1EnqAirJtbyw4yC5jQ5qcFjHK9l6o/MX9QisBg12kNkdHg==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@esbuild/darwin-x64@0.27.0':
|
||||
resolution: {integrity: sha512-8mG6arH3yB/4ZXiEnXof5MK72dE6zM9cDvUcPtxhUZsDjESl9JipZYW60C3JGreKCEP+p8P/72r69m4AZGJd5g==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
'@esbuild/freebsd-arm64@0.27.0':
|
||||
resolution: {integrity: sha512-9FHtyO988CwNMMOE3YIeci+UV+x5Zy8fI2qHNpsEtSF83YPBmE8UWmfYAQg6Ux7Gsmd4FejZqnEUZCMGaNQHQw==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [freebsd]
|
||||
|
||||
'@esbuild/freebsd-x64@0.27.0':
|
||||
resolution: {integrity: sha512-zCMeMXI4HS/tXvJz8vWGexpZj2YVtRAihHLk1imZj4efx1BQzN76YFeKqlDr3bUWI26wHwLWPd3rwh6pe4EV7g==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [freebsd]
|
||||
|
||||
'@esbuild/linux-arm64@0.27.0':
|
||||
resolution: {integrity: sha512-AS18v0V+vZiLJyi/4LphvBE+OIX682Pu7ZYNsdUHyUKSoRwdnOsMf6FDekwoAFKej14WAkOef3zAORJgAtXnlQ==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-arm@0.27.0':
|
||||
resolution: {integrity: sha512-t76XLQDpxgmq2cNXKTVEB7O7YMb42atj2Re2Haf45HkaUpjM2J0UuJZDuaGbPbamzZ7bawyGFUkodL+zcE+jvQ==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-ia32@0.27.0':
|
||||
resolution: {integrity: sha512-Mz1jxqm/kfgKkc/KLHC5qIujMvnnarD9ra1cEcrs7qshTUSksPihGrWHVG5+osAIQ68577Zpww7SGapmzSt4Nw==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [ia32]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-loong64@0.27.0':
|
||||
resolution: {integrity: sha512-QbEREjdJeIreIAbdG2hLU1yXm1uu+LTdzoq1KCo4G4pFOLlvIspBm36QrQOar9LFduavoWX2msNFAAAY9j4BDg==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [loong64]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-mips64el@0.27.0':
|
||||
resolution: {integrity: sha512-sJz3zRNe4tO2wxvDpH/HYJilb6+2YJxo/ZNbVdtFiKDufzWq4JmKAiHy9iGoLjAV7r/W32VgaHGkk35cUXlNOg==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [mips64el]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-ppc64@0.27.0':
|
||||
resolution: {integrity: sha512-z9N10FBD0DCS2dmSABDBb5TLAyF1/ydVb+N4pi88T45efQ/w4ohr/F/QYCkxDPnkhkp6AIpIcQKQ8F0ANoA2JA==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-riscv64@0.27.0':
|
||||
resolution: {integrity: sha512-pQdyAIZ0BWIC5GyvVFn5awDiO14TkT/19FTmFcPdDec94KJ1uZcmFs21Fo8auMXzD4Tt+diXu1LW1gHus9fhFQ==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-s390x@0.27.0':
|
||||
resolution: {integrity: sha512-hPlRWR4eIDDEci953RI1BLZitgi5uqcsjKMxwYfmi4LcwyWo2IcRP+lThVnKjNtk90pLS8nKdroXYOqW+QQH+w==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-x64@0.27.0':
|
||||
resolution: {integrity: sha512-1hBWx4OUJE2cab++aVZ7pObD6s+DK4mPGpemtnAORBvb5l/g5xFGk0vc0PjSkrDs0XaXj9yyob3d14XqvnQ4gw==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/netbsd-arm64@0.27.0':
|
||||
resolution: {integrity: sha512-6m0sfQfxfQfy1qRuecMkJlf1cIzTOgyaeXaiVaaki8/v+WB+U4hc6ik15ZW6TAllRlg/WuQXxWj1jx6C+dfy3w==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [netbsd]
|
||||
|
||||
'@esbuild/netbsd-x64@0.27.0':
|
||||
resolution: {integrity: sha512-xbbOdfn06FtcJ9d0ShxxvSn2iUsGd/lgPIO2V3VZIPDbEaIj1/3nBBe1AwuEZKXVXkMmpr6LUAgMkLD/4D2PPA==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [netbsd]
|
||||
|
||||
'@esbuild/openbsd-arm64@0.27.0':
|
||||
resolution: {integrity: sha512-fWgqR8uNbCQ/GGv0yhzttj6sU/9Z5/Sv/VGU3F5OuXK6J6SlriONKrQ7tNlwBrJZXRYk5jUhuWvF7GYzGguBZQ==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [openbsd]
|
||||
|
||||
'@esbuild/openbsd-x64@0.27.0':
|
||||
resolution: {integrity: sha512-aCwlRdSNMNxkGGqQajMUza6uXzR/U0dIl1QmLjPtRbLOx3Gy3otfFu/VjATy4yQzo9yFDGTxYDo1FfAD9oRD2A==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [openbsd]
|
||||
|
||||
'@esbuild/openharmony-arm64@0.27.0':
|
||||
resolution: {integrity: sha512-nyvsBccxNAsNYz2jVFYwEGuRRomqZ149A39SHWk4hV0jWxKM0hjBPm3AmdxcbHiFLbBSwG6SbpIcUbXjgyECfA==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [openharmony]
|
||||
|
||||
'@esbuild/sunos-x64@0.27.0':
|
||||
resolution: {integrity: sha512-Q1KY1iJafM+UX6CFEL+F4HRTgygmEW568YMqDA5UV97AuZSm21b7SXIrRJDwXWPzr8MGr75fUZPV67FdtMHlHA==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [sunos]
|
||||
|
||||
'@esbuild/win32-arm64@0.27.0':
|
||||
resolution: {integrity: sha512-W1eyGNi6d+8kOmZIwi/EDjrL9nxQIQ0MiGqe/AWc6+IaHloxHSGoeRgDRKHFISThLmsewZ5nHFvGFWdBYlgKPg==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [win32]
|
||||
|
||||
'@esbuild/win32-ia32@0.27.0':
|
||||
resolution: {integrity: sha512-30z1aKL9h22kQhilnYkORFYt+3wp7yZsHWus+wSKAJR8JtdfI76LJ4SBdMsCopTR3z/ORqVu5L1vtnHZWVj4cQ==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [ia32]
|
||||
os: [win32]
|
||||
|
||||
'@esbuild/win32-x64@0.27.0':
|
||||
resolution: {integrity: sha512-aIitBcjQeyOhMTImhLZmtxfdOcuNRpwlPNmlFKPcHQYPhEssw75Cl1TSXJXpMkzaua9FUetx/4OQKq7eJul5Cg==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@isaacs/balanced-match@4.0.1':
|
||||
resolution: {integrity: sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ==}
|
||||
engines: {node: 20 || >=22}
|
||||
@@ -341,6 +500,25 @@ packages:
|
||||
concat-map@0.0.1:
|
||||
resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==}
|
||||
|
||||
convex@1.39.1:
|
||||
resolution: {integrity: sha512-W+gVXA7BpRF1xLlS1kGTtKVaqd5yonqbGESKiPtIUXjV744GdDz8IG7RVsSY5KzHbgxuJBHKaJYk+92OIHTskQ==}
|
||||
engines: {node: '>=18.0.0', npm: '>=7.0.0'}
|
||||
hasBin: true
|
||||
peerDependencies:
|
||||
'@auth0/auth0-react': ^2.0.1
|
||||
'@clerk/clerk-react': ^4.12.8 || ^5.0.0
|
||||
'@clerk/react': ^6.4.3
|
||||
react: ^18.0.0 || ^19.0.0-0 || ^19.0.0
|
||||
peerDependenciesMeta:
|
||||
'@auth0/auth0-react':
|
||||
optional: true
|
||||
'@clerk/clerk-react':
|
||||
optional: true
|
||||
'@clerk/react':
|
||||
optional: true
|
||||
react:
|
||||
optional: true
|
||||
|
||||
core-util-is@1.0.2:
|
||||
resolution: {integrity: sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==}
|
||||
|
||||
@@ -483,6 +661,11 @@ packages:
|
||||
es6-error@4.1.1:
|
||||
resolution: {integrity: sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg==}
|
||||
|
||||
esbuild@0.27.0:
|
||||
resolution: {integrity: sha512-jd0f4NHbD6cALCyGElNpGAOtWxSq46l9X/sWB0Nzd5er4Kz2YTm+Vl0qKFT9KUJvD8+fiO8AvoHhFvEatfVixA==}
|
||||
engines: {node: '>=18'}
|
||||
hasBin: true
|
||||
|
||||
escalade@3.2.0:
|
||||
resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==}
|
||||
engines: {node: '>=6'}
|
||||
@@ -976,6 +1159,11 @@ packages:
|
||||
engines: {node: '>=14.0.0'}
|
||||
hasBin: true
|
||||
|
||||
prettier@3.8.3:
|
||||
resolution: {integrity: sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==}
|
||||
engines: {node: '>=14'}
|
||||
hasBin: true
|
||||
|
||||
proc-log@5.0.0:
|
||||
resolution: {integrity: sha512-Azwzvl90HaF0aCz1JrDdXQykFakSSNPaPoiZ9fm5qJIMHioDZEi7OAdRwSm6rSoPtY3Qutnm3L7ogmg3dc+wbQ==}
|
||||
engines: {node: ^18.17.0 || >=20.5.0}
|
||||
@@ -1246,6 +1434,18 @@ packages:
|
||||
wrappy@1.0.2:
|
||||
resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==}
|
||||
|
||||
ws@8.18.0:
|
||||
resolution: {integrity: sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==}
|
||||
engines: {node: '>=10.0.0'}
|
||||
peerDependencies:
|
||||
bufferutil: ^4.0.1
|
||||
utf-8-validate: '>=5.0.2'
|
||||
peerDependenciesMeta:
|
||||
bufferutil:
|
||||
optional: true
|
||||
utf-8-validate:
|
||||
optional: true
|
||||
|
||||
xmlbuilder@15.1.1:
|
||||
resolution: {integrity: sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg==}
|
||||
engines: {node: '>=8.0'}
|
||||
@@ -1372,6 +1572,84 @@ snapshots:
|
||||
- supports-color
|
||||
optional: true
|
||||
|
||||
'@esbuild/aix-ppc64@0.27.0':
|
||||
optional: true
|
||||
|
||||
'@esbuild/android-arm64@0.27.0':
|
||||
optional: true
|
||||
|
||||
'@esbuild/android-arm@0.27.0':
|
||||
optional: true
|
||||
|
||||
'@esbuild/android-x64@0.27.0':
|
||||
optional: true
|
||||
|
||||
'@esbuild/darwin-arm64@0.27.0':
|
||||
optional: true
|
||||
|
||||
'@esbuild/darwin-x64@0.27.0':
|
||||
optional: true
|
||||
|
||||
'@esbuild/freebsd-arm64@0.27.0':
|
||||
optional: true
|
||||
|
||||
'@esbuild/freebsd-x64@0.27.0':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-arm64@0.27.0':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-arm@0.27.0':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-ia32@0.27.0':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-loong64@0.27.0':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-mips64el@0.27.0':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-ppc64@0.27.0':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-riscv64@0.27.0':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-s390x@0.27.0':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-x64@0.27.0':
|
||||
optional: true
|
||||
|
||||
'@esbuild/netbsd-arm64@0.27.0':
|
||||
optional: true
|
||||
|
||||
'@esbuild/netbsd-x64@0.27.0':
|
||||
optional: true
|
||||
|
||||
'@esbuild/openbsd-arm64@0.27.0':
|
||||
optional: true
|
||||
|
||||
'@esbuild/openbsd-x64@0.27.0':
|
||||
optional: true
|
||||
|
||||
'@esbuild/openharmony-arm64@0.27.0':
|
||||
optional: true
|
||||
|
||||
'@esbuild/sunos-x64@0.27.0':
|
||||
optional: true
|
||||
|
||||
'@esbuild/win32-arm64@0.27.0':
|
||||
optional: true
|
||||
|
||||
'@esbuild/win32-ia32@0.27.0':
|
||||
optional: true
|
||||
|
||||
'@esbuild/win32-x64@0.27.0':
|
||||
optional: true
|
||||
|
||||
'@isaacs/balanced-match@4.0.1': {}
|
||||
|
||||
'@isaacs/brace-expansion@5.0.0':
|
||||
@@ -1713,6 +1991,15 @@ snapshots:
|
||||
|
||||
concat-map@0.0.1: {}
|
||||
|
||||
convex@1.39.1:
|
||||
dependencies:
|
||||
esbuild: 0.27.0
|
||||
prettier: 3.8.3
|
||||
ws: 8.18.0
|
||||
transitivePeerDependencies:
|
||||
- bufferutil
|
||||
- utf-8-validate
|
||||
|
||||
core-util-is@1.0.2:
|
||||
optional: true
|
||||
|
||||
@@ -1906,6 +2193,35 @@ snapshots:
|
||||
es6-error@4.1.1:
|
||||
optional: true
|
||||
|
||||
esbuild@0.27.0:
|
||||
optionalDependencies:
|
||||
'@esbuild/aix-ppc64': 0.27.0
|
||||
'@esbuild/android-arm': 0.27.0
|
||||
'@esbuild/android-arm64': 0.27.0
|
||||
'@esbuild/android-x64': 0.27.0
|
||||
'@esbuild/darwin-arm64': 0.27.0
|
||||
'@esbuild/darwin-x64': 0.27.0
|
||||
'@esbuild/freebsd-arm64': 0.27.0
|
||||
'@esbuild/freebsd-x64': 0.27.0
|
||||
'@esbuild/linux-arm': 0.27.0
|
||||
'@esbuild/linux-arm64': 0.27.0
|
||||
'@esbuild/linux-ia32': 0.27.0
|
||||
'@esbuild/linux-loong64': 0.27.0
|
||||
'@esbuild/linux-mips64el': 0.27.0
|
||||
'@esbuild/linux-ppc64': 0.27.0
|
||||
'@esbuild/linux-riscv64': 0.27.0
|
||||
'@esbuild/linux-s390x': 0.27.0
|
||||
'@esbuild/linux-x64': 0.27.0
|
||||
'@esbuild/netbsd-arm64': 0.27.0
|
||||
'@esbuild/netbsd-x64': 0.27.0
|
||||
'@esbuild/openbsd-arm64': 0.27.0
|
||||
'@esbuild/openbsd-x64': 0.27.0
|
||||
'@esbuild/openharmony-arm64': 0.27.0
|
||||
'@esbuild/sunos-x64': 0.27.0
|
||||
'@esbuild/win32-arm64': 0.27.0
|
||||
'@esbuild/win32-ia32': 0.27.0
|
||||
'@esbuild/win32-x64': 0.27.0
|
||||
|
||||
escalade@3.2.0: {}
|
||||
|
||||
escape-string-regexp@4.0.0:
|
||||
@@ -2421,6 +2737,8 @@ snapshots:
|
||||
commander: 9.5.0
|
||||
optional: true
|
||||
|
||||
prettier@3.8.3: {}
|
||||
|
||||
proc-log@5.0.0: {}
|
||||
|
||||
progress@2.0.3: {}
|
||||
@@ -2700,6 +3018,8 @@ snapshots:
|
||||
|
||||
wrappy@1.0.2: {}
|
||||
|
||||
ws@8.18.0: {}
|
||||
|
||||
xmlbuilder@15.1.1: {}
|
||||
|
||||
y18n@5.0.8: {}
|
||||
|
||||
@@ -10987,6 +10987,7 @@ fn normalize_editor_block_type_for_save(raw_type: &str) -> EditorBlockType {
|
||||
"toc" | "toc_node" | "tocnode" => EditorBlockType::Toc,
|
||||
"page_reference" => EditorBlockType::PageReference,
|
||||
"block_reference" => EditorBlockType::BlockReference,
|
||||
"resource" | "resource_block" => EditorBlockType::Resource,
|
||||
_ => EditorBlockType::Paragraph,
|
||||
}
|
||||
}
|
||||
@@ -11105,6 +11106,13 @@ fn normalize_editor_block_from_legacy_path(block: &Value, path: &[usize]) -> Edi
|
||||
);
|
||||
}
|
||||
}
|
||||
if matches!(normalized_block_type, EditorBlockType::Resource) {
|
||||
if let Some(props_map) = block.get("props").and_then(Value::as_object) {
|
||||
props
|
||||
.extra
|
||||
.insert("resourceProps".into(), Value::Object(props_map.clone()));
|
||||
}
|
||||
}
|
||||
let text = read_trimmed_string_field(block, &["content"])
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or_else(|| extract_inline_text(block));
|
||||
@@ -11122,7 +11130,10 @@ fn normalize_editor_block_from_legacy_path(block: &Value, path: &[usize]) -> Edi
|
||||
}
|
||||
}
|
||||
|
||||
pub fn editor_document_from_legacy_content(document_id: &str, content: &Value) -> EditorBlockDocument {
|
||||
pub fn editor_document_from_legacy_content(
|
||||
document_id: &str,
|
||||
content: &Value,
|
||||
) -> EditorBlockDocument {
|
||||
let mut blocks = Vec::<EditorBlock>::new();
|
||||
let mut root_block_ids = Vec::<String>::new();
|
||||
for (index, block) in normalize_blocks_from_value(content).iter().enumerate() {
|
||||
@@ -11336,6 +11347,18 @@ fn legacy_props_from_editor_block(block: &EditorBlock) -> Option<Value> {
|
||||
props.insert("projectionVersion".into(), json!(projection_version));
|
||||
}
|
||||
}
|
||||
EditorBlockType::Resource => {
|
||||
if let Some(resource_props) = block
|
||||
.props
|
||||
.extra
|
||||
.get("resourceProps")
|
||||
.and_then(Value::as_object)
|
||||
{
|
||||
for (key, value) in resource_props {
|
||||
props.insert(key.clone(), value.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
if let Some(text_align) = block
|
||||
@@ -11372,6 +11395,7 @@ fn legacy_type_from_editor_block(block: &EditorBlock) -> &'static str {
|
||||
EditorBlockType::Toc => "toc",
|
||||
EditorBlockType::PageReference => "page_reference",
|
||||
EditorBlockType::BlockReference => "block_reference",
|
||||
EditorBlockType::Resource => "resource",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11836,7 +11860,7 @@ mod tests {
|
||||
},
|
||||
source: RuntimeSourceWire {
|
||||
channel: "next-route".into(),
|
||||
client: "wolai-frontend".into(),
|
||||
client: "mnote-web".into(),
|
||||
source_kind: None,
|
||||
root_uri: None,
|
||||
workspace_id: None,
|
||||
@@ -11931,7 +11955,7 @@ mod tests {
|
||||
},
|
||||
source: RuntimeSourceWire {
|
||||
channel: "next-route".into(),
|
||||
client: "wolai-frontend".into(),
|
||||
client: "mnote-web".into(),
|
||||
source_kind: None,
|
||||
root_uri: None,
|
||||
workspace_id: None,
|
||||
@@ -12513,6 +12537,38 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn editor_document_roundtrip_preserves_resource_block_type() {
|
||||
let content = json!([
|
||||
{
|
||||
"id": "resource_1",
|
||||
"type": "resource",
|
||||
"props": {
|
||||
"resourceKind": "asset",
|
||||
"assetId": "asset_1"
|
||||
},
|
||||
"content": "资源块"
|
||||
}
|
||||
]);
|
||||
let document = editor_document_from_legacy_content("doc_1", &content);
|
||||
assert_eq!(document.blocks[0].block_type, EditorBlockType::Resource);
|
||||
|
||||
let legacy = legacy_content_from_editor_document(&document);
|
||||
assert_eq!(legacy[0]["id"], json!("resource_1"));
|
||||
assert_eq!(legacy[0]["type"], json!("resource"));
|
||||
assert_eq!(legacy[0]["props"]["resourceKind"], json!("asset"));
|
||||
assert_eq!(legacy[0]["props"]["assetId"], json!("asset_1"));
|
||||
|
||||
let projection = project_legacy_content_to_block_document("doc_1", &legacy, &json!(1))
|
||||
.expect("resource block should project");
|
||||
assert_eq!(projection["blocks"][0]["type"], json!("resource"));
|
||||
assert_eq!(projection["blocks"][0]["editable"], json!(false));
|
||||
assert_eq!(
|
||||
projection["blocks"][0]["unsupportedReason"],
|
||||
json!("复杂块暂不开放 AI 精确写入")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn editor_command_apply_insert_preserves_nested_children() {
|
||||
let content = json!([{
|
||||
@@ -13186,7 +13242,7 @@ mod tests {
|
||||
},
|
||||
source: RuntimeSourceWire {
|
||||
channel: "next-route".into(),
|
||||
client: "wolai-frontend".into(),
|
||||
client: "mnote-web".into(),
|
||||
source_kind: None,
|
||||
root_uri: None,
|
||||
workspace_id: None,
|
||||
@@ -13772,7 +13828,7 @@ mod tests {
|
||||
},
|
||||
source: RuntimeSourceWire {
|
||||
channel: "next-route".into(),
|
||||
client: "wolai-frontend".into(),
|
||||
client: "mnote-web".into(),
|
||||
source_kind: None,
|
||||
root_uri: None,
|
||||
workspace_id: None,
|
||||
@@ -14469,7 +14525,7 @@ mod tests {
|
||||
},
|
||||
source: RuntimeSourceWire {
|
||||
channel: "next-route".into(),
|
||||
client: "wolai-frontend".into(),
|
||||
client: "mnote-web".into(),
|
||||
source_kind: None,
|
||||
root_uri: None,
|
||||
workspace_id: None,
|
||||
@@ -14570,7 +14626,7 @@ mod tests {
|
||||
},
|
||||
source: RuntimeSourceWire {
|
||||
channel: "next-route".into(),
|
||||
client: "wolai-frontend".into(),
|
||||
client: "mnote-web".into(),
|
||||
source_kind: None,
|
||||
root_uri: None,
|
||||
workspace_id: None,
|
||||
@@ -14676,7 +14732,7 @@ mod tests {
|
||||
},
|
||||
source: RuntimeSourceWire {
|
||||
channel: "next-route".into(),
|
||||
client: "wolai-frontend".into(),
|
||||
client: "mnote-web".into(),
|
||||
source_kind: None,
|
||||
root_uri: None,
|
||||
workspace_id: None,
|
||||
@@ -14743,7 +14799,7 @@ mod tests {
|
||||
},
|
||||
source: RuntimeSourceWire {
|
||||
channel: "next-route".into(),
|
||||
client: "wolai-frontend".into(),
|
||||
client: "mnote-web".into(),
|
||||
source_kind: None,
|
||||
root_uri: None,
|
||||
workspace_id: None,
|
||||
@@ -14885,6 +14941,108 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_body_save_artifact_preserves_editor_document_payload() {
|
||||
let context = demo_context();
|
||||
let command = RuntimeCommandEnvelopeWire {
|
||||
name: "page.body.save".into(),
|
||||
command_id: "cmd_save_artifact_editor_document".into(),
|
||||
idempotency_key: Some("idem_save_artifact_editor_document".into()),
|
||||
actor: RuntimeActorWire {
|
||||
actor_type: "user".into(),
|
||||
actor_id: "user_1".into(),
|
||||
session_id: Some("sess_1".into()),
|
||||
},
|
||||
source: RuntimeSourceWire {
|
||||
channel: "next-route".into(),
|
||||
client: "mnote-web".into(),
|
||||
source_kind: None,
|
||||
root_uri: None,
|
||||
workspace_id: None,
|
||||
capabilities: Vec::new(),
|
||||
},
|
||||
target: Some(RuntimeTargetWire {
|
||||
workspace_id: Some("ws_1".into()),
|
||||
page_id: Some("doc_1".into()),
|
||||
block_id: None,
|
||||
}),
|
||||
payload: json!({
|
||||
"documentId": "doc_1",
|
||||
"workspaceId": "ws_1",
|
||||
"revision": 9,
|
||||
"editorDocument": {
|
||||
"documentId": "doc_1",
|
||||
"rootBlockIds": ["editor_block_1"],
|
||||
"blocks": [{
|
||||
"blockId": "editor_block_1",
|
||||
"blockType": "paragraph",
|
||||
"contentNodes": [{
|
||||
"payload": {
|
||||
"type": "text",
|
||||
"text": "来自 editorDocument artifact"
|
||||
},
|
||||
"attrs": {}
|
||||
}],
|
||||
"childBlockIds": []
|
||||
}]
|
||||
},
|
||||
"content": [],
|
||||
"tiptapDocument": {
|
||||
"type": "doc",
|
||||
"content": [{
|
||||
"type": "paragraph",
|
||||
"attrs": { "blockId": "editor_block_1" },
|
||||
"content": [{ "type": "text", "text": "来自 tiptapDocument artifact" }]
|
||||
}]
|
||||
},
|
||||
"conflictDetectionKey": "doc_1:9"
|
||||
}),
|
||||
preflight_data: None,
|
||||
reason: Some("保存 artifact provenance".into()),
|
||||
refs: vec!["page-body-save-artifact-provenance".into()],
|
||||
dry_run: false,
|
||||
validate_only: false,
|
||||
};
|
||||
let plan = execute_runtime_input(RuntimeInput::Command {
|
||||
context: context.clone(),
|
||||
command: command.clone(),
|
||||
})
|
||||
.expect("page.body.save plan should build");
|
||||
let RuntimeExecutionPlan::Command(plan) = plan else {
|
||||
panic!("expected command plan");
|
||||
};
|
||||
let artifacts = build_runtime_command_artifact_plan(
|
||||
&context,
|
||||
&command,
|
||||
&plan,
|
||||
&json!({"ok": true, "revision": 10}),
|
||||
"2026-05-18T00:00:00Z",
|
||||
)
|
||||
.expect("artifact plan should build");
|
||||
|
||||
assert_eq!(
|
||||
artifacts
|
||||
.command_log
|
||||
.payload
|
||||
.pointer("/editorDocument/rootBlockIds/0"),
|
||||
Some(&json!("editor_block_1"))
|
||||
);
|
||||
assert_eq!(
|
||||
artifacts
|
||||
.command_log
|
||||
.payload
|
||||
.pointer("/editorDocument/blocks/0/contentNodes/0/payload/text"),
|
||||
Some(&json!("来自 editorDocument artifact"))
|
||||
);
|
||||
assert_eq!(
|
||||
artifacts
|
||||
.command_log
|
||||
.payload
|
||||
.pointer("/tiptapDocument/content/0/type"),
|
||||
Some(&json!("paragraph"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blocks_move_command_plan_maps_to_blocks_move() {
|
||||
let plan = execute_runtime_input(RuntimeInput::Command {
|
||||
@@ -14900,7 +15058,7 @@ mod tests {
|
||||
},
|
||||
source: RuntimeSourceWire {
|
||||
channel: "next-route".into(),
|
||||
client: "wolai-frontend".into(),
|
||||
client: "mnote-web".into(),
|
||||
source_kind: None,
|
||||
root_uri: None,
|
||||
workspace_id: None,
|
||||
@@ -14995,7 +15153,7 @@ mod tests {
|
||||
},
|
||||
source: RuntimeSourceWire {
|
||||
channel: "next-route".into(),
|
||||
client: "wolai-frontend".into(),
|
||||
client: "mnote-web".into(),
|
||||
source_kind: None,
|
||||
root_uri: None,
|
||||
workspace_id: None,
|
||||
@@ -15161,7 +15319,7 @@ mod tests {
|
||||
},
|
||||
source: RuntimeSourceWire {
|
||||
channel: "next-route".into(),
|
||||
client: "wolai-frontend".into(),
|
||||
client: "mnote-web".into(),
|
||||
source_kind: None,
|
||||
root_uri: None,
|
||||
workspace_id: None,
|
||||
@@ -15250,7 +15408,7 @@ mod tests {
|
||||
},
|
||||
source: RuntimeSourceWire {
|
||||
channel: "next-route".into(),
|
||||
client: "wolai-frontend".into(),
|
||||
client: "mnote-web".into(),
|
||||
source_kind: None,
|
||||
root_uri: None,
|
||||
workspace_id: None,
|
||||
@@ -15443,7 +15601,7 @@ mod tests {
|
||||
},
|
||||
source: RuntimeSourceWire {
|
||||
channel: "next-route".into(),
|
||||
client: "wolai-frontend".into(),
|
||||
client: "mnote-web".into(),
|
||||
source_kind: None,
|
||||
root_uri: None,
|
||||
workspace_id: None,
|
||||
@@ -15491,7 +15649,7 @@ mod tests {
|
||||
},
|
||||
source: RuntimeSourceWire {
|
||||
channel: "next-route".into(),
|
||||
client: "wolai-frontend".into(),
|
||||
client: "mnote-web".into(),
|
||||
source_kind: None,
|
||||
root_uri: None,
|
||||
workspace_id: None,
|
||||
@@ -15560,7 +15718,7 @@ mod tests {
|
||||
},
|
||||
source: RuntimeSourceWire {
|
||||
channel: "next-route".into(),
|
||||
client: "wolai-frontend".into(),
|
||||
client: "mnote-web".into(),
|
||||
source_kind: None,
|
||||
root_uri: None,
|
||||
workspace_id: None,
|
||||
@@ -15629,7 +15787,7 @@ mod tests {
|
||||
},
|
||||
source: RuntimeSourceWire {
|
||||
channel: "next-route".into(),
|
||||
client: "wolai-frontend".into(),
|
||||
client: "mnote-web".into(),
|
||||
source_kind: None,
|
||||
root_uri: None,
|
||||
workspace_id: None,
|
||||
@@ -15698,7 +15856,7 @@ mod tests {
|
||||
},
|
||||
source: RuntimeSourceWire {
|
||||
channel: "next-route".into(),
|
||||
client: "wolai-frontend".into(),
|
||||
client: "mnote-web".into(),
|
||||
source_kind: None,
|
||||
root_uri: None,
|
||||
workspace_id: None,
|
||||
@@ -15755,7 +15913,7 @@ mod tests {
|
||||
},
|
||||
source: RuntimeSourceWire {
|
||||
channel: "next-route".into(),
|
||||
client: "wolai-frontend".into(),
|
||||
client: "mnote-web".into(),
|
||||
source_kind: None,
|
||||
root_uri: None,
|
||||
workspace_id: None,
|
||||
@@ -15919,7 +16077,7 @@ mod tests {
|
||||
},
|
||||
source: RuntimeSourceWire {
|
||||
channel: "next-route".into(),
|
||||
client: "wolai-frontend".into(),
|
||||
client: "mnote-web".into(),
|
||||
source_kind: None,
|
||||
root_uri: None,
|
||||
workspace_id: None,
|
||||
@@ -16029,12 +16187,24 @@ mod tests {
|
||||
fn documents_lifecycle_aliases_are_marked_as_deprecated_tree_protocol_aliases() {
|
||||
let cases = [
|
||||
("documents.create", "tree.node.create", "documents.create"),
|
||||
("documents.title.update", "tree.node.rename", "documents.title.update"),
|
||||
(
|
||||
"documents.title.update",
|
||||
"tree.node.rename",
|
||||
"documents.title.update",
|
||||
),
|
||||
("documents.move", "tree.subtree.move", "documents.move"),
|
||||
("documents.delete", "tree.node.archive", "documents.delete"),
|
||||
("documents.restore", "tree.node.restore", "documents.restore"),
|
||||
(
|
||||
"documents.restore",
|
||||
"tree.node.restore",
|
||||
"documents.restore",
|
||||
),
|
||||
("documents.purge", "tree.node.purge", "documents.purge"),
|
||||
("documents.copy_tree", "tree.subtree.copy", "documents.copy_tree"),
|
||||
(
|
||||
"documents.copy_tree",
|
||||
"tree.subtree.copy",
|
||||
"documents.copy_tree",
|
||||
),
|
||||
];
|
||||
|
||||
for (command_name, preferred_command_name, compat_command_name) in cases {
|
||||
@@ -16049,7 +16219,7 @@ mod tests {
|
||||
},
|
||||
source: RuntimeSourceWire {
|
||||
channel: "next-route".into(),
|
||||
client: "wolai-frontend".into(),
|
||||
client: "mnote-web".into(),
|
||||
source_kind: None,
|
||||
root_uri: None,
|
||||
workspace_id: None,
|
||||
@@ -16140,7 +16310,7 @@ mod tests {
|
||||
},
|
||||
source: RuntimeSourceWire {
|
||||
channel: "next-route".into(),
|
||||
client: "wolai-frontend".into(),
|
||||
client: "mnote-web".into(),
|
||||
source_kind: None,
|
||||
root_uri: None,
|
||||
workspace_id: None,
|
||||
@@ -16215,7 +16385,7 @@ mod tests {
|
||||
},
|
||||
source: RuntimeSourceWire {
|
||||
channel: "next-route".into(),
|
||||
client: "wolai-frontend".into(),
|
||||
client: "mnote-web".into(),
|
||||
source_kind: None,
|
||||
root_uri: None,
|
||||
workspace_id: None,
|
||||
@@ -16315,7 +16485,7 @@ mod tests {
|
||||
},
|
||||
source: RuntimeSourceWire {
|
||||
channel: "next-route".into(),
|
||||
client: "wolai-frontend".into(),
|
||||
client: "mnote-web".into(),
|
||||
source_kind: None,
|
||||
root_uri: None,
|
||||
workspace_id: None,
|
||||
@@ -16401,7 +16571,7 @@ mod tests {
|
||||
},
|
||||
source: RuntimeSourceWire {
|
||||
channel: "next-route".into(),
|
||||
client: "wolai-frontend".into(),
|
||||
client: "mnote-web".into(),
|
||||
source_kind: None,
|
||||
root_uri: None,
|
||||
workspace_id: None,
|
||||
@@ -16484,7 +16654,7 @@ mod tests {
|
||||
},
|
||||
source: RuntimeSourceWire {
|
||||
channel: "next-route".into(),
|
||||
client: "wolai-frontend".into(),
|
||||
client: "mnote-web".into(),
|
||||
source_kind: None,
|
||||
root_uri: None,
|
||||
workspace_id: None,
|
||||
@@ -16572,7 +16742,7 @@ mod tests {
|
||||
},
|
||||
source: RuntimeSourceWire {
|
||||
channel: "next-route".into(),
|
||||
client: "wolai-frontend".into(),
|
||||
client: "mnote-web".into(),
|
||||
source_kind: None,
|
||||
root_uri: None,
|
||||
workspace_id: None,
|
||||
@@ -16713,7 +16883,7 @@ mod tests {
|
||||
},
|
||||
source: RuntimeSourceWire {
|
||||
channel: "next-route".into(),
|
||||
client: "wolai-frontend".into(),
|
||||
client: "mnote-web".into(),
|
||||
source_kind: None,
|
||||
root_uri: None,
|
||||
workspace_id: None,
|
||||
@@ -16812,7 +16982,7 @@ mod tests {
|
||||
},
|
||||
source: RuntimeSourceWire {
|
||||
channel: "next-route".into(),
|
||||
client: "wolai-frontend".into(),
|
||||
client: "mnote-web".into(),
|
||||
source_kind: None,
|
||||
root_uri: None,
|
||||
workspace_id: Some("ws_1".into()),
|
||||
@@ -16896,7 +17066,7 @@ mod tests {
|
||||
},
|
||||
source: RuntimeSourceWire {
|
||||
channel: "next-route".into(),
|
||||
client: "wolai-frontend".into(),
|
||||
client: "mnote-web".into(),
|
||||
source_kind: None,
|
||||
root_uri: None,
|
||||
workspace_id: None,
|
||||
@@ -16968,7 +17138,7 @@ mod tests {
|
||||
},
|
||||
source: RuntimeSourceWire {
|
||||
channel: "next-route".into(),
|
||||
client: "wolai-frontend".into(),
|
||||
client: "mnote-web".into(),
|
||||
source_kind: None,
|
||||
root_uri: None,
|
||||
workspace_id: None,
|
||||
@@ -17058,7 +17228,7 @@ mod tests {
|
||||
},
|
||||
source: RuntimeSourceWire {
|
||||
channel: "next-route".into(),
|
||||
client: "wolai-frontend".into(),
|
||||
client: "mnote-web".into(),
|
||||
source_kind: None,
|
||||
root_uri: None,
|
||||
workspace_id: None,
|
||||
@@ -17126,7 +17296,7 @@ mod tests {
|
||||
},
|
||||
source: RuntimeSourceWire {
|
||||
channel: "onlyoffice-callback".into(),
|
||||
client: "wolai-frontend".into(),
|
||||
client: "mnote-web".into(),
|
||||
source_kind: None,
|
||||
root_uri: None,
|
||||
workspace_id: None,
|
||||
@@ -17204,7 +17374,7 @@ mod tests {
|
||||
},
|
||||
source: RuntimeSourceWire {
|
||||
channel: "next-route".into(),
|
||||
client: "wolai-frontend".into(),
|
||||
client: "mnote-web".into(),
|
||||
source_kind: None,
|
||||
root_uri: None,
|
||||
workspace_id: None,
|
||||
@@ -17492,7 +17662,7 @@ mod tests {
|
||||
},
|
||||
source: RuntimeSourceWire {
|
||||
channel: "next-route".into(),
|
||||
client: "wolai-frontend".into(),
|
||||
client: "mnote-web".into(),
|
||||
source_kind: None,
|
||||
root_uri: None,
|
||||
workspace_id: None,
|
||||
@@ -17684,7 +17854,7 @@ mod tests {
|
||||
},
|
||||
source: RuntimeSourceWire {
|
||||
channel: "next-route".into(),
|
||||
client: "wolai-frontend".into(),
|
||||
client: "mnote-web".into(),
|
||||
source_kind: None,
|
||||
root_uri: None,
|
||||
workspace_id: None,
|
||||
@@ -17741,7 +17911,7 @@ mod tests {
|
||||
},
|
||||
source: RuntimeSourceWire {
|
||||
channel: "next-route".into(),
|
||||
client: "wolai-frontend".into(),
|
||||
client: "mnote-web".into(),
|
||||
source_kind: None,
|
||||
root_uri: None,
|
||||
workspace_id: None,
|
||||
@@ -17781,7 +17951,7 @@ mod tests {
|
||||
},
|
||||
source: RuntimeSourceWire {
|
||||
channel: "next-route".into(),
|
||||
client: "wolai-frontend".into(),
|
||||
client: "mnote-web".into(),
|
||||
source_kind: None,
|
||||
root_uri: None,
|
||||
workspace_id: None,
|
||||
@@ -17827,7 +17997,7 @@ mod tests {
|
||||
},
|
||||
source: RuntimeSourceWire {
|
||||
channel: "next-route".into(),
|
||||
client: "wolai-frontend".into(),
|
||||
client: "mnote-web".into(),
|
||||
source_kind: None,
|
||||
root_uri: None,
|
||||
workspace_id: None,
|
||||
@@ -17952,7 +18122,7 @@ mod tests {
|
||||
},
|
||||
source: RuntimeSourceWire {
|
||||
channel: "next-route".into(),
|
||||
client: "wolai-frontend".into(),
|
||||
client: "mnote-web".into(),
|
||||
source_kind: None,
|
||||
root_uri: None,
|
||||
workspace_id: None,
|
||||
@@ -18015,7 +18185,7 @@ mod tests {
|
||||
},
|
||||
source: RuntimeSourceWire {
|
||||
channel: "next-route".into(),
|
||||
client: "wolai-frontend".into(),
|
||||
client: "mnote-web".into(),
|
||||
source_kind: None,
|
||||
root_uri: None,
|
||||
workspace_id: None,
|
||||
@@ -18079,7 +18249,7 @@ mod tests {
|
||||
},
|
||||
source: RuntimeSourceWire {
|
||||
channel: "next-route".into(),
|
||||
client: "wolai-frontend".into(),
|
||||
client: "mnote-web".into(),
|
||||
source_kind: None,
|
||||
root_uri: None,
|
||||
workspace_id: None,
|
||||
@@ -18146,7 +18316,7 @@ mod tests {
|
||||
},
|
||||
source: RuntimeSourceWire {
|
||||
channel: "next-route".into(),
|
||||
client: "wolai-frontend".into(),
|
||||
client: "mnote-web".into(),
|
||||
source_kind: None,
|
||||
root_uri: None,
|
||||
workspace_id: None,
|
||||
@@ -18244,7 +18414,7 @@ mod tests {
|
||||
},
|
||||
source: RuntimeSourceWire {
|
||||
channel: "next-route".into(),
|
||||
client: "wolai-frontend".into(),
|
||||
client: "mnote-web".into(),
|
||||
source_kind: None,
|
||||
root_uri: None,
|
||||
workspace_id: None,
|
||||
@@ -18343,7 +18513,7 @@ mod tests {
|
||||
},
|
||||
source: RuntimeSourceWire {
|
||||
channel: "next-route".into(),
|
||||
client: "wolai-frontend".into(),
|
||||
client: "mnote-web".into(),
|
||||
source_kind: None,
|
||||
root_uri: None,
|
||||
workspace_id: None,
|
||||
@@ -18456,7 +18626,7 @@ mod tests {
|
||||
},
|
||||
source: RuntimeSourceWire {
|
||||
channel: "next-route".into(),
|
||||
client: "wolai-frontend".into(),
|
||||
client: "mnote-web".into(),
|
||||
source_kind: None,
|
||||
root_uri: None,
|
||||
workspace_id: None,
|
||||
@@ -18530,7 +18700,7 @@ mod tests {
|
||||
},
|
||||
source: RuntimeSourceWire {
|
||||
channel: "next-route".into(),
|
||||
client: "wolai-frontend".into(),
|
||||
client: "mnote-web".into(),
|
||||
source_kind: None,
|
||||
root_uri: None,
|
||||
workspace_id: None,
|
||||
|
||||
@@ -19,6 +19,7 @@ pub enum EditorBlockType {
|
||||
Toc,
|
||||
PageReference,
|
||||
BlockReference,
|
||||
Resource,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
|
||||
@@ -588,6 +588,18 @@ fn block_to_tiptap_node(
|
||||
content: Vec::new(),
|
||||
})
|
||||
}
|
||||
EditorBlockType::Resource => Ok(TiptapNode::Paragraph {
|
||||
attrs: TiptapParagraphAttrs {
|
||||
block_id: Some(block.block_id.clone()),
|
||||
text_align: text_align_from_props(&block.props),
|
||||
mnote_block_type: Some("resource".into()),
|
||||
mindmap_id: None,
|
||||
root_node_id: None,
|
||||
projection_version: None,
|
||||
extra: block.props.extra.clone(),
|
||||
},
|
||||
content,
|
||||
}),
|
||||
EditorBlockType::Heading => Ok(TiptapNode::Heading {
|
||||
attrs: TiptapHeadingAttrs {
|
||||
level: block.props.heading_level.unwrap_or(1),
|
||||
@@ -698,6 +710,21 @@ fn node_to_block(
|
||||
child_block_ids: vec![],
|
||||
})
|
||||
}
|
||||
TiptapNode::Paragraph { attrs, content }
|
||||
if attrs.mnote_block_type.as_deref() == Some("resource") =>
|
||||
{
|
||||
let mut props = props_with_text_align(attrs.text_align.clone());
|
||||
for (key, value) in &attrs.extra {
|
||||
props.extra.insert(key.clone(), value.clone());
|
||||
}
|
||||
Ok(EditorBlock {
|
||||
block_id: attrs.block_id.clone().unwrap_or(fallback_block_id),
|
||||
block_type: EditorBlockType::Resource,
|
||||
props,
|
||||
content_nodes: text_nodes_from_tiptap(content)?,
|
||||
child_block_ids: vec![],
|
||||
})
|
||||
}
|
||||
TiptapNode::Paragraph { attrs, content } => Ok(EditorBlock {
|
||||
block_id: attrs.block_id.clone().unwrap_or(fallback_block_id),
|
||||
block_type: EditorBlockType::Paragraph,
|
||||
|
||||
@@ -179,7 +179,7 @@ impl AcpRunBridge {
|
||||
/// Map an AcpSessionEvent to an SSE event for the frontend.
|
||||
///
|
||||
/// Reference: hermes-vscode-main protocol.ts extractTextContent/parseToolCall/parseToolCallUpdate
|
||||
/// Reference: wolai-frontend bridge.ts HermesRunEvent type
|
||||
/// Reference: recycle/wolai-frontend bridge.ts HermesRunEvent type (historic)
|
||||
pub fn acp_event_to_sse(event: AcpSessionEvent) -> Option<SseEvent> {
|
||||
match event {
|
||||
AcpSessionEvent::TextDelta { text } => Some(SseEvent {
|
||||
@@ -233,6 +233,24 @@ pub fn acp_event_to_sse(event: AcpSessionEvent) -> Option<SseEvent> {
|
||||
event: "usage.updated".into(),
|
||||
data: json!({ "used": used, "size": size }),
|
||||
}),
|
||||
AcpSessionEvent::PermissionRequest {
|
||||
permission_id,
|
||||
tool_name,
|
||||
params,
|
||||
decision,
|
||||
} => Some(SseEvent {
|
||||
event: if decision == "allowed" {
|
||||
"permission.allowed".into()
|
||||
} else {
|
||||
"permission.denied".into()
|
||||
},
|
||||
data: json!({
|
||||
"permissionId": permission_id,
|
||||
"toolName": tool_name,
|
||||
"params": params,
|
||||
"decision": decision,
|
||||
}),
|
||||
}),
|
||||
AcpSessionEvent::SessionInfoUpdate { .. } => {
|
||||
None // Not forwarded to frontend
|
||||
}
|
||||
@@ -292,6 +310,23 @@ mod tests {
|
||||
assert_eq!(sse.data["delta"], "internal reasoning");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn acp_permission_request_emits_frontend_decision_event() {
|
||||
let event = AcpSessionEvent::PermissionRequest {
|
||||
permission_id: "perm_1".into(),
|
||||
tool_name: "mnote.page.save".into(),
|
||||
params: json!({"documentId": "doc_1"}),
|
||||
decision: "denied".into(),
|
||||
};
|
||||
|
||||
let sse = acp_event_to_sse(event).expect("permission decision should be forwarded");
|
||||
assert_eq!(sse.event, "permission.denied");
|
||||
assert_eq!(sse.data["permissionId"], "perm_1");
|
||||
assert_eq!(sse.data["toolName"], "mnote.page.save");
|
||||
assert_eq!(sse.data["params"]["documentId"], "doc_1");
|
||||
assert_eq!(sse.data["decision"], "denied");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn acp_tool_events_keep_detail_for_collapsible_ui() {
|
||||
let started = acp_event_to_sse(AcpSessionEvent::ToolCall {
|
||||
|
||||
@@ -149,7 +149,7 @@ impl AcpClient {
|
||||
.take()
|
||||
.ok_or_else(|| AcpError::Internal("failed to take child stdout".into()))?;
|
||||
|
||||
let writer = BufWriter::new(stdin);
|
||||
let writer = Arc::new(Mutex::new(BufWriter::new(stdin)));
|
||||
let reader = BufReader::new(stdout);
|
||||
|
||||
let pending: Arc<Mutex<HashMap<u64, PendingEntry>>> = Arc::new(Mutex::new(HashMap::new()));
|
||||
@@ -159,15 +159,16 @@ impl AcpClient {
|
||||
// Start background reader task
|
||||
let pending_clone = pending.clone();
|
||||
let handler_clone = notification_handler.clone();
|
||||
let writer_clone = writer.clone();
|
||||
let child_pid = child.id().unwrap_or(0);
|
||||
tokio::spawn(async move {
|
||||
Self::reader_loop(reader, pending_clone, handler_clone).await;
|
||||
Self::reader_loop(reader, writer_clone, pending_clone, handler_clone).await;
|
||||
info!("ACP reader loop ended (pid={})", child_pid);
|
||||
});
|
||||
|
||||
let client = Self {
|
||||
child: Some(child),
|
||||
writer: Arc::new(Mutex::new(writer)),
|
||||
writer,
|
||||
pending,
|
||||
next_id: AtomicU64::new(1),
|
||||
notification_handler,
|
||||
@@ -285,6 +286,7 @@ impl AcpClient {
|
||||
/// Reference: `acpClient.ts` L120-180 (onData + dispatch)
|
||||
async fn reader_loop(
|
||||
mut reader: BufReader<ChildStdout>,
|
||||
writer: Arc<Mutex<BufWriter<ChildStdin>>>,
|
||||
pending: Arc<Mutex<HashMap<u64, PendingEntry>>>,
|
||||
notification_handler: Arc<NotificationHandlerMutex>,
|
||||
) {
|
||||
@@ -319,7 +321,7 @@ impl AcpClient {
|
||||
}
|
||||
};
|
||||
|
||||
Self::dispatch_message(msg, &pending, ¬ification_handler).await;
|
||||
Self::dispatch_message(msg, &writer, &pending, ¬ification_handler).await;
|
||||
}
|
||||
|
||||
// Process died or EOF — resolve all pending
|
||||
@@ -334,6 +336,7 @@ impl AcpClient {
|
||||
/// Reference: `acpClient.ts` L160-200 (dispatch)
|
||||
async fn dispatch_message(
|
||||
msg: Value,
|
||||
writer: &Arc<Mutex<BufWriter<ChildStdin>>>,
|
||||
pending: &Arc<Mutex<HashMap<u64, PendingEntry>>>,
|
||||
notification_handler: &Arc<NotificationHandlerMutex>,
|
||||
) {
|
||||
@@ -348,11 +351,32 @@ impl AcpClient {
|
||||
// Incoming request from agent (e.g. session/request_permission)
|
||||
let method = msg["method"].as_str().unwrap_or("unknown").to_string();
|
||||
let params = msg.get("params").cloned().unwrap_or(Value::Null);
|
||||
// For now, reject all incoming requests since we don't need permission dialogs yet.
|
||||
// Reference: acpClient.ts handleIncomingRequest (L200-220)
|
||||
let id = msg.get("id").cloned().unwrap_or(Value::Null);
|
||||
// 当前还没有权限确认 UI,必须明确拒绝,避免 agent 等待到超时。
|
||||
warn!("ACP incoming request not handled: {method} (params={params:?})");
|
||||
// If we wanted to reply, we'd need to write back a response...
|
||||
// For now just log. Phase C will add permission support.
|
||||
let response = json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": id,
|
||||
"error": {
|
||||
"code": -32601,
|
||||
"message": format!("ACP incoming request not supported: {method}")
|
||||
}
|
||||
});
|
||||
if let Err(error) = Self::write_jsonrpc_message(writer, &response).await {
|
||||
warn!("ACP incoming request response write failed: {error}");
|
||||
}
|
||||
if method == "session/request_permission" {
|
||||
let mut event_params = params;
|
||||
if let Some(object) = event_params.as_object_mut() {
|
||||
object.insert("decision".into(), Value::String("denied".into()));
|
||||
object.insert("method".into(), Value::String(method.clone()));
|
||||
object.insert("jsonrpcId".into(), id);
|
||||
}
|
||||
let handler_guard = notification_handler.lock().unwrap();
|
||||
if let Some(ref handler) = *handler_guard {
|
||||
handler(method, event_params);
|
||||
}
|
||||
}
|
||||
} else if has_id {
|
||||
// Response to one of our requests
|
||||
if let Some(id) = msg["id"].as_u64() {
|
||||
@@ -388,6 +412,18 @@ impl AcpClient {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn write_jsonrpc_message(
|
||||
writer: &Arc<Mutex<BufWriter<ChildStdin>>>,
|
||||
msg: &Value,
|
||||
) -> Result<(), AcpError> {
|
||||
let line = serde_json::to_string(msg)?;
|
||||
let mut writer = writer.lock().await;
|
||||
writer.write_all(line.as_bytes()).await?;
|
||||
writer.write_all(b"\n").await?;
|
||||
writer.flush().await?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for AcpClient {
|
||||
@@ -443,6 +479,48 @@ rl.on('line', (line) => {
|
||||
.expect("spawn mock ACP")
|
||||
}
|
||||
|
||||
async fn spawn_permission_request_mock_server() -> AcpClient {
|
||||
let script = r#"
|
||||
import * as readline from 'node:readline';
|
||||
import { stdin as input, stdout as output } from 'node:process';
|
||||
let permissionResponse = null;
|
||||
const rl = readline.createInterface({ input, output, terminal: false });
|
||||
function send(msg) { process.stdout.write(JSON.stringify(msg) + '\n'); }
|
||||
rl.on('line', (line) => {
|
||||
const msg = JSON.parse(line);
|
||||
if (msg.id !== undefined && msg.method === 'initialize') {
|
||||
send({
|
||||
jsonrpc: '2.0',
|
||||
id: msg.id,
|
||||
result: { protocolVersion: 1, agentCapabilities: {}, agentInfo: { name: 'mock', version: '1.0' }, authMethods: [] }
|
||||
});
|
||||
setTimeout(() => send({
|
||||
jsonrpc: '2.0',
|
||||
id: 77,
|
||||
method: 'session/request_permission',
|
||||
params: { reason: 'test permission' }
|
||||
}), 10);
|
||||
} else if (msg.id === 77 && msg.method === undefined) {
|
||||
permissionResponse = msg;
|
||||
} else if (msg.id !== undefined && msg.method === 'get_permission_response') {
|
||||
send({
|
||||
jsonrpc: '2.0',
|
||||
id: msg.id,
|
||||
result: { permissionResponse }
|
||||
});
|
||||
}
|
||||
});
|
||||
"#;
|
||||
|
||||
let dir = std::env::temp_dir();
|
||||
let script_path = dir.join("acp_permission_request_mock.mjs");
|
||||
std::fs::write(&script_path, script).expect("write permission mock script");
|
||||
|
||||
AcpClient::spawn("node", &[script_path.to_str().unwrap()])
|
||||
.await
|
||||
.expect("spawn permission mock ACP")
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_request_response() {
|
||||
let client = spawn_mock_acp_server().await;
|
||||
@@ -504,4 +582,18 @@ rl.on('line', (line) => {
|
||||
// spawn already calls initialize; if it fails, the test fails
|
||||
let _client = spawn_mock_acp_server().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_incoming_permission_request_gets_response() {
|
||||
let client = spawn_permission_request_mock_server().await;
|
||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||
let result: Value = client
|
||||
.request("get_permission_response", json!({}))
|
||||
.await
|
||||
.expect("permission response probe");
|
||||
let response = &result["permissionResponse"];
|
||||
assert_eq!(response["jsonrpc"], "2.0");
|
||||
assert_eq!(response["id"], 77);
|
||||
assert!(response.get("result").is_some() || response.get("error").is_some());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,6 +43,13 @@ pub enum AcpSessionEvent {
|
||||
},
|
||||
/// Context usage update.
|
||||
UsageUpdate { used: u64, size: u64 },
|
||||
/// Agent 发起权限请求;当前 runtime 会自动拒绝并同步给前端。
|
||||
PermissionRequest {
|
||||
permission_id: String,
|
||||
tool_name: String,
|
||||
params: Value,
|
||||
decision: String,
|
||||
},
|
||||
/// Session metadata update (e.g. auto-title).
|
||||
SessionInfoUpdate { title: String },
|
||||
/// Plan entries update.
|
||||
@@ -51,6 +58,16 @@ pub enum AcpSessionEvent {
|
||||
Disconnected { reason: String },
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct AcpMnoteToolContext {
|
||||
pub mnote_session_id: Option<String>,
|
||||
pub run_id: Option<String>,
|
||||
pub actor_id: Option<String>,
|
||||
pub trace_id: Option<String>,
|
||||
pub workspace_id: Option<String>,
|
||||
pub document_id: Option<String>,
|
||||
}
|
||||
|
||||
/// Handler for session events.
|
||||
pub type SessionEventHandler = Arc<dyn Fn(AcpSessionEvent) + Send + Sync + 'static>;
|
||||
|
||||
@@ -101,6 +118,34 @@ impl AcpSessionManager {
|
||||
let in_prompt_clone = in_prompt.clone();
|
||||
|
||||
client.on_notification(move |method, params| {
|
||||
if method == "session/request_permission" {
|
||||
let permission_id = params
|
||||
.get("permissionId")
|
||||
.or_else(|| params.get("permission_id"))
|
||||
.or_else(|| params.get("requestId"))
|
||||
.or_else(|| params.get("id"))
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("permission_auto_denied")
|
||||
.to_string();
|
||||
let tool_name = params
|
||||
.get("toolName")
|
||||
.or_else(|| params.get("tool"))
|
||||
.or_else(|| params.get("name"))
|
||||
.or_else(|| params.get("method"))
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("session/request_permission")
|
||||
.to_string();
|
||||
let handler = event_handler_clone.lock().unwrap();
|
||||
if let Some(ref h) = *handler {
|
||||
h(AcpSessionEvent::PermissionRequest {
|
||||
permission_id,
|
||||
tool_name,
|
||||
params,
|
||||
decision: "denied".into(),
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
if method != "session/update" {
|
||||
return;
|
||||
}
|
||||
@@ -193,6 +238,14 @@ impl AcpSessionManager {
|
||||
pub async fn run_prompt(
|
||||
&self,
|
||||
prompt: Vec<ContentBlock>,
|
||||
) -> Result<SessionPromptResult, crate::acp_client::AcpError> {
|
||||
self.run_prompt_with_mnote_context(prompt, None).await
|
||||
}
|
||||
|
||||
pub async fn run_prompt_with_mnote_context(
|
||||
&self,
|
||||
prompt: Vec<ContentBlock>,
|
||||
mnote_context: Option<AcpMnoteToolContext>,
|
||||
) -> Result<SessionPromptResult, crate::acp_client::AcpError> {
|
||||
{
|
||||
let mut in_prompt = self.in_prompt.lock().unwrap();
|
||||
@@ -215,9 +268,16 @@ impl AcpSessionManager {
|
||||
)
|
||||
})?;
|
||||
|
||||
let mnote_context = mnote_context.unwrap_or_default();
|
||||
let params = SessionPromptParams {
|
||||
session_id: session_id.clone(),
|
||||
prompt,
|
||||
mnote_session_id: mnote_context.mnote_session_id,
|
||||
run_id: mnote_context.run_id,
|
||||
actor_id: mnote_context.actor_id,
|
||||
trace_id: mnote_context.trace_id,
|
||||
workspace_id: mnote_context.workspace_id,
|
||||
document_id: mnote_context.document_id,
|
||||
};
|
||||
|
||||
debug!("ACP session/prompt (session={})", session_id);
|
||||
|
||||
@@ -163,6 +163,18 @@ pub struct ResourceContent {
|
||||
pub struct SessionPromptParams {
|
||||
pub session_id: String,
|
||||
pub prompt: Vec<ContentBlock>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub mnote_session_id: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub run_id: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub actor_id: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub trace_id: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub workspace_id: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub document_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
|
||||
@@ -430,5 +430,6 @@ fn block_type_name(block_type: &EditorBlockType) -> String {
|
||||
EditorBlockType::Toc => "table_of_contents".into(),
|
||||
EditorBlockType::PageReference => "page_reference".into(),
|
||||
EditorBlockType::BlockReference => "block_reference".into(),
|
||||
EditorBlockType::Resource => "resource".into(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,6 +87,7 @@ pub async fn block_replace(
|
||||
ensure_page_write_preconditions(context, input, &aggregate)?;
|
||||
ensure_block_revision_ref(context, input, &block, "blockRevisionRef")?;
|
||||
ensure_block_editable(context, &block)?;
|
||||
ensure_allowed_target(context, input, &Value::Null, &block_id, "replace")?;
|
||||
let replacement_text = content_to_text(&content);
|
||||
let diff = json!([{
|
||||
"op": "replace",
|
||||
@@ -155,58 +156,73 @@ pub async fn block_insert_after(
|
||||
ensure_page_write_preconditions(context, input, &aggregate)?;
|
||||
ensure_block_revision_ref(context, input, &anchor, "anchorRevisionRef")?;
|
||||
ensure_block_editable(context, &anchor)?;
|
||||
let content = input
|
||||
.arg_value("content")
|
||||
.or_else(|| input.arg_value("block"))
|
||||
.ok_or_else(|| {
|
||||
WebError::bad_request_code(
|
||||
"mnote_tool_bad_request",
|
||||
"mnote.block.insert_after 缺少 content",
|
||||
)
|
||||
.with_context(context)
|
||||
})?;
|
||||
let new_block_id = format!("ai_block_{}", context.trace.request_id.replace('-', "_"));
|
||||
let new_block = build_insert_block(&new_block_id, &content);
|
||||
let diff = json!([{
|
||||
ensure_allowed_target(
|
||||
context,
|
||||
input,
|
||||
&Value::Null,
|
||||
&anchor_block_id,
|
||||
"insert_after",
|
||||
)?;
|
||||
let insert_values = insert_after_values(context, input)?;
|
||||
let id_prefix = format!("ai_block_{}", context.trace.request_id.replace('-', "_"));
|
||||
let inserted_block_ids = insert_values
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(index, _)| {
|
||||
if insert_values.len() == 1 {
|
||||
id_prefix.clone()
|
||||
} else {
|
||||
format!("{id_prefix}_{index}")
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let diff = insert_values
|
||||
.iter()
|
||||
.zip(inserted_block_ids.iter())
|
||||
.map(|(content, block_id)| {
|
||||
json!({
|
||||
"op": "insert_after",
|
||||
"anchorBlockId": anchor_block_id,
|
||||
"after": anchor.get("text").cloned().unwrap_or(Value::Null),
|
||||
"block": new_block
|
||||
}]);
|
||||
"block": build_insert_block(block_id, content)
|
||||
})
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
if input.dry_run.unwrap_or(false) {
|
||||
return Ok(dry_run_result(
|
||||
input,
|
||||
&aggregate,
|
||||
"block_insert_after",
|
||||
diff,
|
||||
false,
|
||||
));
|
||||
let mut result =
|
||||
dry_run_result(input, &aggregate, "block_insert_after", json!(diff), false);
|
||||
result["insertedBlockIds"] = json!(inserted_block_ids);
|
||||
return Ok(result);
|
||||
}
|
||||
let current_content = current_body_content(&aggregate);
|
||||
let mut next_content = current_body_content(&aggregate);
|
||||
let mut changed_blocks = Vec::with_capacity(insert_values.len());
|
||||
let mut delta_opt = None;
|
||||
let mut after_block_id = anchor_block_id.clone();
|
||||
for (content, block_id) in insert_values.iter().zip(inserted_block_ids.iter()) {
|
||||
let command = EditorCommand::InsertBlockAfter(EditorInsertBlockAfter {
|
||||
after_block_id: anchor_block_id.clone(),
|
||||
block: build_editor_block(&new_block_id, &content),
|
||||
after_block_id: after_block_id.clone(),
|
||||
block: build_editor_block(block_id, content),
|
||||
});
|
||||
let (next_content, delta_opt) = compute_next_content_via_actor(
|
||||
let (content_after_command, command_delta) = compute_next_content_via_actor(
|
||||
state,
|
||||
&aggregate,
|
||||
input,
|
||||
¤t_content,
|
||||
&next_content,
|
||||
&command,
|
||||
"block_insert_after",
|
||||
)?;
|
||||
let mut result = execute_page_body_save(
|
||||
state,
|
||||
context,
|
||||
input,
|
||||
next_content,
|
||||
vec![json!({
|
||||
"blockId": new_block_id,
|
||||
next_content = content_after_command;
|
||||
delta_opt = command_delta;
|
||||
changed_blocks.push(json!({
|
||||
"blockId": block_id,
|
||||
"op": "insert_after",
|
||||
"anchorBlockId": anchor_block_id
|
||||
})],
|
||||
)
|
||||
.await?;
|
||||
"anchorBlockId": after_block_id
|
||||
}));
|
||||
after_block_id = block_id.clone();
|
||||
}
|
||||
let mut result =
|
||||
execute_page_body_save(state, context, input, next_content, changed_blocks).await?;
|
||||
result["insertedBlockIds"] = json!(inserted_block_ids);
|
||||
merge_block_delta(&mut result, delta_opt);
|
||||
Ok(result)
|
||||
}
|
||||
@@ -224,6 +240,7 @@ pub async fn block_delete(
|
||||
ensure_page_write_preconditions(context, input, &aggregate)?;
|
||||
ensure_block_revision_ref(context, input, &block, "blockRevisionRef")?;
|
||||
ensure_block_editable(context, &block)?;
|
||||
ensure_allowed_target(context, input, &Value::Null, &block_id, "delete")?;
|
||||
let leaf = block
|
||||
.get("children")
|
||||
.and_then(Value::as_array)
|
||||
@@ -298,6 +315,14 @@ pub async fn block_move_after(
|
||||
ensure_page_write_preconditions(context, input, &aggregate)?;
|
||||
ensure_block_revision_ref(context, input, &block, "blockRevisionRef")?;
|
||||
ensure_block_revision_ref(context, input, &anchor, "anchorRevisionRef")?;
|
||||
ensure_allowed_target(context, input, &Value::Null, &block_id, "move_after")?;
|
||||
ensure_allowed_target(
|
||||
context,
|
||||
input,
|
||||
&Value::Null,
|
||||
&anchor_block_id,
|
||||
"move_after_anchor",
|
||||
)?;
|
||||
let same_parent = block.get("parentBlockId") == anchor.get("parentBlockId");
|
||||
let leaf = block
|
||||
.get("children")
|
||||
@@ -413,6 +438,7 @@ pub async fn doc_apply_block_ops(
|
||||
)
|
||||
.with_context(context));
|
||||
}
|
||||
ensure_page_write_preconditions(context, input, &aggregate)?;
|
||||
|
||||
let blocks = block_projection_blocks(&aggregate);
|
||||
let mut next_content = current_body_content(&aggregate);
|
||||
@@ -431,6 +457,13 @@ pub async fn doc_apply_block_ops(
|
||||
"replace" | "block_replace" => {
|
||||
let block = resolve_target_block(context, &blocks, operation, false)?;
|
||||
ensure_block_editable(context, &block)?;
|
||||
ensure_operation_block_revision_ref(
|
||||
context,
|
||||
input,
|
||||
operation,
|
||||
&block,
|
||||
"blockRevisionRef",
|
||||
)?;
|
||||
let block_id = block_id_of(&block).unwrap_or_default();
|
||||
let content = operation.get("content").cloned().ok_or_else(|| {
|
||||
WebError::bad_request_code("mnote_tool_bad_request", "replace 操作缺少 content")
|
||||
@@ -466,6 +499,13 @@ pub async fn doc_apply_block_ops(
|
||||
"insert_after" | "block_insert_after" => {
|
||||
let anchor = resolve_anchor_block(context, &blocks, operation)?;
|
||||
ensure_block_editable(context, &anchor)?;
|
||||
ensure_operation_block_revision_ref(
|
||||
context,
|
||||
input,
|
||||
operation,
|
||||
&anchor,
|
||||
"anchorRevisionRef",
|
||||
)?;
|
||||
let anchor_block_id = block_id_of(&anchor).unwrap_or_default();
|
||||
ensure_allowed_target(context, input, operation, &anchor_block_id, "insert_after")?;
|
||||
let content = operation.get("content").cloned().ok_or_else(|| {
|
||||
@@ -511,6 +551,13 @@ pub async fn doc_apply_block_ops(
|
||||
"delete" | "block_delete" => {
|
||||
let block = resolve_target_block(context, &blocks, operation, false)?;
|
||||
ensure_block_editable(context, &block)?;
|
||||
ensure_operation_block_revision_ref(
|
||||
context,
|
||||
input,
|
||||
operation,
|
||||
&block,
|
||||
"blockRevisionRef",
|
||||
)?;
|
||||
let block_id = block_id_of(&block).unwrap_or_default();
|
||||
ensure_allowed_target(context, input, operation, &block_id, "delete")?;
|
||||
ensure_leaf_block(context, &block, "delete")?;
|
||||
@@ -540,6 +587,20 @@ pub async fn doc_apply_block_ops(
|
||||
let block = resolve_target_block(context, &blocks, operation, false)?;
|
||||
let anchor = resolve_anchor_block(context, &blocks, operation)?;
|
||||
ensure_block_editable(context, &block)?;
|
||||
ensure_operation_block_revision_ref(
|
||||
context,
|
||||
input,
|
||||
operation,
|
||||
&block,
|
||||
"blockRevisionRef",
|
||||
)?;
|
||||
ensure_operation_block_revision_ref(
|
||||
context,
|
||||
input,
|
||||
operation,
|
||||
&anchor,
|
||||
"anchorRevisionRef",
|
||||
)?;
|
||||
ensure_leaf_block(context, &block, "move_after")?;
|
||||
let block_id = block_id_of(&block).unwrap_or_default();
|
||||
let anchor_block_id = block_id_of(&anchor).unwrap_or_default();
|
||||
@@ -628,7 +689,10 @@ pub async fn doc_apply_block_ops(
|
||||
.await
|
||||
}
|
||||
|
||||
fn ensure_write_contract(context: &RequestContext, input: &ToolCallInput) -> Result<(), WebError> {
|
||||
pub(crate) fn ensure_write_contract(
|
||||
context: &RequestContext,
|
||||
input: &ToolCallInput,
|
||||
) -> Result<(), WebError> {
|
||||
if !input.has_idempotency_key() {
|
||||
return Err(WebError::bad_request_code(
|
||||
"mnote_tool_idempotency_required",
|
||||
@@ -833,6 +897,37 @@ fn ensure_block_revision_ref(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn ensure_operation_block_revision_ref(
|
||||
context: &RequestContext,
|
||||
input: &ToolCallInput,
|
||||
operation: &Value,
|
||||
block: &Value,
|
||||
arg_name: &'static str,
|
||||
) -> Result<(), WebError> {
|
||||
if input.dry_run.unwrap_or(false) {
|
||||
return Ok(());
|
||||
}
|
||||
let expected = operation
|
||||
.get(arg_name)
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.ok_or_else(|| missing_write_precondition(context, arg_name))?;
|
||||
let current = block
|
||||
.get("revisionRef")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.unwrap_or("");
|
||||
if expected != current {
|
||||
return Err(WebError::bad_request_code(
|
||||
"mnote_tool_conflict",
|
||||
format!("{arg_name} 已过期,请重新读取 block projection 后再写入"),
|
||||
)
|
||||
.with_context(context));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn ensure_block_editable(context: &RequestContext, block: &Value) -> Result<(), WebError> {
|
||||
if block
|
||||
.get("editable")
|
||||
@@ -851,6 +946,40 @@ fn ensure_block_editable(context: &RequestContext, block: &Value) -> Result<(),
|
||||
.with_context(context))
|
||||
}
|
||||
|
||||
fn insert_after_values(
|
||||
context: &RequestContext,
|
||||
input: &ToolCallInput,
|
||||
) -> Result<Vec<Value>, WebError> {
|
||||
if let Some(blocks) = input.arg_value("blocks") {
|
||||
let values = blocks.as_array().ok_or_else(|| {
|
||||
WebError::bad_request_code(
|
||||
"mnote_tool_bad_request",
|
||||
"mnote.block.insert_after 的 blocks 必须是数组",
|
||||
)
|
||||
.with_context(context)
|
||||
})?;
|
||||
if values.is_empty() || values.len() > 20 {
|
||||
return Err(WebError::bad_request_code(
|
||||
"mnote_tool_bad_request",
|
||||
"mnote.block.insert_after 的 blocks 数量必须在 1 到 20 之间",
|
||||
)
|
||||
.with_context(context));
|
||||
}
|
||||
return Ok(values.clone());
|
||||
}
|
||||
input
|
||||
.arg_value("content")
|
||||
.or_else(|| input.arg_value("block"))
|
||||
.map(|value| vec![value])
|
||||
.ok_or_else(|| {
|
||||
WebError::bad_request_code(
|
||||
"mnote_tool_bad_request",
|
||||
"mnote.block.insert_after 缺少 content、block 或 blocks",
|
||||
)
|
||||
.with_context(context)
|
||||
})
|
||||
}
|
||||
|
||||
fn missing_write_precondition(context: &RequestContext, name: &'static str) -> WebError {
|
||||
WebError::bad_request_code(
|
||||
"mnote_tool_write_precondition_required",
|
||||
@@ -1049,7 +1178,7 @@ async fn execute_page_body_save(
|
||||
}))
|
||||
}
|
||||
|
||||
async fn execute_page_body_save_from_aggregate(
|
||||
pub(crate) async fn execute_page_body_save_from_aggregate(
|
||||
state: &AppState,
|
||||
context: &RequestContext,
|
||||
input: &ToolCallInput,
|
||||
@@ -1255,7 +1384,7 @@ fn merge_block_delta(response: &mut Value, delta_opt: Option<Value>) {
|
||||
}
|
||||
}
|
||||
|
||||
fn current_body_content(aggregate: &Value) -> Value {
|
||||
pub(crate) fn current_body_content(aggregate: &Value) -> Value {
|
||||
aggregate
|
||||
.pointer("/body/content")
|
||||
.cloned()
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -180,7 +180,11 @@ fn block_replace_tool() -> Value {
|
||||
"content": { "type": ["string", "object", "array"] },
|
||||
"revision": { "type": ["number", "string"] },
|
||||
"conflictDetectionKey": { "type": "string" },
|
||||
"blockRevisionRef": { "type": "string" }
|
||||
"blockRevisionRef": { "type": "string" },
|
||||
"allowedTargetBlockIds": {
|
||||
"type": "array",
|
||||
"items": { "type": "string" }
|
||||
}
|
||||
}),
|
||||
[
|
||||
"blockId",
|
||||
@@ -193,7 +197,7 @@ fn block_replace_tool() -> Value {
|
||||
}
|
||||
|
||||
fn block_insert_after_tool() -> Value {
|
||||
write_tool(
|
||||
let mut tool = write_tool(
|
||||
"mnote.block.insert_after",
|
||||
"在指定块后插入新块;真实写入走 Rust page.body.save 链路",
|
||||
["block.write", "page.write"],
|
||||
@@ -201,18 +205,38 @@ fn block_insert_after_tool() -> Value {
|
||||
"anchorBlockId": { "type": "string" },
|
||||
"content": { "type": ["string", "object", "array"] },
|
||||
"block": { "type": "object" },
|
||||
"blocks": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"maxItems": 20,
|
||||
"items": { "type": ["string", "object", "array"] }
|
||||
},
|
||||
"revision": { "type": ["number", "string"] },
|
||||
"conflictDetectionKey": { "type": "string" },
|
||||
"anchorRevisionRef": { "type": "string" }
|
||||
"anchorRevisionRef": { "type": "string" },
|
||||
"allowedTargetBlockIds": {
|
||||
"type": "array",
|
||||
"items": { "type": "string" }
|
||||
}
|
||||
}),
|
||||
[
|
||||
"anchorBlockId",
|
||||
"content",
|
||||
"revision",
|
||||
"conflictDetectionKey",
|
||||
"anchorRevisionRef",
|
||||
],
|
||||
)
|
||||
);
|
||||
if let Some(schema) = tool.get_mut("inputSchema").and_then(Value::as_object_mut) {
|
||||
schema.insert(
|
||||
"anyOf".into(),
|
||||
json!([
|
||||
{ "required": ["content"] },
|
||||
{ "required": ["block"] },
|
||||
{ "required": ["blocks"] }
|
||||
]),
|
||||
);
|
||||
}
|
||||
tool
|
||||
}
|
||||
|
||||
fn block_move_after_tool() -> Value {
|
||||
@@ -226,7 +250,11 @@ fn block_move_after_tool() -> Value {
|
||||
"revision": { "type": ["number", "string"] },
|
||||
"conflictDetectionKey": { "type": "string" },
|
||||
"blockRevisionRef": { "type": "string" },
|
||||
"anchorRevisionRef": { "type": "string" }
|
||||
"anchorRevisionRef": { "type": "string" },
|
||||
"allowedTargetBlockIds": {
|
||||
"type": "array",
|
||||
"items": { "type": "string" }
|
||||
}
|
||||
}),
|
||||
[
|
||||
"blockId",
|
||||
@@ -248,7 +276,11 @@ fn block_delete_tool() -> Value {
|
||||
"blockId": { "type": "string" },
|
||||
"revision": { "type": ["number", "string"] },
|
||||
"conflictDetectionKey": { "type": "string" },
|
||||
"blockRevisionRef": { "type": "string" }
|
||||
"blockRevisionRef": { "type": "string" },
|
||||
"allowedTargetBlockIds": {
|
||||
"type": "array",
|
||||
"items": { "type": "string" }
|
||||
}
|
||||
}),
|
||||
[
|
||||
"blockId",
|
||||
@@ -411,11 +443,12 @@ fn available_tool(
|
||||
}
|
||||
|
||||
fn doc_markdown_edit_tool() -> Value {
|
||||
let mut properties = base_identity_properties();
|
||||
if let Value::Object(map) = &mut properties {
|
||||
map.insert(
|
||||
"operations".into(),
|
||||
let mut tool = write_tool(
|
||||
"mnote.doc.markdown_edit",
|
||||
"通过文本级搜索替换编辑 markdown 内容(AI 编辑主路径)。在线 Convex 文档和本地 .md 文件共用,不需要 blockId。",
|
||||
["block.write", "page.write"],
|
||||
json!({
|
||||
"operations": {
|
||||
"type": "array",
|
||||
"description": "搜索替换操作列表",
|
||||
"items": {
|
||||
@@ -426,25 +459,24 @@ fn doc_markdown_edit_tool() -> Value {
|
||||
},
|
||||
"required": ["search", "replace"]
|
||||
}
|
||||
}),
|
||||
);
|
||||
map.insert(
|
||||
"full_content".into(),
|
||||
json!({
|
||||
},
|
||||
"full_content": {
|
||||
"type": "string",
|
||||
"description": "完整修改后的 markdown 文本(替代 operations)"
|
||||
}
|
||||
}),
|
||||
[],
|
||||
);
|
||||
if let Some(schema) = tool.get_mut("inputSchema").and_then(Value::as_object_mut) {
|
||||
schema.insert(
|
||||
"anyOf".into(),
|
||||
json!([
|
||||
{ "required": ["operations"] },
|
||||
{ "required": ["full_content"] }
|
||||
]),
|
||||
);
|
||||
}
|
||||
json!({
|
||||
"name": "mnote.doc.markdown_edit",
|
||||
"description": "通过文本级搜索替换编辑 markdown 内容(AI 编辑主路径)。在线 Convex 文档和本地 .md 文件共用,不需要 blockId。",
|
||||
"schemaVersion": TOOL_SCHEMA_VERSION,
|
||||
"capabilityScope": ["block.write", "page.write"],
|
||||
"status": "available",
|
||||
"properties": properties,
|
||||
"annotations": tool_annotations(false, false, true, false)
|
||||
})
|
||||
tool
|
||||
}
|
||||
|
||||
fn tool_annotations(
|
||||
|
||||
@@ -20,3 +20,13 @@ pub mod tree_shell;
|
||||
pub mod workspace_shell;
|
||||
|
||||
pub use app::{build_app, AppConfig, AppState};
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) mod test_support {
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
|
||||
pub(crate) fn hermes_env_lock() -> &'static Mutex<()> {
|
||||
static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
|
||||
LOCK.get_or_init(|| Mutex::new(()))
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -179,14 +179,26 @@ pub fn build_router(state: AppState) -> Router {
|
||||
"/client/sessions",
|
||||
get(hermes_client::list_sessions).post(hermes_client::create_session),
|
||||
)
|
||||
.route(
|
||||
"/client/sessions/search",
|
||||
get(hermes_client::search_sessions),
|
||||
)
|
||||
.route(
|
||||
"/client/sessions/{session_id}",
|
||||
get(hermes_client::get_session),
|
||||
get(hermes_client::get_session).delete(hermes_client::delete_session),
|
||||
)
|
||||
.route(
|
||||
"/client/sessions/{session_id}/resume",
|
||||
post(hermes_client::resume_session),
|
||||
)
|
||||
.route(
|
||||
"/client/sessions/{session_id}/rename",
|
||||
post(hermes_client::rename_session),
|
||||
)
|
||||
.route(
|
||||
"/client/sessions/{session_id}/auto-title",
|
||||
post(hermes_client::auto_title_session),
|
||||
)
|
||||
.route("/client/gateway/health", get(hermes_client::gateway_health))
|
||||
.route("/client/profiles", get(hermes_client::list_profiles))
|
||||
.route(
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use crate::app::AppState;
|
||||
use crate::context::RequestContext;
|
||||
use crate::error::WebError;
|
||||
use crate::hermes_tools::{doc, ToolCallInput};
|
||||
use crate::hermes_tools::ToolCallInput;
|
||||
use axum::extract::{Extension, State};
|
||||
use axum::http::{HeaderMap, StatusCode};
|
||||
use axum::Json;
|
||||
@@ -88,6 +88,30 @@ pub async fn block_edit_workflow(
|
||||
} else {
|
||||
context.auth.actor_id.clone()
|
||||
};
|
||||
let allowed_target_block_ids = ai_context
|
||||
.get("allowedTargetBlockIds")
|
||||
.and_then(Value::as_array)
|
||||
.map(|items| {
|
||||
items
|
||||
.iter()
|
||||
.filter_map(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|item| !item.is_empty())
|
||||
.map(ToOwned::to_owned)
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let mut markdown_args = json!({
|
||||
"operations": markdown_operations.clone()
|
||||
});
|
||||
if !allowed_target_block_ids.is_empty() {
|
||||
if let Value::Object(map) = &mut markdown_args {
|
||||
map.insert(
|
||||
"allowedTargetBlockIds".into(),
|
||||
json!(allowed_target_block_ids),
|
||||
);
|
||||
}
|
||||
}
|
||||
let edit_input = ToolCallInput {
|
||||
tool_name: "mnote.doc.markdown_edit".into(),
|
||||
workspace_id: Some(workspace_id.clone()),
|
||||
@@ -101,12 +125,12 @@ pub async fn block_edit_workflow(
|
||||
idempotency_key: Some(format!("page_ai_fast_edit_{}", context.trace.request_id)),
|
||||
dry_run: Some(false),
|
||||
capability_scope: Some(vec!["block.write".into(), "page.write".into()]),
|
||||
args: Some(json!({
|
||||
"operations": markdown_operations
|
||||
})),
|
||||
args: Some(markdown_args),
|
||||
};
|
||||
let apply_started = Instant::now();
|
||||
let apply_result = doc::doc_markdown_edit(&state, &context, &edit_input).await?;
|
||||
let tool_response =
|
||||
crate::routes::hermes_tools::execute_mnote_tool_call(&state, &context, edit_input).await?;
|
||||
let apply_result = tool_response.get("result").cloned().unwrap_or(Value::Null);
|
||||
let apply_ms = apply_started.elapsed().as_millis();
|
||||
info!(
|
||||
trace_id = %trace_id,
|
||||
@@ -128,6 +152,7 @@ pub async fn block_edit_workflow(
|
||||
"traceId": trace_id,
|
||||
"operations": markdown_operations,
|
||||
"applyResult": apply_result,
|
||||
"toolExecution": tool_response,
|
||||
"message": "已通过页面 markdown 编辑快路径完成写入。",
|
||||
"timingsMs": {
|
||||
"total": started.elapsed().as_millis(),
|
||||
@@ -540,6 +565,124 @@ fn yaml_path_value(content: &str, path: &[&str]) -> Option<String> {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{direct_block_edit_operations, extract_operations_from_model_text};
|
||||
use crate::app::{build_app, AppConfig, AppState};
|
||||
use axum::body::{to_bytes, Body};
|
||||
use axum::http::{Request, StatusCode};
|
||||
use axum::routing::post;
|
||||
use axum::{Json, Router};
|
||||
use serde_json::{json, Value};
|
||||
use std::fs;
|
||||
use std::sync::Mutex;
|
||||
use tower::util::ServiceExt;
|
||||
|
||||
fn env_lock() -> &'static Mutex<()> {
|
||||
crate::test_support::hermes_env_lock()
|
||||
}
|
||||
|
||||
fn app() -> axum::Router {
|
||||
build_app(AppState::new(AppConfig {
|
||||
service_name: "mnote-web".into(),
|
||||
service_version: "0.1.0".into(),
|
||||
bind_addr: "127.0.0.1:0".into(),
|
||||
public_bind_addr: "127.0.0.1:3000".into(),
|
||||
legacy_next_base_url: Some("http://127.0.0.1:3100".into()),
|
||||
enable_legacy_next_compat: true,
|
||||
enable_debug_shell_routes: false,
|
||||
enable_editor_actor: true,
|
||||
hermes_base_path: "/api/hermes".into(),
|
||||
compat_next_base_path: "/api/compat/next".into(),
|
||||
convex_url: None,
|
||||
convex_admin_key: None,
|
||||
allow_dev_fixtures: true,
|
||||
query_fixtures_json: Some(
|
||||
r#"{
|
||||
"documents:getMeta": {
|
||||
"id": "doc_1",
|
||||
"workspace_id": "ws_demo",
|
||||
"title": "服务端页面",
|
||||
"can_edit": true,
|
||||
"wide_layout": false,
|
||||
"use_small_text": false,
|
||||
"show_toc": true,
|
||||
"block_count": 2
|
||||
},
|
||||
"documents:getContent": {
|
||||
"title": "服务端页面",
|
||||
"content": [
|
||||
{
|
||||
"id": "p_1",
|
||||
"type": "paragraph",
|
||||
"content": [{ "type": "text", "text": "第一段" }]
|
||||
},
|
||||
{
|
||||
"id": "p_2",
|
||||
"type": "paragraph",
|
||||
"content": [{ "type": "text", "text": "第二段" }]
|
||||
}
|
||||
],
|
||||
"revision": 7,
|
||||
"conflict_detection_key": "doc_1:7"
|
||||
}
|
||||
}"#
|
||||
.into(),
|
||||
),
|
||||
mutation_fixtures_json: Some(
|
||||
r#"{
|
||||
"documents:updateContent": {"ok": true, "revision": 8, "conflict_detection_key": "doc_1:8"},
|
||||
"bridgeLogs:recordCommandLog": {"ok": true, "id": "cmd_log_fixture"},
|
||||
"bridgeLogs:recordDomainEvent": {"ok": true, "id": "event_fixture"}
|
||||
}"#
|
||||
.into(),
|
||||
),
|
||||
dev_user_id: "dev-user".into(),
|
||||
dev_user_name: "开发用户".into(),
|
||||
dev_user_email: "dev@mnote.local".into(),
|
||||
}))
|
||||
}
|
||||
|
||||
async fn spawn_mock_model_server() -> String {
|
||||
async fn completions() -> Json<Value> {
|
||||
Json(json!({
|
||||
"choices": [{
|
||||
"message": {
|
||||
"content": "{\"operations\":[{\"search\":\"第二段\",\"replace\":\"测试123\"}],\"summary\":\"ok\"}"
|
||||
}
|
||||
}]
|
||||
}))
|
||||
}
|
||||
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
|
||||
.await
|
||||
.expect("bind mock model");
|
||||
let addr = listener.local_addr().expect("mock model addr");
|
||||
let server = Router::new().route("/chat/completions", post(completions));
|
||||
tokio::spawn(async move {
|
||||
let _ = axum::serve(listener, server).await;
|
||||
});
|
||||
format!("http://{addr}")
|
||||
}
|
||||
|
||||
async fn spawn_out_of_scope_mock_model_server() -> String {
|
||||
async fn completions() -> Json<Value> {
|
||||
Json(json!({
|
||||
"choices": [{
|
||||
"message": {
|
||||
"content": "{\"operations\":[{\"search\":\"第一段\",\"replace\":\"越权修改\"}],\"summary\":\"out_of_scope\"}"
|
||||
}
|
||||
}]
|
||||
}))
|
||||
}
|
||||
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
|
||||
.await
|
||||
.expect("bind out-of-scope mock model");
|
||||
let addr = listener.local_addr().expect("mock model addr");
|
||||
let server = Router::new().route("/chat/completions", post(completions));
|
||||
tokio::spawn(async move {
|
||||
let _ = axum::serve(listener, server).await;
|
||||
});
|
||||
format!("http://{addr}")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extracts_operations_from_fenced_model_json() {
|
||||
@@ -570,4 +713,137 @@ mod tests {
|
||||
assert_eq!(operations[2]["op"], "delete");
|
||||
assert_eq!(operations[2]["matchText"], "第三段");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn block_edit_workflow_respects_disabled_markdown_edit_tool() {
|
||||
let _guard = env_lock().lock().expect("env lock");
|
||||
let base_url = spawn_mock_model_server().await;
|
||||
let hermes_home = std::env::temp_dir().join(format!(
|
||||
"mnote-page-ai-workflow-disabled-tool-{}",
|
||||
std::process::id()
|
||||
));
|
||||
let _ = fs::remove_dir_all(&hermes_home);
|
||||
let profile_dir = hermes_home.join("profiles").join("mnoteai");
|
||||
fs::create_dir_all(&profile_dir).expect("profile dir");
|
||||
fs::write(
|
||||
profile_dir.join("config.yaml"),
|
||||
format!(
|
||||
"model:\n provider: mock\n default: mock-model\n base_url: {base_url}\n api_key: test-key\nmnote:\n tools:\n disabled:\n - mnote.doc.markdown_edit\n"
|
||||
),
|
||||
)
|
||||
.expect("profile config");
|
||||
std::env::set_var("HERMES_HOME", &hermes_home);
|
||||
|
||||
let response = app()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/page-ai/block-edit-workflow")
|
||||
.header("content-type", "application/json")
|
||||
.header("x-mnote-actor-id", "user_1")
|
||||
.body(Body::from(
|
||||
json!({
|
||||
"workspaceId": "ws_demo",
|
||||
"documentId": "doc_1",
|
||||
"message": "把第二段改成测试123",
|
||||
"profile": "mnoteai",
|
||||
"sessionId": "sess_page_ai_disabled",
|
||||
"runId": "run_page_ai_disabled",
|
||||
"traceId": "trace_page_ai_disabled",
|
||||
"pageContext": {
|
||||
"aiContext": {
|
||||
"schema": "mnote.page_ai_context.v1",
|
||||
"pageText": "第一段\n\n第二段",
|
||||
"pageXml": "<page><block id=\"p_1\">第一段</block><block id=\"p_2\">第二段</block></page>",
|
||||
"contextBlocks": [
|
||||
{"blockId": "p_1", "text": "第一段"},
|
||||
{"blockId": "p_2", "text": "第二段"}
|
||||
]
|
||||
}
|
||||
}
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.expect("request"),
|
||||
)
|
||||
.await
|
||||
.expect("response");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::FORBIDDEN);
|
||||
let body = to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.expect("body");
|
||||
let payload: Value = serde_json::from_slice(&body).expect("json");
|
||||
assert_eq!(payload["code"], "mnote_tool_disabled");
|
||||
|
||||
std::env::remove_var("HERMES_HOME");
|
||||
let _ = fs::remove_dir_all(&hermes_home);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn block_edit_workflow_forwards_allowed_target_blocks_to_markdown_edit() {
|
||||
let _guard = env_lock().lock().expect("env lock");
|
||||
let base_url = spawn_out_of_scope_mock_model_server().await;
|
||||
let hermes_home = std::env::temp_dir().join(format!(
|
||||
"mnote-page-ai-workflow-selection-scope-{}",
|
||||
std::process::id()
|
||||
));
|
||||
let _ = fs::remove_dir_all(&hermes_home);
|
||||
let profile_dir = hermes_home.join("profiles").join("mnoteai");
|
||||
fs::create_dir_all(&profile_dir).expect("profile dir");
|
||||
fs::write(
|
||||
profile_dir.join("config.yaml"),
|
||||
format!(
|
||||
"model:\n provider: mock\n default: mock-model\n base_url: {base_url}\n api_key: test-key\n"
|
||||
),
|
||||
)
|
||||
.expect("profile config");
|
||||
std::env::set_var("HERMES_HOME", &hermes_home);
|
||||
|
||||
let response = app()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/page-ai/block-edit-workflow")
|
||||
.header("content-type", "application/json")
|
||||
.header("x-mnote-actor-id", "user_1")
|
||||
.body(Body::from(
|
||||
json!({
|
||||
"workspaceId": "ws_demo",
|
||||
"documentId": "doc_1",
|
||||
"message": "把选中段落改成测试123",
|
||||
"profile": "mnoteai",
|
||||
"sessionId": "sess_page_ai_scope",
|
||||
"runId": "run_page_ai_scope",
|
||||
"traceId": "trace_page_ai_scope",
|
||||
"pageContext": {
|
||||
"aiContext": {
|
||||
"schema": "mnote.page_ai_context.v1",
|
||||
"allowedTargetBlockIds": ["p_2"],
|
||||
"pageText": "第一段\n\n第二段",
|
||||
"pageXml": "<page><block id=\"p_1\">第一段</block><block id=\"p_2\">第二段</block></page>",
|
||||
"contextBlocks": [
|
||||
{"blockId": "p_1", "text": "第一段"},
|
||||
{"blockId": "p_2", "text": "第二段"}
|
||||
]
|
||||
}
|
||||
}
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.expect("request"),
|
||||
)
|
||||
.await
|
||||
.expect("response");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
|
||||
let body = to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.expect("body");
|
||||
let payload: Value = serde_json::from_slice(&body).expect("json");
|
||||
assert_eq!(payload["code"], "mnote_markdown_edit_target_out_of_scope");
|
||||
|
||||
std::env::remove_var("HERMES_HOME");
|
||||
let _ = fs::remove_dir_all(&hermes_home);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,9 +2,9 @@ use crate::app::AppState;
|
||||
use crate::context::RequestContext;
|
||||
use crate::error::WebError;
|
||||
use crate::routes::stream_support::{
|
||||
build_stream_delta_payload, load_stream_overview, load_stream_snapshot,
|
||||
read_stream_cursor_from_payload, resolve_stream_change, with_stream_kind, StreamChangeKind,
|
||||
StreamSnapshotQuery,
|
||||
build_stream_delta_payload, build_stream_push_delta_hint, load_stream_overview,
|
||||
load_stream_snapshot, read_stream_cursor_from_payload, resolve_stream_change, with_stream_kind,
|
||||
StreamChangeKind, StreamSnapshotQuery,
|
||||
};
|
||||
use axum::extract::{Extension, Query, State};
|
||||
use axum::http::{HeaderMap, HeaderName, HeaderValue};
|
||||
@@ -94,15 +94,11 @@ async fn events_with_stream_delta(
|
||||
if let Some(ref mut rx) = state.stream_delta_rx {
|
||||
match rx.try_recv() {
|
||||
Ok(payload) => {
|
||||
let hint = json!({
|
||||
"kind": "delta",
|
||||
"hint": "command_committed",
|
||||
"commandName": payload.get("commandName"),
|
||||
"commandId": payload.get("commandId"),
|
||||
"workspaceId": payload.get("workspaceId"),
|
||||
"requestId": payload.get("requestId"),
|
||||
"traceId": payload.get("traceId"),
|
||||
});
|
||||
let hint = build_stream_push_delta_hint(
|
||||
&payload,
|
||||
&state.context.trace.request_id,
|
||||
&state.context.trace.trace_id,
|
||||
);
|
||||
return Some((Ok(stream_event("delta", &hint)), Some(state)));
|
||||
}
|
||||
Err(tokio::sync::broadcast::error::TryRecvError::Empty) => {}
|
||||
@@ -113,9 +109,68 @@ async fn events_with_stream_delta(
|
||||
}
|
||||
}
|
||||
|
||||
// If push-driven and we already checked both broadcasts, brief sleep then re-check
|
||||
// If push-driven and broadcasts are quiet, polling remains the safety net.
|
||||
if state.stream_delta_rx.is_some() {
|
||||
sleep(Duration::from_millis(250)).await;
|
||||
if let Some(max_polls) = max_polls {
|
||||
if state.polls >= max_polls {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
state.polls += 1;
|
||||
sleep(Duration::from_millis(poll_ms)).await;
|
||||
|
||||
let poll_query = live_poll_query(&state.query);
|
||||
let Ok((workspace_id, overview)) =
|
||||
load_stream_overview(state.app_state.config(), &state.context, &poll_query)
|
||||
.await
|
||||
else {
|
||||
return None;
|
||||
};
|
||||
if let Some(change) =
|
||||
resolve_stream_change(&overview, state.current_cursor.as_deref())
|
||||
{
|
||||
state.current_cursor = change.cursor.clone();
|
||||
match change.kind {
|
||||
StreamChangeKind::Delta => {
|
||||
let Ok(payload) = build_stream_delta_payload(
|
||||
state.app_state.config(),
|
||||
&state.context,
|
||||
&poll_query,
|
||||
&workspace_id,
|
||||
&overview,
|
||||
change.cursor,
|
||||
change
|
||||
.delta
|
||||
.unwrap_or_else(|| serde_json::json!({ "op": "noop" })),
|
||||
)
|
||||
.await
|
||||
else {
|
||||
return None;
|
||||
};
|
||||
return Some((Ok(stream_event("delta", &payload)), Some(state)));
|
||||
}
|
||||
StreamChangeKind::Resync => {
|
||||
let Ok(snapshot_payload) = load_stream_snapshot(
|
||||
state.app_state.config(),
|
||||
&state.context,
|
||||
&poll_query,
|
||||
)
|
||||
.await
|
||||
else {
|
||||
return None;
|
||||
};
|
||||
state.current_cursor =
|
||||
read_stream_cursor_from_payload(&snapshot_payload);
|
||||
return Some((
|
||||
Ok(stream_event(
|
||||
"resync",
|
||||
&with_stream_kind(&snapshot_payload, "resync"),
|
||||
)),
|
||||
Some(state),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
return Some((Ok(stream_event("heartbeat", &json!({}))), Some(state)));
|
||||
}
|
||||
|
||||
@@ -289,6 +344,8 @@ mod tests {
|
||||
use crate::routes::stream_support::StreamSnapshotQuery;
|
||||
use axum::body::{to_bytes, Body};
|
||||
use axum::http::{Request, StatusCode};
|
||||
use std::time::Duration;
|
||||
use tokio::time::timeout;
|
||||
use tower::util::ServiceExt;
|
||||
|
||||
fn app() -> axum::Router {
|
||||
@@ -395,6 +452,31 @@ mod tests {
|
||||
assert!(text.contains("\"revision\""));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn tree_events_push_mode_honors_polling_safety_net_max_polls() {
|
||||
let response = app()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/api/tree/events?workspaceId=ws_demo&maxPolls=1&pollMs=1")
|
||||
.body(Body::empty())
|
||||
.expect("request"),
|
||||
)
|
||||
.await
|
||||
.expect("response");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let body = timeout(
|
||||
Duration::from_secs(2),
|
||||
to_bytes(response.into_body(), usize::MAX),
|
||||
)
|
||||
.await
|
||||
.expect("push SSE stream should stop after maxPolls")
|
||||
.expect("body");
|
||||
let text = String::from_utf8(body.to_vec()).expect("utf8");
|
||||
assert!(text.contains("event: snapshot") || text.contains("event:snapshot"));
|
||||
assert!(text.contains("event: heartbeat") || text.contains("event:heartbeat"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn live_poll_query_drops_bridge_pagination_cursor() {
|
||||
let query = StreamSnapshotQuery {
|
||||
|
||||
@@ -574,6 +574,18 @@ pub fn with_stream_kind(payload: &Value, kind: &str) -> Value {
|
||||
})
|
||||
}
|
||||
|
||||
pub fn build_stream_push_delta_hint(payload: &Value, request_id: &str, trace_id: &str) -> Value {
|
||||
json!({
|
||||
"kind": "delta",
|
||||
"hint": "command_committed",
|
||||
"commandName": payload.get("commandName"),
|
||||
"commandId": payload.get("commandId"),
|
||||
"workspaceId": payload.get("workspaceId"),
|
||||
"requestId": request_id,
|
||||
"traceId": trace_id,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn build_stream_delta_payload(
|
||||
config: &AppConfig,
|
||||
context: &RequestContext,
|
||||
@@ -1042,7 +1054,7 @@ mod tests {
|
||||
"id": "clog_2",
|
||||
"command_id": "cmd_2",
|
||||
"created_at": "2026-04-25T10:00:02Z",
|
||||
"command_name": "tree.resource.delete",
|
||||
"command_name": "tree.resource.archive",
|
||||
"payload": {
|
||||
"streamDelta": {
|
||||
"op": "remove_asset",
|
||||
|
||||
@@ -2018,12 +2018,17 @@ fn build_tree_shell_html(
|
||||
|
||||
const getFileTreeRowDocumentId = (item) => {
|
||||
if (!item) return "";
|
||||
if (item.resourceMeta?.documentId) return item.resourceMeta.documentId;
|
||||
if (item.rowKind === "document" || item.rowKind === "markdown") return item.nodeId;
|
||||
if (item.rowKind === "index") return item.nodeId.replace(/^index:/, "");
|
||||
return "";
|
||||
};
|
||||
|
||||
const getFileTreeRowOwnerDocumentId = (item) => {
|
||||
if (!item) return "";
|
||||
if (item.resourceMeta?.documentId) return item.resourceMeta.documentId;
|
||||
return getFileTreeRowDocumentId(item);
|
||||
};
|
||||
|
||||
const getFileTreeRowAssetId = (item) => {
|
||||
if (!item) return "";
|
||||
if (item.resourceMeta?.assetId) return item.resourceMeta.assetId;
|
||||
@@ -2084,6 +2089,7 @@ fn build_tree_shell_html(
|
||||
rowKind: "root",
|
||||
nodeId: null,
|
||||
documentId: null,
|
||||
ownerDocumentId: null,
|
||||
assetId: null,
|
||||
};
|
||||
}
|
||||
@@ -2092,6 +2098,7 @@ fn build_tree_shell_html(
|
||||
rowKind: normalizeText(row.dataset.rowKind, "document"),
|
||||
nodeId: normalizeText(row.dataset.nodeId) || null,
|
||||
documentId: normalizeText(row.dataset.documentId) || null,
|
||||
ownerDocumentId: normalizeText(row.dataset.ownerDocumentId) || null,
|
||||
assetId: normalizeText(row.dataset.assetId) || null,
|
||||
};
|
||||
};
|
||||
@@ -2938,9 +2945,10 @@ fn build_tree_shell_html(
|
||||
rowKind: targetItem.rowKind,
|
||||
nodeId: targetItem.nodeId,
|
||||
documentId: getFileTreeRowDocumentId(targetItem) || null,
|
||||
ownerDocumentId: getFileTreeRowOwnerDocumentId(targetItem) || null,
|
||||
assetId: getFileTreeRowAssetId(targetItem) || null,
|
||||
}
|
||||
: { rowId: null, rowKind: "root", nodeId: null, documentId: null, assetId: null };
|
||||
: { rowId: null, rowKind: "root", nodeId: null, documentId: null, ownerDocumentId: null, assetId: null };
|
||||
if (sourceKind === "local_folder") {
|
||||
const pasted = await executeLocalFileTreeInternalDrop(
|
||||
target,
|
||||
@@ -3030,7 +3038,12 @@ fn build_tree_shell_html(
|
||||
if (event.key === "F2") {
|
||||
event.preventDefault();
|
||||
const rowId = item?.rowId || fileTreeFocusedRowId;
|
||||
if (rowId) beginInlineRename("filetree", rowId);
|
||||
const renameItem = rowId ? fileTreeRowById.get(rowId) : null;
|
||||
if (rowId && getFileTreeRowDocumentId(renameItem)) {
|
||||
beginInlineRename("filetree", rowId);
|
||||
} else {
|
||||
setLastAction("当前资源暂不支持重命名", "error");
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (event.key === "ArrowDown") {
|
||||
@@ -3057,7 +3070,11 @@ fn build_tree_shell_html(
|
||||
event.preventDefault();
|
||||
if (item) {
|
||||
const documentId = getFileTreeRowDocumentId(item);
|
||||
if (documentId) handleNavigate(documentId);
|
||||
if (documentId) {
|
||||
handleNavigate(documentId);
|
||||
} else {
|
||||
openHydratedFileTreeItem(item);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -3219,7 +3236,7 @@ fn build_tree_shell_html(
|
||||
});
|
||||
}
|
||||
normalizedItems.slice().forEach((item) => {
|
||||
const itemDocumentId = getFileTreeRowDocumentId(item) || item.resourceMeta?.documentId || item.nodeId;
|
||||
const itemDocumentId = getFileTreeRowOwnerDocumentId(item) || item.nodeId;
|
||||
if (removedNodeIds.has(item.nodeId) || itemDocumentId === normalizedDocumentId) {
|
||||
removeTreeItemEverywhere(item);
|
||||
changed = true;
|
||||
@@ -4149,8 +4166,7 @@ fn build_tree_shell_html(
|
||||
rowKind === "markdown" ||
|
||||
rowKind === "document" ||
|
||||
rowKind === "folder" ||
|
||||
rowKind === "index" ||
|
||||
(localSource && rowKind === "asset");
|
||||
rowKind === "index";
|
||||
const canCopyCut =
|
||||
rowKind === "markdown" ||
|
||||
rowKind === "document" ||
|
||||
@@ -4282,7 +4298,9 @@ fn build_tree_shell_html(
|
||||
return;
|
||||
}
|
||||
if (kind === "rename") {
|
||||
if (target.rowId) beginInlineRename("filetree", target.rowId);
|
||||
if (target.rowId && getFileTreeRowDocumentId(item)) {
|
||||
beginInlineRename("filetree", target.rowId);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (kind === "copy" || kind === "cut") {
|
||||
@@ -4639,8 +4657,14 @@ fn build_tree_shell_html(
|
||||
const id = inlineRenameState.id;
|
||||
const item = renameMode === "filetree" ? fileTreeRowById.get(id) : itemById.get(id);
|
||||
const documentId = renameMode === "filetree"
|
||||
? getFileTreeRowDocumentId(item) || item?.nodeId || id
|
||||
? getFileTreeRowDocumentId(item)
|
||||
: id;
|
||||
if (!documentId) {
|
||||
inlineRenameState = { mode: null, id: null, committing: false };
|
||||
setLastAction("当前资源暂不支持重命名", "error");
|
||||
renderTree();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const result = await sendCommand({
|
||||
action: "rename",
|
||||
@@ -4953,6 +4977,7 @@ fn build_tree_shell_html(
|
||||
|
||||
const openHydratedFileTreeItem = (item) => {
|
||||
const documentId = getFileTreeRowDocumentId(item);
|
||||
const ownerDocumentId = getFileTreeRowOwnerDocumentId(item);
|
||||
const assetId = getFileTreeRowAssetId(item);
|
||||
if (item.rowKind === "document" || item.rowKind === "index") {
|
||||
handleNavigate(documentId || item.nodeId);
|
||||
@@ -4960,12 +4985,12 @@ fn build_tree_shell_html(
|
||||
}
|
||||
setLastAction(`准备打开资源 ${assetId || item.rowId}`);
|
||||
postToHost("tree.asset.open", {
|
||||
documentId: documentId || null,
|
||||
documentId: ownerDocumentId || null,
|
||||
assetId: assetId || null,
|
||||
objectIdentity: item.resourceMeta?.objectIdentity || null,
|
||||
target: { documentId: documentId || null },
|
||||
target: { documentId: ownerDocumentId || null },
|
||||
payload: {
|
||||
documentId: documentId || null,
|
||||
documentId: ownerDocumentId || null,
|
||||
assetId: assetId || null,
|
||||
rowId: item.rowId,
|
||||
rowKind: item.rowKind,
|
||||
@@ -5122,6 +5147,7 @@ fn build_tree_shell_html(
|
||||
const bindFileTreeRowEvents = (row, item) => {
|
||||
if (!(row instanceof HTMLElement) || !item) return;
|
||||
const documentId = getFileTreeRowDocumentId(item) || null;
|
||||
const ownerDocumentId = getFileTreeRowOwnerDocumentId(item) || null;
|
||||
const assetId = getFileTreeRowAssetId(item) || null;
|
||||
row.dataset.active = String(
|
||||
item.rowKind === "document" && documentId === currentActiveDocumentId
|
||||
@@ -5130,6 +5156,7 @@ fn build_tree_shell_html(
|
||||
row.dataset.rowId = item.rowId;
|
||||
row.dataset.rowKind = item.rowKind;
|
||||
row.dataset.documentId = documentId || "";
|
||||
row.dataset.ownerDocumentId = ownerDocumentId || "";
|
||||
row.dataset.assetId = assetId || "";
|
||||
row.dataset.objectIdentity = item.resourceMeta?.objectIdentity
|
||||
? JSON.stringify(item.resourceMeta.objectIdentity)
|
||||
@@ -5577,6 +5604,7 @@ fn build_tree_shell_html(
|
||||
|
||||
const openFileTreeItem = (item) => {
|
||||
const documentId = getFileTreeRowDocumentId(item);
|
||||
const ownerDocumentId = getFileTreeRowOwnerDocumentId(item);
|
||||
const assetId = getFileTreeRowAssetId(item);
|
||||
if (item.rowKind === "document" || item.rowKind === "index" || item.rowKind === "markdown") {
|
||||
handleNavigate(documentId || item.nodeId);
|
||||
@@ -5584,12 +5612,12 @@ fn build_tree_shell_html(
|
||||
}
|
||||
setLastAction(`准备打开资源 ${assetId || item.rowId}`);
|
||||
postToHost("tree.asset.open", {
|
||||
documentId: documentId || null,
|
||||
documentId: ownerDocumentId || null,
|
||||
assetId: assetId || null,
|
||||
objectIdentity: item.resourceMeta?.objectIdentity || null,
|
||||
target: { documentId: documentId || null },
|
||||
target: { documentId: ownerDocumentId || null },
|
||||
payload: {
|
||||
documentId: documentId || null,
|
||||
documentId: ownerDocumentId || null,
|
||||
assetId: assetId || null,
|
||||
rowId: item.rowId,
|
||||
rowKind: item.rowKind,
|
||||
@@ -5649,6 +5677,7 @@ fn build_tree_shell_html(
|
||||
const children = getSiblings(item.nodeId);
|
||||
const hasBranches = canExpandFileTreeRow(item) && children.length > 0;
|
||||
const documentId = getFileTreeRowDocumentId(item) || null;
|
||||
const ownerDocumentId = getFileTreeRowOwnerDocumentId(item) || null;
|
||||
const assetId = getFileTreeRowAssetId(item) || null;
|
||||
|
||||
const row = document.createElement("div");
|
||||
@@ -5661,6 +5690,7 @@ fn build_tree_shell_html(
|
||||
row.dataset.rowId = item.rowId;
|
||||
row.dataset.rowKind = item.rowKind;
|
||||
row.dataset.documentId = documentId || "";
|
||||
row.dataset.ownerDocumentId = ownerDocumentId || "";
|
||||
row.dataset.assetId = assetId || "";
|
||||
row.dataset.objectIdentity = item.resourceMeta?.objectIdentity
|
||||
? JSON.stringify(item.resourceMeta.objectIdentity)
|
||||
@@ -5769,6 +5799,7 @@ fn build_tree_shell_html(
|
||||
|
||||
const actions = document.createElement("div");
|
||||
actions.className = "tree-actions";
|
||||
if (getFileTreeRowDocumentId(item)) {
|
||||
actions.appendChild(
|
||||
createActionButton(
|
||||
ICONS.edit,
|
||||
@@ -5778,6 +5809,7 @@ fn build_tree_shell_html(
|
||||
false,
|
||||
),
|
||||
);
|
||||
}
|
||||
actions.appendChild(
|
||||
createActionButton(
|
||||
ICONS.more,
|
||||
@@ -7143,6 +7175,11 @@ mod tests {
|
||||
assert!(html.contains("tree.filetree.external-drop"));
|
||||
assert!(html.contains("\"rowKind\":\"asset_folder\""));
|
||||
assert!(html.contains("\"resourceMeta\""));
|
||||
assert!(html.contains("const getFileTreeRowOwnerDocumentId = (item) => {"));
|
||||
assert!(html.contains("row.dataset.ownerDocumentId = ownerDocumentId || \"\";"));
|
||||
assert!(html.contains("if (rowId && getFileTreeRowDocumentId(renameItem))"));
|
||||
assert!(html.contains("if (getFileTreeRowDocumentId(item))"));
|
||||
assert!(html.contains("documentId: ownerDocumentId || null"));
|
||||
assert!(html.contains("dragover"));
|
||||
assert!(html.contains("hydrateInitialFileTree"));
|
||||
assert!(html.contains("const usedRustInitialRenderer = hydrateInitialRenderer();"));
|
||||
@@ -7897,7 +7934,7 @@ mod tests {
|
||||
payload["result"]["documentId"],
|
||||
Value::String("page_child".into())
|
||||
);
|
||||
assert_eq!(payload["result"]["sortOrder"], Value::from(1));
|
||||
assert_eq!(payload["result"]["sortOrder"], Value::Null);
|
||||
assert_eq!(
|
||||
payload["result"]["execution"]["deletedCount"],
|
||||
Value::from(1)
|
||||
|
||||
@@ -1210,6 +1210,18 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
|
||||
titleEndpoint: '/api/documents/title',
|
||||
editorHostKind: 'leptos_tiptap_island',
|
||||
});
|
||||
const syncPageAggregateScript = (session, aggregate) => {
|
||||
if (!session || !aggregate) return;
|
||||
const scriptId = session.pageAggregateScriptId || '__MNOTE_PAGE_AGGREGATE__';
|
||||
const node = document.getElementById(scriptId);
|
||||
if (!node) return;
|
||||
try {
|
||||
node.textContent = JSON.stringify(aggregate);
|
||||
node.setAttribute('data-mnote-page-aggregate-synced-at', String(Date.now()));
|
||||
} catch (error) {
|
||||
console.warn('mnote Page Aggregate script 同步失败', error);
|
||||
}
|
||||
};
|
||||
|
||||
const documentSessionRegistry = new Map();
|
||||
const localFolderEventRegistry = new Map();
|
||||
@@ -1622,6 +1634,7 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
|
||||
}
|
||||
const nextRevision = Number.isInteger(nextBody.revision) ? nextBody.revision : revisionFromConflictKey(nextConflictKey);
|
||||
session.latestAggregate = nextAggregate;
|
||||
syncPageAggregateScript(session, nextAggregate);
|
||||
session.title = nextAggregate?.head?.title || session.title;
|
||||
session.currentTiptapDocument = nextTiptapDocument;
|
||||
session.currentSerialized = nextSerialized;
|
||||
@@ -1891,6 +1904,7 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
|
||||
sourceKind,
|
||||
rootUri: runtimeDescriptor.bootstrap.rootUri,
|
||||
saveEndpoint: runtimeDescriptor.bootstrap.saveEndpoint || '/api/documents/save',
|
||||
pageAggregateScriptId: runtimeDescriptor.bootstrap.pageAggregateScriptId || '__MNOTE_PAGE_AGGREGATE__',
|
||||
latestAggregate: runtimeDescriptor.aggregate,
|
||||
title: runtimeDescriptor.aggregate.head?.title || '无标题',
|
||||
currentTiptapDocument: tiptapDocument,
|
||||
@@ -3082,6 +3096,7 @@ mod tests {
|
||||
assert!(html.contains("mnote.tree_live_bootstrap.v1"));
|
||||
assert!(html.contains("/api/tree/events"));
|
||||
assert!(html.contains("data-mnote-tree-live-transport"));
|
||||
assert!(html.contains("syncPageAggregateScript(session, nextAggregate);"));
|
||||
assert!(!html.contains("mnote-web-document-shell"));
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
use crate::app::AppState;
|
||||
use crate::context::RequestContext;
|
||||
use crate::error::WebError;
|
||||
use crate::routes::stream_support::{load_stream_snapshot, StreamSnapshotQuery};
|
||||
use crate::routes::stream_support::{
|
||||
build_stream_push_delta_hint, load_stream_snapshot, StreamSnapshotQuery,
|
||||
};
|
||||
use axum::extract::ws::{Message, WebSocket, WebSocketUpgrade};
|
||||
use axum::extract::{Extension, Query, State};
|
||||
use axum::response::Response;
|
||||
@@ -41,14 +43,15 @@ async fn handle_socket(
|
||||
delta_result = stream_delta_rx.recv() => {
|
||||
match delta_result {
|
||||
Ok(delta) => {
|
||||
let notify = json!({
|
||||
"kind": "delta",
|
||||
"data": delta,
|
||||
"requestId": context.trace.request_id,
|
||||
"traceId": context.trace.trace_id,
|
||||
"workspaceId": delta.get("workspaceId").and_then(Value::as_str).unwrap_or(""),
|
||||
});
|
||||
if socket.send(Message::Text(notify.to_string().into())).await.is_err() {
|
||||
if socket
|
||||
.send(serialize_delta_message(
|
||||
&delta,
|
||||
&context.trace.request_id,
|
||||
&context.trace.trace_id,
|
||||
))
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -130,6 +133,14 @@ fn serialize_snapshot_message(payload: &Value) -> Message {
|
||||
Message::Text(payload.to_string().into())
|
||||
}
|
||||
|
||||
fn serialize_delta_message(payload: &Value, request_id: &str, trace_id: &str) -> Message {
|
||||
Message::Text(
|
||||
build_stream_push_delta_hint(payload, request_id, trace_id)
|
||||
.to_string()
|
||||
.into(),
|
||||
)
|
||||
}
|
||||
|
||||
fn serialize_resync_message(payload: &Value) -> Message {
|
||||
Message::Text(
|
||||
json!({
|
||||
@@ -156,7 +167,10 @@ fn is_resync_request(text: &str) -> bool {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{is_resync_request, serialize_resync_message, serialize_snapshot_message};
|
||||
use super::{
|
||||
is_resync_request, serialize_delta_message, serialize_resync_message,
|
||||
serialize_snapshot_message,
|
||||
};
|
||||
use axum::extract::ws::Message;
|
||||
use serde_json::json;
|
||||
|
||||
@@ -183,4 +197,29 @@ mod tests {
|
||||
};
|
||||
assert!(resync_text.contains("\"kind\":\"resync\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ws_delta_serializer_uses_stream_hint_contract() {
|
||||
let delta = json!({
|
||||
"kind": "command_committed",
|
||||
"commandName": "tree.rename",
|
||||
"commandId": "cmd_1",
|
||||
"workspaceId": "ws_demo",
|
||||
"requestId": "req_command",
|
||||
"traceId": "trace_command"
|
||||
});
|
||||
let Message::Text(delta_text) = serialize_delta_message(&delta, "req_ws", "trace_ws")
|
||||
else {
|
||||
panic!("delta message 应该是文本帧");
|
||||
};
|
||||
let payload: serde_json::Value = serde_json::from_str(&delta_text).expect("delta json");
|
||||
assert_eq!(payload["kind"], "delta");
|
||||
assert_eq!(payload["hint"], "command_committed");
|
||||
assert_eq!(payload["commandName"], "tree.rename");
|
||||
assert_eq!(payload["commandId"], "cmd_1");
|
||||
assert_eq!(payload["workspaceId"], "ws_demo");
|
||||
assert_eq!(payload["requestId"], "req_ws");
|
||||
assert_eq!(payload["traceId"], "trace_ws");
|
||||
assert!(payload.get("data").is_none());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,13 +61,32 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
pageAiSkillQuery: '',
|
||||
pageAiSkillError: '',
|
||||
pageAiSessions: [],
|
||||
pageAiActiveSessionId: ''
|
||||
pageAiActiveSessionId: '',
|
||||
pageAiSessionSearchQuery: '',
|
||||
pageAiSessionSearchResults: [],
|
||||
pageAiSessionSearchTimer: 0,
|
||||
pageAiSessionError: '',
|
||||
pageAiPermissionRequests: []
|
||||
};
|
||||
|
||||
function closestAction(target, selector) {
|
||||
return target && typeof target.closest === 'function' ? target.closest(selector) : null;
|
||||
}
|
||||
|
||||
function toggleWorkspaceSidebar(trigger) {
|
||||
var shell = document.querySelector('.mnote-shell, .wolai-workspace-shell');
|
||||
if (!(shell instanceof HTMLElement)) return;
|
||||
var collapsed = shell.getAttribute('data-mnote-sidebar-collapsed') === 'true';
|
||||
var next = !collapsed;
|
||||
shell.setAttribute('data-mnote-sidebar-collapsed', String(next));
|
||||
document.documentElement.setAttribute('data-mnote-sidebar-collapsed', String(next));
|
||||
document.documentElement.setAttribute('data-mnote-sidebar-toggle-applied', 'true');
|
||||
if (trigger instanceof HTMLElement) {
|
||||
trigger.setAttribute('aria-pressed', String(next));
|
||||
trigger.setAttribute('title', next ? '展开侧栏' : '切换侧栏');
|
||||
}
|
||||
}
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value == null ? '' : value)
|
||||
.replace(/&/g, '&')
|
||||
@@ -227,20 +246,7 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
function currentPageAiContextSnapshot() {
|
||||
var aggregate = currentPageAggregate();
|
||||
var body = aggregate.body || {};
|
||||
var serverContent = body.content || null;
|
||||
var serverText = searchText(textFromUnknown(serverContent));
|
||||
var localBlocks = readLocalEditorBlocks();
|
||||
var localText = searchText(localBlocks.map(function(block) { return block.content || ''; }).join(' '));
|
||||
var serverSubtree = aggregate.tree && aggregate.tree.pageSubtree ? aggregate.tree.pageSubtree : null;
|
||||
var title = aggregate.head && aggregate.head.title ? aggregate.head.title : '';
|
||||
if (localBlocks.length && localText && localText !== serverText) {
|
||||
return {
|
||||
aggregate: aggregate,
|
||||
body: Object.assign({}, body, { content: localBlocks }),
|
||||
subtree: buildPageAiLocalSubtree(localBlocks, title),
|
||||
pageSubtreeSource: 'local'
|
||||
};
|
||||
}
|
||||
return {
|
||||
aggregate: aggregate,
|
||||
body: body,
|
||||
@@ -3930,6 +3936,30 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
return 'hermes_page_ai_session:' + currentDocumentId();
|
||||
}
|
||||
|
||||
function pageAiTimestamp(value) {
|
||||
if (typeof value === 'number' && Number.isFinite(value)) return value;
|
||||
if (typeof value === 'string' && value.trim()) {
|
||||
var parsed = Date.parse(value);
|
||||
if (Number.isFinite(parsed)) return parsed;
|
||||
}
|
||||
return Date.now();
|
||||
}
|
||||
|
||||
function pageAiBackendSessionQuery(extra) {
|
||||
var params = new URLSearchParams();
|
||||
params.set('source', 'acp');
|
||||
params.set('workspaceId', resolveWorkspaceId(document.body));
|
||||
params.set('documentId', currentDocumentId());
|
||||
params.set('profile', pageAiRunProfile());
|
||||
Object.keys(extra || {}).forEach(function(key) {
|
||||
var value = extra[key];
|
||||
if (value !== undefined && value !== null && String(value).trim() !== '') {
|
||||
params.set(key, String(value));
|
||||
}
|
||||
});
|
||||
return params.toString();
|
||||
}
|
||||
|
||||
function pageAiNewSession(title) {
|
||||
var now = Date.now();
|
||||
return {
|
||||
@@ -3939,10 +3969,126 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
acpRuntime: pageUiState.pageAiAcpRuntime || '',
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
source: 'local',
|
||||
usage: null,
|
||||
status: 'idle',
|
||||
messages: []
|
||||
};
|
||||
}
|
||||
|
||||
function pageAiUsageSummary(usage) {
|
||||
if (!usage || typeof usage !== 'object') return '';
|
||||
var used = usage.used ?? usage.contextUsed ?? usage.input_tokens ?? usage.inputTokens ?? usage.prompt_tokens ?? usage.promptTokens;
|
||||
var size = usage.size ?? usage.contextSize ?? usage.total_tokens ?? usage.totalTokens;
|
||||
var output = usage.output_tokens ?? usage.outputTokens ?? usage.completion_tokens ?? usage.completionTokens;
|
||||
var parts = [];
|
||||
if (Number.isFinite(Number(used))) parts.push('ctx ' + Number(used));
|
||||
if (Number.isFinite(Number(size)) && Number(size) > 0) parts.push('/ ' + Number(size));
|
||||
if (Number.isFinite(Number(output)) && Number(output) > 0) parts.push('out ' + Number(output));
|
||||
return parts.join(' ') || '';
|
||||
}
|
||||
|
||||
function pageAiPermissionMessage(payload, eventType) {
|
||||
payload = payload && typeof payload === 'object' ? payload : {};
|
||||
var permissionId = String(payload.permissionId || payload.permission_id || payload.id || ('perm_' + Date.now())).trim();
|
||||
var toolName = String(payload.toolName || payload.tool || payload.name || payload.method || 'session/request_permission').trim();
|
||||
var args = payload.arguments || payload.args || payload.input || payload.params || payload;
|
||||
var decision = String(payload.decision || payload.result || '').trim();
|
||||
if (!decision && eventType === 'permission.denied') decision = 'denied';
|
||||
if (!decision && eventType === 'permission.allowed') decision = 'allowed';
|
||||
return {
|
||||
role: 'tool',
|
||||
kind: 'permission',
|
||||
permissionId: permissionId,
|
||||
toolName: toolName,
|
||||
argsSummary: pageAiPreviewValue(args),
|
||||
content: decision === 'denied' ? '已自动拒绝权限请求' : (decision === 'allowed' ? '已自动允许权限请求' : '等待权限确认'),
|
||||
resolved: decision === 'denied' || decision === 'allowed',
|
||||
decision: decision
|
||||
};
|
||||
}
|
||||
|
||||
function pageAiApplyPermissionEvent(eventName, payloadText) {
|
||||
var payload = null;
|
||||
try {
|
||||
payload = JSON.parse(payloadText || 'null');
|
||||
} catch (_) {
|
||||
payload = {};
|
||||
}
|
||||
var message = pageAiPermissionMessage(payload, eventName);
|
||||
var existing = pageUiState.pageAiMessages.find(function(item) {
|
||||
return item.kind === 'permission' && item.permissionId === message.permissionId;
|
||||
});
|
||||
if (existing) {
|
||||
Object.assign(existing, message);
|
||||
} else {
|
||||
pageUiState.pageAiMessages.push(message);
|
||||
}
|
||||
pageUiState.pageAiPermissionRequests = pageUiState.pageAiPermissionRequests.filter(function(item) {
|
||||
return item.permissionId !== message.permissionId;
|
||||
}).concat([message]).slice(-20);
|
||||
if (!message.resolved) {
|
||||
pageAiShowPermissionDialog(message);
|
||||
} else {
|
||||
pageAiHidePermissionDialog();
|
||||
}
|
||||
}
|
||||
|
||||
function pageAiResolvePermission(permissionId, decision) {
|
||||
permissionId = String(permissionId || '').trim();
|
||||
if (!permissionId) return;
|
||||
pageUiState.pageAiMessages.forEach(function(item) {
|
||||
if (item.kind === 'permission' && item.permissionId === permissionId) {
|
||||
item.resolved = true;
|
||||
item.decision = decision;
|
||||
item.content = decision === 'allow' ? '已允许权限请求' : '已拒绝权限请求';
|
||||
}
|
||||
});
|
||||
var dialog = document.querySelector('[data-page-ai-permission-dialog]');
|
||||
if (dialog instanceof HTMLElement) dialog.hidden = true;
|
||||
renderPageAiConversation();
|
||||
}
|
||||
|
||||
function pageAiHidePermissionDialog() {
|
||||
var dialog = document.querySelector('[data-page-ai-permission-dialog]');
|
||||
if (dialog instanceof HTMLElement) dialog.hidden = true;
|
||||
}
|
||||
|
||||
function pageAiShowPermissionDialog(message) {
|
||||
if (!message || message.kind !== 'permission') return;
|
||||
if (message.resolved) {
|
||||
pageAiHidePermissionDialog();
|
||||
return;
|
||||
}
|
||||
var dialog = document.querySelector('[data-page-ai-permission-dialog]');
|
||||
if (!(dialog instanceof HTMLElement)) {
|
||||
dialog = document.createElement('div');
|
||||
dialog.className = 'wolai-page-ai-permission-dialog';
|
||||
dialog.setAttribute('data-page-ai-permission-dialog', 'true');
|
||||
dialog.innerHTML = '' +
|
||||
'<div class="wolai-page-ai-permission-panel" role="dialog" aria-modal="false">' +
|
||||
'<div class="wolai-page-ai-memory-title" data-page-ai-permission-tool></div>' +
|
||||
'<div class="wolai-page-ai-tool-meta" data-page-ai-permission-args></div>' +
|
||||
'<div class="wolai-page-ai-message-actions">' +
|
||||
'<button type="button" class="wolai-page-ai-ghost" data-page-ai-permission-action="allow" data-page-ai-permission-dialog-action>允许</button>' +
|
||||
'<button type="button" class="wolai-page-ai-ghost" data-page-ai-permission-action="deny" data-page-ai-permission-dialog-action>拒绝</button>' +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
document.body.appendChild(dialog);
|
||||
}
|
||||
var tool = dialog.querySelector('[data-page-ai-permission-tool]');
|
||||
if (tool instanceof HTMLElement) tool.textContent = message.toolName || 'session/request_permission';
|
||||
var args = dialog.querySelector('[data-page-ai-permission-args]');
|
||||
if (args instanceof HTMLElement) args.textContent = message.argsSummary || message.content || '';
|
||||
dialog.querySelectorAll('[data-page-ai-permission-dialog-action]').forEach(function(button) {
|
||||
if (button instanceof HTMLButtonElement) {
|
||||
button.setAttribute('data-page-ai-permission-id', message.permissionId || '');
|
||||
button.disabled = Boolean(message.resolved);
|
||||
}
|
||||
});
|
||||
dialog.hidden = false;
|
||||
}
|
||||
|
||||
function pageAiNormalizeSessions(sessions) {
|
||||
return (Array.isArray(sessions) ? sessions : [])
|
||||
.slice(0, 20)
|
||||
@@ -3952,8 +4098,13 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
title: String(session && session.title || '').trim() || '新会话',
|
||||
profile: String(session && session.profile || pageAiCurrentProfile()).trim() || 'default',
|
||||
acpRuntime: String(session && session.acpRuntime || '').trim(),
|
||||
createdAt: Number(session && session.createdAt || Date.now()),
|
||||
updatedAt: Number(session && session.updatedAt || Date.now()),
|
||||
createdAt: pageAiTimestamp(session && session.createdAt),
|
||||
updatedAt: pageAiTimestamp(session && session.updatedAt),
|
||||
source: String(session && session.source || 'local').trim() || 'local',
|
||||
runId: String(session && (session.runId || session.run_id) || '').trim(),
|
||||
status: String(session && session.status || '').trim(),
|
||||
usage: session && session.usage && typeof session.usage === 'object' ? session.usage : null,
|
||||
preview: String(session && session.preview || '').trim(),
|
||||
messages: Array.isArray(session && session.messages) ? session.messages.slice(-300) : []
|
||||
};
|
||||
})
|
||||
@@ -3962,6 +4113,43 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
});
|
||||
}
|
||||
|
||||
function pageAiNormalizeBackendSessionRow(row) {
|
||||
if (!row || typeof row !== 'object') return null;
|
||||
var payload = row.payload && typeof row.payload === 'object' ? row.payload : {};
|
||||
var sessionId = String(row.sessionId || row.session_id || '').trim();
|
||||
if (!sessionId) return null;
|
||||
var title = String(row.title || payload.title || payload.message || '').trim();
|
||||
if (title.length > 28) title = title.slice(0, 28) + '…';
|
||||
return {
|
||||
id: sessionId,
|
||||
title: title || '当前页问答',
|
||||
profile: String(row.profile || payload.profile || pageAiRunProfile()).trim() || 'default',
|
||||
acpRuntime: String(row.acpRuntime || row.acp_runtime || payload.acpRuntime || 'reasonix').trim(),
|
||||
createdAt: pageAiTimestamp(row.createdAt || row.created_at),
|
||||
updatedAt: pageAiTimestamp(row.updatedAt || row.updated_at),
|
||||
source: 'acp',
|
||||
runId: String(row.runId || row.run_id || '').trim(),
|
||||
status: String(row.status || (row.runtime && row.runtime.status) || '').trim(),
|
||||
usage: row.usage && typeof row.usage === 'object' ? row.usage : null,
|
||||
preview: String(payload.message || row.snippet || '').trim(),
|
||||
messages: []
|
||||
};
|
||||
}
|
||||
|
||||
function pageAiMergeSessions(localSessions, backendSessions) {
|
||||
var byId = {};
|
||||
pageAiNormalizeSessions(localSessions).forEach(function(session) {
|
||||
byId[session.id] = session;
|
||||
});
|
||||
pageAiNormalizeSessions(backendSessions).forEach(function(session) {
|
||||
var existing = byId[session.id];
|
||||
byId[session.id] = Object.assign({}, existing || {}, session, {
|
||||
messages: session.messages.length ? session.messages : (existing && existing.messages || [])
|
||||
});
|
||||
});
|
||||
return pageAiNormalizeSessions(Object.keys(byId).map(function(id) { return byId[id]; }));
|
||||
}
|
||||
|
||||
function pageAiLoadSessions() {
|
||||
if (pageUiState.pageAiSessions.length && pageUiState.pageAiActiveSessionId) return;
|
||||
try {
|
||||
@@ -3996,6 +4184,135 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
pageUiState.pageAiMessages = [];
|
||||
}
|
||||
|
||||
async function pageAiLoadBackendSessions() {
|
||||
var response = await fetch('/api/hermes/client/sessions?' + pageAiBackendSessionQuery({ limit: 50 }), {
|
||||
headers: { 'accept': 'application/json' }
|
||||
});
|
||||
var payload = await response.json().catch(function(){ return null; });
|
||||
if (!response.ok || !payload || payload.ok !== true) {
|
||||
throw new Error(pageAiErrorMessage(payload, 'session_list_failed_' + response.status));
|
||||
}
|
||||
var backendSessions = pageAiNormalizeArray(payload.sessions).map(pageAiNormalizeBackendSessionRow).filter(Boolean);
|
||||
if (!backendSessions.length) return [];
|
||||
pageUiState.pageAiSessions = pageAiMergeSessions(pageUiState.pageAiSessions, backendSessions);
|
||||
if (!pageUiState.pageAiActiveSessionId || !pageUiState.pageAiSessions.find(function(session) { return session.id === pageUiState.pageAiActiveSessionId; })) {
|
||||
pageUiState.pageAiActiveSessionId = pageUiState.pageAiSessions[0].id;
|
||||
}
|
||||
var active = pageAiCurrentSession();
|
||||
if (active) {
|
||||
if (active.profile) pageAiSetActiveProfile(active.profile);
|
||||
if (active.acpRuntime) pageUiState.pageAiAcpRuntime = active.acpRuntime;
|
||||
pageUiState.pageAiMessages = Array.isArray(active.messages) ? active.messages.slice() : pageUiState.pageAiMessages;
|
||||
}
|
||||
pageUiState.pageAiSessionError = '';
|
||||
pageAiPersistSessions();
|
||||
renderPageAiConversation();
|
||||
renderPageAiControls();
|
||||
return backendSessions;
|
||||
}
|
||||
|
||||
function pageAiMessageFromRuntimeEvent(event) {
|
||||
if (!event || typeof event !== 'object') return null;
|
||||
var eventType = String(event.eventType || event.event_type || event.event || '').trim();
|
||||
var payload = event.payload && typeof event.payload === 'object' ? event.payload : event;
|
||||
if (eventType === 'message.delta') {
|
||||
var delta = String(payload.delta || payload.text || payload.output_text || '').trim();
|
||||
return delta ? { role: 'assistant', content: delta } : null;
|
||||
}
|
||||
if (eventType === 'thought.delta') {
|
||||
var thought = String(payload.delta || payload.text || '').trim();
|
||||
return thought ? { role: 'assistant', kind: 'thought', content: thought } : null;
|
||||
}
|
||||
if (eventType === 'tool.started' || eventType === 'tool.completed' || eventType === 'tool.failed') {
|
||||
var toolName = String(payload.toolName || payload.tool || payload.name || eventType).trim();
|
||||
return {
|
||||
role: 'tool',
|
||||
content: toolName,
|
||||
toolName: toolName,
|
||||
toolCallId: String(payload.toolCallId || payload.tool_call_id || payload.id || ''),
|
||||
toolKind: String(payload.kind || ''),
|
||||
status: eventType === 'tool.completed' ? 'completed' : (eventType === 'tool.failed' ? 'failed' : 'running'),
|
||||
argsSummary: pageAiPreviewValue(payload.args || payload.arguments || payload.input),
|
||||
resultSummary: pageAiPreviewValue(payload.summary || payload.result || payload.output || payload.error),
|
||||
traceId: String(payload.traceId || payload.trace_id || ''),
|
||||
auditId: String(payload.auditId || payload.audit_id || '')
|
||||
};
|
||||
}
|
||||
if (eventType === 'permission.requested' || eventType === 'permission.denied' || eventType === 'permission.allowed') {
|
||||
return pageAiPermissionMessage(payload, eventType);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function pageAiApplyBackendSessionDetail(payload) {
|
||||
var sessionPayload = payload && payload.session ? payload.session : {};
|
||||
var runs = pageAiNormalizeArray(sessionPayload.runs);
|
||||
var latest = runs.length ? pageAiNormalizeBackendSessionRow(runs[0]) : null;
|
||||
var events = pageAiNormalizeArray(payload && payload.events);
|
||||
var messages = pageAiNormalizeArray(sessionPayload.messages).map(function(message) {
|
||||
return {
|
||||
role: message.role === 'user' ? 'user' : (message.role === 'tool' ? 'tool' : 'assistant'),
|
||||
content: String(message.content || '')
|
||||
};
|
||||
}).filter(function(message) { return message.content; });
|
||||
events.forEach(function(event) {
|
||||
var message = pageAiMessageFromRuntimeEvent(event);
|
||||
if (message) messages.push(message);
|
||||
});
|
||||
var current = pageAiCurrentSession();
|
||||
if (latest && current) {
|
||||
Object.assign(current, latest);
|
||||
}
|
||||
if (current) {
|
||||
current.messages = messages.slice(-300);
|
||||
current.updatedAt = Math.max(Number(current.updatedAt || 0), Date.now());
|
||||
if (latest && latest.usage) current.usage = latest.usage;
|
||||
}
|
||||
pageAiApplyRuntimeState(payload && payload.runtime);
|
||||
pageUiState.pageAiMessages = messages.slice(-300);
|
||||
pageAiPersistSessions();
|
||||
}
|
||||
|
||||
async function pageAiLoadBackendSessionDetail(sessionId) {
|
||||
sessionId = String(sessionId || pageUiState.pageAiActiveSessionId || '').trim();
|
||||
if (!sessionId) return null;
|
||||
var response = await fetch('/api/hermes/client/sessions/' + encodeURIComponent(sessionId) + '?' + pageAiBackendSessionQuery({ limit: 200 }), {
|
||||
headers: { 'accept': 'application/json' }
|
||||
});
|
||||
var payload = await response.json().catch(function(){ return null; });
|
||||
if (!response.ok || !payload || payload.ok !== true) {
|
||||
throw new Error(pageAiErrorMessage(payload, 'session_detail_failed_' + response.status));
|
||||
}
|
||||
pageAiApplyBackendSessionDetail(payload);
|
||||
renderPageAiConversation();
|
||||
renderPageAiControls();
|
||||
return payload;
|
||||
}
|
||||
|
||||
async function pageAiSearchBackendSessions(query) {
|
||||
var q = String(query || '').trim();
|
||||
pageUiState.pageAiSessionSearchQuery = q;
|
||||
if (!q) {
|
||||
pageUiState.pageAiSessionSearchResults = [];
|
||||
renderPageAiConversation();
|
||||
return [];
|
||||
}
|
||||
var response = await fetch('/api/hermes/client/sessions/search?' + pageAiBackendSessionQuery({ q: q, limit: 20 }), {
|
||||
headers: { 'accept': 'application/json' }
|
||||
});
|
||||
var payload = await response.json().catch(function(){ return null; });
|
||||
if (!response.ok || !payload || payload.ok !== true) {
|
||||
throw new Error(pageAiErrorMessage(payload, 'session_search_failed_' + response.status));
|
||||
}
|
||||
pageUiState.pageAiSessionSearchResults = pageAiNormalizeArray(payload.results).map(function(row) {
|
||||
var normalized = pageAiNormalizeBackendSessionRow(row) || {};
|
||||
normalized.snippet = String(row && row.snippet || normalized.preview || '').trim();
|
||||
return normalized;
|
||||
}).filter(function(row) { return row.id; });
|
||||
renderPageAiConversation();
|
||||
return pageUiState.pageAiSessionSearchResults;
|
||||
}
|
||||
|
||||
function pageAiPersistSessions() {
|
||||
document.documentElement.setAttribute('data-mnote-page-ai-session-owner', 'hermes');
|
||||
document.documentElement.setAttribute('data-mnote-page-ai-session-key', pageAiStorageKey());
|
||||
@@ -4049,12 +4366,18 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
async function pageAiRestoreHermesSession() {
|
||||
var current = pageAiCurrentSession();
|
||||
if (!current || !String(current.id || '').startsWith('mnote_')) return;
|
||||
var response = await fetch('/api/hermes/client/sessions/' + encodeURIComponent(current.id) + '/resume', {
|
||||
var response = await fetch('/api/hermes/client/sessions/' + encodeURIComponent(current.id) + '/resume?' + pageAiBackendSessionQuery({}), {
|
||||
method: 'POST',
|
||||
headers: { 'accept': 'application/json' }
|
||||
});
|
||||
if (!response.ok) return;
|
||||
var payload = await response.json().catch(function(){ return null; });
|
||||
if (payload && payload.persistence === 'convex_acp_runtime_store') {
|
||||
pageAiApplyBackendSessionDetail(payload);
|
||||
renderPageAiConversation();
|
||||
renderPageAiControls();
|
||||
return;
|
||||
}
|
||||
var session = payload && (payload.session || (payload.upstream && payload.upstream.session) || payload);
|
||||
var messages = session && Array.isArray(session.messages) ? session.messages : [];
|
||||
pageAiApplyRuntimeState(payload && payload.runtime);
|
||||
@@ -4101,6 +4424,12 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
pageAiPersistSessions();
|
||||
renderPageAiControls();
|
||||
renderPageAiConversation();
|
||||
if (session.source === 'acp' || String(session.id || '').startsWith('mnote_')) {
|
||||
void pageAiLoadBackendSessionDetail(session.id).catch(function(error) {
|
||||
pageUiState.pageAiSessionError = error instanceof Error ? error.message : String(error);
|
||||
renderPageAiControls();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function pageAiStartNewSession() {
|
||||
@@ -4114,6 +4443,71 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
renderPageAiConversation();
|
||||
}
|
||||
|
||||
async function pageAiRenameBackendSession(sessionId) {
|
||||
var session = pageUiState.pageAiSessions.find(function(item) { return item.id === sessionId; });
|
||||
if (!session) return;
|
||||
var title = window.prompt('重命名 AI 会话', session.title || '当前页问答');
|
||||
if (title === null) return;
|
||||
title = String(title || '').trim();
|
||||
if (!title) return;
|
||||
var response = await fetch('/api/hermes/client/sessions/' + encodeURIComponent(sessionId) + '/rename?' + pageAiBackendSessionQuery({}), {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json', 'accept': 'application/json' },
|
||||
body: JSON.stringify({ title: title })
|
||||
});
|
||||
var payload = await response.json().catch(function(){ return null; });
|
||||
if (!response.ok || !payload || payload.ok !== true) {
|
||||
throw new Error(pageAiErrorMessage(payload, 'session_rename_failed_' + response.status));
|
||||
}
|
||||
session.title = String((payload.result && payload.result.title) || title);
|
||||
session.updatedAt = Date.now();
|
||||
pageAiPersistSessions();
|
||||
renderPageAiConversation();
|
||||
renderPageAiControls();
|
||||
}
|
||||
|
||||
async function pageAiDeleteBackendSession(sessionId) {
|
||||
var session = pageUiState.pageAiSessions.find(function(item) { return item.id === sessionId; });
|
||||
if (!session) return;
|
||||
if (!window.confirm('确定删除 AI 会话“' + (session.title || sessionId) + '”吗?')) return;
|
||||
var response = await fetch('/api/hermes/client/sessions/' + encodeURIComponent(sessionId) + '?' + pageAiBackendSessionQuery({}), {
|
||||
method: 'DELETE',
|
||||
headers: { 'accept': 'application/json' }
|
||||
});
|
||||
var payload = await response.json().catch(function(){ return null; });
|
||||
if (!response.ok || !payload || payload.ok !== true) {
|
||||
throw new Error(pageAiErrorMessage(payload, 'session_delete_failed_' + response.status));
|
||||
}
|
||||
pageUiState.pageAiSessions = pageUiState.pageAiSessions.filter(function(item) { return item.id !== sessionId; });
|
||||
if (pageUiState.pageAiActiveSessionId === sessionId) {
|
||||
var next = pageUiState.pageAiSessions[0] || pageAiNewSession();
|
||||
if (!pageUiState.pageAiSessions.length) pageUiState.pageAiSessions = [next];
|
||||
pageUiState.pageAiActiveSessionId = next.id;
|
||||
pageUiState.pageAiMessages = Array.isArray(next.messages) ? next.messages.slice() : [];
|
||||
}
|
||||
pageAiPersistSessions();
|
||||
renderPageAiConversation();
|
||||
renderPageAiControls();
|
||||
}
|
||||
|
||||
async function pageAiResumeBackendSession(sessionId) {
|
||||
sessionId = String(sessionId || pageUiState.pageAiActiveSessionId || '').trim();
|
||||
if (!sessionId) return;
|
||||
pageAiSetActiveSession(sessionId);
|
||||
var response = await fetch('/api/hermes/client/sessions/' + encodeURIComponent(sessionId) + '/resume?' + pageAiBackendSessionQuery({}), {
|
||||
method: 'POST',
|
||||
headers: { 'accept': 'application/json' }
|
||||
});
|
||||
var payload = await response.json().catch(function(){ return null; });
|
||||
if (!response.ok || !payload || payload.ok !== true) {
|
||||
throw new Error(pageAiErrorMessage(payload, 'session_resume_failed_' + response.status));
|
||||
}
|
||||
pageAiApplyBackendSessionDetail(payload);
|
||||
pageUiState.pageAiPage = 'chat';
|
||||
renderPageAiConversation();
|
||||
renderPageAiControls();
|
||||
}
|
||||
|
||||
function pageAiProviderLabel(provider) {
|
||||
if (provider === 'codex') return 'Codex';
|
||||
if (provider === 'claudecode') return 'ClaudeCode';
|
||||
@@ -4970,7 +5364,10 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
var sessionNode = drawer.querySelector('[data-page-ai-session-status]');
|
||||
if (sessionNode instanceof HTMLElement) {
|
||||
var session = pageAiCurrentSession();
|
||||
sessionNode.textContent = session && session.id ? String(session.title || '当前页问答') : '等待 Hermes session';
|
||||
var usageText = session && session.usage ? pageAiUsageSummary(session.usage) : '';
|
||||
sessionNode.textContent = session && session.id
|
||||
? [String(session.title || '当前页问答'), usageText].filter(Boolean).join(' · ')
|
||||
: '等待 Hermes session';
|
||||
}
|
||||
var modelNode = drawer.querySelector('[data-page-ai-model-status]');
|
||||
if (modelNode instanceof HTMLElement) modelNode.textContent = pageAiCurrentModelLabel();
|
||||
@@ -5024,6 +5421,15 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
skillError.textContent = pageUiState.pageAiSkillError || '';
|
||||
skillError.hidden = !pageUiState.pageAiSkillError;
|
||||
}
|
||||
var sessionError = drawer.querySelector('[data-page-ai-session-error]');
|
||||
if (sessionError instanceof HTMLElement) {
|
||||
sessionError.textContent = pageUiState.pageAiSessionError || '';
|
||||
sessionError.hidden = !pageUiState.pageAiSessionError;
|
||||
}
|
||||
var sessionSearch = drawer.querySelector('[data-page-ai-session-search]');
|
||||
if (sessionSearch instanceof HTMLInputElement && document.activeElement !== sessionSearch) {
|
||||
sessionSearch.value = pageUiState.pageAiSessionSearchQuery || '';
|
||||
}
|
||||
var skillSearch = drawer.querySelector('[data-page-ai-skill-search]');
|
||||
if (skillSearch instanceof HTMLInputElement && document.activeElement !== skillSearch) {
|
||||
skillSearch.value = pageUiState.pageAiSkillQuery;
|
||||
@@ -5290,6 +5696,11 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
'<div class="wolai-page-ai-settings-head">' +
|
||||
'<button type="button" class="wolai-page-ai-back" data-page-ai-tab="chat">← 聊天</button>' +
|
||||
'</div>' +
|
||||
'<label class="wolai-page-ai-skill-search">' +
|
||||
'<span>搜索会话</span>' +
|
||||
'<input type="search" data-page-ai-session-search placeholder="搜索后端 AI 会话…" />' +
|
||||
'</label>' +
|
||||
'<div class="wolai-page-ai-inline-error" data-page-ai-session-error hidden></div>' +
|
||||
'<div class="wolai-page-ai-conversation" data-page-ai-conversation></div>' +
|
||||
'</section>' +
|
||||
'</div>' +
|
||||
@@ -5332,20 +5743,33 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
var conversation = drawer.querySelector('[data-page-ai-panel="' + cssEscape(pageUiState.pageAiPage || 'chat') + '"] [data-page-ai-conversation]');
|
||||
if (!(conversation instanceof HTMLElement)) return;
|
||||
if (pageUiState.pageAiPage === 'history') {
|
||||
if (!pageUiState.pageAiSessions.length) {
|
||||
var historyRows = pageUiState.pageAiSessionSearchResults.length
|
||||
? pageUiState.pageAiSessionSearchResults
|
||||
: pageUiState.pageAiSessions;
|
||||
if (!historyRows.length) {
|
||||
conversation.innerHTML = '<div class="wolai-page-ai-empty">尚未产生历史会话。</div>';
|
||||
return;
|
||||
}
|
||||
conversation.innerHTML = pageUiState.pageAiSessions.map(function(session) {
|
||||
conversation.innerHTML = historyRows.map(function(session) {
|
||||
var preview = Array.isArray(session.messages) && session.messages.length
|
||||
? session.messages.slice(-1)[0].content
|
||||
: '暂无消息';
|
||||
: (session.snippet || session.preview || '暂无消息');
|
||||
var active = session.id === pageUiState.pageAiActiveSessionId;
|
||||
var usage = pageAiUsageSummary(session.usage);
|
||||
var meta = [session.source === 'acp' ? 'Convex' : '本地', session.status, usage].filter(Boolean).join(' · ');
|
||||
return '' +
|
||||
'<button type="button" class="wolai-page-ai-message wolai-page-ai-message--history' + (active ? ' is-active' : '') + '" data-page-ai-session="' + escapeHtml(session.id) + '">' +
|
||||
'<div class="wolai-page-ai-message-role">会话</div>' +
|
||||
'<div class="wolai-page-ai-message-text"><strong>' + escapeHtml(session.title || '新会话') + '</strong><br />' + escapeHtml(preview) + '</div>' +
|
||||
'</button>';
|
||||
'<div class="wolai-page-ai-message wolai-page-ai-message--history' + (active ? ' is-active' : '') + '" data-page-ai-session-row="' + escapeHtml(session.id) + '">' +
|
||||
'<button type="button" class="wolai-page-ai-message-text" data-page-ai-session="' + escapeHtml(session.id) + '">' +
|
||||
'<strong>' + escapeHtml(session.title || '新会话') + '</strong><br />' +
|
||||
'<span>' + escapeHtml(preview) + '</span>' +
|
||||
(meta ? '<br /><span class="wolai-page-ai-tool-meta">' + escapeHtml(meta) + '</span>' : '') +
|
||||
'</button>' +
|
||||
'<div class="wolai-page-ai-message-actions">' +
|
||||
'<button type="button" class="wolai-page-ai-ghost" data-page-ai-session-resume="' + escapeHtml(session.id) + '">恢复</button>' +
|
||||
'<button type="button" class="wolai-page-ai-ghost" data-page-ai-session-rename="' + escapeHtml(session.id) + '">重命名</button>' +
|
||||
'<button type="button" class="wolai-page-ai-ghost" data-page-ai-session-delete="' + escapeHtml(session.id) + '">删除</button>' +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
}).join('');
|
||||
return;
|
||||
}
|
||||
@@ -5376,6 +5800,30 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
'</div>' +
|
||||
'</div>';
|
||||
}
|
||||
if (item.kind === 'thought') {
|
||||
return '' +
|
||||
'<details class="wolai-page-ai-message wolai-page-ai-message--assistant" data-page-ai-thought-delta="true">' +
|
||||
'<summary class="wolai-page-ai-message-role" style="cursor:pointer;user-select:none;opacity:0.7;font-size:0.85em">思考过程</summary>' +
|
||||
'<div class="wolai-page-ai-message-text" style="margin-top:4px;opacity:0.6;font-size:0.9em;border-left:2px solid var(--wolai-border-color, #ddd);padding-left:8px">' + escapeHtml(item.content || '') + '</div>' +
|
||||
'</details>';
|
||||
}
|
||||
if (item.kind === 'permission') {
|
||||
var permissionActions = item.resolved ? '' : (
|
||||
'<div class="wolai-page-ai-message-actions">' +
|
||||
'<button type="button" class="wolai-page-ai-ghost" data-page-ai-permission-action="allow" data-page-ai-permission-id="' + escapeHtml(item.permissionId || '') + '">允许</button>' +
|
||||
'<button type="button" class="wolai-page-ai-ghost" data-page-ai-permission-action="deny" data-page-ai-permission-id="' + escapeHtml(item.permissionId || '') + '">拒绝</button>' +
|
||||
'</div>'
|
||||
);
|
||||
return '' +
|
||||
'<div class="wolai-page-ai-message wolai-page-ai-message--tool" data-page-ai-permission-request="' + escapeHtml(item.permissionId || '') + '">' +
|
||||
'<div class="wolai-page-ai-message-role">权限</div>' +
|
||||
'<div class="wolai-page-ai-message-text">' +
|
||||
'<strong>' + escapeHtml(item.toolName || 'session/request_permission') + '</strong><br />' +
|
||||
'<span>' + escapeHtml(item.argsSummary || item.content || '') + '</span>' +
|
||||
permissionActions +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
}
|
||||
var streamingAttr = item.streaming ? ' data-page-ai-streaming="true"' : '';
|
||||
return '' +
|
||||
'<div class="wolai-page-ai-message wolai-page-ai-message--' + escapeHtml(item.role || 'assistant') + '"' + streamingAttr + '>' +
|
||||
@@ -5401,7 +5849,8 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
pageAiLoadProfileMemory(),
|
||||
pageAiLoadSkills(),
|
||||
pageAiLoadTools(),
|
||||
pageAiLoadGatewayHealth()
|
||||
pageAiLoadGatewayHealth(),
|
||||
pageAiLoadBackendSessions()
|
||||
]).then(function() {
|
||||
renderPageAiControls();
|
||||
}).catch(function() {}).then(function() {
|
||||
@@ -5687,10 +6136,54 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
if (pageUiState.pageAiStoppedRunIds[runId]) return;
|
||||
assistantText = pageAiAppendStreamingAssistantDelta(runId, pageAiDecodeDeltaText(payloadText));
|
||||
}
|
||||
if (eventName === 'thought.delta') {
|
||||
try {
|
||||
var thoughtPayload = JSON.parse(payloadText || 'null');
|
||||
var thoughtText = String((thoughtPayload && (thoughtPayload.delta || thoughtPayload.text)) || '');
|
||||
if (thoughtText) {
|
||||
var msgs = pageUiState.pageAiMessages;
|
||||
var lastThought = msgs.length > 0 && msgs[msgs.length - 1].kind === 'thought' ? msgs[msgs.length - 1] : null;
|
||||
if (lastThought) {
|
||||
lastThought.content += thoughtText;
|
||||
} else {
|
||||
msgs.push({ role: 'assistant', kind: 'thought', content: thoughtText });
|
||||
}
|
||||
pageAiSyncCurrentSessionMessages();
|
||||
pageAiPersistSessions();
|
||||
renderPageAiConversation();
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
if (eventName === 'usage.updated') {
|
||||
try {
|
||||
var usagePayload = JSON.parse(payloadText || 'null') || {};
|
||||
var sessionForUsage = pageAiCurrentSession();
|
||||
if (sessionForUsage) {
|
||||
sessionForUsage.usage = {
|
||||
source: 'usage_update',
|
||||
used: Number(usagePayload.used ?? usagePayload.contextUsed ?? 0),
|
||||
size: Number(usagePayload.size ?? usagePayload.contextSize ?? 0)
|
||||
};
|
||||
pageAiPersistSessions();
|
||||
}
|
||||
} catch (_) {}
|
||||
renderPageAiControls();
|
||||
}
|
||||
if (eventName === 'permission.requested' || eventName === 'permission.denied' || eventName === 'permission.allowed') {
|
||||
pageAiApplyPermissionEvent(eventName, payloadText);
|
||||
pageAiSyncCurrentSessionMessages();
|
||||
pageAiPersistSessions();
|
||||
renderPageAiConversation();
|
||||
renderPageAiControls();
|
||||
}
|
||||
if (eventName === 'run.completed') {
|
||||
try {
|
||||
var completed = JSON.parse(payloadText || 'null');
|
||||
if (completed && completed.output) assistantText = String(completed.output || '');
|
||||
if (completed && completed.usage) {
|
||||
var completedSession = pageAiCurrentSession();
|
||||
if (completedSession) completedSession.usage = completed.usage;
|
||||
}
|
||||
} catch (_) {}
|
||||
pageAiSetRunStatus('completed', runId);
|
||||
}
|
||||
@@ -6181,6 +6674,13 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
if (activeTreeContextMenu && activeTreeContextMenu.contains(e.target)) return;
|
||||
if (activeTreeContextMenu) closeTreeContextMenu();
|
||||
|
||||
var sidebarToggle = closestAction(e.target, '[data-mnote-action="toggle-sidebar"]');
|
||||
if (sidebarToggle) {
|
||||
e.preventDefault();
|
||||
toggleWorkspaceSidebar(sidebarToggle);
|
||||
return;
|
||||
}
|
||||
|
||||
var attachmentAction = closestAction(e.target, '[data-attachment-action]');
|
||||
if (attachmentAction) {
|
||||
e.preventDefault();
|
||||
@@ -6362,6 +6862,46 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
return;
|
||||
}
|
||||
|
||||
var pageAiSessionResume = closestAction(e.target, '[data-page-ai-session-resume]');
|
||||
if (pageAiSessionResume) {
|
||||
e.preventDefault();
|
||||
void pageAiResumeBackendSession(pageAiSessionResume.getAttribute('data-page-ai-session-resume') || '').catch(function(error) {
|
||||
pageUiState.pageAiSessionError = error instanceof Error ? error.message : String(error);
|
||||
renderPageAiControls();
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
var pageAiSessionRename = closestAction(e.target, '[data-page-ai-session-rename]');
|
||||
if (pageAiSessionRename) {
|
||||
e.preventDefault();
|
||||
void pageAiRenameBackendSession(pageAiSessionRename.getAttribute('data-page-ai-session-rename') || '').catch(function(error) {
|
||||
pageUiState.pageAiSessionError = error instanceof Error ? error.message : String(error);
|
||||
renderPageAiControls();
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
var pageAiSessionDelete = closestAction(e.target, '[data-page-ai-session-delete]');
|
||||
if (pageAiSessionDelete) {
|
||||
e.preventDefault();
|
||||
void pageAiDeleteBackendSession(pageAiSessionDelete.getAttribute('data-page-ai-session-delete') || '').catch(function(error) {
|
||||
pageUiState.pageAiSessionError = error instanceof Error ? error.message : String(error);
|
||||
renderPageAiControls();
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
var pageAiPermissionAction = closestAction(e.target, '[data-page-ai-permission-action]');
|
||||
if (pageAiPermissionAction) {
|
||||
e.preventDefault();
|
||||
pageAiResolvePermission(
|
||||
pageAiPermissionAction.getAttribute('data-page-ai-permission-id') || '',
|
||||
pageAiPermissionAction.getAttribute('data-page-ai-permission-action') || 'deny'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
var pageAiSession = closestAction(e.target, '[data-page-ai-session]');
|
||||
if (pageAiSession) {
|
||||
e.preventDefault();
|
||||
@@ -6396,6 +6936,12 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
pageUiState.pageAiPage = pageUiState.pageAiPage === 'history' ? 'chat' : 'history';
|
||||
renderPageAiControls();
|
||||
renderPageAiConversation();
|
||||
if (pageUiState.pageAiPage === 'history') {
|
||||
void pageAiLoadBackendSessions().catch(function(error) {
|
||||
pageUiState.pageAiSessionError = error instanceof Error ? error.message : String(error);
|
||||
renderPageAiControls();
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -6661,6 +7207,18 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
renderPageAiControls();
|
||||
return;
|
||||
}
|
||||
var sessionSearch = closestAction(event.target, '[data-page-ai-session-search]');
|
||||
if (sessionSearch instanceof HTMLInputElement) {
|
||||
var sessionQuery = sessionSearch.value;
|
||||
window.clearTimeout(pageUiState.pageAiSessionSearchTimer || 0);
|
||||
pageUiState.pageAiSessionSearchTimer = window.setTimeout(function() {
|
||||
void pageAiSearchBackendSessions(sessionQuery).catch(function(error) {
|
||||
pageUiState.pageAiSessionError = error instanceof Error ? error.message : String(error);
|
||||
renderPageAiControls();
|
||||
});
|
||||
}, 200);
|
||||
return;
|
||||
}
|
||||
var memoryEditor = closestAction(event.target, '[data-page-ai-memory-editor]');
|
||||
if (memoryEditor instanceof HTMLTextAreaElement) {
|
||||
var section = memoryEditor.getAttribute('data-page-ai-memory-editor') || '';
|
||||
@@ -6681,6 +7239,9 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
void pageAiLoadProfiles();
|
||||
if (next !== 'reasonix') void pageAiLoadProfileMemory();
|
||||
void pageAiLoadSkills();
|
||||
void pageAiLoadBackendSessions().catch(function(error) {
|
||||
pageUiState.pageAiSessionError = error instanceof Error ? error.message : String(error);
|
||||
});
|
||||
renderPageAiControls();
|
||||
renderPageAiProviderButtons();
|
||||
return;
|
||||
@@ -7343,7 +7904,7 @@ pub fn PageLayout(
|
||||
<div class="mnote-main">
|
||||
<header class="wolai-topbar" data-testid="wolai-topbar">
|
||||
<div class="wolai-topbar-left">
|
||||
<button type="button" class="wolai-icon-button wolai-menu-button" aria-label="切换侧栏"><span class="material-symbols-outlined" data-icon="menu" aria-hidden="true"></span></button>
|
||||
<button type="button" class="wolai-icon-button wolai-menu-button" aria-label="切换侧栏" aria-pressed="false" data-mnote-action="toggle-sidebar" data-testid="wolai-sidebar-toggle"><span class="material-symbols-outlined" data-icon="menu" aria-hidden="true"></span></button>
|
||||
<nav class="wolai-breadcrumb" aria-label="页面路径">
|
||||
<span class="wolai-breadcrumb-root">{ws_name.clone()}</span>
|
||||
<span class="wolai-breadcrumb-separator" aria-hidden="true">"›"</span>
|
||||
@@ -7445,6 +8006,59 @@ mod tests {
|
||||
assert!(SIDEBAR_TREE_JS.contains("mnote-recent-local-root"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_ai_context_uses_page_aggregate_subtree_as_single_truth() {
|
||||
assert!(
|
||||
!SIDEBAR_TREE_JS.contains("pageSubtreeSource: 'local'"),
|
||||
"页面 AI context 不能从编辑器 DOM 派生 local page subtree;必须以 Rust Page Aggregate projection 为准"
|
||||
);
|
||||
assert!(
|
||||
!SIDEBAR_TREE_JS.contains("buildPageAiLocalSubtree(localBlocks"),
|
||||
"页面 AI context 不应调用本地 page subtree builder"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_ai_uses_backend_acp_session_runtime_store() {
|
||||
assert!(SIDEBAR_TREE_JS.contains("function pageAiLoadBackendSessions"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("/api/hermes/client/sessions?"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("params.set('source', 'acp')"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("function pageAiLoadBackendSessionDetail"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("function pageAiSearchBackendSessions"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("data-page-ai-session-rename"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("data-page-ai-session-delete"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("data-page-ai-session-resume"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("data-page-ai-session-search"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("pageAiUsageSummary"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("usage.updated"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("thought.delta"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("permission.requested"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("data-page-ai-permission-action=\"allow\""));
|
||||
assert!(SIDEBAR_TREE_JS.contains("data-page-ai-permission-action=\"deny\""));
|
||||
assert!(SIDEBAR_TREE_JS.contains("function pageAiHidePermissionDialog"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("if (!message.resolved)"));
|
||||
assert!(
|
||||
!SIDEBAR_TREE_JS.contains("(item.resolved ? ' disabled' : '')"),
|
||||
"已决 ACP permission 事件不能继续展示假审批按钮"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sidebar_tree_runtime_does_not_use_retired_query_preferred_snapshot_selector() {
|
||||
assert!(TREE_LIVE_CONTROLLER_JS.contains("tree:snapshot"));
|
||||
assert!(TREE_LIVE_CONTROLLER_JS.contains("tree:delta"));
|
||||
assert!(!SIDEBAR_TREE_JS.contains("usePreferredSidebarSnapshot"));
|
||||
assert!(!SIDEBAR_TREE_JS.contains("preferredSidebarSnapshot"));
|
||||
assert!(!SIDEBAR_TREE_JS.contains("liveSidebarTitle"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_layout_sidebar_toggle_is_wired_to_shell_state() {
|
||||
assert!(SIDEBAR_TREE_JS.contains("function toggleWorkspaceSidebar"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("data-mnote-sidebar-collapsed"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("[data-mnote-action=\"toggle-sidebar\"]"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sidebar_workspace_source_switch_remembers_real_cloud_workspace_before_local_folder() {
|
||||
assert!(
|
||||
@@ -7593,6 +8207,9 @@ mod tests {
|
||||
#[test]
|
||||
fn tree_live_controller_marks_transport_and_closes_source_on_pagehide() {
|
||||
assert!(TREE_LIVE_CONTROLLER_JS.contains("convex-command-log-sse"));
|
||||
assert!(TREE_LIVE_CONTROLLER_JS.contains("convex-command-log-ws"));
|
||||
assert!(TREE_LIVE_CONTROLLER_JS.contains("new WebSocket(url.toString())"));
|
||||
assert!(TREE_LIVE_CONTROLLER_JS.contains("startWithSseFallback"));
|
||||
assert!(TREE_LIVE_CONTROLLER_JS.contains("data-mnote-tree-live-transport"));
|
||||
assert!(TREE_LIVE_CONTROLLER_JS.contains("pagehide"));
|
||||
assert!(TREE_LIVE_CONTROLLER_JS.contains("__mnoteTreeLiveEventSource"));
|
||||
|
||||
@@ -224,6 +224,11 @@ a:hover {
|
||||
background: var(--wolai-bg);
|
||||
}
|
||||
|
||||
.mnote-shell[data-mnote-sidebar-collapsed="true"] .mnote-sidebar,
|
||||
.mnote-shell[data-mnote-sidebar-collapsed="true"] .wolai-sidebar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.wolai-workspace-shell {
|
||||
--wolai-bg-sidebar: #F7F7F6;
|
||||
--wolai-bg-selected: #F8E6E7;
|
||||
@@ -3376,7 +3381,6 @@ body {
|
||||
width: 100%;
|
||||
border: 1px solid rgba(27, 28, 28, 0.08);
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.wolai-page-ai-message--history.is-active {
|
||||
@@ -3399,6 +3403,39 @@ body {
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
button.wolai-page-ai-message-text {
|
||||
width: 100%;
|
||||
border: 0;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.wolai-page-ai-message-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.wolai-page-ai-permission-dialog {
|
||||
position: fixed;
|
||||
right: 28px;
|
||||
bottom: 28px;
|
||||
z-index: 80;
|
||||
max-width: min(360px, calc(100vw - 32px));
|
||||
}
|
||||
|
||||
.wolai-page-ai-permission-panel {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
padding: 14px;
|
||||
border: 1px solid rgba(27, 28, 28, 0.12);
|
||||
border-radius: 8px;
|
||||
background: #FFFFFF;
|
||||
box-shadow: 0 18px 48px rgba(15, 23, 42, 0.16);
|
||||
}
|
||||
|
||||
.wolai-page-ai-tool-details {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
@@ -3670,6 +3707,7 @@ mod tests {
|
||||
assert!(MNOTE_CSS.contains(".mnote-sidebar"));
|
||||
assert!(MNOTE_CSS.contains(".mnote-main"));
|
||||
assert!(MNOTE_CSS.contains(".mnote-content"));
|
||||
assert!(MNOTE_CSS.contains("[data-mnote-sidebar-collapsed=\"true\"]"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -3713,7 +3751,7 @@ mod tests {
|
||||
fn mnote_css_is_reasonably_sized() {
|
||||
// 至少 2000 字符才能包含完整样式
|
||||
assert!(MNOTE_CSS.len() > 2000);
|
||||
// 当前整合了工作区壳、编辑器样式、树菜单和双 pane 布局,仍保持在单文件可审阅范围内。
|
||||
assert!(MNOTE_CSS.len() < 70000);
|
||||
// 当前整合了工作区壳、编辑器样式、树菜单、页面 AI 和双 pane 布局,仍保持在单文件可审阅范围内。
|
||||
assert!(MNOTE_CSS.len() < 90000);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,8 +83,14 @@ fn render_filetree_row(
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
let command_document_id = match row.row_kind.as_str() {
|
||||
"document" | "markdown" => row.document_id.as_deref().unwrap_or(&row.node_id),
|
||||
"index" => row.node_id.strip_prefix("index:").unwrap_or(&row.node_id),
|
||||
_ => "",
|
||||
};
|
||||
let owner_document_id = row.document_id.as_deref().unwrap_or_default();
|
||||
html.push_str(&format!(
|
||||
r#"<li class="tree-node" data-node-id="{node_id}"><div class="tree-row" role="treeitem" aria-level="{aria_level}" aria-expanded="{expanded_attr}" data-rust-rendered-row="filetree" data-testid="{test_id}" data-row-id="{row_id}" data-row-kind="{row_kind}" data-node-id="{node_id}"{parent_attr} data-document-id="{document_id}" data-doc-id="{document_id}" data-asset-id="{asset_id}" data-object-identity="{object_identity}" data-shell-mode="filetree" data-selected="{selected}" data-active="false" draggable="true">{toggle_html}<span class="tree-kind-badge" data-kind="{icon_kind}" aria-hidden="true"></span><button type="button" class="tree-link" data-rust-action="open" data-row-id="{row_id}" data-document-id="{document_id}" data-asset-id="{asset_id}"><span class="tree-link-title">{title}</span></button><div class="tree-actions">{create_action_html}<button type="button" class="tree-action" data-testid="filetree-action-menu" data-rust-action="menu" data-row-id="{row_id}" aria-label="更多操作">…</button></div></div>"#,
|
||||
r#"<li class="tree-node" data-node-id="{node_id}"><div class="tree-row" role="treeitem" aria-level="{aria_level}" aria-expanded="{expanded_attr}" data-rust-rendered-row="filetree" data-testid="{test_id}" data-row-id="{row_id}" data-row-kind="{row_kind}" data-node-id="{node_id}"{parent_attr} data-document-id="{document_id}" data-doc-id="{document_id}" data-owner-document-id="{owner_document_id}" data-asset-id="{asset_id}" data-object-identity="{object_identity}" data-shell-mode="filetree" data-selected="{selected}" data-active="false" draggable="true">{toggle_html}<span class="tree-kind-badge" data-kind="{icon_kind}" aria-hidden="true"></span><button type="button" class="tree-link" data-rust-action="open" data-row-id="{row_id}" data-document-id="{document_id}" data-owner-document-id="{owner_document_id}" data-asset-id="{asset_id}"><span class="tree-link-title">{title}</span></button><div class="tree-actions">{create_action_html}<button type="button" class="tree-action" data-testid="filetree-action-menu" data-rust-action="menu" data-row-id="{row_id}" aria-label="更多操作">…</button></div></div>"#,
|
||||
node_id = escape_html(&row.node_id),
|
||||
aria_level = row.depth + 1,
|
||||
expanded_attr = if row.expandable && row.expanded { "true" } else { "false" },
|
||||
@@ -92,7 +98,8 @@ fn render_filetree_row(
|
||||
row_id = escape_html(&row.row_id),
|
||||
row_kind = escape_html(&row.row_kind),
|
||||
parent_attr = parent_attr,
|
||||
document_id = escape_html(row.document_id.as_deref().unwrap_or_default()),
|
||||
document_id = escape_html(command_document_id),
|
||||
owner_document_id = escape_html(owner_document_id),
|
||||
asset_id = escape_html(row.asset_id.as_deref().unwrap_or_default()),
|
||||
object_identity = escape_html(row.object_identity.as_deref().unwrap_or_default()),
|
||||
selected = row.selected,
|
||||
@@ -208,6 +215,7 @@ mod tests {
|
||||
assert!(!html.contains("data-testid=\"filetree-index-row\""));
|
||||
assert!(!html.contains("index.md"));
|
||||
assert!(html.contains("data-doc-id=\"page_root\""));
|
||||
assert!(html.contains("data-row-id=\"asset:mind_1\" data-row-kind=\"asset\" data-node-id=\"asset:mind_1\" data-parent-id=\"page_root\" data-document-id=\"\" data-doc-id=\"\" data-owner-document-id=\"page_root\" data-asset-id=\"mind_1\""));
|
||||
assert!(html.contains("data-asset-id=\"mind_1\""));
|
||||
assert!(html.contains("data-object-identity=\"{"objectKind":"mindmap""));
|
||||
assert!(html.contains("tree-children"));
|
||||
|
||||
@@ -171,7 +171,7 @@ mod tests {
|
||||
},
|
||||
source: SourcePayload {
|
||||
channel: "next-route".into(),
|
||||
client: "wolai-frontend".into(),
|
||||
client: "mnote-web".into(),
|
||||
},
|
||||
target: Some(TargetRef {
|
||||
workspace_id: Some("ws_1".into()),
|
||||
@@ -210,7 +210,7 @@ mod tests {
|
||||
},
|
||||
source: SourcePayload {
|
||||
channel: "next-route".into(),
|
||||
client: "wolai-frontend".into(),
|
||||
client: "mnote-web".into(),
|
||||
},
|
||||
target: Some(TargetRef {
|
||||
workspace_id: Some("ws_1".into()),
|
||||
@@ -257,7 +257,7 @@ mod tests {
|
||||
},
|
||||
source: SourcePayload {
|
||||
channel: "next-route".into(),
|
||||
client: "wolai-frontend".into(),
|
||||
client: "mnote-web".into(),
|
||||
},
|
||||
target: Some(TargetRef {
|
||||
workspace_id: Some("ws_1".into()),
|
||||
@@ -294,7 +294,7 @@ mod tests {
|
||||
},
|
||||
source: SourcePayload {
|
||||
channel: "next-route".into(),
|
||||
client: "wolai-frontend".into(),
|
||||
client: "mnote-web".into(),
|
||||
},
|
||||
target: Some(TargetRef {
|
||||
workspace_id: Some("ws_1".into()),
|
||||
@@ -334,7 +334,7 @@ mod tests {
|
||||
},
|
||||
source: SourcePayload {
|
||||
channel: "next-route".into(),
|
||||
client: "wolai-frontend".into(),
|
||||
client: "mnote-web".into(),
|
||||
},
|
||||
target: Some(TargetRef {
|
||||
workspace_id: Some("ws_1".into()),
|
||||
@@ -374,7 +374,7 @@ mod tests {
|
||||
},
|
||||
source: SourcePayload {
|
||||
channel: "next-route".into(),
|
||||
client: "wolai-frontend".into(),
|
||||
client: "mnote-web".into(),
|
||||
},
|
||||
target: Some(TargetRef {
|
||||
workspace_id: Some("ws_1".into()),
|
||||
@@ -413,7 +413,7 @@ mod tests {
|
||||
};
|
||||
let base_source = SourcePayload {
|
||||
channel: "next-route".into(),
|
||||
client: "wolai-frontend".into(),
|
||||
client: "mnote-web".into(),
|
||||
};
|
||||
|
||||
let create_request = build_write_request(
|
||||
@@ -594,7 +594,7 @@ mod tests {
|
||||
},
|
||||
source: SourcePayload {
|
||||
channel: "next-route".into(),
|
||||
client: "wolai-frontend".into(),
|
||||
client: "mnote-web".into(),
|
||||
},
|
||||
target: Some(TargetRef {
|
||||
workspace_id: Some("ws_1".into()),
|
||||
@@ -651,7 +651,7 @@ mod tests {
|
||||
},
|
||||
source: SourcePayload {
|
||||
channel: "next-route".into(),
|
||||
client: "wolai-frontend".into(),
|
||||
client: "mnote-web".into(),
|
||||
},
|
||||
target: Some(TargetRef {
|
||||
workspace_id: Some("ws_1".into()),
|
||||
@@ -694,7 +694,7 @@ mod tests {
|
||||
},
|
||||
source: SourcePayload {
|
||||
channel: "next-route".into(),
|
||||
client: "wolai-frontend".into(),
|
||||
client: "mnote-web".into(),
|
||||
},
|
||||
target: Some(TargetRef {
|
||||
workspace_id: Some("ws_1".into()),
|
||||
@@ -745,7 +745,7 @@ mod tests {
|
||||
},
|
||||
source: SourcePayload {
|
||||
channel: "next-route".into(),
|
||||
client: "wolai-frontend".into(),
|
||||
client: "mnote-web".into(),
|
||||
},
|
||||
target: Some(TargetRef {
|
||||
workspace_id: Some("ws_1".into()),
|
||||
@@ -796,7 +796,7 @@ mod tests {
|
||||
},
|
||||
source: SourcePayload {
|
||||
channel: "onlyoffice-callback".into(),
|
||||
client: "wolai-frontend".into(),
|
||||
client: "mnote-web".into(),
|
||||
},
|
||||
target: Some(TargetRef {
|
||||
workspace_id: Some("ws_1".into()),
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"rustc_fingerprint":10059341515723286937,"outputs":{"17747080675513052775":{"success":true,"status":"","code":0,"stdout":"rustc 1.95.0 (59807616e 2026-04-14)\nbinary: rustc\ncommit-hash: 59807616e1fa2540724bfbac14d7976d7e4a3860\ncommit-date: 2026-04-14\nhost: x86_64-unknown-linux-gnu\nrelease: 1.95.0\nLLVM version: 22.1.2\n","stderr":""},"7971740275564407648":{"success":true,"status":"","code":0,"stdout":"___\nlib___.rlib\nlib___.so\nlib___.so\nlib___.a\nlib___.so\n/home/lix/.rustup/toolchains/stable-x86_64-unknown-linux-gnu\noff\npacked\nunpacked\n___\ndebug_assertions\npanic=\"unwind\"\nproc_macro\ntarget_abi=\"\"\ntarget_arch=\"x86_64\"\ntarget_endian=\"little\"\ntarget_env=\"gnu\"\ntarget_family=\"unix\"\ntarget_feature=\"fxsr\"\ntarget_feature=\"sse\"\ntarget_feature=\"sse2\"\ntarget_has_atomic=\"16\"\ntarget_has_atomic=\"32\"\ntarget_has_atomic=\"64\"\ntarget_has_atomic=\"8\"\ntarget_has_atomic=\"ptr\"\ntarget_os=\"linux\"\ntarget_pointer_width=\"64\"\ntarget_vendor=\"unknown\"\nunix\n","stderr":""}},"successes":{}}
|
||||
{"rustc_fingerprint":9228011546279038255,"outputs":{"17747080675513052775":{"success":true,"status":"","code":0,"stdout":"rustc 1.89.0 (29483883e 2025-08-04)\nbinary: rustc\ncommit-hash: 29483883eed69d5fb4db01964cdf2af4d86e9cb2\ncommit-date: 2025-08-04\nhost: x86_64-unknown-linux-gnu\nrelease: 1.89.0\nLLVM version: 20.1.7\n","stderr":""},"7971740275564407648":{"success":true,"status":"","code":0,"stdout":"___\nlib___.rlib\nlib___.so\nlib___.so\nlib___.a\nlib___.so\n/home/lix/.rustup/toolchains/1.89.0-x86_64-unknown-linux-gnu\noff\npacked\nunpacked\n___\ndebug_assertions\npanic=\"unwind\"\nproc_macro\ntarget_abi=\"\"\ntarget_arch=\"x86_64\"\ntarget_endian=\"little\"\ntarget_env=\"gnu\"\ntarget_family=\"unix\"\ntarget_feature=\"fxsr\"\ntarget_feature=\"sse\"\ntarget_feature=\"sse2\"\ntarget_has_atomic=\"16\"\ntarget_has_atomic=\"32\"\ntarget_has_atomic=\"64\"\ntarget_has_atomic=\"8\"\ntarget_has_atomic=\"ptr\"\ntarget_os=\"linux\"\ntarget_pointer_width=\"64\"\ntarget_vendor=\"unknown\"\nunix\n","stderr":""}},"successes":{}}
|
||||
@@ -22,6 +22,7 @@
|
||||
import { createInterface } from 'node:readline';
|
||||
import { stdin, stdout } from 'node:process';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { AsyncLocalStorage } from 'node:async_hooks';
|
||||
import { readFileSync, existsSync } from 'node:fs';
|
||||
import { homedir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
@@ -33,6 +34,97 @@ function debugLog(message) {
|
||||
if (DEBUG) process.stderr.write(`${message}\n`);
|
||||
}
|
||||
|
||||
const toolContextStorage = new AsyncLocalStorage();
|
||||
|
||||
function isWriteMnoteTool(toolName) {
|
||||
return !['mnote.doc.fetch', 'mnote.page.get', 'mnote.block.fetch'].includes(toolName);
|
||||
}
|
||||
|
||||
function stableIdPart(value, fallback) {
|
||||
const text = String(value || fallback || '')
|
||||
.trim()
|
||||
.replace(/[^a-zA-Z0-9_.:-]+/g, '_')
|
||||
.slice(0, 80);
|
||||
return text || String(fallback || 'unknown');
|
||||
}
|
||||
|
||||
function buildMnoteToolPayload(toolName, rawArgs = {}, context = {}) {
|
||||
const {
|
||||
actorId,
|
||||
sessionId,
|
||||
runId,
|
||||
toolCallId,
|
||||
traceId,
|
||||
idempotencyKey,
|
||||
dryRun,
|
||||
workspaceId,
|
||||
...args
|
||||
} = rawArgs || {};
|
||||
const effectiveSessionId =
|
||||
sessionId || context.sessionId || context.acpSessionId || `reasonix_acp_session_${randomUUID()}`;
|
||||
const effectiveRunId = runId || context.runId || effectiveSessionId;
|
||||
const effectiveToolCallId =
|
||||
toolCallId || `reasonix_${stableIdPart(toolName, 'tool')}_${randomUUID()}`;
|
||||
const effectiveTraceId = traceId || context.traceId || `trace_${stableIdPart(effectiveRunId, 'run')}`;
|
||||
const writeTool = isWriteMnoteTool(toolName);
|
||||
const effectiveDryRun = typeof dryRun === 'boolean' ? dryRun : writeTool ? false : false;
|
||||
const effectiveIdempotencyKey =
|
||||
idempotencyKey ||
|
||||
`idem_${stableIdPart(toolName, 'tool')}_${stableIdPart(effectiveRunId, 'run')}_${stableIdPart(effectiveToolCallId, 'call')}`;
|
||||
return {
|
||||
toolName,
|
||||
args,
|
||||
workspaceId: workspaceId || context.workspaceId || 'default',
|
||||
documentId: args.documentId || context.documentId,
|
||||
actorId: actorId || context.actorId || process.env.MNOTE_ACTOR_ID || 'reasonix-acp',
|
||||
sessionId: effectiveSessionId,
|
||||
runId: effectiveRunId,
|
||||
toolCallId: effectiveToolCallId,
|
||||
traceId: effectiveTraceId,
|
||||
dryRun: effectiveDryRun,
|
||||
idempotencyKey: effectiveIdempotencyKey,
|
||||
};
|
||||
}
|
||||
|
||||
if (process.env.MNOTE_REASONIX_ACP_SELFTEST === '1') {
|
||||
const payload = buildMnoteToolPayload(
|
||||
'mnote.doc.markdown_edit',
|
||||
{
|
||||
workspaceId: 'ws_demo',
|
||||
documentId: 'doc_1',
|
||||
operations: [{ search: '旧', replace: '新' }],
|
||||
},
|
||||
{
|
||||
actorId: 'user_1',
|
||||
sessionId: 'sess_1',
|
||||
runId: 'run_1',
|
||||
traceId: 'trace_1',
|
||||
},
|
||||
);
|
||||
const required = [
|
||||
'toolName',
|
||||
'workspaceId',
|
||||
'documentId',
|
||||
'actorId',
|
||||
'sessionId',
|
||||
'runId',
|
||||
'toolCallId',
|
||||
'traceId',
|
||||
'dryRun',
|
||||
'idempotencyKey',
|
||||
];
|
||||
for (const key of required) {
|
||||
if (payload[key] === undefined || payload[key] === null || payload[key] === '') {
|
||||
throw new Error(`selftest missing ${key}`);
|
||||
}
|
||||
}
|
||||
if (payload.args.workspaceId !== undefined || payload.args.actorId !== undefined) {
|
||||
throw new Error('selftest expected identity fields outside args');
|
||||
}
|
||||
process.stderr.write('[reasonix-acp-mnote] selftest ok\n');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// 读取 DeepSeek API key:先环境变量,再 Reasonix 官方 JSON 配置,最后兼容旧 YAML。
|
||||
function loadApiKey() {
|
||||
if (process.env.DEEPSEEK_API_KEY) return process.env.DEEPSEEK_API_KEY;
|
||||
@@ -229,14 +321,15 @@ const REASONIX_TOOL_TO_MNOTE_TOOL = {
|
||||
|
||||
async function callMnoteTool(toolName, args) {
|
||||
const url = `${MNOTE_WEB_URL}/api/hermes/tools/mnote/call`;
|
||||
const payload = buildMnoteToolPayload(toolName, args, toolContextStorage.getStore() || {});
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
toolName,
|
||||
args,
|
||||
workspaceId: args.workspaceId || 'default',
|
||||
}),
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'x-mnote-actor-id': payload.actorId,
|
||||
'x-mnote-workspace-id': payload.workspaceId,
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const text = await response.text().catch(() => '');
|
||||
@@ -357,6 +450,15 @@ onRequest('session/prompt', async (params) => {
|
||||
}
|
||||
|
||||
session.aborter = new AbortController();
|
||||
const toolContext = {
|
||||
acpSessionId: params.sessionId,
|
||||
sessionId: params.mnoteSessionId || params.sessionId,
|
||||
runId: params.runId || params.mnoteRunId || params.sessionId,
|
||||
actorId: params.actorId || params.mnoteActorId,
|
||||
traceId: params.traceId || params.mnoteTraceId,
|
||||
workspaceId: params.workspaceId,
|
||||
documentId: params.documentId,
|
||||
};
|
||||
let stopReason = 'end_turn';
|
||||
let hasAssistantOutput = false;
|
||||
let hasToolCall = false;
|
||||
@@ -386,6 +488,7 @@ onRequest('session/prompt', async (params) => {
|
||||
}
|
||||
|
||||
try {
|
||||
await toolContextStorage.run(toolContext, async () => {
|
||||
for await (const ev of session.loop.step(text)) {
|
||||
if (session.aborter?.signal.aborted) {
|
||||
stopReason = 'cancelled';
|
||||
@@ -481,6 +584,7 @@ onRequest('session/prompt', async (params) => {
|
||||
emitUsage(session.id, ev.stats.inputTokens || 0, ev.stats.cacheHitTokens || 0);
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
const message = err.message || String(err);
|
||||
const stack = err.stack || '';
|
||||
|
||||
@@ -1,9 +1,18 @@
|
||||
#!/usr/bin/env node
|
||||
const { spawn } = require("child_process");
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
process.env.CONVEX_TMPDIR = "/mnt/Data1T/mnote/.convex-tmp";
|
||||
|
||||
const adminKey = "mnote-local|01cedce68c51e168c6aacb282a90f7d233f56eddfabb07944a1d9dd9506f73ba888fc7daff";
|
||||
const repoRoot = "/mnt/Data1T/mnote";
|
||||
const rootConvexDir = path.join(repoRoot, "convex");
|
||||
const recycleConvexDir = path.join(repoRoot, "recycle", "wolai-frontend", "convex");
|
||||
const rootHasFullSchema = fs.existsSync(path.join(rootConvexDir, "pages.ts"));
|
||||
const deployCwd = rootHasFullSchema
|
||||
? repoRoot
|
||||
: path.dirname(recycleConvexDir);
|
||||
|
||||
const args = [
|
||||
"deploy",
|
||||
@@ -14,7 +23,7 @@ const args = [
|
||||
];
|
||||
|
||||
const child = spawn("npx", ["convex", ...args], {
|
||||
cwd: "/mnt/Data1T/mnote/wolai-frontend",
|
||||
cwd: deployCwd,
|
||||
stdio: "inherit",
|
||||
env: { ...process.env, CONVEX_TMPDIR: "/mnt/Data1T/mnote/.convex-tmp" },
|
||||
});
|
||||
|
||||
@@ -16,6 +16,15 @@ const {
|
||||
} = require("./tree-shell-smoke-helpers");
|
||||
|
||||
const OUT_DIR = path.join(process.cwd(), "tmp", "page-aggregate-refresh-persistence-smoke");
|
||||
const CHROME_EXECUTABLE = process.env.PLAYWRIGHT_CHROME_EXECUTABLE || "";
|
||||
|
||||
async function callMnoteTool(requestContext, payload) {
|
||||
return requestJson(requestContext, "/api/hermes/tools/mnote/call", {
|
||||
method: "POST",
|
||||
headers: { "x-mnote-actor-id": payload.actorId || "smoke-user" },
|
||||
data: payload,
|
||||
});
|
||||
}
|
||||
|
||||
async function fetchPageAggregate(requestContext, workspaceId, documentId) {
|
||||
const payload = await requestJson(
|
||||
@@ -27,6 +36,37 @@ async function fetchPageAggregate(requestContext, workspaceId, documentId) {
|
||||
return payload.result;
|
||||
}
|
||||
|
||||
function pageSubtreeSummary(aggregate) {
|
||||
const subtree = aggregate?.tree?.pageSubtree ?? null;
|
||||
return {
|
||||
rootNodeId: subtree?.rootNodeId ?? null,
|
||||
outlineLength: Array.isArray(subtree?.outline) ? subtree.outline.length : null,
|
||||
};
|
||||
}
|
||||
|
||||
async function fetchAiDoc(requestContext, target, actorId, suffix, label) {
|
||||
const response = await callMnoteTool(requestContext, {
|
||||
toolName: "mnote.doc.fetch",
|
||||
workspaceId: target.workspaceId,
|
||||
documentId: target.documentId,
|
||||
actorId,
|
||||
sessionId: `sess_page_aggregate_ai_fetch_${suffix}`,
|
||||
runId: `run_page_aggregate_ai_fetch_${suffix}_${label}`,
|
||||
toolCallId: `call_page_aggregate_ai_fetch_${suffix}_${label}`,
|
||||
traceId: `trace_page_aggregate_ai_fetch_${suffix}_${label}`,
|
||||
capabilityScope: ["page.read"],
|
||||
args: {
|
||||
scope: "full",
|
||||
detail: "with_ids",
|
||||
maxBlocks: 20,
|
||||
},
|
||||
});
|
||||
assert.equal(response.ok, true, `${label}: mnote.doc.fetch 应成功`);
|
||||
assert.equal(response.result.schema, "mnote.page_ai_context.v1", `${label}: AI fetch schema 应稳定`);
|
||||
assert(Array.isArray(response.result.blocks), `${label}: AI fetch 应返回 blocks`);
|
||||
return response.result;
|
||||
}
|
||||
|
||||
function findBlockWithText(aggregate, text) {
|
||||
const blocks = aggregate?.body?.blockDocument?.blocks;
|
||||
if (!Array.isArray(blocks)) return null;
|
||||
@@ -289,7 +329,11 @@ async function main() {
|
||||
const bodyText = `Page Aggregate refresh body ${suffix}`;
|
||||
const evidencePath = path.join(OUT_DIR, `${suffix}.json`);
|
||||
const screenshotPath = path.join(OUT_DIR, `${suffix}.png`);
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const browser = await chromium.launch({
|
||||
headless: true,
|
||||
...(CHROME_EXECUTABLE ? { executablePath: CHROME_EXECUTABLE } : {}),
|
||||
args: ["--no-sandbox", "--disable-dev-shm-usage"],
|
||||
});
|
||||
const context = await browser.newContext({ viewport: { width: 1440, height: 960 } });
|
||||
const page = await context.newPage();
|
||||
const createdIds = [];
|
||||
@@ -325,6 +369,20 @@ async function main() {
|
||||
title,
|
||||
bodyText,
|
||||
);
|
||||
const beforeReloadAiFetch = await fetchAiDoc(context.request, target, viewer.userId, suffix, "before_reload");
|
||||
const beforeReloadAiBlock = beforeReloadAiFetch.blocks.find((block) => block.blockId === beforeReloadBlock.blockId);
|
||||
assert.equal(beforeReloadAiFetch.revision, beforeReloadAggregate.body?.revision, "刷新前 AI fetch revision 应与 Page Aggregate 一致");
|
||||
assert.equal(
|
||||
beforeReloadAiFetch.conflictDetectionKey,
|
||||
beforeReloadAggregate.body?.conflictDetectionKey,
|
||||
"刷新前 AI fetch conflictDetectionKey 应与 Page Aggregate 一致",
|
||||
);
|
||||
assert.equal(beforeReloadAiBlock?.text, bodyText, "刷新前 AI fetch 应回读 Page Aggregate 正文块");
|
||||
assert.equal(
|
||||
beforeReloadAggregate.tree?.pageSubtree?.rootNodeId,
|
||||
target.documentId,
|
||||
"刷新前 Page Aggregate pageSubtree.rootNodeId 应等于 documentId",
|
||||
);
|
||||
|
||||
await page.reload({ waitUntil: "commit", timeout: UI_TIMEOUT_MS });
|
||||
await waitForPageTitleInput(page);
|
||||
@@ -354,6 +412,20 @@ async function main() {
|
||||
title,
|
||||
bodyText,
|
||||
);
|
||||
const afterReloadAiFetch = await fetchAiDoc(context.request, target, viewer.userId, suffix, "after_reload");
|
||||
const afterReloadAiBlock = afterReloadAiFetch.blocks.find((block) => block.blockId === afterReloadBlock.blockId);
|
||||
assert.equal(afterReloadAiFetch.revision, afterReloadAggregate.body?.revision, "刷新后 AI fetch revision 应与 Page Aggregate 一致");
|
||||
assert.equal(
|
||||
afterReloadAiFetch.conflictDetectionKey,
|
||||
afterReloadAggregate.body?.conflictDetectionKey,
|
||||
"刷新后 AI fetch conflictDetectionKey 应与 Page Aggregate 一致",
|
||||
);
|
||||
assert.equal(afterReloadAiBlock?.text, bodyText, "刷新后 AI fetch 应回读 Page Aggregate 正文块");
|
||||
assert.equal(
|
||||
afterReloadAggregate.tree?.pageSubtree?.rootNodeId,
|
||||
target.documentId,
|
||||
"刷新后 Page Aggregate pageSubtree.rootNodeId 应等于 documentId",
|
||||
);
|
||||
|
||||
await page.screenshot({ path: screenshotPath, fullPage: false });
|
||||
const evidence = {
|
||||
@@ -383,12 +455,23 @@ async function main() {
|
||||
conflictDetectionKey: beforeReloadAggregate.body?.conflictDetectionKey ?? null,
|
||||
pageOptions: beforeReloadAggregate.layout?.pageOptions ?? null,
|
||||
blockProjectionVersion: beforeReloadAggregate.body?.blockProjectionVersion ?? null,
|
||||
pageSubtree: pageSubtreeSummary(beforeReloadAggregate),
|
||||
},
|
||||
matchedBlock: {
|
||||
blockId: beforeReloadBlock.blockId,
|
||||
text: beforeReloadBlock.text,
|
||||
revisionRef: beforeReloadBlock.revisionRef,
|
||||
},
|
||||
aiFetch: {
|
||||
schema: beforeReloadAiFetch.schema,
|
||||
revision: beforeReloadAiFetch.revision,
|
||||
conflictDetectionKey: beforeReloadAiFetch.conflictDetectionKey,
|
||||
matchedBlock: {
|
||||
blockId: beforeReloadAiBlock?.blockId ?? null,
|
||||
text: beforeReloadAiBlock?.text ?? null,
|
||||
revisionRef: beforeReloadAiBlock?.revisionRef ?? null,
|
||||
},
|
||||
},
|
||||
runtime: beforeReloadRuntime,
|
||||
},
|
||||
afterReload: {
|
||||
@@ -398,12 +481,23 @@ async function main() {
|
||||
conflictDetectionKey: afterReloadAggregate.body?.conflictDetectionKey ?? null,
|
||||
pageOptions: afterReloadAggregate.layout?.pageOptions ?? null,
|
||||
blockProjectionVersion: afterReloadAggregate.body?.blockProjectionVersion ?? null,
|
||||
pageSubtree: pageSubtreeSummary(afterReloadAggregate),
|
||||
},
|
||||
matchedBlock: {
|
||||
blockId: afterReloadBlock.blockId,
|
||||
text: afterReloadBlock.text,
|
||||
revisionRef: afterReloadBlock.revisionRef,
|
||||
},
|
||||
aiFetch: {
|
||||
schema: afterReloadAiFetch.schema,
|
||||
revision: afterReloadAiFetch.revision,
|
||||
conflictDetectionKey: afterReloadAiFetch.conflictDetectionKey,
|
||||
matchedBlock: {
|
||||
blockId: afterReloadAiBlock?.blockId ?? null,
|
||||
text: afterReloadAiBlock?.text ?? null,
|
||||
revisionRef: afterReloadAiBlock?.revisionRef ?? null,
|
||||
},
|
||||
},
|
||||
title: afterReloadTitle,
|
||||
editorText: afterReloadEditorText,
|
||||
runtime: afterReloadRuntime,
|
||||
|
||||
@@ -16,6 +16,7 @@ const {
|
||||
} = require("./tree-shell-smoke-helpers");
|
||||
|
||||
const OUT_DIR = path.join(process.cwd(), "tmp", "page-block-ai-conflict-idempotency-smoke");
|
||||
const CHROME_EXECUTABLE = process.env.PLAYWRIGHT_CHROME_EXECUTABLE || "";
|
||||
|
||||
async function callMnoteTool(request, payload) {
|
||||
return requestJson(request, "/api/hermes/tools/mnote/call", {
|
||||
@@ -87,7 +88,11 @@ async function main() {
|
||||
};
|
||||
await fs.mkdir(OUT_DIR, { recursive: true });
|
||||
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const browser = await chromium.launch({
|
||||
headless: true,
|
||||
...(CHROME_EXECUTABLE ? { executablePath: CHROME_EXECUTABLE } : {}),
|
||||
args: ["--no-sandbox", "--disable-dev-shm-usage"],
|
||||
});
|
||||
const context = await browser.newContext({ viewport: { width: 1280, height: 900 } });
|
||||
const page = await context.newPage();
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ const {
|
||||
} = require("./tree-shell-smoke-helpers");
|
||||
|
||||
const OUT_DIR = path.join(process.cwd(), "tmp", "page-block-ai-tools-smoke");
|
||||
const CHROME_EXECUTABLE = process.env.PLAYWRIGHT_CHROME_EXECUTABLE || "";
|
||||
|
||||
function blockById(blocks, blockId) {
|
||||
return blocks.find((block) => block.blockId === blockId);
|
||||
@@ -26,6 +27,16 @@ function blockTexts(blocks) {
|
||||
return blocks.map((block) => block.text);
|
||||
}
|
||||
|
||||
function aggregateBlocks(aggregate) {
|
||||
const blocks = aggregate?.body?.blockDocument?.blocks;
|
||||
assert(Array.isArray(blocks), "Page Aggregate 必须返回 body.blockDocument.blocks");
|
||||
return blocks;
|
||||
}
|
||||
|
||||
function aggregateBlockById(aggregate, blockId) {
|
||||
return aggregateBlocks(aggregate).find((block) => block.blockId === blockId);
|
||||
}
|
||||
|
||||
async function waitForVisibleTexts(page, expectedTexts) {
|
||||
await page.waitForFunction(
|
||||
({ expected }) => {
|
||||
@@ -63,6 +74,16 @@ async function callMnoteTool(request, payload) {
|
||||
});
|
||||
}
|
||||
|
||||
async function fetchPageAggregate(request, workspaceId, documentId) {
|
||||
const payload = await requestJson(
|
||||
request,
|
||||
`/api/page-aggregate/${encodeURIComponent(documentId)}?workspaceId=${encodeURIComponent(workspaceId)}`,
|
||||
{ method: "GET" },
|
||||
);
|
||||
assert.equal(payload.schema, "mnote.page_aggregate.v1", "Page Aggregate schema 应保持稳定");
|
||||
return payload.result;
|
||||
}
|
||||
|
||||
async function fetchBlocks(request, target, suffix, label, actorId) {
|
||||
const response = await callMnoteTool(request, {
|
||||
toolName: "mnote.doc.fetch",
|
||||
@@ -96,8 +117,14 @@ async function main() {
|
||||
steps: [],
|
||||
};
|
||||
await fs.mkdir(OUT_DIR, { recursive: true });
|
||||
const hermesTesterDir = path.join(process.cwd(), "tmp", "hermes-tester", `page-block-ai-tools-${suffix}`);
|
||||
await fs.mkdir(hermesTesterDir, { recursive: true });
|
||||
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const browser = await chromium.launch({
|
||||
headless: true,
|
||||
...(CHROME_EXECUTABLE ? { executablePath: CHROME_EXECUTABLE } : {}),
|
||||
args: ["--no-sandbox", "--disable-dev-shm-usage"],
|
||||
});
|
||||
const context = await browser.newContext({ viewport: { width: 1280, height: 900 } });
|
||||
const page = await context.newPage();
|
||||
|
||||
@@ -111,9 +138,28 @@ async function main() {
|
||||
await renameDocument(context.request, target.workspaceId, target.documentId, title);
|
||||
|
||||
const initialContent = [
|
||||
{ id: "heading_1", type: "heading", props: { level: 1 }, content: [{ type: "text", text: `标题 ${suffix}` }] },
|
||||
{ id: "p_1", type: "paragraph", content: [{ type: "text", text: `第一段 ${suffix}` }] },
|
||||
{ id: "p_2", type: "paragraph", content: [{ type: "text", text: `第二段 ${suffix}` }] },
|
||||
{ id: "p_3", type: "paragraph", content: [{ type: "text", text: `第三段 ${suffix}` }] },
|
||||
{
|
||||
id: "heading_parent",
|
||||
type: "heading",
|
||||
props: { level: 2 },
|
||||
content: [{ type: "text", text: `带子块标题 ${suffix}` }],
|
||||
children: [
|
||||
{ id: "heading_child", type: "paragraph", content: [{ type: "text", text: `标题子段落 ${suffix}` }] },
|
||||
],
|
||||
},
|
||||
{ id: "list_item_1", type: "bullet_list_item", content: [{ type: "text", text: `列表项 ${suffix}` }] },
|
||||
{ id: "table_1", type: "table", content: [{ type: "text", text: `表格块 ${suffix}` }] },
|
||||
{
|
||||
id: "mindmap_1",
|
||||
type: "mindmap",
|
||||
props: { mindmapId: `mindmap_smoke_${suffix}` },
|
||||
content: [{ type: "text", text: `思维导图块 ${suffix}` }],
|
||||
},
|
||||
{ id: "resource_1", type: "resource", content: [{ type: "text", text: `资源块 ${suffix}` }] },
|
||||
];
|
||||
const seed = await callMnoteTool(context.request, {
|
||||
toolName: "mnote.page.save",
|
||||
@@ -136,16 +182,139 @@ async function main() {
|
||||
evidence.steps.push({ name: "seed", commandName: seed.result.commandName });
|
||||
|
||||
let snapshot = await fetchBlocks(context.request, target, suffix, "initial", actorId);
|
||||
const initialAggregate = await fetchPageAggregate(context.request, target.workspaceId, target.documentId);
|
||||
await fs.writeFile(
|
||||
path.join(hermesTesterDir, "page-aggregate.json"),
|
||||
JSON.stringify(initialAggregate, null, 2),
|
||||
"utf8",
|
||||
);
|
||||
const p1 = blockById(snapshot.blocks, "p_1");
|
||||
const p2 = blockById(snapshot.blocks, "p_2");
|
||||
const p3 = blockById(snapshot.blocks, "p_3");
|
||||
assert(p1 && p2 && p3, "初始化后应能读取 p_1/p_2/p_3");
|
||||
const heading = blockById(snapshot.blocks, "heading_1");
|
||||
assert(heading && p1 && p2 && p3, "初始化后应能读取 heading_1/p_1/p_2/p_3");
|
||||
assert(p1.revisionRef && p2.revisionRef && p3.revisionRef, "块投影必须返回 revisionRef");
|
||||
const initialAggregateBlocks = aggregateBlocks(initialAggregate);
|
||||
assert.deepEqual(
|
||||
initialAggregateBlocks.map((block) => block.blockId),
|
||||
snapshot.blocks.map((block) => block.blockId),
|
||||
"Page Aggregate blockDocument 与 mnote.doc.fetch 块顺序必须一致",
|
||||
);
|
||||
assert(
|
||||
["heading_1", "p_1", "p_2", "p_3", "list_item_1"].every((blockId) => {
|
||||
const block = aggregateBlockById(initialAggregate, blockId);
|
||||
return block?.editable === true && Boolean(block.revisionRef);
|
||||
}),
|
||||
"普通可编辑块必须在 Page Aggregate 中带 blockId/revisionRef",
|
||||
);
|
||||
const complexBlocks = ["heading_parent", "list_item_1", "table_1", "mindmap_1", "resource_1"]
|
||||
.map((blockId) => blockById(snapshot.blocks, blockId));
|
||||
assert(complexBlocks.every(Boolean), "初始化后应能读取复杂块投影");
|
||||
const unsupportedBlocks = ["table_1", "mindmap_1", "resource_1"].map((blockId) => blockById(snapshot.blocks, blockId));
|
||||
assert(
|
||||
unsupportedBlocks.every((block) => block.editable === false && block.unsupportedReason),
|
||||
"table/mindmap/resource 不能在 AI 投影中伪装成完全可编辑块",
|
||||
);
|
||||
assert.deepEqual(
|
||||
blockById(snapshot.blocks, "heading_parent").children,
|
||||
["heading_child"],
|
||||
"带子块标题必须在投影中保留 children,用于阻断 move_after",
|
||||
);
|
||||
evidence.steps.push({
|
||||
name: "doc.fetch.initial",
|
||||
revision: snapshot.revision,
|
||||
conflictDetectionKey: snapshot.conflictDetectionKey,
|
||||
blockIds: snapshot.blocks.map((block) => block.blockId),
|
||||
aggregatePath: `/api/page-aggregate/${target.documentId}`,
|
||||
aggregateEvidencePath: path.join(hermesTesterDir, "page-aggregate.json"),
|
||||
aggregateBlockProjectionVersion: initialAggregate.body.blockProjectionVersion,
|
||||
aggregateProjectionSource: initialAggregate.body.projectionSource,
|
||||
aggregateBlockCount: initialAggregateBlocks.length,
|
||||
unsupportedBlocks: unsupportedBlocks.map((block) => ({
|
||||
blockId: block.blockId,
|
||||
type: block.type,
|
||||
editable: block.editable,
|
||||
unsupportedReason: block.unsupportedReason,
|
||||
})),
|
||||
});
|
||||
|
||||
const outline = await callMnoteTool(context.request, {
|
||||
toolName: "mnote.doc.fetch",
|
||||
workspaceId: target.workspaceId,
|
||||
documentId: target.documentId,
|
||||
actorId,
|
||||
sessionId: `sess_outline_${suffix}`,
|
||||
runId: `run_outline_${suffix}`,
|
||||
toolCallId: `call_outline_${suffix}`,
|
||||
traceId: `trace_outline_${suffix}`,
|
||||
capabilityScope: ["page.read"],
|
||||
args: {
|
||||
scope: "outline",
|
||||
detail: "with_ids",
|
||||
},
|
||||
});
|
||||
assert.equal(outline.ok, true, "doc.fetch outline 应成功");
|
||||
assert.deepEqual(
|
||||
outline.result.blocks.map((block) => block.blockId),
|
||||
["heading_1", "heading_parent"],
|
||||
"outline 应只返回标题块",
|
||||
);
|
||||
|
||||
const truncated = await callMnoteTool(context.request, {
|
||||
toolName: "mnote.doc.fetch",
|
||||
workspaceId: target.workspaceId,
|
||||
documentId: target.documentId,
|
||||
actorId,
|
||||
sessionId: `sess_truncated_${suffix}`,
|
||||
runId: `run_truncated_${suffix}`,
|
||||
toolCallId: `call_truncated_${suffix}`,
|
||||
traceId: `trace_truncated_${suffix}`,
|
||||
capabilityScope: ["page.read"],
|
||||
args: {
|
||||
scope: "full",
|
||||
detail: "with_ids",
|
||||
maxBlocks: 2,
|
||||
},
|
||||
});
|
||||
assert.equal(truncated.result.truncated, true, "maxBlocks 裁剪应返回 truncated=true");
|
||||
assert.equal(truncated.result.blocks.length, 2, "maxBlocks=2 应只返回两个块");
|
||||
assert(truncated.result.continuation, "truncated 结果必须返回 continuation");
|
||||
|
||||
const findP2 = await callMnoteTool(context.request, {
|
||||
toolName: "mnote.doc.find",
|
||||
workspaceId: target.workspaceId,
|
||||
documentId: target.documentId,
|
||||
actorId,
|
||||
sessionId: `sess_find_${suffix}`,
|
||||
runId: `run_find_${suffix}`,
|
||||
toolCallId: `call_find_${suffix}`,
|
||||
traceId: `trace_find_${suffix}`,
|
||||
capabilityScope: ["page.read"],
|
||||
args: {
|
||||
query: `第二段 ${suffix}`,
|
||||
},
|
||||
});
|
||||
assert.equal(findP2.result.matches[0].blockId, "p_2", "doc.find 应定位第二段");
|
||||
await fs.writeFile(
|
||||
path.join(hermesTesterDir, "doc-fetch-find.json"),
|
||||
JSON.stringify(
|
||||
{
|
||||
full: snapshot,
|
||||
outline: outline.result,
|
||||
truncated: truncated.result,
|
||||
find: findP2.result,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
"utf8",
|
||||
);
|
||||
evidence.steps.push({
|
||||
name: "doc.fetch.find",
|
||||
outlineBlockIds: outline.result.blocks.map((block) => block.blockId),
|
||||
truncated: truncated.result.truncated,
|
||||
findBlockId: findP2.result.matches[0].blockId,
|
||||
evidencePath: path.join(hermesTesterDir, "doc-fetch-find.json"),
|
||||
});
|
||||
|
||||
const blockFetch = await callMnoteTool(context.request, {
|
||||
@@ -159,15 +328,180 @@ async function main() {
|
||||
traceId: `trace_block_${suffix}`,
|
||||
capabilityScope: ["block.read"],
|
||||
args: {
|
||||
blockId: "p_2",
|
||||
blockId: findP2.result.matches[0].blockId,
|
||||
includeChildren: true,
|
||||
contextBefore: 1,
|
||||
contextAfter: 1,
|
||||
},
|
||||
});
|
||||
assert.equal(blockFetch.result.block.blockId, "p_2", "block.fetch 应读取目标块");
|
||||
assert(blockFetch.result.block.revisionRef, "block.fetch 应返回 revisionRef");
|
||||
assert.equal(blockFetch.result.context.before[0].blockId, "p_1", "block.fetch before 应来自同父级");
|
||||
assert.equal(blockFetch.result.context.after[0].blockId, "p_3", "block.fetch after 应来自同父级");
|
||||
evidence.steps.push({ name: "block.fetch", blockId: "p_2" });
|
||||
evidence.steps.push({
|
||||
name: "block.fetch",
|
||||
blockId: "p_2",
|
||||
revisionRef: blockFetch.result.block.revisionRef,
|
||||
before: blockFetch.result.context.before.map((block) => block.blockId),
|
||||
after: blockFetch.result.context.after.map((block) => block.blockId),
|
||||
});
|
||||
|
||||
const complexMoveBlocked = [];
|
||||
for (const blockId of ["heading_parent", "list_item_1", "table_1", "mindmap_1", "resource_1"]) {
|
||||
const complexBlock = blockById(snapshot.blocks, blockId);
|
||||
const plan = await callMnoteTool(context.request, {
|
||||
toolName: "mnote.doc.plan_update",
|
||||
workspaceId: target.workspaceId,
|
||||
documentId: target.documentId,
|
||||
actorId,
|
||||
sessionId: `sess_plan_complex_${suffix}`,
|
||||
runId: `run_plan_complex_${suffix}_${blockId}`,
|
||||
toolCallId: `call_plan_complex_${suffix}_${blockId}`,
|
||||
traceId: `trace_plan_complex_${suffix}_${blockId}`,
|
||||
idempotencyKey: `idem_plan_complex_${suffix}_${blockId}`,
|
||||
dryRun: true,
|
||||
capabilityScope: ["block.write"],
|
||||
args: {
|
||||
command: "block_move_after",
|
||||
blockId,
|
||||
anchorBlockId: "p_1",
|
||||
},
|
||||
});
|
||||
assert.equal(plan.result.blocked, true, `${blockId} move_after dry-run 必须阻断`);
|
||||
const blockedMove = await callMnoteTool(context.request, {
|
||||
toolName: "mnote.block.move_after",
|
||||
workspaceId: target.workspaceId,
|
||||
documentId: target.documentId,
|
||||
actorId,
|
||||
sessionId: `sess_move_complex_${suffix}`,
|
||||
runId: `run_move_complex_${suffix}_${blockId}`,
|
||||
toolCallId: `call_move_complex_${suffix}_${blockId}`,
|
||||
traceId: `trace_move_complex_${suffix}_${blockId}`,
|
||||
idempotencyKey: `idem_move_complex_${suffix}_${blockId}`,
|
||||
dryRun: false,
|
||||
capabilityScope: ["block.write"],
|
||||
args: {
|
||||
blockId,
|
||||
anchorBlockId: "p_1",
|
||||
revision: snapshot.revision,
|
||||
conflictDetectionKey: snapshot.conflictDetectionKey,
|
||||
blockRevisionRef: complexBlock.revisionRef,
|
||||
anchorRevisionRef: p1.revisionRef,
|
||||
},
|
||||
});
|
||||
assert.equal(blockedMove.result.blocked, true, `${blockId} 正式 move_after 必须返回 blocked`);
|
||||
assert.equal(
|
||||
blockedMove.result.warnings[0].code,
|
||||
"block_move_after_blocked",
|
||||
`${blockId} 正式 move_after 必须返回阻断 warning`,
|
||||
);
|
||||
complexMoveBlocked.push({
|
||||
blockId,
|
||||
type: complexBlock.type,
|
||||
editable: complexBlock.editable,
|
||||
dryRunBlocked: plan.result.blocked,
|
||||
writeBlocked: blockedMove.result.blocked,
|
||||
warningCode: blockedMove.result.warnings[0].code,
|
||||
});
|
||||
}
|
||||
const afterComplexBlocked = await fetchBlocks(context.request, target, suffix, "after_complex_blocked", actorId);
|
||||
assert.equal(afterComplexBlocked.revision, snapshot.revision, "复杂块 move_after 阻断后 revision 不应变化");
|
||||
assert.deepEqual(blockTexts(afterComplexBlocked.blocks), blockTexts(snapshot.blocks), "复杂块 move_after 阻断后正文不应变化");
|
||||
evidence.steps.push({
|
||||
name: "block.move_after.complex_blocked",
|
||||
blocked: complexMoveBlocked,
|
||||
revisionAfterBlocked: afterComplexBlocked.revision,
|
||||
});
|
||||
|
||||
const dryRunBaselineTexts = blockTexts(snapshot.blocks);
|
||||
const dryRunBaselineAggregate = await fetchPageAggregate(
|
||||
context.request,
|
||||
target.workspaceId,
|
||||
target.documentId,
|
||||
);
|
||||
const replacePlan = await callMnoteTool(context.request, {
|
||||
toolName: "mnote.doc.plan_update",
|
||||
workspaceId: target.workspaceId,
|
||||
documentId: target.documentId,
|
||||
actorId,
|
||||
sessionId: `sess_plan_replace_${suffix}`,
|
||||
runId: `run_plan_replace_${suffix}`,
|
||||
toolCallId: `call_plan_replace_${suffix}`,
|
||||
traceId: `trace_plan_replace_${suffix}`,
|
||||
idempotencyKey: `idem_plan_replace_${suffix}`,
|
||||
dryRun: true,
|
||||
capabilityScope: ["block.write"],
|
||||
args: {
|
||||
command: "block_replace",
|
||||
blockId: "p_2",
|
||||
content: `dry-run 替换 ${suffix}`,
|
||||
},
|
||||
});
|
||||
assert.equal(replacePlan.result.dryRun, true, "block_replace plan 必须是 dry-run");
|
||||
assert.equal(replacePlan.result.diff[0].before, `第二段 ${suffix}`, "replace plan 应解释 before");
|
||||
assert.equal(replacePlan.result.diff[0].after, `dry-run 替换 ${suffix}`, "replace plan 应解释 after");
|
||||
|
||||
const insertPlan = await callMnoteTool(context.request, {
|
||||
toolName: "mnote.doc.plan_update",
|
||||
workspaceId: target.workspaceId,
|
||||
documentId: target.documentId,
|
||||
actorId,
|
||||
sessionId: `sess_plan_insert_${suffix}`,
|
||||
runId: `run_plan_insert_${suffix}`,
|
||||
toolCallId: `call_plan_insert_${suffix}`,
|
||||
traceId: `trace_plan_insert_${suffix}`,
|
||||
idempotencyKey: `idem_plan_insert_${suffix}`,
|
||||
dryRun: true,
|
||||
capabilityScope: ["block.write"],
|
||||
args: {
|
||||
command: "block_insert_after",
|
||||
anchorBlockId: "p_1",
|
||||
content: `dry-run 插入 ${suffix}`,
|
||||
},
|
||||
});
|
||||
assert.equal(insertPlan.result.dryRun, true, "block_insert_after plan 必须是 dry-run");
|
||||
assert.equal(insertPlan.result.diff[0].after, `第一段 ${suffix}`, "insert plan 应解释 anchor after");
|
||||
|
||||
const blockedPlan = await callMnoteTool(context.request, {
|
||||
toolName: "mnote.doc.plan_update",
|
||||
workspaceId: target.workspaceId,
|
||||
documentId: target.documentId,
|
||||
actorId,
|
||||
sessionId: `sess_plan_blocked_${suffix}`,
|
||||
runId: `run_plan_blocked_${suffix}`,
|
||||
toolCallId: `call_plan_blocked_${suffix}`,
|
||||
traceId: `trace_plan_blocked_${suffix}`,
|
||||
idempotencyKey: `idem_plan_blocked_${suffix}`,
|
||||
dryRun: true,
|
||||
capabilityScope: ["block.write"],
|
||||
args: {
|
||||
command: "block_move_after",
|
||||
blockId: "p_3",
|
||||
anchorBlockId: "p_3",
|
||||
},
|
||||
});
|
||||
assert.equal(blockedPlan.result.blocked, true, "不支持 move 场景应返回 blocked=true");
|
||||
|
||||
const dryRunAfterAggregate = await fetchPageAggregate(
|
||||
context.request,
|
||||
target.workspaceId,
|
||||
target.documentId,
|
||||
);
|
||||
const dryRunAfter = await fetchBlocks(context.request, target, suffix, "after_dry_run", actorId);
|
||||
assert.equal(dryRunAfter.revision, snapshot.revision, "dry-run 后 revision 不应变化");
|
||||
assert.deepEqual(blockTexts(dryRunAfter.blocks), dryRunBaselineTexts, "dry-run 后正文不应变化");
|
||||
assert.equal(
|
||||
dryRunAfterAggregate.body.conflictDetectionKey,
|
||||
dryRunBaselineAggregate.body.conflictDetectionKey,
|
||||
"dry-run 后 Page Aggregate conflictDetectionKey 不应变化",
|
||||
);
|
||||
evidence.steps.push({
|
||||
name: "plan_update.dry_run",
|
||||
replaceDiff: replacePlan.result.diff[0],
|
||||
insertDiff: insertPlan.result.diff[0],
|
||||
blocked: blockedPlan.result.blocked,
|
||||
revisionAfterDryRun: dryRunAfter.revision,
|
||||
});
|
||||
|
||||
const replacedText = `第二段已替换 ${suffix}`;
|
||||
const replace = await callMnoteTool(context.request, {
|
||||
@@ -192,8 +526,25 @@ async function main() {
|
||||
});
|
||||
assert.equal(replace.result.commandName, "page.body.save", "block.replace 必须走 page.body.save");
|
||||
snapshot = await fetchBlocks(context.request, target, suffix, "after_replace", actorId);
|
||||
const afterReplaceAggregate = await fetchPageAggregate(context.request, target.workspaceId, target.documentId);
|
||||
assert.equal(blockById(snapshot.blocks, "p_2").text, replacedText, "替换后 doc.fetch 应回读新文本");
|
||||
evidence.steps.push({ name: "block.replace", changedBlocks: replace.result.changedBlocks });
|
||||
assert.equal(
|
||||
aggregateBlockById(afterReplaceAggregate, "p_2").text,
|
||||
replacedText,
|
||||
"替换后 Page Aggregate 必须回读新文本",
|
||||
);
|
||||
assert.equal(blockById(snapshot.blocks, "p_1").text, `第一段 ${suffix}`, "替换后相邻 p_1 不应变化");
|
||||
assert.equal(blockById(snapshot.blocks, "p_3").text, `第三段 ${suffix}`, "替换后相邻 p_3 不应变化");
|
||||
assert(blockById(snapshot.blocks, "p_2"), "替换后目标 block id 必须保持 p_2");
|
||||
evidence.steps.push({
|
||||
name: "block.replace",
|
||||
changedBlocks: replace.result.changedBlocks,
|
||||
targetBlockStillExists: Boolean(blockById(snapshot.blocks, "p_2")),
|
||||
aggregateRevision: afterReplaceAggregate.body.revision,
|
||||
aggregateConflictDetectionKey: afterReplaceAggregate.body.conflictDetectionKey,
|
||||
aggregateText: aggregateBlockById(afterReplaceAggregate, "p_2").text,
|
||||
adjacentTexts: [blockById(snapshot.blocks, "p_1").text, blockById(snapshot.blocks, "p_3").text],
|
||||
});
|
||||
|
||||
const insertedText = `插入段 ${suffix}`;
|
||||
const anchorAfterReplace = blockById(snapshot.blocks, "p_1");
|
||||
@@ -211,7 +562,11 @@ async function main() {
|
||||
capabilityScope: ["block.write"],
|
||||
args: {
|
||||
anchorBlockId: "p_1",
|
||||
content: insertedText,
|
||||
content: {
|
||||
type: "todo",
|
||||
props: { checked: false },
|
||||
content: [{ type: "text", text: insertedText }],
|
||||
},
|
||||
revision: snapshot.revision,
|
||||
conflictDetectionKey: snapshot.conflictDetectionKey,
|
||||
anchorRevisionRef: anchorAfterReplace.revisionRef,
|
||||
@@ -221,7 +576,73 @@ async function main() {
|
||||
assert(insertedBlockId.startsWith("ai_block_"), "插入块 id 必须由 Rust/mnote 侧生成");
|
||||
snapshot = await fetchBlocks(context.request, target, suffix, "after_insert", actorId);
|
||||
assert.equal(blockById(snapshot.blocks, insertedBlockId).text, insertedText, "插入后 doc.fetch 应回读新块");
|
||||
evidence.steps.push({ name: "block.insert_after", insertedBlockId });
|
||||
const orderAfterInsert = snapshot.blocks.map((block) => block.blockId);
|
||||
assert.equal(
|
||||
orderAfterInsert.indexOf(insertedBlockId),
|
||||
orderAfterInsert.indexOf("p_1") + 1,
|
||||
"插入块必须紧跟 p_1",
|
||||
);
|
||||
const insertedBlockFetch = await callMnoteTool(context.request, {
|
||||
toolName: "mnote.block.fetch",
|
||||
workspaceId: target.workspaceId,
|
||||
documentId: target.documentId,
|
||||
actorId,
|
||||
sessionId: `sess_insert_fetch_${suffix}`,
|
||||
runId: `run_insert_fetch_${suffix}`,
|
||||
toolCallId: `call_insert_fetch_${suffix}`,
|
||||
traceId: `trace_insert_fetch_${suffix}`,
|
||||
capabilityScope: ["block.read"],
|
||||
args: {
|
||||
blockId: insertedBlockId,
|
||||
contextBefore: 1,
|
||||
contextAfter: 1,
|
||||
},
|
||||
});
|
||||
assert.equal(insertedBlockFetch.result.block.blockId, insertedBlockId, "block.fetch 应能读取新插入块");
|
||||
assert.equal(insertedBlockFetch.result.block.type, "todo", "insert_after 应能插入 todo 块");
|
||||
const replayInsertText = `不应重复插入 ${suffix}`;
|
||||
const insertReplay = await callMnoteTool(context.request, {
|
||||
toolName: "mnote.block.insert_after",
|
||||
workspaceId: target.workspaceId,
|
||||
documentId: target.documentId,
|
||||
actorId,
|
||||
sessionId: `sess_insert_replay_${suffix}`,
|
||||
runId: `run_insert_replay_${suffix}`,
|
||||
toolCallId: `call_insert_replay_${suffix}`,
|
||||
traceId: `trace_insert_replay_${suffix}`,
|
||||
idempotencyKey: `idem_insert_${suffix}`,
|
||||
dryRun: false,
|
||||
capabilityScope: ["block.write"],
|
||||
args: {
|
||||
anchorBlockId: "p_1",
|
||||
content: replayInsertText,
|
||||
revision: snapshot.revision,
|
||||
conflictDetectionKey: snapshot.conflictDetectionKey,
|
||||
anchorRevisionRef: blockById(snapshot.blocks, "p_1").revisionRef,
|
||||
},
|
||||
});
|
||||
assert.equal(
|
||||
insertReplay.audit.commandId,
|
||||
insert.audit.commandId,
|
||||
"重复 insert_after idempotencyKey 应返回缓存 commandId",
|
||||
);
|
||||
const afterInsertReplay = await fetchBlocks(context.request, target, suffix, "after_insert_replay", actorId);
|
||||
assert.equal(
|
||||
afterInsertReplay.blocks.filter((block) => block.blockId === insertedBlockId).length,
|
||||
1,
|
||||
"重复 insert_after idempotencyKey 不应产生第二个相同插入块",
|
||||
);
|
||||
assert(!afterInsertReplay.blocks.some((block) => block.text === replayInsertText), "重复 insert_after 不应写入新内容");
|
||||
assert.equal(afterInsertReplay.revision, snapshot.revision, "重复 insert_after 后 revision 不应再次递增");
|
||||
snapshot = afterInsertReplay;
|
||||
evidence.steps.push({
|
||||
name: "block.insert_after",
|
||||
insertedBlockId,
|
||||
insertedBlockType: insertedBlockFetch.result.block.type,
|
||||
orderAfterInsert,
|
||||
replayCommandId: insertReplay.audit.commandId,
|
||||
revisionAfterReplay: snapshot.revision,
|
||||
});
|
||||
|
||||
const moveBlock = blockById(snapshot.blocks, "p_3");
|
||||
const moveAnchor = blockById(snapshot.blocks, "p_1");
|
||||
@@ -269,14 +690,31 @@ async function main() {
|
||||
snapshot = await fetchBlocks(context.request, target, suffix, "after_move", actorId);
|
||||
const order = snapshot.blocks.map((block) => block.blockId);
|
||||
assert(order.indexOf("p_3") === order.indexOf("p_1") + 1, "移动后 p_3 必须紧跟 p_1");
|
||||
evidence.steps.push({ name: "block.move_after", order, texts: blockTexts(snapshot.blocks) });
|
||||
assert(blockById(snapshot.blocks, "p_3"), "移动后 moving block id 必须保持 p_3");
|
||||
evidence.steps.push({
|
||||
name: "block.move_after",
|
||||
dryRunBlocked: moveDryRun.result.blocked,
|
||||
dryRunDiff: moveDryRun.result.diff[0],
|
||||
order,
|
||||
texts: blockTexts(snapshot.blocks),
|
||||
movingBlockStillExists: Boolean(blockById(snapshot.blocks, "p_3")),
|
||||
});
|
||||
|
||||
await openDocument(page, target.workspaceId, target.documentId);
|
||||
await waitForVisibleTexts(page, [replacedText, insertedText, `第三段 ${suffix}`]);
|
||||
await page.reload({ waitUntil: "domcontentloaded" });
|
||||
await waitForVisibleTexts(page, [replacedText, insertedText, `第三段 ${suffix}`]);
|
||||
const afterRefreshAggregate = await fetchPageAggregate(context.request, target.workspaceId, target.documentId);
|
||||
assert.deepEqual(
|
||||
aggregateBlocks(afterRefreshAggregate).map((block) => block.blockId),
|
||||
snapshot.blocks.map((block) => block.blockId),
|
||||
"页面刷新后 Page Aggregate block ids 必须保持稳定",
|
||||
);
|
||||
const screenshotPath = path.join(OUT_DIR, `${suffix}-page.png`);
|
||||
await page.screenshot({ path: screenshotPath, fullPage: true });
|
||||
evidence.screenshot = screenshotPath;
|
||||
evidence.visibleTextCheck = [replacedText, insertedText, `第三段 ${suffix}`];
|
||||
evidence.afterRefreshBlockIds = aggregateBlocks(afterRefreshAggregate).map((block) => block.blockId);
|
||||
evidence.finalTexts = blockTexts(snapshot.blocks);
|
||||
evidence.ok = true;
|
||||
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.*
|
||||
.yarn/*
|
||||
!.yarn/patches
|
||||
!.yarn/plugins
|
||||
!.yarn/releases
|
||||
!.yarn/versions
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/out/
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# env files (can opt-in for committing if needed)
|
||||
.env*
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
|
||||
#test
|
||||
**/test/
|
||||
pw-tests/**
|
||||
pw-tests/
|
||||
wolai-frontend/public/documents/**
|
||||
@@ -1,22 +0,0 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "new-york",
|
||||
"rsc": true,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "",
|
||||
"css": "src/app/globals.css",
|
||||
"baseColor": "neutral",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"iconLibrary": "lucide",
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
},
|
||||
"registries": {}
|
||||
}
|
||||
-125
@@ -1,125 +0,0 @@
|
||||
/* eslint-disable */
|
||||
/**
|
||||
* Generated `api` utility.
|
||||
*
|
||||
* THIS CODE IS AUTOMATICALLY GENERATED.
|
||||
*
|
||||
* To regenerate, run `npx convex dev`.
|
||||
* @module
|
||||
*/
|
||||
|
||||
import type * as _utils_attachmentExtract from "../_utils/attachmentExtract.js";
|
||||
import type * as _utils_auth from "../_utils/auth.js";
|
||||
import type * as _utils_documentMoveOrder from "../_utils/documentMoveOrder.js";
|
||||
import type * as _utils_documentRecord from "../_utils/documentRecord.js";
|
||||
import type * as _utils_documentTree from "../_utils/documentTree.js";
|
||||
import type * as _utils_documentVisibility from "../_utils/documentVisibility.js";
|
||||
import type * as _utils_id from "../_utils/id.js";
|
||||
import type * as _utils_ingestJobs from "../_utils/ingestJobs.js";
|
||||
import type * as _utils_lightrag from "../_utils/lightrag.js";
|
||||
import type * as _utils_text from "../_utils/text.js";
|
||||
import type * as _utils_time from "../_utils/time.js";
|
||||
import type * as agentActions from "../agentActions.js";
|
||||
import type * as audit from "../audit.js";
|
||||
import type * as auth from "../auth.js";
|
||||
import type * as blocks from "../blocks.js";
|
||||
import type * as bridgeLogs from "../bridgeLogs.js";
|
||||
import type * as comments from "../comments.js";
|
||||
import type * as crons from "../crons.js";
|
||||
import type * as documentGroupShares from "../documentGroupShares.js";
|
||||
import type * as documentShares from "../documentShares.js";
|
||||
import type * as documentStars from "../documentStars.js";
|
||||
import type * as documents from "../documents.js";
|
||||
import type * as groupInvitations from "../groupInvitations.js";
|
||||
import type * as groupMembers from "../groupMembers.js";
|
||||
import type * as groups from "../groups.js";
|
||||
import type * as http from "../http.js";
|
||||
import type * as jobs from "../jobs.js";
|
||||
import type * as maintenance from "../maintenance.js";
|
||||
import type * as mediaAssets from "../mediaAssets.js";
|
||||
import type * as mindmaps from "../mindmaps.js";
|
||||
import type * as mnoteNextBridge from "../mnoteNextBridge.js";
|
||||
import type * as pages from "../pages.js";
|
||||
import type * as ping from "../ping.js";
|
||||
import type * as recents from "../recents.js";
|
||||
import type * as references from "../references.js";
|
||||
import type * as sidebar from "../sidebar.js";
|
||||
import type * as tables from "../tables.js";
|
||||
import type * as users from "../users.js";
|
||||
import type * as workspaces from "../workspaces.js";
|
||||
|
||||
import type {
|
||||
ApiFromModules,
|
||||
FilterApi,
|
||||
FunctionReference,
|
||||
} from "convex/server";
|
||||
|
||||
declare const fullApi: ApiFromModules<{
|
||||
"_utils/attachmentExtract": typeof _utils_attachmentExtract;
|
||||
"_utils/auth": typeof _utils_auth;
|
||||
"_utils/documentMoveOrder": typeof _utils_documentMoveOrder;
|
||||
"_utils/documentRecord": typeof _utils_documentRecord;
|
||||
"_utils/documentTree": typeof _utils_documentTree;
|
||||
"_utils/documentVisibility": typeof _utils_documentVisibility;
|
||||
"_utils/id": typeof _utils_id;
|
||||
"_utils/ingestJobs": typeof _utils_ingestJobs;
|
||||
"_utils/lightrag": typeof _utils_lightrag;
|
||||
"_utils/text": typeof _utils_text;
|
||||
"_utils/time": typeof _utils_time;
|
||||
agentActions: typeof agentActions;
|
||||
audit: typeof audit;
|
||||
auth: typeof auth;
|
||||
blocks: typeof blocks;
|
||||
bridgeLogs: typeof bridgeLogs;
|
||||
comments: typeof comments;
|
||||
crons: typeof crons;
|
||||
documentGroupShares: typeof documentGroupShares;
|
||||
documentShares: typeof documentShares;
|
||||
documentStars: typeof documentStars;
|
||||
documents: typeof documents;
|
||||
groupInvitations: typeof groupInvitations;
|
||||
groupMembers: typeof groupMembers;
|
||||
groups: typeof groups;
|
||||
http: typeof http;
|
||||
jobs: typeof jobs;
|
||||
maintenance: typeof maintenance;
|
||||
mediaAssets: typeof mediaAssets;
|
||||
mindmaps: typeof mindmaps;
|
||||
mnoteNextBridge: typeof mnoteNextBridge;
|
||||
pages: typeof pages;
|
||||
ping: typeof ping;
|
||||
recents: typeof recents;
|
||||
references: typeof references;
|
||||
sidebar: typeof sidebar;
|
||||
tables: typeof tables;
|
||||
users: typeof users;
|
||||
workspaces: typeof workspaces;
|
||||
}>;
|
||||
|
||||
/**
|
||||
* A utility for referencing Convex functions in your app's public API.
|
||||
*
|
||||
* Usage:
|
||||
* ```js
|
||||
* const myFunctionReference = api.myModule.myFunction;
|
||||
* ```
|
||||
*/
|
||||
export declare const api: FilterApi<
|
||||
typeof fullApi,
|
||||
FunctionReference<any, "public">
|
||||
>;
|
||||
|
||||
/**
|
||||
* A utility for referencing Convex functions in your app's internal API.
|
||||
*
|
||||
* Usage:
|
||||
* ```js
|
||||
* const myFunctionReference = internal.myModule.myFunction;
|
||||
* ```
|
||||
*/
|
||||
export declare const internal: FilterApi<
|
||||
typeof fullApi,
|
||||
FunctionReference<any, "internal">
|
||||
>;
|
||||
|
||||
export declare const components: {};
|
||||
@@ -1,250 +0,0 @@
|
||||
import JSZip from "jszip";
|
||||
|
||||
const MAX_TEXT_CHARS = 120_000;
|
||||
|
||||
function clampText(input: string, maxChars = MAX_TEXT_CHARS): string {
|
||||
const trimmed = String(input || "").replace(/\s+\n/g, "\n").trim();
|
||||
if (!trimmed) return "";
|
||||
if (trimmed.length <= maxChars) return trimmed;
|
||||
return `${trimmed.slice(0, maxChars)}…`;
|
||||
}
|
||||
|
||||
function decodeXmlEntities(input: string): string {
|
||||
return input
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/&/g, "&")
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'")
|
||||
.replace(/&#x([0-9a-fA-F]+);/g, (_, hex) => {
|
||||
const code = Number.parseInt(hex, 16);
|
||||
if (!Number.isFinite(code)) return "";
|
||||
return String.fromCodePoint(code);
|
||||
})
|
||||
.replace(/&#(\d+);/g, (_, dec) => {
|
||||
const code = Number.parseInt(dec, 10);
|
||||
if (!Number.isFinite(code)) return "";
|
||||
return String.fromCodePoint(code);
|
||||
});
|
||||
}
|
||||
|
||||
function getExtension(fileName: string | null | undefined): string {
|
||||
const raw = String(fileName ?? "").trim().toLowerCase();
|
||||
const idx = raw.lastIndexOf(".");
|
||||
if (idx === -1) return "";
|
||||
return raw.slice(idx + 1).replace(/[^a-z0-9]+/g, "");
|
||||
}
|
||||
|
||||
function detectKind(args: { mimeType?: string | null; fileName?: string | null }): "pdf" | "docx" | "pptx" | "xlsx" | null {
|
||||
const mime = String(args.mimeType ?? "").toLowerCase().trim();
|
||||
if (mime === "application/pdf") return "pdf";
|
||||
if (mime === "application/vnd.openxmlformats-officedocument.wordprocessingml.document") return "docx";
|
||||
if (mime === "application/vnd.openxmlformats-officedocument.presentationml.presentation") return "pptx";
|
||||
if (mime === "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet") return "xlsx";
|
||||
|
||||
const ext = getExtension(args.fileName ?? null);
|
||||
if (ext === "pdf") return "pdf";
|
||||
if (ext === "docx") return "docx";
|
||||
if (ext === "pptx") return "pptx";
|
||||
if (ext === "xlsx") return "xlsx";
|
||||
return null;
|
||||
}
|
||||
|
||||
function extractTextFromXmlByTag(xml: string, tagName: string): string {
|
||||
const out: string[] = [];
|
||||
const re = new RegExp(`<${tagName}[^>]*>([\\s\\S]*?)<\\/${tagName}>`, "g");
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = re.exec(xml))) {
|
||||
const raw = m[1] ?? "";
|
||||
const clean = decodeXmlEntities(raw).replace(/\s+/g, " ").trim();
|
||||
if (clean) out.push(clean);
|
||||
}
|
||||
return out.join(" ").trim();
|
||||
}
|
||||
|
||||
function extractDocxText(documentXml: string): string {
|
||||
const paras = documentXml.split(/<w:p[\s>]/g);
|
||||
const out: string[] = [];
|
||||
for (const p of paras) {
|
||||
const line: string[] = [];
|
||||
const re = /<w:t[^>]*>([\s\S]*?)<\/w:t>/g;
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = re.exec(p))) {
|
||||
const raw = m[1] ?? "";
|
||||
const clean = decodeXmlEntities(raw).replace(/\s+/g, " ").trim();
|
||||
if (clean) line.push(clean);
|
||||
}
|
||||
const joined = line.join("").trim();
|
||||
if (joined) out.push(joined);
|
||||
}
|
||||
return out.join("\n").trim();
|
||||
}
|
||||
|
||||
function extractPptxText(slideXmls: string[]): string {
|
||||
const out: string[] = [];
|
||||
for (const xml of slideXmls) {
|
||||
const line = extractTextFromXmlByTag(xml, "a:t");
|
||||
if (line) out.push(line);
|
||||
}
|
||||
return out.join("\n\n").trim();
|
||||
}
|
||||
|
||||
function extractXlsxText(args: { sharedStringsXml: string | null; sheetXmls: string[] }): string {
|
||||
const sharedStrings: string[] = [];
|
||||
if (args.sharedStringsXml) {
|
||||
const re = /<t[^>]*>([\s\S]*?)<\/t>/g;
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = re.exec(args.sharedStringsXml))) {
|
||||
const raw = m[1] ?? "";
|
||||
const clean = decodeXmlEntities(raw).replace(/\s+/g, " ").trim();
|
||||
if (clean) sharedStrings.push(clean);
|
||||
}
|
||||
}
|
||||
|
||||
const out: string[] = [];
|
||||
for (const xml of args.sheetXmls) {
|
||||
const rows = xml.split(/<row[\s>]/g);
|
||||
for (const r of rows) {
|
||||
const cells: string[] = [];
|
||||
|
||||
// t="s" => sharedStrings index;t="inlineStr" => <is><t>...;默认 => <v>number
|
||||
const cellRe = /<c\b[^>]*?(?:t="([^"]+)")?[^>]*>([\s\S]*?)<\/c>/g;
|
||||
let cm: RegExpExecArray | null;
|
||||
while ((cm = cellRe.exec(r))) {
|
||||
const t = (cm[1] ?? "").trim();
|
||||
const body = cm[2] ?? "";
|
||||
if (t === "inlineStr") {
|
||||
const inline = extractTextFromXmlByTag(body, "t");
|
||||
if (inline) cells.push(inline);
|
||||
continue;
|
||||
}
|
||||
const vMatch = /<v>([\s\S]*?)<\/v>/.exec(body);
|
||||
if (!vMatch) continue;
|
||||
const rawV = decodeXmlEntities(String(vMatch[1] ?? "")).trim();
|
||||
if (!rawV) continue;
|
||||
if (t === "s") {
|
||||
const idx = Number.parseInt(rawV, 10);
|
||||
const s = Number.isFinite(idx) ? (sharedStrings[idx] ?? "") : "";
|
||||
if (s) cells.push(s);
|
||||
} else {
|
||||
cells.push(rawV);
|
||||
}
|
||||
}
|
||||
|
||||
const line = cells.join(" ").replace(/\s+/g, " ").trim();
|
||||
if (line) out.push(line);
|
||||
}
|
||||
}
|
||||
return out.join("\n").trim();
|
||||
}
|
||||
|
||||
export type AttachmentExtractOk = {
|
||||
ok: true;
|
||||
strategy: string;
|
||||
text: string;
|
||||
meta?: Record<string, unknown>;
|
||||
};
|
||||
export type AttachmentExtractResult =
|
||||
| AttachmentExtractOk
|
||||
| { ok: false; strategy: string; reason: string; meta?: Record<string, unknown> };
|
||||
|
||||
export async function extractTextFromAttachment(args: {
|
||||
mimeType: string | null;
|
||||
fileName: string | null;
|
||||
bytes: ArrayBuffer;
|
||||
}): Promise<AttachmentExtractResult> {
|
||||
const kind = detectKind({ mimeType: args.mimeType, fileName: args.fileName });
|
||||
if (!kind) {
|
||||
return { ok: false, strategy: "unsupported", reason: "暂不支持该附件类型" };
|
||||
}
|
||||
|
||||
if (kind === "pdf") {
|
||||
try {
|
||||
const mod = await import("pdfjs-dist/legacy/build/pdf.mjs");
|
||||
const data = new Uint8Array(args.bytes);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const loadingTask = (mod as any).getDocument({ data });
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const pdf = await (loadingTask as any).promise;
|
||||
const out: string[] = [];
|
||||
const pages = Number(pdf?.numPages ?? 0) || 0;
|
||||
for (let i = 1; i <= pages; i += 1) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const page = await (pdf as any).getPage(i);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const content = await (page as any).getTextContent();
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const items = Array.isArray((content as any)?.items) ? (content as any).items : [];
|
||||
const line = items
|
||||
.map((it: any) => (typeof it?.str === "string" ? it.str : ""))
|
||||
.join(" ")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
if (line) out.push(line);
|
||||
}
|
||||
const text = clampText(out.join("\n\n"));
|
||||
if (!text) {
|
||||
return { ok: false, strategy: "pdfjs", reason: "未提取到可用文本", meta: { pages } };
|
||||
}
|
||||
return { ok: true, strategy: "pdfjs", text, meta: { pages } };
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
return { ok: false, strategy: "pdfjs", reason: message };
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const zip = await JSZip.loadAsync(args.bytes);
|
||||
if (kind === "docx") {
|
||||
const file = zip.file("word/document.xml");
|
||||
const xml = file ? await file.async("string") : "";
|
||||
const text = clampText(extractDocxText(xml));
|
||||
if (!text) return { ok: false, strategy: "docx.xml", reason: "未提取到可用文本" };
|
||||
return { ok: true, strategy: "docx.xml", text };
|
||||
}
|
||||
|
||||
if (kind === "pptx") {
|
||||
const slideFiles = Object.keys(zip.files)
|
||||
.filter((p) => /^ppt\/slides\/slide\d+\.xml$/i.test(p))
|
||||
.sort((a, b) => a.localeCompare(b, undefined, { numeric: true }));
|
||||
const slideXmls: string[] = [];
|
||||
for (const p of slideFiles) {
|
||||
const f = zip.file(p);
|
||||
if (!f) continue;
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
slideXmls.push(await f.async("string"));
|
||||
}
|
||||
const text = clampText(extractPptxText(slideXmls));
|
||||
if (!text) return { ok: false, strategy: "pptx.xml", reason: "未提取到可用文本" };
|
||||
return { ok: true, strategy: "pptx.xml", text, meta: { slides: slideXmls.length } };
|
||||
}
|
||||
|
||||
if (kind === "xlsx") {
|
||||
const sharedStringsXml = await (async () => {
|
||||
const f = zip.file("xl/sharedStrings.xml");
|
||||
if (!f) return null;
|
||||
return await f.async("string");
|
||||
})();
|
||||
|
||||
const sheetFiles = Object.keys(zip.files)
|
||||
.filter((p) => /^xl\/worksheets\/sheet\d+\.xml$/i.test(p))
|
||||
.sort((a, b) => a.localeCompare(b, undefined, { numeric: true }));
|
||||
const sheetXmls: string[] = [];
|
||||
for (const p of sheetFiles) {
|
||||
const f = zip.file(p);
|
||||
if (!f) continue;
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
sheetXmls.push(await f.async("string"));
|
||||
}
|
||||
const text = clampText(extractXlsxText({ sharedStringsXml, sheetXmls }));
|
||||
if (!text) return { ok: false, strategy: "xlsx.xml", reason: "未提取到可用文本" };
|
||||
return { ok: true, strategy: "xlsx.xml", text, meta: { sheets: sheetXmls.length } };
|
||||
}
|
||||
|
||||
return { ok: false, strategy: "unsupported", reason: "暂不支持该附件类型" };
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
return { ok: false, strategy: "zip", reason: message };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
import { getAuthUserId } from "@convex-dev/auth/server";
|
||||
|
||||
function readEnv(key: string): string | undefined {
|
||||
const raw = process.env[key];
|
||||
if (!raw) return undefined;
|
||||
const trimmed = raw.trim();
|
||||
return trimmed ? trimmed : undefined;
|
||||
}
|
||||
|
||||
function isDevAuthEnabled(): boolean {
|
||||
return readEnv("MNOTE_DEV_AUTH") === "1" || readEnv("NEXT_PUBLIC_MNOTE_DEV_AUTH") === "1";
|
||||
}
|
||||
|
||||
export async function requireUserId(ctx: any): Promise<string> {
|
||||
const userId = await getAuthUserId(ctx);
|
||||
if (userId !== null) {
|
||||
return String(userId);
|
||||
}
|
||||
|
||||
const devUserId = readEnv("DEV_USER_ID");
|
||||
if (isDevAuthEnabled() || devUserId) {
|
||||
return devUserId ?? "dev-user";
|
||||
}
|
||||
|
||||
throw new Error("未登录");
|
||||
}
|
||||
@@ -1,205 +0,0 @@
|
||||
export type DocumentMoveOrderDocument = {
|
||||
id: string;
|
||||
parent_id?: string | null;
|
||||
sort_order?: number | null;
|
||||
created_at?: string | null;
|
||||
};
|
||||
|
||||
export type DocumentMoveOrderPatch = {
|
||||
documentId: string;
|
||||
parentId: string | null;
|
||||
sortOrder: number;
|
||||
moved: boolean;
|
||||
};
|
||||
|
||||
export type DocumentMoveOrderPlan = {
|
||||
documentId: string;
|
||||
fromParentId: string | null;
|
||||
toParentId: string | null;
|
||||
requestedSortOrder: number;
|
||||
normalizedSortOrder: number;
|
||||
patches: DocumentMoveOrderPatch[];
|
||||
};
|
||||
|
||||
export type DocumentMoveWriteOperation = DocumentMoveOrderPlan & {
|
||||
family: "tree";
|
||||
schema: "mnote.tree.write_operation";
|
||||
schemaVersion: 1;
|
||||
operation: "tree.subtree.move.write";
|
||||
workspaceId: string;
|
||||
};
|
||||
|
||||
function normalizeParentId(value: string | null | undefined): string | null {
|
||||
const trimmed = typeof value === "string" ? value.trim() : "";
|
||||
return trimmed.length > 0 ? trimmed : null;
|
||||
}
|
||||
|
||||
function normalizeSortOrder(value: number | null | undefined): number {
|
||||
if (typeof value !== "number" || !Number.isFinite(value)) {
|
||||
return Number.MAX_SAFE_INTEGER;
|
||||
}
|
||||
return Math.floor(value);
|
||||
}
|
||||
|
||||
function compareDocumentMoveOrder(a: DocumentMoveOrderDocument, b: DocumentMoveOrderDocument): number {
|
||||
const orderA = normalizeSortOrder(a.sort_order);
|
||||
const orderB = normalizeSortOrder(b.sort_order);
|
||||
if (orderA !== orderB) return orderA - orderB;
|
||||
const createdA = String(a.created_at ?? "");
|
||||
const createdB = String(b.created_at ?? "");
|
||||
if (createdA !== createdB) return createdA.localeCompare(createdB);
|
||||
return a.id.localeCompare(b.id);
|
||||
}
|
||||
|
||||
function clampMoveIndex(raw: number, max: number): number {
|
||||
const value = Number.isFinite(raw) ? Math.floor(raw) : 0;
|
||||
if (value < 0) return 0;
|
||||
if (value > max) return max;
|
||||
return value;
|
||||
}
|
||||
|
||||
function appendOrderPatches(
|
||||
patches: DocumentMoveOrderPatch[],
|
||||
ordered: DocumentMoveOrderDocument[],
|
||||
parentId: string | null,
|
||||
movedDocumentId: string,
|
||||
) {
|
||||
ordered.forEach((document, index) => {
|
||||
const moved = document.id === movedDocumentId;
|
||||
if (normalizeParentId(document.parent_id) === parentId && normalizeSortOrder(document.sort_order) === index && !moved) {
|
||||
return;
|
||||
}
|
||||
|
||||
patches.push({
|
||||
documentId: document.id,
|
||||
parentId,
|
||||
sortOrder: index,
|
||||
moved,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function buildDocumentMoveOrderPlanFromDocuments(input: {
|
||||
documents: readonly DocumentMoveOrderDocument[];
|
||||
documentId: string;
|
||||
parentId: string | null;
|
||||
sortOrder: number;
|
||||
}): DocumentMoveOrderPlan {
|
||||
const source = input.documents.find((document) => document.id === input.documentId);
|
||||
if (!source) {
|
||||
throw new Error("源页面不存在或无权限");
|
||||
}
|
||||
|
||||
const fromParentId = normalizeParentId(source.parent_id);
|
||||
const toParentId = normalizeParentId(input.parentId);
|
||||
const siblingsByParent = new Map<string | null, DocumentMoveOrderDocument[]>();
|
||||
input.documents.forEach((document) => {
|
||||
const parentId = normalizeParentId(document.parent_id);
|
||||
const bucket = siblingsByParent.get(parentId);
|
||||
if (bucket) bucket.push(document);
|
||||
else siblingsByParent.set(parentId, [document]);
|
||||
});
|
||||
siblingsByParent.forEach((siblings) => siblings.sort(compareDocumentMoveOrder));
|
||||
|
||||
const patches: DocumentMoveOrderPatch[] = [];
|
||||
let normalizedSortOrder = 0;
|
||||
|
||||
if (fromParentId === toParentId) {
|
||||
const siblings = [...(siblingsByParent.get(toParentId) ?? [])].filter((document) => document.id !== input.documentId);
|
||||
normalizedSortOrder = clampMoveIndex(input.sortOrder, siblings.length);
|
||||
siblings.splice(normalizedSortOrder, 0, source);
|
||||
appendOrderPatches(patches, siblings, toParentId, input.documentId);
|
||||
} else {
|
||||
const oldSiblings = [...(siblingsByParent.get(fromParentId) ?? [])].filter((document) => document.id !== input.documentId);
|
||||
appendOrderPatches(patches, oldSiblings, fromParentId, input.documentId);
|
||||
|
||||
const newSiblings = [...(siblingsByParent.get(toParentId) ?? [])].filter((document) => document.id !== input.documentId);
|
||||
normalizedSortOrder = clampMoveIndex(input.sortOrder, newSiblings.length);
|
||||
newSiblings.splice(normalizedSortOrder, 0, source);
|
||||
appendOrderPatches(patches, newSiblings, toParentId, input.documentId);
|
||||
}
|
||||
|
||||
return {
|
||||
documentId: input.documentId,
|
||||
fromParentId,
|
||||
toParentId,
|
||||
requestedSortOrder: input.sortOrder,
|
||||
normalizedSortOrder,
|
||||
patches,
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeDocumentMoveOrderPlan(value: unknown): DocumentMoveOrderPlan | null {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
return null;
|
||||
}
|
||||
const record = value as Partial<DocumentMoveOrderPlan>;
|
||||
if (
|
||||
typeof record.documentId !== "string" ||
|
||||
typeof record.requestedSortOrder !== "number" ||
|
||||
typeof record.normalizedSortOrder !== "number" ||
|
||||
!Array.isArray(record.patches)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
documentId: record.documentId,
|
||||
fromParentId: normalizeParentId(record.fromParentId),
|
||||
toParentId: normalizeParentId(record.toParentId),
|
||||
requestedSortOrder: record.requestedSortOrder,
|
||||
normalizedSortOrder: record.normalizedSortOrder,
|
||||
patches: record.patches.map((patch) => {
|
||||
const item = patch as Partial<DocumentMoveOrderPatch>;
|
||||
return {
|
||||
documentId: String(item.documentId ?? ""),
|
||||
parentId: normalizeParentId(item.parentId),
|
||||
sortOrder: typeof item.sortOrder === "number" && Number.isFinite(item.sortOrder) ? Math.floor(item.sortOrder) : -1,
|
||||
moved: Boolean(item.moved),
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeDocumentMoveWriteOperation(value: unknown): DocumentMoveWriteOperation | null {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
return null;
|
||||
}
|
||||
const record = value as Partial<DocumentMoveWriteOperation>;
|
||||
if (
|
||||
record.family !== "tree" ||
|
||||
record.schema !== "mnote.tree.write_operation" ||
|
||||
record.schemaVersion !== 1 ||
|
||||
record.operation !== "tree.subtree.move.write" ||
|
||||
typeof record.workspaceId !== "string" ||
|
||||
!record.workspaceId.trim()
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const normalizedPlan = normalizeDocumentMoveOrderPlan(value);
|
||||
if (!normalizedPlan) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
family: "tree",
|
||||
schema: "mnote.tree.write_operation",
|
||||
schemaVersion: 1,
|
||||
operation: "tree.subtree.move.write",
|
||||
workspaceId: record.workspaceId.trim(),
|
||||
...normalizedPlan,
|
||||
};
|
||||
}
|
||||
|
||||
export function assertDocumentMoveWriteOperationMatches(expected: unknown, actual: DocumentMoveOrderPlan): DocumentMoveOrderPlan {
|
||||
const normalizedExpected = normalizeDocumentMoveWriteOperation(expected);
|
||||
if (!normalizedExpected) {
|
||||
throw new Error("Rust move write operation 与 Convex 当前排序状态不一致");
|
||||
}
|
||||
|
||||
const { family: _family, schema: _schema, schemaVersion: _schemaVersion, operation: _operation, workspaceId: _workspaceId, ...expectedPlan } =
|
||||
normalizedExpected;
|
||||
if (JSON.stringify(expectedPlan) !== JSON.stringify(actual)) {
|
||||
throw new Error("Rust move write operation 与 Convex 当前排序状态不一致");
|
||||
}
|
||||
|
||||
return expectedPlan;
|
||||
}
|
||||
@@ -1,99 +0,0 @@
|
||||
type DocumentRecordLike = {
|
||||
_id: unknown;
|
||||
id?: string | null;
|
||||
user_id?: string | null;
|
||||
parent_id?: string | null;
|
||||
deleted_at?: string | null;
|
||||
created_at?: string | null;
|
||||
updated_at?: string | null;
|
||||
};
|
||||
|
||||
export function compareDocumentCanonicalOrder(a: DocumentRecordLike, b: DocumentRecordLike): number {
|
||||
const aDeleted = a?.deleted_at != null;
|
||||
const bDeleted = b?.deleted_at != null;
|
||||
if (aDeleted !== bDeleted) {
|
||||
return aDeleted ? 1 : -1;
|
||||
}
|
||||
|
||||
const updatedA = String(a?.updated_at ?? "");
|
||||
const updatedB = String(b?.updated_at ?? "");
|
||||
if (updatedA !== updatedB) {
|
||||
return updatedB.localeCompare(updatedA);
|
||||
}
|
||||
|
||||
const createdA = String(a?.created_at ?? "");
|
||||
const createdB = String(b?.created_at ?? "");
|
||||
if (createdA !== createdB) {
|
||||
return createdB.localeCompare(createdA);
|
||||
}
|
||||
|
||||
return String(a?._id ?? "").localeCompare(String(b?._id ?? ""));
|
||||
}
|
||||
|
||||
export function pickCanonicalDocumentRecord<T extends DocumentRecordLike>(records: T[]): T | null {
|
||||
if (records.length === 0) return null;
|
||||
return [...records].sort(compareDocumentCanonicalOrder)[0] ?? null;
|
||||
}
|
||||
|
||||
export function pickCanonicalDocumentRecordsByBusinessId<
|
||||
T extends DocumentRecordLike & { id?: string | null },
|
||||
>(records: readonly T[]): T[] {
|
||||
const grouped = new Map<string, T[]>();
|
||||
for (const record of records) {
|
||||
const businessId = String(record.id ?? "").trim();
|
||||
if (!businessId) continue;
|
||||
|
||||
const bucket = grouped.get(businessId);
|
||||
if (bucket) {
|
||||
bucket.push(record);
|
||||
} else {
|
||||
grouped.set(businessId, [record]);
|
||||
}
|
||||
}
|
||||
|
||||
const result: T[] = [];
|
||||
for (const bucket of grouped.values()) {
|
||||
const canonical = pickCanonicalDocumentRecord([...bucket]);
|
||||
if (canonical) {
|
||||
result.push(canonical);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function getCanonicalDocumentByBusinessId<T extends DocumentRecordLike>(
|
||||
ctx: any,
|
||||
documentId: string,
|
||||
): Promise<T | null> {
|
||||
const records = await ctx.db
|
||||
.query("documents")
|
||||
.withIndex("by_document_id", (q: any) => q.eq("id", documentId))
|
||||
.collect();
|
||||
return pickCanonicalDocumentRecord(records) as T | null;
|
||||
}
|
||||
|
||||
export async function getCanonicalParentDocumentId(
|
||||
ctx: any,
|
||||
documentId: string | null | undefined,
|
||||
): Promise<string | null> {
|
||||
const normalizedId = String(documentId ?? "").trim();
|
||||
if (!normalizedId) return null;
|
||||
|
||||
const doc = await getCanonicalDocumentByBusinessId<DocumentRecordLike>(ctx, normalizedId);
|
||||
return doc?.parent_id ?? null;
|
||||
}
|
||||
|
||||
export async function requireCanonicalOwnedDocument<T extends DocumentRecordLike & { user_id?: string | null }>(
|
||||
ctx: any,
|
||||
documentId: string,
|
||||
userId: string,
|
||||
): Promise<T> {
|
||||
const doc = await getCanonicalDocumentByBusinessId<T>(ctx, documentId);
|
||||
if (!doc) {
|
||||
throw new Error("页面不存在");
|
||||
}
|
||||
if (String(doc.user_id ?? "") !== String(userId)) {
|
||||
throw new Error("无权限");
|
||||
}
|
||||
return doc;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user