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”。
|
- `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` 已退出文档页默认主路径,只保留为历史参考实现 / 对照材料。
|
- 文档页默认主编辑器已切到页面内 `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 仍在,页面域单一真源未完全闭环。
|
- 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` 是历史参考实现 / 对照材料,不再是运行时系统组件、默认回退编辑器或系统事实源。
|
- `BlockNote` 是历史参考实现 / 对照材料,不再是运行时系统组件、默认回退编辑器或系统事实源。
|
||||||
- `Mindmap` 是 `tree-first graph` 的一种视图和编辑挂件,不是对象真相层。
|
- `Mindmap` 是 `tree-first graph` 的一种视图和编辑挂件,不是对象真相层。
|
||||||
- `OnlyOffice` 是独立页面型编辑器,不直接嵌入 `BlockNote` 画布;正文中通常通过附件块跳转进入。
|
- `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`。
|
- 树域三层模型:`Resource Tree`(kernel 对象组织真源)→ `File Tree`(主组织投影,`{title}.md` 为页面正文行)→ `Page Tree`(导航投影,不持有结构真相)。涉及资源归属优先走 Rust kernel 的 `KernelObjectIdentity` / `KernelProjectionResourceKind`。
|
||||||
|
|
||||||
## 目录优先级
|
## 目录优先级
|
||||||
|
|
||||||
- `/mnt/Data1T/mnote/wolai-frontend/src/`
|
- `/mnt/Data1T/mnote/recycle/wolai-frontend/src/`
|
||||||
页面、编辑器、Sidebar、树视图、阅读态、交互壳默认先看这里。
|
wolai-frontend(Next.js React 前端)已退役移入 recycle,不作为当前实现依据。页面、编辑器、Sidebar、树视图、阅读态、交互壳默认先看 Rust mnote-web SSR(`rust/crates/mnote-web/src/`)。
|
||||||
- `/mnt/Data1T/mnote/wolai-frontend/convex/`
|
- `/mnt/Data1T/mnote/recycle/wolai-frontend/convex/`
|
||||||
仍在使用的 Convex functions 与数据侧逻辑先看这里。
|
已随 wolai-frontend 移入 recycle。当前活跃 Convex functions 在 `infra/convex/` 与对应 Rust bridge 中。
|
||||||
- `/mnt/Data1T/mnote/rust/crates/core-protocol/`
|
- `/mnt/Data1T/mnote/rust/crates/core-protocol/`
|
||||||
Kernel 类型、projection 协议、树/图核心术语先看这里。
|
Kernel 类型、projection 协议、树/图核心术语先看这里。
|
||||||
- `/mnt/Data1T/mnote/rust/crates/bridge-runtime/`
|
- `/mnt/Data1T/mnote/rust/crates/bridge-runtime/`
|
||||||
@@ -52,7 +52,7 @@
|
|||||||
- 涉及页面新建、重命名、移动、归档、恢复、嵌入时,优先沿 `tree.*` 正式命名推进;`documents.*` 只视为兼容层,不应继续扩写为长期命令面。
|
- 涉及页面新建、重命名、移动、归档、恢复、嵌入时,优先沿 `tree.*` 正式命名推进;`documents.*` 只视为兼容层,不应继续扩写为长期命令面。
|
||||||
- 涉及 AI 读取、定位或精确编辑页面块时,优先沿 `mnote.doc.*` / `mnote.block.*` Hermes tools 与 Rust `EditorCommand` 推进;`mnote.page.save` 只作为页面级兜底写入工具。
|
- 涉及 AI 读取、定位或精确编辑页面块时,优先沿 `mnote.doc.*` / `mnote.block.*` Hermes tools 与 Rust `EditorCommand` 推进;`mnote.page.save` 只作为页面级兜底写入工具。
|
||||||
- 涉及资源归属(哪个页面拥有哪个 mindmap/附件)、object identity 和资源生命周期时,优先沿 Resource Tree → File Tree projection → `tree.resource.*` 命令面推进;`tree.*` 是正式命令面,`documents.*` 只视为兼容层。
|
- 涉及资源归属(哪个页面拥有哪个 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/ARCHITECTURE.md`
|
||||||
- `/mnt/Data1T/mnote/design/01-05-current-priority-overview.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
|
- `/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`
|
- `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/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/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/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`
|
- `/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`
|
- 根目录热启动:`npm run desktop:hot`
|
||||||
- 前端开发:`cd /mnt/Data1T/mnote/wolai-frontend && pnpm dev`
|
- 前端开发(已迁移至 Rust SSR):`npm run desktop:hot` 启动后直接访问 `http://localhost:3000`
|
||||||
- 前端检查:`cd /mnt/Data1T/mnote/wolai-frontend && pnpm lint && pnpm test`
|
- 前端检查:Rust 测试 `cargo test -p mnote-web`(Rust SSR 与 API),旧 React 前端已移入 recycle
|
||||||
- 后端开发:`cd /mnt/Data1T/mnote/wolai-backend && uvicorn app.main:app --reload --port 8000`
|
- 后端开发:`cd /mnt/Data1T/mnote/wolai-backend && uvicorn app.main:app --reload --port 8000`
|
||||||
- Convex 自托管:参考 `/mnt/Data1T/mnote/infra/convex/README.md`
|
- Convex 自托管:参考 `/mnt/Data1T/mnote/infra/convex/README.md`
|
||||||
|
|
||||||
@@ -152,4 +154,4 @@
|
|||||||
- 所有新增或修改文件统一使用 UTF-8。
|
- 所有新增或修改文件统一使用 UTF-8。
|
||||||
- TS/TSX 默认 2 空格缩进,Python 默认 4 空格缩进。
|
- TS/TSX 默认 2 空格缩进,Python 默认 4 空格缩进。
|
||||||
- 注释使用简体中文。
|
- 注释使用简体中文。
|
||||||
-当使用deepseek模型编程时,遇到疑难问题可以使用MCP求助codex,但需要注意codex回复比较慢,可能需要等待长一点的时间>3min。
|
-当使用deepseek模型编程时,遇到疑难问题可以使用MCP求助codex,但需要注意codex回复比较慢,可能需要等待长一点的时间>3min。
|
||||||
|
|||||||
+43
-69
@@ -35,16 +35,16 @@
|
|||||||
|
|
||||||
## 2. 当前主线目录
|
## 2. 当前主线目录
|
||||||
|
|
||||||
- `/mnt/Data1T/mnote/wolai-frontend/`
|
- `/mnt/Data1T/mnote/recycle/wolai-frontend/`
|
||||||
当前 React island 与 legacy compat 代码来源;Next.js App Router 不再作为 3000 主入口,只在显式 legacy/debug 迁移边界继续服务重交互运行态与对照链。
|
已退役移入 recycle。React island 与 legacy compat 代码的历史来源;Next.js App Router 已被 Rust mnote-web SSR 替代,不再作为 3000 主入口。
|
||||||
- `/mnt/Data1T/mnote/rust/crates/core-protocol/`
|
- `/mnt/Data1T/mnote/rust/crates/core-protocol/`
|
||||||
Kernel 类型、projection 协议、编辑器协议、树 / 图核心术语。
|
Kernel 类型、projection 协议、编辑器协议、树 / 图核心术语。
|
||||||
- `/mnt/Data1T/mnote/rust/crates/bridge-runtime/`
|
- `/mnt/Data1T/mnote/rust/crates/bridge-runtime/`
|
||||||
Kernel query / command、bridge transport、Tiptap <-> editor document 适配、命令解释层。
|
Kernel query / command、bridge transport、Tiptap <-> editor document 适配、命令解释层。
|
||||||
- `/mnt/Data1T/mnote/rust/crates/mnote-web/`
|
- `/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。
|
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/`
|
- `/mnt/Data1T/mnote/recycle/wolai-frontend/convex/`
|
||||||
仍在使用的 Convex functions 与数据侧逻辑。
|
已随 wolai-frontend 移入 recycle。当前活跃 Convex functions 位于 `infra/convex/` 与 Rust bridge 中。
|
||||||
- `/mnt/Data1T/mnote/infra/convex/`
|
- `/mnt/Data1T/mnote/infra/convex/`
|
||||||
Convex 自托管部署与基础设施。
|
Convex 自托管部署与基础设施。
|
||||||
- `/mnt/Data1T/mnote/wolai-backend/`
|
- `/mnt/Data1T/mnote/wolai-backend/`
|
||||||
@@ -52,97 +52,71 @@
|
|||||||
|
|
||||||
## 3. 当前页面运行结构
|
## 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`
|
||||||
|
|
||||||
职责:
|
该文件已随 wolai-frontend 移入 `recycle/`,不再作为当前实现依据。
|
||||||
|
|
||||||
- 作为 legacy Next / React island bundle source 的根布局
|
|
||||||
- 保留 Convex / Query Provider 等前端 island 运行时依赖
|
|
||||||
- 服务显式 legacy/debug/迁移边界
|
|
||||||
|
|
||||||
注意:
|
注意:
|
||||||
|
|
||||||
> **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`
|
以下文件已随 wolai-frontend 移入 `recycle/`,不再作为当前实现依据:
|
||||||
- `/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`
|
|
||||||
|
|
||||||
当前 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
|
当前 Sidebar/工作区壳的当前实现在 Rust mnote-web SSR:
|
||||||
2. query / refetch snapshot
|
- 工作区壳渲染入口:`gateway.rs`(`root_entry`)
|
||||||
3. tree stream live snapshot
|
- 文档页壳/文档面板:`web_shell.rs`(`document_page_shell`、`build_document_panes_bootstrap_json`)
|
||||||
|
- Sidebar tree stream 消费:`layout.rs` 嵌入 JS(`startWithSse` / `startWithWebSocket`)
|
||||||
`PreferredSidebarSnapshotProvider` 负责在 query 与 tree stream 之间做 freshness 选择,再把同一份 preferred snapshot 同时给:
|
- FileTree 渲染与交互:`tree.rs` 嵌入 JS
|
||||||
|
|
||||||
- `Sidebar`
|
|
||||||
- `Breadcrumb`
|
|
||||||
- 文档页头标题消费链
|
|
||||||
|
|
||||||
这说明当前工作区树链已经进入正式收口阶段:`3000` 主壳已直接接入 `/api/tree/events` 的 snapshot / delta / resync consumer,并有浏览器 smoke 验证;但 preferred snapshot、page subtree、filetree 与补偿链还没有完全统一成唯一 live cache。
|
|
||||||
|
|
||||||
## 4. 文档页主链
|
## 4. 文档页主链
|
||||||
|
|
||||||
### 4.1 入口
|
### 4.1 入口
|
||||||
|
|
||||||
- `/mnt/Data1T/mnote/wolai-frontend/src/lib/documents/page-aggregate-loader.ts`
|
文档页入口的当前实现在 Rust mnote-web:
|
||||||
- `/mnt/Data1T/mnote/wolai-frontend/src/app/(app)/documents/[id]/page.tsx`
|
|
||||||
|
|
||||||
当前文档页读取主链已经不再由页面入口手工拼若干独立字段,而是:
|
- 文档页壳 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`
|
历史 wolai-frontend Next 文档页入口(已移入 `recycle/`):
|
||||||
2. `DocumentContent` 的内容重试补拉也直接请求同一条 `/api/page-aggregate/:id` 正式读链
|
- `/mnt/Data1T/mnote/recycle/wolai-frontend/src/lib/documents/page-aggregate-loader.ts`
|
||||||
3. 读取主链不再回退到 TS builder;snapshot 不可用或不可信时直接显式失败
|
- `/mnt/Data1T/mnote/recycle/wolai-frontend/src/app/(app)/documents/[id]/page.tsx`
|
||||||
4. Next `/api/documents/page` 已退场为明确 `410` 的 compat 边界,不再参与文档页运行时主路径
|
- `/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`
|
### 4.3 默认编辑器 host(已迁移至 WASM island)
|
||||||
- `/mnt/Data1T/mnote/wolai-frontend/src/components/editor/document-content.tsx`
|
|
||||||
|
|
||||||
`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
|
当前默认主编辑器是页面内 Leptos/WASM island,由 Rust mnote-web SSR 加载:
|
||||||
|
- WASM 产物:`rust/spikes/leptos-tiptap-spike/`
|
||||||
- `/mnt/Data1T/mnote/wolai-frontend/src/components/editor/editor-host-config.ts`
|
- 加载路由:`web_shell.rs` → `leptos_tiptap_manifest` / `leptos_tiptap_asset`
|
||||||
- `/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
|
|
||||||
- 处理同页事件、命令、保存与状态同步
|
|
||||||
|
|
||||||
### 4.5 BlockNote 的当前位置
|
### 4.5 BlockNote 的当前位置
|
||||||
|
|
||||||
- `/mnt/Data1T/mnote/recycle/wolai-frontend/src/components/editor/blocknote-editor.tsx`
|
- `/mnt/Data1T/mnote/recycle/wolai-frontend/src/components/editor/blocknote-editor.tsx`
|
||||||
|
|
||||||
`BlockNote` 当前仍保留回收副本,但其定位已变成:
|
`BlockNote` 已随 wolai-frontend 移入 recycle,仅作为历史对照材料,不参与当前实现。
|
||||||
|
|
||||||
- 历史参考实现
|
|
||||||
- 对照材料 / 调试素材
|
|
||||||
|
|
||||||
而不是默认主编辑器方向。
|
而不是默认主编辑器方向。
|
||||||
|
|
||||||
@@ -197,7 +171,7 @@
|
|||||||
|
|
||||||
### 6.2 树命令现状
|
### 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/mnote-web/src/routes/tree.rs`
|
||||||
- `/mnt/Data1T/mnote/rust/crates/storage-convex-bridge/src/mapping.rs`
|
- `/mnt/Data1T/mnote/rust/crates/storage-convex-bridge/src/mapping.rs`
|
||||||
|
|
||||||
|
|||||||
+55
-57
@@ -36,25 +36,25 @@
|
|||||||
|
|
||||||
- 正式命令面应落在 `tree.*` 与 `tree.resource.*`。
|
- 正式命令面应落在 `tree.*` 与 `tree.resource.*`。
|
||||||
- `mnote-web` 同时暴露 `tree` 命令路由与兼容/过渡路由。
|
- `mnote-web` 同时暴露 `tree` 命令路由与兼容/过渡路由。
|
||||||
- 当前最大冲突是 FileTree 资源行仍可能被当成页面命令对象处理,违反 Resource Tree / File Tree / Page Tree 分层。
|
- FileTree 资源行已与页面命令对象隔离:资源 owner 只作为上下文,`data-document-id` / 页面命令目标只保留给真正页面行。
|
||||||
|
|
||||||
### 2.4 Transport / Realtime 层
|
### 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 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`)。
|
- 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 默认。
|
- 当前默认 `bootstrap.transport` 已切到 `convex-command-log-ws`,主壳先走 WS;SSE 保留为 fallback。
|
||||||
- `wolai-frontend`(Next.js legacy 侧)仅使用 SSE(`use-sidebar-tree-stream.ts`),无 WS 消费者。
|
- `recycle/wolai-frontend`(已退役的 Next.js legacy 侧)仅使用 SSE,无 WS 消费者。
|
||||||
- SSE 内部还保留 push/polling 两种语义,WS 与 SSE 的 delta / snapshot 结构也未完全收敛。
|
- WS 与 SSE 的 snapshot / delta 载荷合同已在 Rust 主壳消费层统一;后续重点是继续减少兼容 fallback 与 live cache 补偿链。
|
||||||
|
|
||||||
### 2.5 Page Aggregate 层
|
### 2.5 Page Aggregate 层
|
||||||
|
|
||||||
- 文档页入口已经优先消费 Rust `page-aggregate` 快照。
|
- 文档页入口已经优先消费 Rust `page-aggregate` 快照。
|
||||||
- 但前端仍保留 `PageAggregateClientState` reducer 和本地 `page tree` 投影。
|
- AI context 的 page subtree 已只读 Rust Page Aggregate 的稳定 projection,不再由前端本地构造第二份 page tree 真相。
|
||||||
- 当前 page aggregate 是“Rust join + 前端局部状态”的过渡闭环,不是 EditorBlockDocument 原生单一真源闭环。
|
- 当前 page aggregate 仍是从 `documents.content` / local markdown content 投影出来的过渡闭环,不是 EditorBlockDocument 原生单一真源闭环。
|
||||||
|
|
||||||
### 2.6 Editor Runtime 层
|
### 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 加载。
|
- `leptos-tiptap` island 已是文档页默认编辑 host,以 WASM 形式由 Rust SSR 加载。
|
||||||
- 保存正文仍会经过 `documents/save` 兼容面。
|
- 保存正文仍会经过 `documents/save` 兼容面。
|
||||||
- 这意味着编辑器体验已经切主,但写回语义还没有完全切到唯一主命令面。
|
- 这意味着编辑器体验已经切主,但写回语义还没有完全切到唯一主命令面。
|
||||||
@@ -62,7 +62,8 @@
|
|||||||
### 2.7 AI Runtime 层
|
### 2.7 AI Runtime 层
|
||||||
|
|
||||||
- `mnote.doc.fetch`、`mnote.doc.markdown_edit`、`mnote.doc.apply_block_ops`、`page_ai_workflow`、Hermes / ACP / Reasonix 构成当前 AI 主链。
|
- `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. 当前成立的事实与过渡态
|
## 3. 当前成立的事实与过渡态
|
||||||
|
|
||||||
@@ -72,95 +73,92 @@
|
|||||||
- Tree 主链已经从旧兼容入口退向 Rust Web。
|
- Tree 主链已经从旧兼容入口退向 Rust Web。
|
||||||
- 文档页主编辑器已经切到 `leptos-tiptap` island。
|
- 文档页主编辑器已经切到 `leptos-tiptap` island。
|
||||||
- AI 页面编辑已经不再是纯前端本地逻辑。
|
- 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 过渡态
|
### 3.2 过渡态
|
||||||
|
|
||||||
- Page Aggregate 仍从 `documents.content` 侧 join 构造,而不是原生 EditorBlockDocument 真源。
|
- Page Aggregate 仍从 `documents.content` 侧 join 构造,而不是原生 EditorBlockDocument 真源。
|
||||||
- 前端 Sidebar 仍有本地投影与多源抢真相。
|
- 页面正文写回仍经过兼容保存面,尚未完全切到唯一主命令面。
|
||||||
- `mnote.doc.markdown_edit` 已进入主线,但写入合同尚不完整。
|
- realtime / Page Aggregate / AI 写入的 P0 合同已基本收口,但兼容路由和历史 adapter 仍偏多。
|
||||||
- ACP / Reasonix 已接入,但调用字段和运行时所有权仍不统一。
|
|
||||||
|
|
||||||
## 4. 架构冲突矩阵
|
## 4. 架构冲突矩阵
|
||||||
|
|
||||||
### 4.1 Tree realtime
|
### 4.1 Tree realtime
|
||||||
|
|
||||||
- 事实:Rust Web 已注册 WS。
|
- 事实:Rust Web 同时注册 WS 与 SSE。
|
||||||
- 事实:前端仍只见 SSE consumer。
|
- 事实:Rust SSR 主壳默认以 WS 为主链,SSE 是断线 fallback。
|
||||||
- 冲突:系统口径已升级,但前端主链未同步切换。
|
- 当前状态:原“WS 文档口径 / 前端 SSE 实现”冲突已修复。
|
||||||
- 影响:live cache、调试、回放与验收都会出现双口径。
|
- 剩余风险:live cache 与兼容 fallback 仍需要继续瘦身,避免未来再次出现多链路补偿。
|
||||||
|
|
||||||
### 4.2 FileTree 资源行
|
### 4.2 FileTree 资源行
|
||||||
|
|
||||||
- 事实:资源投影行带 `documentId`。
|
- 事实:资源投影行带 `documentId`。
|
||||||
- 事实:`tree.tsx` 相关路径会把它当作页面对象处理。
|
- 事实:资源行的 owner document 与页面命令目标已分离,资源行不再暴露页面命令目标 ID。
|
||||||
- 冲突:资源树与页面树边界被突破。
|
- 当前状态:原“资源行被当成页面命令对象处理”冲突已修复。
|
||||||
- 影响:误删、误重命名、误移动风险上升。
|
- 剩余风险:资源对象的完整 `tree.resource.*` 命令面仍应继续补齐。
|
||||||
|
|
||||||
### 4.3 Page Aggregate
|
### 4.3 Page Aggregate
|
||||||
|
|
||||||
- 事实:Rust snapshot 已是入口事实。
|
- 事实:Rust snapshot 已是入口事实。
|
||||||
- 事实:前端 reducer 与本地 page tree 还在派生第二份状态。
|
- 事实:AI context 的 page subtree 已只读 Rust Page Aggregate projection,外部 AI 写入后会同步本地 aggregate script。
|
||||||
- 冲突:页面标题、正文、树投影可能各自取源。
|
- 当前状态:原“AI context / 文档页 / Rust Aggregate 多源抢真相”的 P0 问题已修复。
|
||||||
- 影响:AI context、Sidebar、正文显示不一定同源。
|
- 剩余风险:Page Aggregate 仍从 `documents.content` 投影,不是 EditorBlockDocument 原生落库真相。
|
||||||
|
|
||||||
### 4.4 AI 写入
|
### 4.4 AI 写入
|
||||||
|
|
||||||
- 事实:`markdown_edit` 已是主推路径。
|
- 事实:`markdown_edit` 已是简单正文编辑主路径。
|
||||||
- 事实:Hermes guidance、manifest、page_ai_workflow、apply_block_ops 仍在不同层保留旧习惯。
|
- 事实:Hermes guidance、manifest、page_ai_workflow、tool executor、Reasonix ACP payload 已同步到同一合同。
|
||||||
- 冲突:同一条编辑链路里存在 markdown / block / tool executor 三套合同。
|
- 当前状态:原“markdown / block / tool executor 三套合同漂移”的 P0 问题已修复。
|
||||||
- 影响:写入正确性、幂等性、回读一致性都不稳定。
|
- 剩余风险:复杂结构编辑仍应明确落到 `apply_block_ops` / `mnote.block.*`,Phase C review / streaming apply 尚未实施。
|
||||||
|
|
||||||
## 5. 缺失功能
|
## 5. 缺失功能
|
||||||
|
|
||||||
- 前端树流仍缺统一的 WS 主 consumer。
|
- Page Aggregate 仍缺 EditorBlockDocument 原生落库真相。
|
||||||
- Page Aggregate 仍缺前端第二真相收口。
|
- 正文保存仍经过 `documents/save` 兼容面,唯一主命令面尚未完全闭环。
|
||||||
- FileTree 资源行仍缺严格的命令面隔离。
|
- `tree.resource.*` 仍需要补完整资源对象生命周期命令。
|
||||||
- `mnote.doc.markdown_edit` 仍缺完整写入合同。
|
- 兼容路由、历史 adapter 与 fallback 仍偏多,需要继续减小长期维护面。
|
||||||
- ACP / Reasonix 仍缺统一身份、会话、运行、幂等字段。
|
|
||||||
- 兼容路由仍偏多,正式命令面与过渡面边界还不够硬。
|
|
||||||
|
|
||||||
## 6. 推荐收口顺序
|
## 6. 推荐收口顺序
|
||||||
|
|
||||||
1. 先收 `tree` 实时链路口径,让前端主消费与后端主发布一致。
|
1. 先推进 Page Aggregate 原生 EditorBlockDocument 落库,减少 `documents.content` 投影过渡层。
|
||||||
2. 再收 FileTree 资源行命令边界,避免资源对象误走页面命令。
|
2. 再收正文保存主命令面,把 `documents/save` 兼容写入逐步迁到正式页面 / 编辑器命令。
|
||||||
3. 再收 Page Aggregate 单一真源,减少 Sidebar / 文档页 / AI context 多源抢真相。
|
3. 再补齐 `tree.resource.*` 资源对象生命周期命令,避免资源操作长期停留在禁用或兼容态。
|
||||||
4. 最后收 AI 写入合同,把 `markdown_edit`、`apply_block_ops`、Hermes / ACP / Reasonix 的调用协议统一起来。
|
4. 最后清理兼容路由、历史 adapter 与 fallback,使 Rust kernel / Rust Web / SSR 主壳的合同成为唯一运行口径。
|
||||||
|
|
||||||
## 7. 相关审查与缺陷
|
## 7. 相关审查与缺陷
|
||||||
|
|
||||||
- [设计审查:当前 mnote 项目 AI / Page Aggregate 定向 Review](./design/10-review/process/10-current-mnote-ai-runtime-review-v1.md)
|
- [设计审查:当前 mnote 项目 AI / Page Aggregate 定向 Review](./design/10-review/done/10-current-mnote-ai-runtime-review-v1.md)
|
||||||
- [设计审查:当前完整架构 Review](./design/10-review/process/11-current-full-architecture-review-v1.md)
|
- [设计审查:当前完整架构 Review](./design/10-review/done/11-current-full-architecture-review-v1.md)
|
||||||
|
|
||||||
## 8. 本次落档缺陷索引
|
## 8. 本次落档缺陷索引
|
||||||
|
|
||||||
### 8.1 Rust Web / Realtime / ACP
|
### 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-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/process/3-17-sse-push-skips-polling-fallback-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/process/3-18-ws-sse-delta-contract-split-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/process/3-19-acp-reasonix-tool-call-missing-identity-fields-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/process/3-20-acp-request-permission-no-response-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/process/3-21-acp-run-payload-consumed-and-removed-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
|
### 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
|
### 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-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/process/5-16-sidebar-preferred-snapshot-query-overrides-live-stream-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/process/5-17-document-title-source-drift-live-sidebar-over-head-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/process/5-18-ai-write-body-does-not-sync-page-aggregate-content-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
|
### 8.4 AI
|
||||||
|
|
||||||
- [7-18 AI markdown_edit 阶段状态合同漂移](./bugs/07-ai/process/7-18-ai-markdown-edit-phase-state-contract-drift-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/process/7-19-hermes-tool-guidance-markdown-edit-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/process/7-20-page-ai-workflow-bypasses-hermes-tool-executor-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/process/7-21-markdown-edit-local-write-contract-bypass-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/process/7-22-apply-block-ops-missing-write-preconditions-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/process/7-23-markdown-edit-manifest-schema-contract-drift-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/process/7-24-markdown-edit-online-write-does-not-use-final-markdown-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/process/7-25-reasonix-block-edit-workflow-empty-block-ops-after-markdown-match-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
|
> 发现时间:2026-05-17
|
||||||
>
|
>
|
||||||
> 状态:`[process]`
|
> 状态:`[done]`
|
||||||
>
|
>
|
||||||
> 关联主线:`03-rust-web`
|
> 关联主线:`03-rust-web`
|
||||||
|
|
||||||
@@ -29,6 +29,19 @@
|
|||||||
- 增加浏览器 smoke,断言网络中实际使用的是 WS 主链还是 SSE fallback。
|
- 增加浏览器 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
|
> 发现时间:2026-05-17
|
||||||
>
|
>
|
||||||
> 状态:`[process]`
|
> 状态:`[done]`
|
||||||
>
|
>
|
||||||
> 关联主线:`03-rust-web`
|
> 关联主线:`03-rust-web`
|
||||||
|
|
||||||
@@ -27,6 +27,19 @@ SSE route 注释和架构口径暗示 polling 可作为 safety net,但实现
|
|||||||
- 增加测试:模拟 `stream_delta_rx` 无消息但 bridgeLogs 有新事件,SSE 应产出 delta/resync。
|
- 增加测试:模拟 `stream_delta_rx` 无消息但 bridgeLogs 有新事件,SSE 应产出 delta/resync。
|
||||||
- 明确 `pollMs` 与 push channel 同时存在时的优先级。
|
- 明确 `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
|
> 发现时间:2026-05-17
|
||||||
>
|
>
|
||||||
> 状态:`[process]`
|
> 状态:`[done]`
|
||||||
>
|
>
|
||||||
> 关联主线:`03-rust-web`
|
> 关联主线:`03-rust-web`
|
||||||
|
|
||||||
@@ -28,6 +28,20 @@ SSE polling 可以从 bridgeLogs / domainEvents 解析 `streamDelta` 并附带 s
|
|||||||
- 对同一命令分别跑 WS / SSE 合同测试,断言事件 kind、cursor、snapshot、resync 语义一致。
|
- 对同一命令分别跑 WS / SSE 合同测试,断言事件 kind、cursor、snapshot、resync 语义一致。
|
||||||
- 前端 reducer 只消费统一 envelope,不感知 transport 差异。
|
- 前端 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
|
> 发现时间:2026-05-17
|
||||||
>
|
>
|
||||||
> 状态:`[process]`
|
> 状态:`[done]`
|
||||||
>
|
>
|
||||||
> 关联主线:`03-rust-web`
|
> 关联主线:`03-rust-web`
|
||||||
|
|
||||||
@@ -27,6 +27,12 @@ ACP run 的 payload 在 `stream_events` 路径被消费后从内存表移除。
|
|||||||
- stream subscription 不应拥有 payload 的删除权。
|
- stream subscription 不应拥有 payload 的删除权。
|
||||||
- 增加 SSE 断线重连与重复订阅测试。
|
- 增加 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
|
> 更新时间:2026-05-15
|
||||||
>
|
>
|
||||||
> 分类归属:
|
> 分类归属:
|
||||||
> - `04-tree-domain/process`
|
> - `04-tree-domain/done`
|
||||||
> - 涉及边界:workspace source switch、本地文件夹入口、Convex workspace 恢复、Sidebar File Tree/Page Tree projection
|
> - 涉及边界: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
|
> 发现时间:2026-05-17
|
||||||
>
|
>
|
||||||
> 状态:`[process]`
|
> 更新时间:2026-05-17
|
||||||
|
>
|
||||||
|
> 状态:`[done]`
|
||||||
>
|
>
|
||||||
> 关联主线:`04-tree-domain`
|
> 关联主线:`04-tree-domain`
|
||||||
|
|
||||||
@@ -34,4 +36,26 @@ FileTree asset row 的 `resourceMeta` 同时带 `documentId` 和 `assetId`,但
|
|||||||
|
|
||||||
## 5. 状态
|
## 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
|
> 更新时间: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`
|
> - 涉及边界:`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
|
> 发现时间:2026-05-17
|
||||||
>
|
>
|
||||||
> 状态:`[process]`
|
> 状态:`[done]`
|
||||||
>
|
>
|
||||||
> 关联主线:`05-editor-mainline`
|
> 关联主线:`05-editor-mainline`
|
||||||
|
|
||||||
@@ -28,6 +28,14 @@
|
|||||||
- AI context 应优先读取 Rust Page Aggregate 的稳定 projection。
|
- AI context 应优先读取 Rust Page Aggregate 的稳定 projection。
|
||||||
- 增加测试:本地编辑后 AI context 与 Rust 回读 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
|
> 发现时间:2026-05-17
|
||||||
>
|
>
|
||||||
> 状态:`[process]`
|
> 状态:`[done]`
|
||||||
>
|
>
|
||||||
> 关联主线:`05-editor-mainline`
|
> 关联主线:`05-editor-mainline`
|
||||||
|
|
||||||
@@ -27,6 +27,13 @@ Sidebar 同时启动 query/live/initial 与 tree stream,并通过 preferred sn
|
|||||||
- 同版本不同内容时必须记录诊断事件,而不是静默按 freshness 选择。
|
- 同版本不同内容时必须记录诊断事件,而不是静默按 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
|
> 发现时间:2026-05-17
|
||||||
>
|
>
|
||||||
> 状态:`[process]`
|
> 状态:`[done]`
|
||||||
>
|
>
|
||||||
> 关联主线:`05-editor-mainline`
|
> 关联主线:`05-editor-mainline`
|
||||||
|
|
||||||
@@ -27,6 +27,13 @@
|
|||||||
- Sidebar title 与 Page Aggregate head 冲突时记录诊断并触发 resync。
|
- Sidebar title 与 Page Aggregate head 冲突时记录诊断并触发 resync。
|
||||||
- 增加 smoke:改标题后 Sidebar、Breadcrumb、文档页标题同源一致。
|
- 增加 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
|
> 发现时间:2026-05-17
|
||||||
>
|
>
|
||||||
> 状态:`[process]`
|
> 状态:`[done]`
|
||||||
>
|
>
|
||||||
> 关联主线:`05-editor-mainline`
|
> 关联主线:`05-editor-mainline`
|
||||||
|
|
||||||
@@ -28,6 +28,16 @@
|
|||||||
- AI context 应从最新 Rust Page Aggregate snapshot 读取,而不是依赖可能过期的本地 reducer。
|
- AI context 应从最新 Rust Page Aggregate snapshot 读取,而不是依赖可能过期的本地 reducer。
|
||||||
- 增加 smoke:AI 修改正文后不刷新页面连续再问一次 AI,断言第二次 context 包含新内容。
|
- 增加 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`
|
> - 对应体验主线:`5-9 Wolai-aline continuous checklist`
|
||||||
>
|
>
|
||||||
> 关联文档:
|
> 关联文档:
|
||||||
@@ -104,7 +104,7 @@ Hermes tester 首轮取证:
|
|||||||
|
|
||||||
## 7. 流转条件
|
## 7. 流转条件
|
||||||
|
|
||||||
当前状态:`process`
|
当前状态:`done`
|
||||||
|
|
||||||
只有在以下条件都满足后,本文才能移动到 `bugs/05-editor-mainline/done/`:
|
只有在以下条件都满足后,本文才能移动到 `bugs/05-editor-mainline/done/`:
|
||||||
|
|
||||||
@@ -112,3 +112,21 @@ Hermes tester 首轮取证:
|
|||||||
2. smoke 已覆盖且通过
|
2. smoke 已覆盖且通过
|
||||||
3. 浏览器复测确认收起/展开都生效
|
3. 浏览器复测确认收起/展开都生效
|
||||||
4. 证据路径已补齐到修复后的截图或日志
|
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
|
> 发现时间:2026-05-17
|
||||||
>
|
>
|
||||||
> 状态:`[process]`
|
> 状态:`[done]`
|
||||||
>
|
>
|
||||||
> 关联主线:`07-ai`
|
> 关联主线:`07-ai`
|
||||||
>
|
>
|
||||||
@@ -56,6 +56,18 @@ replace:二段
|
|||||||
- 或者只在同一字符串上使用 byte index,并确保切片边界来自同一个原始字符串的 `char_indices` 映射。
|
- 或者只在同一字符串上使用 byte index,并确保切片边界来自同一个原始字符串的 `char_indices` 映射。
|
||||||
- 增加中文、多字节标点、全角英文 / 数字混排的 `search_replace` 单测。
|
- 增加中文、多字节标点、全角英文 / 数字混排的 `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
|
> 发现时间:2026-05-17
|
||||||
>
|
>
|
||||||
> 状态:`[process]`
|
> 状态:`[done]`
|
||||||
>
|
>
|
||||||
> 关联主线:`07-ai`
|
> 关联主线:`07-ai`
|
||||||
>
|
>
|
||||||
@@ -70,6 +70,22 @@ Convex 写回使用的是 `block_ops`,而不是 `md`。这让“markdown 编
|
|||||||
- 增加同一 block 多 operation 的单测和真实 Page Aggregate 回读 smoke。
|
- 增加同一 block 多 operation 的单测和真实 Page Aggregate 回读 smoke。
|
||||||
- 明确部分失败是否允许写入;默认建议任一 operation 失败时不写入,除非调用方显式允许 partial apply。
|
- 明确部分失败是否允许写入;默认建议任一 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
|
> 发现时间:2026-05-17
|
||||||
>
|
>
|
||||||
> 状态:`[process]`
|
> 状态:`[done]`
|
||||||
>
|
>
|
||||||
> 关联主线:`07-ai`
|
> 关联主线:`07-ai`
|
||||||
|
|
||||||
@@ -30,6 +30,22 @@
|
|||||||
- 若已切主,应把 `page_ai_workflow`、Hermes guidance、manifest 和 smoke 都同步到该口径。
|
- 若已切主,应把 `page_ai_workflow`、Hermes guidance、manifest 和 smoke 都同步到该口径。
|
||||||
- 若未切主,应限制 `markdown_edit` 的 runtime 使用面,并把设计稿状态回退为实验态。
|
- 若未切主,应限制 `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
|
> 发现时间:2026-05-17
|
||||||
>
|
>
|
||||||
> 状态:`[process]`
|
> 状态:`[done]`
|
||||||
>
|
>
|
||||||
> 关联主线:`07-ai`
|
> 关联主线:`07-ai`
|
||||||
|
|
||||||
@@ -30,6 +30,15 @@
|
|||||||
- 仅在需要结构性块移动、资源块、复杂定位时才引导模型使用 `apply_block_ops` 或 `mnote.block.*`。
|
- 仅在需要结构性块移动、资源块、复杂定位时才引导模型使用 `apply_block_ops` 或 `mnote.block.*`。
|
||||||
- 同步更新 smoke:模型生成工具调用时应优先产出 `mnote_doc_markdown_edit`。
|
- 同步更新 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
|
> 发现时间:2026-05-17
|
||||||
>
|
>
|
||||||
> 状态:`[process]`
|
> 状态:`[done]`
|
||||||
>
|
>
|
||||||
> 关联主线:`07-ai`
|
> 关联主线:`07-ai`
|
||||||
|
|
||||||
@@ -28,6 +28,19 @@
|
|||||||
- 对 `dryRun=true` 返回 diff / preview,不落盘。
|
- 对 `dryRun=true` 返回 diff / preview,不落盘。
|
||||||
- 增加本地 `.md` dryRun 单测,断言文件内容不变。
|
- 增加本地 `.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
|
> 发现时间:2026-05-17
|
||||||
>
|
>
|
||||||
> 状态:`[process]`
|
> 状态:`[done]`
|
||||||
>
|
>
|
||||||
> 关联主线:`07-ai`
|
> 关联主线:`07-ai`
|
||||||
|
|
||||||
@@ -29,6 +29,19 @@
|
|||||||
- manifest 与 Rust runtime 校验保持一致。
|
- manifest 与 Rust runtime 校验保持一致。
|
||||||
- 增加 manifest snapshot 测试,防止字段再次漂移。
|
- 增加 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
|
> 发现时间:2026-05-17
|
||||||
>
|
>
|
||||||
> 状态:`[process]`
|
> 状态:`[done]`
|
||||||
>
|
>
|
||||||
> 关联主线:`07-ai`
|
> 关联主线:`07-ai`
|
||||||
|
|
||||||
@@ -30,6 +30,15 @@
|
|||||||
- 对 full_content、跨块 search/replace、列表/标题变更增加回读一致性测试。
|
- 对 full_content、跨块 search/replace、列表/标题变更增加回读一致性测试。
|
||||||
- route 成功条件必须从“operationsApplied > 0”提升为“Page Aggregate 回读等于预期最终 markdown”。
|
- 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
|
> 发现时间:2026-05-17
|
||||||
>
|
>
|
||||||
> 状态:`[process]`
|
> 状态:`[done]`
|
||||||
>
|
>
|
||||||
> 关联主线:`07-ai`
|
> 关联主线:`07-ai`
|
||||||
>
|
>
|
||||||
@@ -64,7 +64,7 @@ mnote.page_ai.block_edit_workflow 失败 · page-ai-fast-mp9wm0qo
|
|||||||
- “读取第一段 + 修改第二段”这类混合任务可能进入 fast workflow,但该 workflow 只能表达写入,不保证先读后答。
|
- “读取第一段 + 修改第二段”这类混合任务可能进入 fast workflow,但该 workflow 只能表达写入,不保证先读后答。
|
||||||
- 如果模型给出的 `search` 是段落序号、fuzzy 片段、带格式文本或跨块文本,markdown 层可能成功,block 写回层仍失败。
|
- 如果模型给出的 `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 映射失败的明确错误。
|
- 断言失败时错误码不能是 `mnote.doc.apply_block_ops operations 不能为空`,应是 markdown 到 block 映射失败的明确错误。
|
||||||
- 修复后回读 Page Aggregate,断言第二段实际变为 `测试123`,同时 AI 回复能正常说明已读取到第一段。
|
- 修复后回读 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-16(架构定位修正)
|
> 更新时间:2026-05-18(运行时收口验证)
|
||||||
>
|
>
|
||||||
> 状态:`[process]`
|
> 状态:`[done]`
|
||||||
>
|
>
|
||||||
> 关联主线:`07-ai` / `05-editor-mainline`
|
> 关联主线:`07-ai` / `05-editor-mainline`
|
||||||
>
|
>
|
||||||
@@ -100,3 +100,20 @@ replace 操作缺少 content
|
|||||||
- Model path(自然语言,真 AI): ❌ 400,replace 缺少 content
|
- Model path(自然语言,真 AI): ❌ 400,replace 缺少 content
|
||||||
- 代码:`page_ai_workflow.rs` system prompt 缺 operation schema
|
- 代码:`page_ai_workflow.rs` system prompt 缺 operation schema
|
||||||
- 用户反馈:direct path 不是 AI 面板应有的行为,应退役
|
- 用户反馈: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 编排中心。
|
- `PageAIApplyController` 后续应收口为 review session / tool executor / readback controller,不能成为第二套 agent 编排中心。
|
||||||
- 当前 `usedHermesRun=false` 的快路径只能理解为 deterministic shortcut,不代表 mnote 新建长期 agent runtime。
|
- 当前 `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)
|
## 1.1 当前执行状态(2026-05-16)
|
||||||
|
|
||||||
已完成并有代码/测试/smoke 证据:
|
已完成并有代码/测试/smoke 证据:
|
||||||
@@ -57,18 +67,19 @@
|
|||||||
- 脚本:`/mnt/Data1T/mnote/scripts/task-page-block-ai-context-format-smoke.js`。
|
- 脚本:`/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`。
|
- 证据:`/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`。
|
- 覆盖:`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 快速块编辑第一阶段已完成:
|
- 页面 AI 快速块编辑第一阶段已完成:
|
||||||
- 新增 `/api/page-ai/block-edit-workflow`,简单块增删改不再默认进入 Hermes agent run。
|
- 新增 `/api/page-ai/block-edit-workflow`,简单块增删改不再默认进入 Hermes agent run。
|
||||||
- 对明确中文指令 `把「A」替换为「B」/ 在「C」后插入「D」/ 删除「E」` 已先由 mnote 本地 planner 生成 `mnote.doc.apply_block_ops` operations;无法解析时才进入小模型 operations 路径。
|
- 对明确中文指令 `把「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 成本”。
|
- 快路径失败时,除 `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`。
|
- 真实浏览器 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`。
|
- 验证结果:`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/`:
|
仍未完成,本文继续留在 `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 首版。
|
- 持久审阅/preview UI 仍是后续可选项;当前默认 yolo 模式不做写入审批,`scope=selection` 与 `format=page_xml/text` 已进入工具与 PageAIContextBuilder 首版。
|
||||||
- PageAIIntentParser / PageAIOperationPlanner / PageAIOperationValidator / PageAIApplyController 仍需继续建设;当前本地 planner 只覆盖低歧义文本块增删改,不应被视为完整 AI 编辑 runtime。
|
- 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:
|
真实页面 smoke:
|
||||||
|
|
||||||
- [ ] 登录 `http://localhost:3000/auth` 测试账号。
|
- [x] 登录测试账号并创建综合页面 `TEST-AI-BLOCK-TOOLS-<timestamp>`。
|
||||||
- [ ] 新建页面 `TEST-AI-BLOCK-PROJECTION-<timestamp>`。
|
- [x] 写入段落、标题、todo、列表、mindmap/resource 占位。
|
||||||
- [ ] 输入段落、标题、todo、列表、mindmap/resource 占位至少各一个。
|
- [x] 调 `/api/page-aggregate/:id` 保存响应到 `tmp/hermes-tester/page-block-ai-tools-<run-id>/page-aggregate.json`。
|
||||||
- [ ] 调 `/api/page-aggregate/:id` 保存响应到 `tmp/hermes-tester/<run-id>/page-aggregate.json`。
|
- [x] 断言所有普通可编辑块有 `blockId` 和 `revisionRef`。
|
||||||
- [ ] 断言所有普通可编辑块有 `blockId` 和 `revisionRef`。
|
|
||||||
|
|
||||||
通过标准:
|
通过标准:
|
||||||
|
|
||||||
- [ ] 页面刷新后 block ids 不变化。
|
- [x] 页面刷新后 block ids 不变化。
|
||||||
- [ ] projection 中 `blockCount` 与页面可编辑块数量大体一致;复杂块允许受限但必须有 warning。
|
- [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:
|
真实页面 smoke:
|
||||||
|
|
||||||
- [ ] 使用 `TEST-AI-BLOCK-FETCH-<timestamp>` 页面。
|
- [x] 使用综合页面 `TEST-AI-BLOCK-TOOLS-<timestamp>`。
|
||||||
- [ ] `mnote.doc.fetch scope=full detail=with_ids` 读取整页。
|
- [x] `mnote.doc.fetch scope=full detail=with_ids` 读取整页。
|
||||||
- [ ] `mnote.doc.fetch scope=outline detail=with_ids` 只返回标题结构。
|
- [x] `mnote.doc.fetch scope=outline detail=with_ids` 只返回标题结构。
|
||||||
- [ ] `mnote.doc.find query=<唯一前缀>` 定位目标段落。
|
- [x] `mnote.doc.find query=<唯一前缀>` 定位目标段落。
|
||||||
- [ ] 保存工具返回到 `tmp/hermes-tester/<run-id>/doc-fetch-find.json`。
|
- [x] 保存工具返回到 `tmp/hermes-tester/page-block-ai-tools-<run-id>/doc-fetch-find.json`。
|
||||||
|
|
||||||
补充 smoke 证据:
|
补充 smoke 证据:
|
||||||
|
|
||||||
@@ -181,9 +196,16 @@ cargo test -p bridge-runtime doc_find
|
|||||||
|
|
||||||
通过标准:
|
通过标准:
|
||||||
|
|
||||||
- [ ] 不读取浏览器 DOM。
|
- [x] 不读取浏览器 DOM。
|
||||||
- [ ] `doc.find` 返回的 block id 能被 `block.fetch` 读取。
|
- [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:
|
真实页面 smoke:
|
||||||
|
|
||||||
- [ ] 从 `doc.find` 结果选择一个段落 block。
|
- [x] 从 `doc.find` 结果选择一个段落 block。
|
||||||
- [ ] 调 `mnote.block.fetch includeChildren=true contextBefore=1 contextAfter=1`。
|
- [x] 调 `mnote.block.fetch includeChildren=true contextBefore=1 contextAfter=1`。
|
||||||
- [ ] 断言 before/after 只来自同父级。
|
- [x] 断言 before/after 只来自同父级。
|
||||||
|
|
||||||
补充 smoke 证据:
|
补充 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] `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 文本与页面显示一致。
|
- [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_replace`。
|
||||||
- [x] 支持 `command=block_insert_after`。
|
- [x] 支持 `command=block_insert_after`。
|
||||||
- [x] 支持 `command=block_move_after` dry-run。
|
- [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] 缺少 `revision/conflictDetectionKey` 时只允许 dry-run。
|
||||||
- [x] 返回 `planId`、`diff`、`warnings`、`risk`、`blocked`。
|
- [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
|
```bash
|
||||||
@@ -252,16 +281,21 @@ cargo test -p bridge-runtime doc_insert_blocks
|
|||||||
|
|
||||||
真实页面 smoke:
|
真实页面 smoke:
|
||||||
|
|
||||||
- [ ] 对目标段落执行 `block_replace dryRun=true`。
|
- [x] 对目标段落执行 `block_replace dryRun=true`。
|
||||||
- [ ] 对目标段落执行 `block_insert_after dryRun=true`。
|
- [x] 对目标段落执行 `block_insert_after dryRun=true`。
|
||||||
- [ ] 对两个同父级普通块执行 `block_move_after dryRun=true`。
|
- [x] 对两个同父级普通块执行 `block_move_after dryRun=true`。
|
||||||
- [ ] dry-run 前后分别保存 `/api/page-aggregate/:id`,确认内容 hash 不变。
|
- [x] dry-run 前后分别读取 `/api/page-aggregate/:id` 与 `mnote.doc.fetch`,确认 revision / conflictDetectionKey / 正文文本不变。
|
||||||
|
|
||||||
通过标准:
|
通过标准:
|
||||||
|
|
||||||
- [ ] dry-run 不写 Convex。
|
- [x] dry-run 不写 Convex。
|
||||||
- [ ] plan 能解释 before/after。
|
- [x] plan 能解释 before/after。
|
||||||
- [ ] 不支持场景返回 `blocked=true`。
|
- [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:
|
真实页面 smoke:
|
||||||
|
|
||||||
- [ ] 新建 `TEST-AI-BLOCK-REPLACE-<timestamp>` 页面。
|
- [x] 使用综合页面 `TEST-AI-BLOCK-TOOLS-<timestamp>`。
|
||||||
- [ ] 写入两段不同文本。
|
- [x] 写入两段不同文本。
|
||||||
- [ ] `doc.find` 找到第二段 block id。
|
- [x] `doc.find` 找到第二段 block id。
|
||||||
- [ ] `block.replace dryRun=true` 查看 plan。
|
- [x] `block.replace dryRun=true` 查看 plan。
|
||||||
- [ ] `block.replace dryRun=false` 替换第二段。
|
- [x] `block.replace dryRun=false` 替换第二段。
|
||||||
- [ ] 页面截图证明只替换第二段。
|
- [x] 页面截图和工具回读证明只替换第二段。
|
||||||
- [ ] `/api/page-aggregate/:id` 回读证明持久化。
|
- [x] `/api/page-aggregate/:id` 回读证明持久化。
|
||||||
- [ ] `mnote.doc.fetch` 再次证明 AI 可读回。
|
- [x] `mnote.doc.fetch` 再次证明 AI 可读回。
|
||||||
|
|
||||||
通过标准:
|
通过标准:
|
||||||
|
|
||||||
- [ ] 相邻块不变化。
|
- [x] 相邻块不变化。
|
||||||
- [ ] 目标 block id 保持不变。
|
- [x] 目标 block id 保持不变。
|
||||||
- [ ] 旧 revision 写入返回 conflict。
|
- [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] tool manifest 增加 `mnote.block.insert_after`。
|
||||||
- [x] 输入必须包含 `anchorBlockId/revision/conflictDetectionKey/idempotencyKey/dryRun`。
|
- [x] 输入必须包含 `anchorBlockId/revision/conflictDetectionKey/idempotencyKey/dryRun`。
|
||||||
- [x] 新块 id 由 Rust runtime 分配。
|
- [x] 新块 id 由 Rust runtime 分配。
|
||||||
- [ ] 支持单块和最多 20 个普通块插入。(当前最小闭环为单块插入。)
|
- [x] 支持单块和最多 20 个普通块插入。
|
||||||
- [x] 第一阶段支持 paragraph/heading/todo。
|
- [x] 第一阶段支持 paragraph/heading/todo。
|
||||||
- [x] 返回 inserted block ids 和新 revision。
|
- [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
|
```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
|
cargo test -p bridge-runtime doc_insert_blocks_tool_emits_editor_commands
|
||||||
```
|
```
|
||||||
|
|
||||||
真实页面 smoke:
|
真实页面 smoke:
|
||||||
|
|
||||||
- [ ] 新建 `TEST-AI-BLOCK-INSERT-<timestamp>` 页面。
|
- [x] 使用综合页面 `TEST-AI-BLOCK-TOOLS-<timestamp>`。
|
||||||
- [ ] 在第一段后插入 todo。
|
- [x] 在第一段后插入 todo。
|
||||||
- [ ] 截图证明位置准确。
|
- [x] 截图证明位置准确。
|
||||||
- [ ] 刷新页面后再次确认。
|
- [x] 刷新页面后再次确认。
|
||||||
- [ ] `mnote.block.fetch` 能读取新 block id。
|
- [x] `mnote.block.fetch` 能读取新 block id。
|
||||||
|
|
||||||
通过标准:
|
通过标准:
|
||||||
|
|
||||||
- [ ] 插入位置准确。
|
- [x] 插入位置准确。
|
||||||
- [ ] 新 block id 不是 AI 自造未校验 id。
|
- [x] 新 block id 不是 AI 自造未校验 id。
|
||||||
- [ ] 重复同一 `idempotencyKey` 不重复插入。
|
- [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] tool manifest 增加 `mnote.block.move_after`。
|
||||||
- [x] `dryRun=true` 支持同父级叶子块 diff。
|
- [x] `dryRun=true` 支持同父级叶子块 diff。
|
||||||
- [ ] `dryRun=false` 前先继续阻断所有复杂块。(已有同父级/叶子/类型/editable/self 阻断,复杂块矩阵 smoke 待补。)
|
- [x] `dryRun=false` 前先继续阻断所有复杂块。(已有同父级/叶子/类型/editable/self 阻断,复杂块矩阵 route smoke 已补。)
|
||||||
- [x] 检查 `blockRevisionRef` 与 `anchorRevisionRef`。
|
- [x] 检查 `blockRevisionRef` 与 `anchorRevisionRef`。
|
||||||
- [x] 阻断移动到自身、移动到子树、跨页面移动。
|
- [x] 阻断移动到自身、移动到子树、跨页面移动。
|
||||||
- [x] 返回 from/to parent/order。
|
- [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
|
```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
|
cargo test -p mnote-editor-core command_executor
|
||||||
```
|
```
|
||||||
|
|
||||||
真实页面 smoke:
|
真实页面 smoke:
|
||||||
|
|
||||||
- [ ] 新建 `TEST-AI-BLOCK-MOVE-<timestamp>` 页面,包含三段普通段落。
|
- [x] 使用综合页面 `TEST-AI-BLOCK-TOOLS-<timestamp>`,包含三段普通段落。
|
||||||
- [ ] dry-run 移动第三段到第一段后。
|
- [x] dry-run 移动第三段到第一段后。
|
||||||
- [ ] 正式执行同父级移动。
|
- [x] 正式执行同父级移动。
|
||||||
- [ ] 截图证明顺序为第一段、第三段、第二段。
|
- [x] 截图证明顺序为第一段、第三段、第二段。
|
||||||
- [ ] `mnote.doc.fetch` 回读顺序一致。
|
- [x] `mnote.doc.fetch` 回读顺序一致。
|
||||||
- [ ] 对标题带子块、列表项、表格、mindmap 执行 move dry-run,必须返回 blocked。
|
- [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/`,直到:
|
本 checklist 不能移动到 `done/`,直到:
|
||||||
|
|
||||||
- [ ] Phase 1 到 Phase 6 全部完成。
|
- [x] Phase 1 到 Phase 6 全部完成。
|
||||||
- [ ] Phase 7 至少完成 dry-run 和阻断规则;若真实 move 未完成,`7-9` 必须仍标注受限。
|
- [x] Phase 7 至少完成 dry-run 和阻断规则;若真实 move 未完成,`7-9` 必须仍标注受限。
|
||||||
- [ ] 每个写工具都有真实页面 smoke 证据。
|
- [x] 每个写工具都有真实页面 smoke 证据。
|
||||||
- [ ] 失败项已经写入 `bugs/07-ai/process/` 或真实 owner 分类。
|
- [x] 失败项已经写入 `bugs/07-ai/process/` 或真实 owner 分类。
|
||||||
- [ ] `mnote.page.save` 不再被任何设计描述为精确块编辑主入口。
|
- [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 写入安全边界。
|
> 本稿目的:修正“页面 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/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-9-page-block-ai-tooling-roadmap-v1.md`
|
||||||
> - `/mnt/Data1T/mnote/design/07-ai/done/7-6-mnote-hermes-plugin-tool-contract-v1.md`
|
> - `/mnt/Data1T/mnote/design/07-ai/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. 本轮结论
|
## 1. 本轮结论
|
||||||
|
|
||||||
页面 AI 编辑卡顿的根因不是“Rust apply 慢”,而是模型和工具之间缺少稳定、低歧义、可审计的编辑命令面:
|
页面 AI 编辑卡顿的根因不是“Rust apply 慢”,而是模型和工具之间缺少稳定、低歧义、可审计的编辑命令面:
|
||||||
@@ -348,6 +358,96 @@ stale
|
|||||||
|
|
||||||
第一阶段可以保留 yolo,但仍应让工具返回 review-compatible 数据结构,避免后续 UI 重写。
|
第一阶段可以保留 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. 关键流程
|
## 6. 关键流程
|
||||||
@@ -508,7 +608,7 @@ profile disabled mnote.block.fetch
|
|||||||
|
|
||||||
### Phase D:Review session
|
### 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。
|
- [ ] `mnote.doc.plan_update` 与 `mnote.doc.apply_block_ops dryRun=true` 返回 review-compatible draft。
|
||||||
- [ ] 页面 AI UI 展示 diff/warnings/risk/blocked。
|
- [ ] 页面 AI UI 展示 diff/warnings/risk/blocked。
|
||||||
- [ ] accept/reject/retry/abort 可用。
|
- [ ] 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 审查总览
|
# 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)
|
- [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`
|
> - `/mnt/Data1T/mnote/AGENTS.md`
|
||||||
@@ -35,14 +35,14 @@
|
|||||||
- Page Aggregate 仍是过渡态,block projection 主要从 `documents.content` / local markdown content 投影,不是 EditorBlockDocument 原生落库完成态。
|
- Page Aggregate 仍是过渡态,block projection 主要从 `documents.content` / local markdown content 投影,不是 EditorBlockDocument 原生落库完成态。
|
||||||
- 标题、正文、页面设置、page tree、AI 写入口尚未完全闭环到同一组 projection / command family。
|
- 标题、正文、页面设置、page tree、AI 写入口尚未完全闭环到同一组 projection / command family。
|
||||||
- `tree.*` 已是 preferred command name,但 `documents.*` 兼容命令面仍未完全降级。
|
- `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 写入路径。
|
- AI 块工具已有最小闭环,但当前编辑路径以块操作(blockId)为中心——AI 被迫理解 UI 层概念,且本地 `.md` 文件无 AI 写入路径。`7-14` 提出应将 AI 编辑主路径修正为 markdown 文本级(search/replace),`mnote.block.*` 降级为结构性辅助,在线 Convex 文档与本地文件共用同一条 markdown 写入路径。
|
||||||
|
|
||||||
补充 BlockNote / Tiptap AI 参考后的判断:
|
补充 BlockNote / Tiptap AI 参考后的判断:
|
||||||
|
|
||||||
- BlockNote AI 和 Tiptap AI Toolkit 可以参考的是 AI runtime shape 与 tool contract,不是 mnote 的事实源。
|
- BlockNote AI 和 Tiptap AI Toolkit 可以参考的是 AI runtime shape 与 tool contract,不是 mnote 的事实源。
|
||||||
- AI 方向下一步只应补基础底座:`PageAIContextBuilder`、`scope=selection`、`page_xml/text`、tool manifest annotations、`PageAIReviewSession`、accept/reject/retry/abort 状态机。
|
- AI 方向下一步只应补基础底座:`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 或跨页面智能功能。
|
- 在非 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 补偿链和旧快照回闪。
|
- 减少 query/refetch/freshness 补偿链和旧快照回闪。
|
||||||
|
|
||||||
### P0:AI 基础工具与审阅底座
|
### P0:AI 基础工具与审阅底座
|
||||||
@@ -115,13 +115,13 @@
|
|||||||
- 不新增复杂 AI 自动化功能面。
|
- 不新增复杂 AI 自动化功能面。
|
||||||
- 不把 `@blocknote/xl-ai` 或 BlockNote `AIExtension` 作为 runtime dependency。
|
- 不把 `@blocknote/xl-ai` 或 BlockNote `AIExtension` 作为 runtime dependency。
|
||||||
- 不让 AI 写入绕过 `dryRun/idempotencyKey/revision/conflictDetectionKey/revisionRef`。
|
- 不让 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.*` 工具;现有保留为结构性辅助。**
|
- **不新增 `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 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 B(下一阶段)**:退役 `direct_block_edit_operations`,`page_ai_workflow.rs` 改走 `markdown_edit`(7-14 v2 Phase B)。
|
- **Phase C 仍冻结**:流式 apply + suggest/review 只保留设计边界,当前不实施、不扩新 AI 功能。
|
||||||
- 在线 Convex 文档和本地 `.md` 文件共用同一条 markdown AI 写入路径(`resolve_source` → Convex | LocalFS)。
|
- 在线 Convex 文档和本地 `.md` 文件共用同一条 markdown AI 写入路径(`resolve_source` → Convex | LocalFS)。
|
||||||
|
|
||||||
### P1:定向 Bug Hunt
|
### P1:定向 Bug Hunt
|
||||||
@@ -144,8 +144,8 @@
|
|||||||
|
|
||||||
### 3.1 Page Aggregate 单一真源
|
### 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-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-builder*` 的引用,确认只剩 test helper / historical adapter。
|
||||||
- [x] 检查 `/api/documents/page` 仍返回明确 `410`,不参与 runtime 主链。
|
- [x] 检查 `/api/documents/page` 仍返回明确 `410`,不参与 runtime 主链。
|
||||||
- [x] 跑文档页打开 smoke,记录 `/api/page-aggregate/:id` 是首要读链。
|
- [x] 跑文档页打开 smoke,记录 `/api/page-aggregate/:id` 是首要读链。
|
||||||
- [x] 新建页面后检查 Page Aggregate `identity/head/body/tree/stats` 字段完整。
|
- [x] 新建页面后检查 Page Aggregate `identity/head/body/tree/stats` 字段完整。
|
||||||
@@ -156,7 +156,7 @@
|
|||||||
- [x] 破坏或暂停 Convex query,检查响应是 degraded/error,不返回伪 fixture。
|
- [x] 破坏或暂停 Convex query,检查响应是 degraded/error,不返回伪 fixture。
|
||||||
- [x] 在 `design/05-editor-mainline/process/5-6-page-aggregate-alignment-checklist-v1.md` 勾选已验证项,并补证据路径。
|
- [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。
|
- `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`。
|
- `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`。
|
- 证据:`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` 均保持最新值。
|
- 验证结果:刷新前后 `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 应用。
|
- 修复点:`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`。
|
- 缺陷记录:`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`。
|
- 配套测试已通过:`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
|
### 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 scope=selection` 补真实选区上下文输入和返回结构。
|
||||||
- [x] 给 `mnote.doc.fetch format=page_xml` 补最小 PageXML 输出。
|
- [x] 给 `mnote.doc.fetch format=page_xml` 补最小 PageXML 输出。
|
||||||
- [x] 给 `mnote.block.fetch format=text/page_xml` 补格式分支。
|
- [x] 给 `mnote.block.fetch format=text/page_xml` 补格式分支。
|
||||||
- [x] 给 tool manifest 补 `annotations`,区分 readonly / destructive / requiresApproval / selectionEffect。
|
- [x] 给 tool manifest 补 `annotations`,区分 readonly / destructive / requiresApproval / selectionEffect。
|
||||||
- [x] 定义 `mnote.page_ai_context.v1`,明确 context 只来自 Page Aggregate projection。
|
- [x] 定义 `mnote.page_ai_context.v1`,明确 context 只来自 Page Aggregate projection。
|
||||||
- [ ] 定义 `mnote.page_ai_review_session.v1`,明确 accept / reject / retry / abort 语义。
|
- [x] 定义 `mnote.page_ai_review_session.v1`,明确 accept / reject / retry / abort 语义。
|
||||||
- [ ] 给 `mnote.block.insert_after` 补多块插入限制和返回 inserted block ids。
|
- [x] 给 `mnote.block.insert_after` 补多块插入限制和返回 inserted block ids。
|
||||||
- [ ] 给 `mnote.block.move_after` 补标题带子块阻断 smoke。
|
- [x] 给 `mnote.block.move_after` 补标题带子块阻断 smoke。
|
||||||
- [ ] 给 `mnote.block.move_after` 补列表项阻断 smoke。
|
- [x] 给 `mnote.block.move_after` 补列表项阻断 smoke。
|
||||||
- [ ] 给 `mnote.block.move_after` 补表格 / mindmap / resource 阻断 smoke。
|
- [x] 给 `mnote.block.move_after` 补表格 / mindmap / resource 阻断 smoke。
|
||||||
- [x] 给 `mnote.block.replace` 补 stale revision 失败用例。
|
- [x] 给 `mnote.block.replace` 补 stale revision 失败用例。
|
||||||
- [x] 给 `mnote.block.replace` 补 stale blockRevisionRef 失败用例。
|
- [x] 给 `mnote.block.replace` 补 stale blockRevisionRef 失败用例。
|
||||||
- [x] 给写工具补重复 idempotencyKey 的端到端用例。
|
- [x] 给写工具补重复 idempotencyKey 的端到端用例。
|
||||||
- [x] 检查 `mnote.page.save` 在 manifest / UI 中继续标为页面级兜底,不显示为精确块编辑主入口。
|
- [x] 检查 `mnote.page.save` 在 manifest / UI 中继续标为页面级兜底,不显示为精确块编辑主入口。
|
||||||
- [ ] 检查任何新增 AI surface 是否只是基础 review/context/tooling 验收,不是新功能扩展。
|
- [x] 检查任何新增 AI surface 是否只是基础 review/context/tooling 验收,不是新功能扩展。
|
||||||
- [ ] 更新 `design/07-ai/process/7-10-page-block-ai-tooling-execution-checklist-v1.md` 的每个 phase 证据。
|
- [x] 更新 `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] 更新 `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(当前唯一活跃实施):**
|
- 代码:`rust/crates/mnote-web/src/hermes_tools/block.rs` 支持 `content` / `block` / `blocks` 输入;`blocks` 限制 `1..=20`;多块写入按输入顺序连续插入,并在结果返回 `insertedBlockIds`。
|
||||||
- [ ] `mnote.doc.markdown_edit` 核心实现:resolve_source → search_replace → write_target
|
- manifest:`rust/crates/mnote-web/src/hermes_tools/manifest.rs` 已声明 `blocks.minItems=1/maxItems=20`,并用 `anyOf` 表达 `content | block | blocks` 三种输入。
|
||||||
- [ ] `mnote.doc.markdown_edit` 搜索替换语义:精确匹配 → fuzzy fallback → operationsFailed(7-14 §5)
|
- 测试:`cargo test --manifest-path rust/Cargo.toml -p mnote-web hermes_tools_block_insert_after -- --nocapture` 通过,覆盖多块成功和超过 20 个失败。
|
||||||
- [ ] `mnote.doc.markdown_edit` Convex 写入 adapter(复用 `page.body.save` 链路)
|
- 回归:`cargo test --manifest-path rust/Cargo.toml -p mnote-web hermes_tools -- --nocapture` 通过,覆盖 Hermes tools 38 项。
|
||||||
- [ ] `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)
|
|
||||||
|
|
||||||
**Phase B(下一阶段):**
|
2026-05-18 `mnote.block.move_after` 复杂块阻断证据:
|
||||||
- [ ] `direct_block_edit_operations` 退役(`page_ai_workflow.rs` `local_rule` 分支)
|
|
||||||
- [ ] `page_ai_workflow.rs` 修正:system prompt 改为产 search/replace 对,底层走 `mnote.doc.markdown_edit`
|
- 代码:`rust/crates/mnote-web/src/hermes_tools/block.rs` 继续以同父级、叶子块、可移动类型、editable、自身移动为真实写入前阻断条件。
|
||||||
- [ ] 补全 operation schema(`content` 字段)
|
- 测试:`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`。
|
||||||
- [ ] 浏览器 smoke:自然语言编辑可用(不使用「」格式)
|
- 回归:`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(设计冻结,不实施):**
|
**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 证据:
|
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 写保护矩阵。
|
- 边界:本轮只验证 `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`。
|
- 配套验证已通过:`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` 验证冲突和幂等安全边界。
|
- 新增并运行 `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/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`。
|
- 首次 `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`,确认不重复写入。
|
- 使用同一 `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` 调用 `mnote.block.replace` 返回 HTTP `400`、错误码 `mnote_tool_conflict`,正文保持首次写入结果。
|
||||||
- 使用最新 `revision/conflictDetectionKey` 但旧 `blockRevisionRef` 调用 `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 已完成。
|
- 边界:本轮只覆盖 `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`。
|
- 配套验证已通过:`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] 对 compat alias 返回增加 owner / deprecated 标识,避免被当作主链。
|
||||||
- [x] 更新 `design/04-tree-domain/done/4-6-tree-command-protocol-cutover-stage2-v1.md` 或新增后续 process checklist。
|
- [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/**'`。
|
- 只读盘点命令:`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 前端主链:`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 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 作为主链输入。
|
- 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`。
|
- 兼容保留:`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 把审计字段当写入参数。
|
- 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 检查 Sidebar / Breadcrumb / File Tree 一致更新。
|
||||||
- [x] 移动页面后,双浏览器 A/B 检查 tree order 不回闪。
|
- [x] 移动页面后,双浏览器 A/B 检查 tree order 不回闪。
|
||||||
- [x] 删除 / 恢复后检查 trash 与主树事件一致。
|
- [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` 的已验证项。
|
- [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`。
|
- `/api/realtime/ws` 已成为 Rust SSR 主壳默认 transport(`convex-command-log-ws`),`/api/tree/events` 仅作为 SSE fallback。
|
||||||
- 仍未统一的补偿链:`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。
|
- `CURRENT_ARCHITECTURE.md` 与 `bugs/03-rust-web/done/3-16` 到 `3-18` 已同步:原“WS 文档口径 / 前端 SSE 实现”冲突、SSE push 跳过 polling safety net、WS/SSE delta 合同分裂均已修复。
|
||||||
- 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。
|
- 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。
|
- 本轮新增 `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。
|
- 本轮强化 `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
|
### 3.5 定向 Bug Hunt
|
||||||
|
|
||||||
- [ ] 建立 bugs 分类:Page Aggregate 问题归 `bugs/05-editor-mainline/process/`。
|
- [x] 建立 bugs 分类:Page Aggregate 问题归 `bugs/05-editor-mainline/process/`。
|
||||||
- [ ] Tree command / realtime / File Tree 问题归 `bugs/04-tree-domain/process/`。
|
- [x] Tree command / realtime / File Tree 问题归 `bugs/04-tree-domain/process/`。
|
||||||
- [ ] AI tools 问题归 `bugs/07-ai/process/`。
|
- [x] AI tools 问题归 `bugs/07-ai/process/`。
|
||||||
- [ ] 每个 bug 必须包含复现步骤、期望、实际、证据截图或 JSON、owner 判断。
|
- [x] 每个 bug 必须包含复现步骤、期望、实际、证据截图或 JSON、owner 判断。
|
||||||
- [ ] 先补最小 failing smoke,再修实现。
|
- [x] 先补最小 failing smoke 或定向单测,再修实现。
|
||||||
- [ ] 修复后移动到对应 `done/`,并记录验证命令。
|
- [x] 修复后移动到对应 `done/`,并记录验证命令。
|
||||||
|
|
||||||
重点 bug 方向:
|
重点 bug 方向:
|
||||||
|
|
||||||
- [ ] Page Aggregate 与页头标题不一致。
|
- [x] Page Aggregate 与页头标题不一致。
|
||||||
- [ ] Page Aggregate 与 File Tree `{title}.md` 不一致。
|
- [x] Page Aggregate 与 File Tree `{title}.md` 不一致。
|
||||||
- [ ] AI 写入成功但 Page Aggregate 回读旧内容。
|
- [x] AI 写入成功但 Page Aggregate 回读旧内容。
|
||||||
- [ ] stale revision 未阻断写入。
|
- [x] stale revision 未阻断写入。
|
||||||
- [ ] tree stream 断线恢复后 UI 停在旧快照。
|
- [x] tree stream 断线恢复后 UI 停在旧快照。
|
||||||
- [ ] 旧 compat / debug route 在 3000 首屏被误用。
|
- [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 暂停交接进展摘要
|
### 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. 当前不优先做
|
## 4. 当前不优先做
|
||||||
|
|
||||||
- [ ] 不优先增加新的编辑器 UI 大功能。
|
- [x] 不优先增加新的编辑器 UI 大功能。
|
||||||
- [ ] 不优先新增 AI Agent 工作流功能。
|
- [x] 不优先新增 AI Agent 工作流功能。
|
||||||
- [ ] 不优先扩展 AI 功能面;AI 只做基础上下文、工具合同、审阅会话、冲突与回滚。
|
- [x] 不优先扩展 AI 功能面;AI 只做基础上下文、工具合同、审阅会话、冲突与回滚。
|
||||||
- [ ] 不优先大规模替换 Convex。
|
- [x] 不优先大规模替换 Convex。
|
||||||
- [ ] 不优先重写 `leptos-tiptap` 输入层。
|
- [x] 不优先重写 `leptos-tiptap` 输入层。
|
||||||
- [ ] 不把 `mnote.page.save` 包装成精确块编辑长期方案。
|
- [x] 不把 `mnote.page.save` 包装成精确块编辑长期方案。
|
||||||
- [ ] 不在 compat route 继续扩写长期业务语义。
|
- [x] 不在 compat route 继续扩写长期业务语义。
|
||||||
- [ ] 不把 BlockNote / Tiptap AI runtime 作为 mnote runtime 依赖。
|
- [x] 不把 BlockNote / Tiptap AI runtime 作为 mnote runtime 依赖。
|
||||||
- [ ] 不把 `direct_block_edit_operations` / `local_rule` planner 当作长期 AI 编辑路径(当前为过渡实现,将被 `mnote.doc.markdown_edit` 替代)。
|
- [x] 不把 `direct_block_edit_operations` / `local_rule` planner 当作当前或长期 AI 编辑路径;简单正文编辑当前主路径是 `mnote.doc.markdown_edit`。
|
||||||
- [ ] 不新增 `mnote.block.*` 工具;`mnote.block.*` 保留为结构性辅助,不做块操作向新工具的扩张。
|
- [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/`,直到:
|
本 checklist 不能迁入 `done/`,直到:
|
||||||
|
|
||||||
- [ ] Page Aggregate 的标题 / 正文 / 页面设置 / page tree / AI fetch 回读形成同一组可验证真相。
|
- [x] Page Aggregate 的标题 / 正文 / 页面设置 / page tree / AI fetch 回读形成同一组可验证真相。
|
||||||
- [ ] `7-10` 的 Page Block AI Tooling 剩余矩阵完成或明确拆出后续 process 文档。
|
- [x] `7-10` 的 Page Block AI Tooling 剩余矩阵完成或明确拆出后续 process 文档。
|
||||||
- [ ] `7-11` 的 AI 基础 runtime 口径完成:context、format、manifest annotations、review session、状态机边界都有代码或明确后续 checklist。
|
- [x] `7-12` 的 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 自行完成。
|
- [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 修复共同完成。
|
||||||
- [ ] `tree.*` command 面对主要页面生命周期动作成为唯一 preferred runtime 主链。
|
- [x] `tree.*` command 面对主要页面生命周期动作成为唯一 preferred runtime 主链。
|
||||||
- [ ] tree realtime live cache 覆盖 Sidebar、Page Tree、File Tree、page subtree 的关键写后更新。
|
- [x] tree realtime live cache 覆盖 Sidebar、Page Tree、File Tree、page subtree 的关键写后更新。
|
||||||
- [ ] 至少一轮定向 bug hunt 完成,所有 P0/P1 blocker 已归档到 `bugs/*/done/` 或明确保留为后续 process。
|
- [x] 至少一轮定向 bug hunt 完成,所有 P0/P1 blocker 已归档到 `bugs/*/done/` 或明确保留为后续 process。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 6. 给后续 /goal 的持续执行 Prompt
|
## 6. 给后续 /goal 的持续执行 Prompt
|
||||||
|
|
||||||
```text
|
```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
|
# 09 页面 AI 快速块编辑 Runtime Review
|
||||||
|
|
||||||
> 状态:`process`
|
> 状态:`done`
|
||||||
>
|
>
|
||||||
> 日期:2026-05-16
|
> 日期: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. 本轮结论
|
## 1. 本轮结论
|
||||||
|
|
||||||
页面 AI 块写入的慢点不在 Rust 块工具本身,也不在 Convex 持久化本身。
|
页面 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
|
> 更新时间:2026-05-17
|
||||||
>
|
>
|
||||||
> 执行状态:`process`
|
> 执行状态:`done`
|
||||||
>
|
>
|
||||||
> 范围:当前主线中 Page Aggregate 单一真源、页面 AI 快速编辑、`mnote.doc.markdown_edit` 与 ACP / Hermes runtime 的源码级定向审查。
|
> 范围:当前主线中 Page Aggregate 单一真源、页面 AI 快速编辑、`mnote.doc.markdown_edit` 与 ACP / Hermes runtime 的源码级定向审查。
|
||||||
|
|
||||||
## 1. 本轮结论
|
## 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` 调用。
|
- `mnote.doc.markdown_edit` 是简单正文编辑主路径,并被 `/api/page-ai/block-edit-workflow` 通过统一 mnote tool executor 调用。
|
||||||
- 该工具当前先在 markdown 字符串上顺序执行 search/replace,再把原始 operations 重新转换成 `mnote.doc.apply_block_ops`。
|
- 中文归一化匹配不再把 UTF-8 byte index 当 char index。
|
||||||
- 转换层仍按“单块文本包含 search”查找目标块,无法忠实表达 markdown 层已经算出的最终结果。
|
- 同一块内多次 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. 发现的问题
|
## 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”可能只保留最后一次修改。
|
- AI 一次请求中常见的“把同一段里的 A 改成 B,同时把 C 改成 D”可能只保留最后一次修改。
|
||||||
- 前端快路径仍可能显示“已通过页面 markdown 编辑快路径完成写入”,但正文只部分生效。
|
- 前端快路径仍可能显示“已通过页面 markdown 编辑快路径完成写入”,但正文只部分生效。
|
||||||
|
|
||||||
## 3. 次级风险
|
## 3. 修复状态
|
||||||
|
|
||||||
- `doc_markdown_edit` 当前允许部分 operation 失败后继续写入已成功的子集,并返回 `ok=true`。如果这是有意设计,需要在 manifest / UI 中明确“非原子”;如果不是,应改为任一 operation 失败时不写入。
|
- `mnote.doc.markdown_edit` manifest 已补齐 required / write contract。
|
||||||
- `changedText` 通过 `operations.iter().take(applied)` 生成摘要;当前如果第一个 operation 失败、第二个成功,摘要会错误地展示第一个失败 operation。
|
- 本地 `.md` 写入已尊重 dryRun / idempotency。
|
||||||
- `/api/page-ai/block-edit-workflow` 的系统 prompt 已改成 search/replace,但仍强依赖模型精确复制 `pageText`;一旦模型输出跨块片段,Convex 写回层不能表达该编辑。
|
- 在线写回已使用最终 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 映射。
|
1. Phase C 的 review session / streaming apply 仍冻结,当前不扩新 AI 功能。
|
||||||
2. 给 `doc_markdown_edit` 补同一 block 多 operation 的失败用例,明确应以最终 markdown 生成落库内容,或在转换层合并同块操作。
|
2. 复杂结构编辑继续走 `apply_block_ops` / `mnote.block.*`,不要把正文 search/replace 回退到结构性块工具。
|
||||||
3. 明确 `markdown_edit` 的失败原子性:默认建议任一 operation 失败时不写入,除非请求显式允许 partial apply。
|
3. Page Aggregate 仍需后续推进 EditorBlockDocument 原生落库真相。
|
||||||
4. 将 `/api/page-ai/block-edit-workflow` 的验收从“route 成功”提升为“Page Aggregate 回读与预期最终 markdown 一致”。
|
|
||||||
|
|
||||||
## 5. 本轮验证
|
## 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/**'
|
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
|
```bash
|
||||||
cargo test --manifest-path rust/Cargo.toml -p mnote-web markdown_edit
|
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
|
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
+4652
-74
File diff suppressed because it is too large
Load Diff
+67
-66
@@ -1,66 +1,67 @@
|
|||||||
{
|
{
|
||||||
"name": "mnote-desktop",
|
"name": "mnote-desktop",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"main": "desktop-electron/main.js",
|
"main": "desktop-electron/main.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"desktop": "node scripts/desktop-prod.js",
|
"desktop": "node scripts/desktop-prod.js",
|
||||||
"desktop:hot": "node scripts/desktop-hot.js",
|
"desktop:hot": "node scripts/desktop-hot.js",
|
||||||
"dev:hot": "node scripts/dev-hot.js",
|
"dev:hot": "node scripts/dev-hot.js",
|
||||||
"desktop:local": "node scripts/desktop-local.js",
|
"desktop:local": "node scripts/desktop-local.js",
|
||||||
"desktop:electron": "node desktop-electron/dev-runner.js",
|
"desktop:electron": "node desktop-electron/dev-runner.js",
|
||||||
"build:desktop:next": "node scripts/prepare-desktop-next.js",
|
"build:desktop:next": "node scripts/prepare-desktop-next.js",
|
||||||
"dist:win": "pnpm run build:desktop:next && electron-builder --win nsis"
|
"dist:win": "pnpm run build:desktop:next && electron-builder --win nsis"
|
||||||
},
|
},
|
||||||
"dependencies": {},
|
"dependencies": {},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@playwright/test": "^1.56.1",
|
"@playwright/test": "^1.56.1",
|
||||||
"electron": "^34.0.0",
|
"convex": "^1.39.1",
|
||||||
"electron-builder": "^26.0.12",
|
"electron": "^34.0.0",
|
||||||
"playwright": "^1.56.1"
|
"electron-builder": "^26.0.12",
|
||||||
},
|
"playwright": "^1.56.1"
|
||||||
"build": {
|
},
|
||||||
"appId": "org.aichem.mnote",
|
"build": {
|
||||||
"productName": "MNOTE",
|
"appId": "org.aichem.mnote",
|
||||||
"asar": true,
|
"productName": "MNOTE",
|
||||||
"directories": {
|
"asar": true,
|
||||||
"output": "dist",
|
"directories": {
|
||||||
"buildResources": "desktop-electron/build"
|
"output": "dist",
|
||||||
},
|
"buildResources": "desktop-electron/build"
|
||||||
"files": [
|
},
|
||||||
"package.json",
|
"files": [
|
||||||
"desktop-electron/**",
|
"package.json",
|
||||||
"!desktop-electron/desktop-next{,/**}"
|
"desktop-electron/**",
|
||||||
],
|
"!desktop-electron/desktop-next{,/**}"
|
||||||
"extraResources": [
|
],
|
||||||
{
|
"extraResources": [
|
||||||
"from": "desktop-electron/desktop-next",
|
{
|
||||||
"to": "desktop-next",
|
"from": "desktop-electron/desktop-next",
|
||||||
"filter": [
|
"to": "desktop-next",
|
||||||
"**/*",
|
"filter": [
|
||||||
"!node_modules{,/**}"
|
"**/*",
|
||||||
]
|
"!node_modules{,/**}"
|
||||||
},
|
]
|
||||||
{
|
},
|
||||||
"from": "desktop-electron/desktop-next/node_modules",
|
{
|
||||||
"to": "desktop-next/node_modules"
|
"from": "desktop-electron/desktop-next/node_modules",
|
||||||
}
|
"to": "desktop-next/node_modules"
|
||||||
],
|
}
|
||||||
"win": {
|
],
|
||||||
"target": [
|
"win": {
|
||||||
"nsis"
|
"target": [
|
||||||
]
|
"nsis"
|
||||||
},
|
]
|
||||||
"nsis": {
|
},
|
||||||
"oneClick": false,
|
"nsis": {
|
||||||
"allowToChangeInstallationDirectory": true,
|
"oneClick": false,
|
||||||
"include": "desktop-electron/build/installer.nsh"
|
"allowToChangeInstallationDirectory": true,
|
||||||
}
|
"include": "desktop-electron/build/installer.nsh"
|
||||||
},
|
}
|
||||||
"pnpm": {
|
},
|
||||||
"onlyBuiltDependencies": [
|
"pnpm": {
|
||||||
"electron",
|
"onlyBuiltDependencies": [
|
||||||
"electron-winstaller"
|
"electron",
|
||||||
]
|
"electron-winstaller"
|
||||||
}
|
]
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|||||||
Generated
+320
@@ -11,6 +11,9 @@ importers:
|
|||||||
'@playwright/test':
|
'@playwright/test':
|
||||||
specifier: ^1.56.1
|
specifier: ^1.56.1
|
||||||
version: 1.57.0
|
version: 1.57.0
|
||||||
|
convex:
|
||||||
|
specifier: ^1.39.1
|
||||||
|
version: 1.39.1
|
||||||
electron:
|
electron:
|
||||||
specifier: ^34.0.0
|
specifier: ^34.0.0
|
||||||
version: 34.5.8
|
version: 34.5.8
|
||||||
@@ -66,6 +69,162 @@ packages:
|
|||||||
engines: {node: '>=14.14'}
|
engines: {node: '>=14.14'}
|
||||||
hasBin: true
|
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':
|
'@isaacs/balanced-match@4.0.1':
|
||||||
resolution: {integrity: sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ==}
|
resolution: {integrity: sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ==}
|
||||||
engines: {node: 20 || >=22}
|
engines: {node: 20 || >=22}
|
||||||
@@ -341,6 +500,25 @@ packages:
|
|||||||
concat-map@0.0.1:
|
concat-map@0.0.1:
|
||||||
resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==}
|
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:
|
core-util-is@1.0.2:
|
||||||
resolution: {integrity: sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==}
|
resolution: {integrity: sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==}
|
||||||
|
|
||||||
@@ -483,6 +661,11 @@ packages:
|
|||||||
es6-error@4.1.1:
|
es6-error@4.1.1:
|
||||||
resolution: {integrity: sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg==}
|
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:
|
escalade@3.2.0:
|
||||||
resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==}
|
resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==}
|
||||||
engines: {node: '>=6'}
|
engines: {node: '>=6'}
|
||||||
@@ -976,6 +1159,11 @@ packages:
|
|||||||
engines: {node: '>=14.0.0'}
|
engines: {node: '>=14.0.0'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
prettier@3.8.3:
|
||||||
|
resolution: {integrity: sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==}
|
||||||
|
engines: {node: '>=14'}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
proc-log@5.0.0:
|
proc-log@5.0.0:
|
||||||
resolution: {integrity: sha512-Azwzvl90HaF0aCz1JrDdXQykFakSSNPaPoiZ9fm5qJIMHioDZEi7OAdRwSm6rSoPtY3Qutnm3L7ogmg3dc+wbQ==}
|
resolution: {integrity: sha512-Azwzvl90HaF0aCz1JrDdXQykFakSSNPaPoiZ9fm5qJIMHioDZEi7OAdRwSm6rSoPtY3Qutnm3L7ogmg3dc+wbQ==}
|
||||||
engines: {node: ^18.17.0 || >=20.5.0}
|
engines: {node: ^18.17.0 || >=20.5.0}
|
||||||
@@ -1246,6 +1434,18 @@ packages:
|
|||||||
wrappy@1.0.2:
|
wrappy@1.0.2:
|
||||||
resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==}
|
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:
|
xmlbuilder@15.1.1:
|
||||||
resolution: {integrity: sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg==}
|
resolution: {integrity: sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg==}
|
||||||
engines: {node: '>=8.0'}
|
engines: {node: '>=8.0'}
|
||||||
@@ -1372,6 +1572,84 @@ snapshots:
|
|||||||
- supports-color
|
- supports-color
|
||||||
optional: true
|
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/balanced-match@4.0.1': {}
|
||||||
|
|
||||||
'@isaacs/brace-expansion@5.0.0':
|
'@isaacs/brace-expansion@5.0.0':
|
||||||
@@ -1713,6 +1991,15 @@ snapshots:
|
|||||||
|
|
||||||
concat-map@0.0.1: {}
|
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:
|
core-util-is@1.0.2:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
@@ -1906,6 +2193,35 @@ snapshots:
|
|||||||
es6-error@4.1.1:
|
es6-error@4.1.1:
|
||||||
optional: true
|
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: {}
|
escalade@3.2.0: {}
|
||||||
|
|
||||||
escape-string-regexp@4.0.0:
|
escape-string-regexp@4.0.0:
|
||||||
@@ -2421,6 +2737,8 @@ snapshots:
|
|||||||
commander: 9.5.0
|
commander: 9.5.0
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
prettier@3.8.3: {}
|
||||||
|
|
||||||
proc-log@5.0.0: {}
|
proc-log@5.0.0: {}
|
||||||
|
|
||||||
progress@2.0.3: {}
|
progress@2.0.3: {}
|
||||||
@@ -2700,6 +3018,8 @@ snapshots:
|
|||||||
|
|
||||||
wrappy@1.0.2: {}
|
wrappy@1.0.2: {}
|
||||||
|
|
||||||
|
ws@8.18.0: {}
|
||||||
|
|
||||||
xmlbuilder@15.1.1: {}
|
xmlbuilder@15.1.1: {}
|
||||||
|
|
||||||
y18n@5.0.8: {}
|
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,
|
"toc" | "toc_node" | "tocnode" => EditorBlockType::Toc,
|
||||||
"page_reference" => EditorBlockType::PageReference,
|
"page_reference" => EditorBlockType::PageReference,
|
||||||
"block_reference" => EditorBlockType::BlockReference,
|
"block_reference" => EditorBlockType::BlockReference,
|
||||||
|
"resource" | "resource_block" => EditorBlockType::Resource,
|
||||||
_ => EditorBlockType::Paragraph,
|
_ => 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"])
|
let text = read_trimmed_string_field(block, &["content"])
|
||||||
.filter(|value| !value.is_empty())
|
.filter(|value| !value.is_empty())
|
||||||
.unwrap_or_else(|| extract_inline_text(block));
|
.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 blocks = Vec::<EditorBlock>::new();
|
||||||
let mut root_block_ids = Vec::<String>::new();
|
let mut root_block_ids = Vec::<String>::new();
|
||||||
for (index, block) in normalize_blocks_from_value(content).iter().enumerate() {
|
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));
|
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
|
if let Some(text_align) = block
|
||||||
@@ -11372,6 +11395,7 @@ fn legacy_type_from_editor_block(block: &EditorBlock) -> &'static str {
|
|||||||
EditorBlockType::Toc => "toc",
|
EditorBlockType::Toc => "toc",
|
||||||
EditorBlockType::PageReference => "page_reference",
|
EditorBlockType::PageReference => "page_reference",
|
||||||
EditorBlockType::BlockReference => "block_reference",
|
EditorBlockType::BlockReference => "block_reference",
|
||||||
|
EditorBlockType::Resource => "resource",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -11836,7 +11860,7 @@ mod tests {
|
|||||||
},
|
},
|
||||||
source: RuntimeSourceWire {
|
source: RuntimeSourceWire {
|
||||||
channel: "next-route".into(),
|
channel: "next-route".into(),
|
||||||
client: "wolai-frontend".into(),
|
client: "mnote-web".into(),
|
||||||
source_kind: None,
|
source_kind: None,
|
||||||
root_uri: None,
|
root_uri: None,
|
||||||
workspace_id: None,
|
workspace_id: None,
|
||||||
@@ -11931,7 +11955,7 @@ mod tests {
|
|||||||
},
|
},
|
||||||
source: RuntimeSourceWire {
|
source: RuntimeSourceWire {
|
||||||
channel: "next-route".into(),
|
channel: "next-route".into(),
|
||||||
client: "wolai-frontend".into(),
|
client: "mnote-web".into(),
|
||||||
source_kind: None,
|
source_kind: None,
|
||||||
root_uri: None,
|
root_uri: None,
|
||||||
workspace_id: 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]
|
#[test]
|
||||||
fn editor_command_apply_insert_preserves_nested_children() {
|
fn editor_command_apply_insert_preserves_nested_children() {
|
||||||
let content = json!([{
|
let content = json!([{
|
||||||
@@ -13186,7 +13242,7 @@ mod tests {
|
|||||||
},
|
},
|
||||||
source: RuntimeSourceWire {
|
source: RuntimeSourceWire {
|
||||||
channel: "next-route".into(),
|
channel: "next-route".into(),
|
||||||
client: "wolai-frontend".into(),
|
client: "mnote-web".into(),
|
||||||
source_kind: None,
|
source_kind: None,
|
||||||
root_uri: None,
|
root_uri: None,
|
||||||
workspace_id: None,
|
workspace_id: None,
|
||||||
@@ -13227,11 +13283,11 @@ mod tests {
|
|||||||
"children": [],
|
"children": [],
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
assert_eq!(plan.args_json["createOnly"], json!(true));
|
assert_eq!(plan.args_json["createOnly"], json!(true));
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
plan.args_json["streamDeltaHint"],
|
plan.args_json["streamDeltaHint"],
|
||||||
json!({
|
json!({
|
||||||
"family": "tree",
|
"family": "tree",
|
||||||
"kind": "resync_required",
|
"kind": "resync_required",
|
||||||
"args": {
|
"args": {
|
||||||
"reason": "mindmap.put",
|
"reason": "mindmap.put",
|
||||||
@@ -13308,11 +13364,11 @@ mod tests {
|
|||||||
.expect("mindmap update command plan should build");
|
.expect("mindmap update command plan should build");
|
||||||
|
|
||||||
match plan {
|
match plan {
|
||||||
RuntimeExecutionPlan::Command(plan) => {
|
RuntimeExecutionPlan::Command(plan) => {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
plan.args_json["streamDeltaHint"],
|
plan.args_json["streamDeltaHint"],
|
||||||
json!({
|
json!({
|
||||||
"family": "tree",
|
"family": "tree",
|
||||||
"kind": "noop",
|
"kind": "noop",
|
||||||
"args": {}
|
"args": {}
|
||||||
})
|
})
|
||||||
@@ -13772,7 +13828,7 @@ mod tests {
|
|||||||
},
|
},
|
||||||
source: RuntimeSourceWire {
|
source: RuntimeSourceWire {
|
||||||
channel: "next-route".into(),
|
channel: "next-route".into(),
|
||||||
client: "wolai-frontend".into(),
|
client: "mnote-web".into(),
|
||||||
source_kind: None,
|
source_kind: None,
|
||||||
root_uri: None,
|
root_uri: None,
|
||||||
workspace_id: None,
|
workspace_id: None,
|
||||||
@@ -14469,7 +14525,7 @@ mod tests {
|
|||||||
},
|
},
|
||||||
source: RuntimeSourceWire {
|
source: RuntimeSourceWire {
|
||||||
channel: "next-route".into(),
|
channel: "next-route".into(),
|
||||||
client: "wolai-frontend".into(),
|
client: "mnote-web".into(),
|
||||||
source_kind: None,
|
source_kind: None,
|
||||||
root_uri: None,
|
root_uri: None,
|
||||||
workspace_id: None,
|
workspace_id: None,
|
||||||
@@ -14570,7 +14626,7 @@ mod tests {
|
|||||||
},
|
},
|
||||||
source: RuntimeSourceWire {
|
source: RuntimeSourceWire {
|
||||||
channel: "next-route".into(),
|
channel: "next-route".into(),
|
||||||
client: "wolai-frontend".into(),
|
client: "mnote-web".into(),
|
||||||
source_kind: None,
|
source_kind: None,
|
||||||
root_uri: None,
|
root_uri: None,
|
||||||
workspace_id: None,
|
workspace_id: None,
|
||||||
@@ -14676,7 +14732,7 @@ mod tests {
|
|||||||
},
|
},
|
||||||
source: RuntimeSourceWire {
|
source: RuntimeSourceWire {
|
||||||
channel: "next-route".into(),
|
channel: "next-route".into(),
|
||||||
client: "wolai-frontend".into(),
|
client: "mnote-web".into(),
|
||||||
source_kind: None,
|
source_kind: None,
|
||||||
root_uri: None,
|
root_uri: None,
|
||||||
workspace_id: None,
|
workspace_id: None,
|
||||||
@@ -14743,7 +14799,7 @@ mod tests {
|
|||||||
},
|
},
|
||||||
source: RuntimeSourceWire {
|
source: RuntimeSourceWire {
|
||||||
channel: "next-route".into(),
|
channel: "next-route".into(),
|
||||||
client: "wolai-frontend".into(),
|
client: "mnote-web".into(),
|
||||||
source_kind: None,
|
source_kind: None,
|
||||||
root_uri: None,
|
root_uri: None,
|
||||||
workspace_id: 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]
|
#[test]
|
||||||
fn blocks_move_command_plan_maps_to_blocks_move() {
|
fn blocks_move_command_plan_maps_to_blocks_move() {
|
||||||
let plan = execute_runtime_input(RuntimeInput::Command {
|
let plan = execute_runtime_input(RuntimeInput::Command {
|
||||||
@@ -14900,7 +15058,7 @@ mod tests {
|
|||||||
},
|
},
|
||||||
source: RuntimeSourceWire {
|
source: RuntimeSourceWire {
|
||||||
channel: "next-route".into(),
|
channel: "next-route".into(),
|
||||||
client: "wolai-frontend".into(),
|
client: "mnote-web".into(),
|
||||||
source_kind: None,
|
source_kind: None,
|
||||||
root_uri: None,
|
root_uri: None,
|
||||||
workspace_id: None,
|
workspace_id: None,
|
||||||
@@ -14995,7 +15153,7 @@ mod tests {
|
|||||||
},
|
},
|
||||||
source: RuntimeSourceWire {
|
source: RuntimeSourceWire {
|
||||||
channel: "next-route".into(),
|
channel: "next-route".into(),
|
||||||
client: "wolai-frontend".into(),
|
client: "mnote-web".into(),
|
||||||
source_kind: None,
|
source_kind: None,
|
||||||
root_uri: None,
|
root_uri: None,
|
||||||
workspace_id: None,
|
workspace_id: None,
|
||||||
@@ -15161,7 +15319,7 @@ mod tests {
|
|||||||
},
|
},
|
||||||
source: RuntimeSourceWire {
|
source: RuntimeSourceWire {
|
||||||
channel: "next-route".into(),
|
channel: "next-route".into(),
|
||||||
client: "wolai-frontend".into(),
|
client: "mnote-web".into(),
|
||||||
source_kind: None,
|
source_kind: None,
|
||||||
root_uri: None,
|
root_uri: None,
|
||||||
workspace_id: None,
|
workspace_id: None,
|
||||||
@@ -15250,7 +15408,7 @@ mod tests {
|
|||||||
},
|
},
|
||||||
source: RuntimeSourceWire {
|
source: RuntimeSourceWire {
|
||||||
channel: "next-route".into(),
|
channel: "next-route".into(),
|
||||||
client: "wolai-frontend".into(),
|
client: "mnote-web".into(),
|
||||||
source_kind: None,
|
source_kind: None,
|
||||||
root_uri: None,
|
root_uri: None,
|
||||||
workspace_id: None,
|
workspace_id: None,
|
||||||
@@ -15333,18 +15491,18 @@ mod tests {
|
|||||||
"documentId": "doc_1",
|
"documentId": "doc_1",
|
||||||
"workspaceId": "ws_1",
|
"workspaceId": "ws_1",
|
||||||
}),
|
}),
|
||||||
json!({
|
json!({
|
||||||
"id": "doc_1",
|
"id": "doc_1",
|
||||||
"commandProtocol": {
|
"commandProtocol": {
|
||||||
"family": "tree",
|
"family": "tree",
|
||||||
"owner": "rust-runtime-kernel",
|
"owner": "rust-runtime-kernel",
|
||||||
"preferredCommandName": "tree.node.archive",
|
"preferredCommandName": "tree.node.archive",
|
||||||
"compatCommandName": "documents.delete",
|
"compatCommandName": "documents.delete",
|
||||||
"deprecatedAlias": false
|
"deprecatedAlias": false
|
||||||
},
|
},
|
||||||
"streamDeltaHint": {
|
"streamDeltaHint": {
|
||||||
"family": "tree",
|
"family": "tree",
|
||||||
"kind": "remove_document",
|
"kind": "remove_document",
|
||||||
"args": {
|
"args": {
|
||||||
"documentId": "doc_1"
|
"documentId": "doc_1"
|
||||||
}
|
}
|
||||||
@@ -15375,18 +15533,18 @@ mod tests {
|
|||||||
"documentId": "doc_1",
|
"documentId": "doc_1",
|
||||||
"workspaceId": "ws_1",
|
"workspaceId": "ws_1",
|
||||||
}),
|
}),
|
||||||
json!({
|
json!({
|
||||||
"id": "doc_1",
|
"id": "doc_1",
|
||||||
"commandProtocol": {
|
"commandProtocol": {
|
||||||
"family": "tree",
|
"family": "tree",
|
||||||
"owner": "rust-runtime-kernel",
|
"owner": "rust-runtime-kernel",
|
||||||
"preferredCommandName": "tree.node.restore",
|
"preferredCommandName": "tree.node.restore",
|
||||||
"compatCommandName": "documents.restore",
|
"compatCommandName": "documents.restore",
|
||||||
"deprecatedAlias": false
|
"deprecatedAlias": false
|
||||||
},
|
},
|
||||||
"streamDeltaHint": {
|
"streamDeltaHint": {
|
||||||
"family": "tree",
|
"family": "tree",
|
||||||
"kind": "document_result",
|
"kind": "document_result",
|
||||||
"args": {
|
"args": {
|
||||||
"documentField": "document"
|
"documentField": "document"
|
||||||
}
|
}
|
||||||
@@ -15416,17 +15574,17 @@ mod tests {
|
|||||||
json!({
|
json!({
|
||||||
"documentId": "doc_1",
|
"documentId": "doc_1",
|
||||||
}),
|
}),
|
||||||
json!({
|
json!({
|
||||||
"id": "doc_1",
|
"id": "doc_1",
|
||||||
"commandProtocol": {
|
"commandProtocol": {
|
||||||
"family": "tree",
|
"family": "tree",
|
||||||
"owner": "rust-runtime-kernel",
|
"owner": "rust-runtime-kernel",
|
||||||
"preferredCommandName": "tree.node.purge",
|
"preferredCommandName": "tree.node.purge",
|
||||||
"compatCommandName": "documents.purge",
|
"compatCommandName": "documents.purge",
|
||||||
"deprecatedAlias": false
|
"deprecatedAlias": false
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
];
|
];
|
||||||
|
|
||||||
for (command_name, function_name, payload, args_json) in cases {
|
for (command_name, function_name, payload, args_json) in cases {
|
||||||
@@ -15443,7 +15601,7 @@ mod tests {
|
|||||||
},
|
},
|
||||||
source: RuntimeSourceWire {
|
source: RuntimeSourceWire {
|
||||||
channel: "next-route".into(),
|
channel: "next-route".into(),
|
||||||
client: "wolai-frontend".into(),
|
client: "mnote-web".into(),
|
||||||
source_kind: None,
|
source_kind: None,
|
||||||
root_uri: None,
|
root_uri: None,
|
||||||
workspace_id: None,
|
workspace_id: None,
|
||||||
@@ -15491,7 +15649,7 @@ mod tests {
|
|||||||
},
|
},
|
||||||
source: RuntimeSourceWire {
|
source: RuntimeSourceWire {
|
||||||
channel: "next-route".into(),
|
channel: "next-route".into(),
|
||||||
client: "wolai-frontend".into(),
|
client: "mnote-web".into(),
|
||||||
source_kind: None,
|
source_kind: None,
|
||||||
root_uri: None,
|
root_uri: None,
|
||||||
workspace_id: None,
|
workspace_id: None,
|
||||||
@@ -15560,7 +15718,7 @@ mod tests {
|
|||||||
},
|
},
|
||||||
source: RuntimeSourceWire {
|
source: RuntimeSourceWire {
|
||||||
channel: "next-route".into(),
|
channel: "next-route".into(),
|
||||||
client: "wolai-frontend".into(),
|
client: "mnote-web".into(),
|
||||||
source_kind: None,
|
source_kind: None,
|
||||||
root_uri: None,
|
root_uri: None,
|
||||||
workspace_id: None,
|
workspace_id: None,
|
||||||
@@ -15629,7 +15787,7 @@ mod tests {
|
|||||||
},
|
},
|
||||||
source: RuntimeSourceWire {
|
source: RuntimeSourceWire {
|
||||||
channel: "next-route".into(),
|
channel: "next-route".into(),
|
||||||
client: "wolai-frontend".into(),
|
client: "mnote-web".into(),
|
||||||
source_kind: None,
|
source_kind: None,
|
||||||
root_uri: None,
|
root_uri: None,
|
||||||
workspace_id: None,
|
workspace_id: None,
|
||||||
@@ -15698,7 +15856,7 @@ mod tests {
|
|||||||
},
|
},
|
||||||
source: RuntimeSourceWire {
|
source: RuntimeSourceWire {
|
||||||
channel: "next-route".into(),
|
channel: "next-route".into(),
|
||||||
client: "wolai-frontend".into(),
|
client: "mnote-web".into(),
|
||||||
source_kind: None,
|
source_kind: None,
|
||||||
root_uri: None,
|
root_uri: None,
|
||||||
workspace_id: None,
|
workspace_id: None,
|
||||||
@@ -15755,7 +15913,7 @@ mod tests {
|
|||||||
},
|
},
|
||||||
source: RuntimeSourceWire {
|
source: RuntimeSourceWire {
|
||||||
channel: "next-route".into(),
|
channel: "next-route".into(),
|
||||||
client: "wolai-frontend".into(),
|
client: "mnote-web".into(),
|
||||||
source_kind: None,
|
source_kind: None,
|
||||||
root_uri: None,
|
root_uri: None,
|
||||||
workspace_id: None,
|
workspace_id: None,
|
||||||
@@ -15919,7 +16077,7 @@ mod tests {
|
|||||||
},
|
},
|
||||||
source: RuntimeSourceWire {
|
source: RuntimeSourceWire {
|
||||||
channel: "next-route".into(),
|
channel: "next-route".into(),
|
||||||
client: "wolai-frontend".into(),
|
client: "mnote-web".into(),
|
||||||
source_kind: None,
|
source_kind: None,
|
||||||
root_uri: None,
|
root_uri: None,
|
||||||
workspace_id: None,
|
workspace_id: None,
|
||||||
@@ -15955,20 +16113,20 @@ mod tests {
|
|||||||
panic!("expected command plan");
|
panic!("expected command plan");
|
||||||
};
|
};
|
||||||
|
|
||||||
assert_eq!(plan.command_name, "tree.subtree.move");
|
assert_eq!(plan.command_name, "tree.subtree.move");
|
||||||
assert_eq!(plan.function_name, "documents:move");
|
assert_eq!(plan.function_name, "documents:move");
|
||||||
assert_eq!(plan.args_json["sortOrder"], json!(-2));
|
assert_eq!(plan.args_json["sortOrder"], json!(-2));
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
plan.args_json["commandProtocol"],
|
plan.args_json["commandProtocol"],
|
||||||
json!({
|
json!({
|
||||||
"family": "tree",
|
"family": "tree",
|
||||||
"owner": "rust-runtime-kernel",
|
"owner": "rust-runtime-kernel",
|
||||||
"preferredCommandName": "tree.subtree.move",
|
"preferredCommandName": "tree.subtree.move",
|
||||||
"compatCommandName": "documents.move",
|
"compatCommandName": "documents.move",
|
||||||
"deprecatedAlias": false
|
"deprecatedAlias": false
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
assert!(plan.args_json.get("normalizedMove").is_none());
|
assert!(plan.args_json.get("normalizedMove").is_none());
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
plan.args_json["treeWriteOperation"],
|
plan.args_json["treeWriteOperation"],
|
||||||
json!({
|
json!({
|
||||||
@@ -16022,111 +16180,123 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn documents_lifecycle_aliases_are_marked_as_deprecated_tree_protocol_aliases() {
|
fn documents_lifecycle_aliases_are_marked_as_deprecated_tree_protocol_aliases() {
|
||||||
let cases = [
|
let cases = [
|
||||||
("documents.create", "tree.node.create", "documents.create"),
|
("documents.create", "tree.node.create", "documents.create"),
|
||||||
("documents.title.update", "tree.node.rename", "documents.title.update"),
|
(
|
||||||
("documents.move", "tree.subtree.move", "documents.move"),
|
"documents.title.update",
|
||||||
("documents.delete", "tree.node.archive", "documents.delete"),
|
"tree.node.rename",
|
||||||
("documents.restore", "tree.node.restore", "documents.restore"),
|
"documents.title.update",
|
||||||
("documents.purge", "tree.node.purge", "documents.purge"),
|
),
|
||||||
("documents.copy_tree", "tree.subtree.copy", "documents.copy_tree"),
|
("documents.move", "tree.subtree.move", "documents.move"),
|
||||||
];
|
("documents.delete", "tree.node.archive", "documents.delete"),
|
||||||
|
(
|
||||||
|
"documents.restore",
|
||||||
|
"tree.node.restore",
|
||||||
|
"documents.restore",
|
||||||
|
),
|
||||||
|
("documents.purge", "tree.node.purge", "documents.purge"),
|
||||||
|
(
|
||||||
|
"documents.copy_tree",
|
||||||
|
"tree.subtree.copy",
|
||||||
|
"documents.copy_tree",
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
for (command_name, preferred_command_name, compat_command_name) in cases {
|
for (command_name, preferred_command_name, compat_command_name) in cases {
|
||||||
let command = RuntimeCommandEnvelopeWire {
|
let command = RuntimeCommandEnvelopeWire {
|
||||||
name: command_name.into(),
|
name: command_name.into(),
|
||||||
command_id: format!("cmd_{command_name}"),
|
command_id: format!("cmd_{command_name}"),
|
||||||
idempotency_key: Some(format!("idem_{command_name}")),
|
idempotency_key: Some(format!("idem_{command_name}")),
|
||||||
actor: RuntimeActorWire {
|
actor: RuntimeActorWire {
|
||||||
actor_type: "user".into(),
|
actor_type: "user".into(),
|
||||||
actor_id: "user_1".into(),
|
actor_id: "user_1".into(),
|
||||||
session_id: Some("sess_1".into()),
|
session_id: Some("sess_1".into()),
|
||||||
},
|
},
|
||||||
source: RuntimeSourceWire {
|
source: RuntimeSourceWire {
|
||||||
channel: "next-route".into(),
|
channel: "next-route".into(),
|
||||||
client: "wolai-frontend".into(),
|
client: "mnote-web".into(),
|
||||||
source_kind: None,
|
source_kind: None,
|
||||||
root_uri: None,
|
root_uri: None,
|
||||||
workspace_id: None,
|
workspace_id: None,
|
||||||
capabilities: Vec::new(),
|
capabilities: Vec::new(),
|
||||||
},
|
},
|
||||||
target: Some(RuntimeTargetWire {
|
target: Some(RuntimeTargetWire {
|
||||||
workspace_id: Some("ws_1".into()),
|
workspace_id: Some("ws_1".into()),
|
||||||
page_id: Some("doc_1".into()),
|
page_id: Some("doc_1".into()),
|
||||||
block_id: None,
|
block_id: None,
|
||||||
}),
|
}),
|
||||||
payload: match command_name {
|
payload: match command_name {
|
||||||
"documents.create" => json!({
|
"documents.create" => json!({
|
||||||
"documentId": "doc_1",
|
"documentId": "doc_1",
|
||||||
"workspaceId": "ws_1",
|
"workspaceId": "ws_1",
|
||||||
"parentId": null,
|
"parentId": null,
|
||||||
"title": "新页面",
|
"title": "新页面",
|
||||||
"accessScope": "private",
|
"accessScope": "private",
|
||||||
"content": []
|
"content": []
|
||||||
}),
|
}),
|
||||||
"documents.title.update" => json!({
|
"documents.title.update" => json!({
|
||||||
"documentId": "doc_1",
|
"documentId": "doc_1",
|
||||||
"title": "改名"
|
"title": "改名"
|
||||||
}),
|
}),
|
||||||
"documents.move" => json!({
|
"documents.move" => json!({
|
||||||
"documentId": "doc_1",
|
"documentId": "doc_1",
|
||||||
"parentId": null,
|
"parentId": null,
|
||||||
"sortOrder": 0
|
"sortOrder": 0
|
||||||
}),
|
}),
|
||||||
"documents.delete" | "documents.restore" | "documents.purge" => json!({
|
"documents.delete" | "documents.restore" | "documents.purge" => json!({
|
||||||
"documentId": "doc_1",
|
"documentId": "doc_1",
|
||||||
"workspaceId": "ws_1"
|
"workspaceId": "ws_1"
|
||||||
}),
|
}),
|
||||||
"documents.copy_tree" => json!({
|
"documents.copy_tree" => json!({
|
||||||
"items": [{"documentId": "doc_1", "recursive": true}],
|
"items": [{"documentId": "doc_1", "recursive": true}],
|
||||||
"targetParentId": null
|
"targetParentId": null
|
||||||
}),
|
}),
|
||||||
_ => unreachable!("unexpected command"),
|
_ => unreachable!("unexpected command"),
|
||||||
},
|
},
|
||||||
preflight_data: if command_name == "documents.move" {
|
preflight_data: if command_name == "documents.move" {
|
||||||
Some(json!({
|
Some(json!({
|
||||||
"documents": [
|
"documents": [
|
||||||
{ "id": "doc_1", "workspace_id": "ws_1", "parent_id": null, "sort_order": 0, "created_at": "2026-04-25T00:00:01Z" }
|
{ "id": "doc_1", "workspace_id": "ws_1", "parent_id": null, "sort_order": 0, "created_at": "2026-04-25T00:00:01Z" }
|
||||||
]
|
]
|
||||||
}))
|
}))
|
||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
},
|
},
|
||||||
reason: Some("兼容 alias 标识验证".into()),
|
reason: Some("兼容 alias 标识验证".into()),
|
||||||
refs: vec![],
|
refs: vec![],
|
||||||
dry_run: false,
|
dry_run: false,
|
||||||
validate_only: false,
|
validate_only: false,
|
||||||
};
|
};
|
||||||
let plan = execute_runtime_input(RuntimeInput::Command {
|
let plan = execute_runtime_input(RuntimeInput::Command {
|
||||||
context: demo_context(),
|
context: demo_context(),
|
||||||
command,
|
command,
|
||||||
})
|
})
|
||||||
.expect("compat command plan should build");
|
.expect("compat command plan should build");
|
||||||
let RuntimeExecutionPlan::Command(plan) = plan else {
|
let RuntimeExecutionPlan::Command(plan) = plan else {
|
||||||
panic!("expected command plan");
|
panic!("expected command plan");
|
||||||
};
|
};
|
||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
plan.args_json["commandProtocol"],
|
plan.args_json["commandProtocol"],
|
||||||
json!({
|
json!({
|
||||||
"family": "tree",
|
"family": "tree",
|
||||||
"owner": "rust-runtime-kernel",
|
"owner": "rust-runtime-kernel",
|
||||||
"preferredCommandName": preferred_command_name,
|
"preferredCommandName": preferred_command_name,
|
||||||
"compatCommandName": compat_command_name,
|
"compatCommandName": compat_command_name,
|
||||||
"deprecatedAlias": true
|
"deprecatedAlias": true
|
||||||
}),
|
}),
|
||||||
"{command_name} 应标记为 tree compat deprecated alias"
|
"{command_name} 应标记为 tree compat deprecated alias"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn tree_subtree_move_command_includes_tree_write_operation_from_snapshot() {
|
fn tree_subtree_move_command_includes_tree_write_operation_from_snapshot() {
|
||||||
let plan = execute_runtime_input(RuntimeInput::Command {
|
let plan = execute_runtime_input(RuntimeInput::Command {
|
||||||
context: demo_context(),
|
context: demo_context(),
|
||||||
command: RuntimeCommandEnvelopeWire {
|
command: RuntimeCommandEnvelopeWire {
|
||||||
@@ -16140,7 +16310,7 @@ mod tests {
|
|||||||
},
|
},
|
||||||
source: RuntimeSourceWire {
|
source: RuntimeSourceWire {
|
||||||
channel: "next-route".into(),
|
channel: "next-route".into(),
|
||||||
client: "wolai-frontend".into(),
|
client: "mnote-web".into(),
|
||||||
source_kind: None,
|
source_kind: None,
|
||||||
root_uri: None,
|
root_uri: None,
|
||||||
workspace_id: None,
|
workspace_id: None,
|
||||||
@@ -16215,7 +16385,7 @@ mod tests {
|
|||||||
},
|
},
|
||||||
source: RuntimeSourceWire {
|
source: RuntimeSourceWire {
|
||||||
channel: "next-route".into(),
|
channel: "next-route".into(),
|
||||||
client: "wolai-frontend".into(),
|
client: "mnote-web".into(),
|
||||||
source_kind: None,
|
source_kind: None,
|
||||||
root_uri: None,
|
root_uri: None,
|
||||||
workspace_id: None,
|
workspace_id: None,
|
||||||
@@ -16315,7 +16485,7 @@ mod tests {
|
|||||||
},
|
},
|
||||||
source: RuntimeSourceWire {
|
source: RuntimeSourceWire {
|
||||||
channel: "next-route".into(),
|
channel: "next-route".into(),
|
||||||
client: "wolai-frontend".into(),
|
client: "mnote-web".into(),
|
||||||
source_kind: None,
|
source_kind: None,
|
||||||
root_uri: None,
|
root_uri: None,
|
||||||
workspace_id: None,
|
workspace_id: None,
|
||||||
@@ -16401,7 +16571,7 @@ mod tests {
|
|||||||
},
|
},
|
||||||
source: RuntimeSourceWire {
|
source: RuntimeSourceWire {
|
||||||
channel: "next-route".into(),
|
channel: "next-route".into(),
|
||||||
client: "wolai-frontend".into(),
|
client: "mnote-web".into(),
|
||||||
source_kind: None,
|
source_kind: None,
|
||||||
root_uri: None,
|
root_uri: None,
|
||||||
workspace_id: None,
|
workspace_id: None,
|
||||||
@@ -16484,7 +16654,7 @@ mod tests {
|
|||||||
},
|
},
|
||||||
source: RuntimeSourceWire {
|
source: RuntimeSourceWire {
|
||||||
channel: "next-route".into(),
|
channel: "next-route".into(),
|
||||||
client: "wolai-frontend".into(),
|
client: "mnote-web".into(),
|
||||||
source_kind: None,
|
source_kind: None,
|
||||||
root_uri: None,
|
root_uri: None,
|
||||||
workspace_id: None,
|
workspace_id: None,
|
||||||
@@ -16572,7 +16742,7 @@ mod tests {
|
|||||||
},
|
},
|
||||||
source: RuntimeSourceWire {
|
source: RuntimeSourceWire {
|
||||||
channel: "next-route".into(),
|
channel: "next-route".into(),
|
||||||
client: "wolai-frontend".into(),
|
client: "mnote-web".into(),
|
||||||
source_kind: None,
|
source_kind: None,
|
||||||
root_uri: None,
|
root_uri: None,
|
||||||
workspace_id: None,
|
workspace_id: None,
|
||||||
@@ -16713,7 +16883,7 @@ mod tests {
|
|||||||
},
|
},
|
||||||
source: RuntimeSourceWire {
|
source: RuntimeSourceWire {
|
||||||
channel: "next-route".into(),
|
channel: "next-route".into(),
|
||||||
client: "wolai-frontend".into(),
|
client: "mnote-web".into(),
|
||||||
source_kind: None,
|
source_kind: None,
|
||||||
root_uri: None,
|
root_uri: None,
|
||||||
workspace_id: None,
|
workspace_id: None,
|
||||||
@@ -16755,15 +16925,15 @@ mod tests {
|
|||||||
"recursive": true,
|
"recursive": true,
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"targetParentId": "parent_1",
|
"targetParentId": "parent_1",
|
||||||
"commandProtocol": {
|
"commandProtocol": {
|
||||||
"family": "tree",
|
"family": "tree",
|
||||||
"owner": "rust-runtime-kernel",
|
"owner": "rust-runtime-kernel",
|
||||||
"preferredCommandName": "tree.subtree.copy",
|
"preferredCommandName": "tree.subtree.copy",
|
||||||
"compatCommandName": "documents.copy_tree",
|
"compatCommandName": "documents.copy_tree",
|
||||||
"deprecatedAlias": false
|
"deprecatedAlias": false
|
||||||
},
|
},
|
||||||
"streamDeltaHint": {
|
"streamDeltaHint": {
|
||||||
"family": "tree",
|
"family": "tree",
|
||||||
"kind": "copy_result",
|
"kind": "copy_result",
|
||||||
"args": {
|
"args": {
|
||||||
@@ -16812,7 +16982,7 @@ mod tests {
|
|||||||
},
|
},
|
||||||
source: RuntimeSourceWire {
|
source: RuntimeSourceWire {
|
||||||
channel: "next-route".into(),
|
channel: "next-route".into(),
|
||||||
client: "wolai-frontend".into(),
|
client: "mnote-web".into(),
|
||||||
source_kind: None,
|
source_kind: None,
|
||||||
root_uri: None,
|
root_uri: None,
|
||||||
workspace_id: Some("ws_1".into()),
|
workspace_id: Some("ws_1".into()),
|
||||||
@@ -16896,7 +17066,7 @@ mod tests {
|
|||||||
},
|
},
|
||||||
source: RuntimeSourceWire {
|
source: RuntimeSourceWire {
|
||||||
channel: "next-route".into(),
|
channel: "next-route".into(),
|
||||||
client: "wolai-frontend".into(),
|
client: "mnote-web".into(),
|
||||||
source_kind: None,
|
source_kind: None,
|
||||||
root_uri: None,
|
root_uri: None,
|
||||||
workspace_id: None,
|
workspace_id: None,
|
||||||
@@ -16968,7 +17138,7 @@ mod tests {
|
|||||||
},
|
},
|
||||||
source: RuntimeSourceWire {
|
source: RuntimeSourceWire {
|
||||||
channel: "next-route".into(),
|
channel: "next-route".into(),
|
||||||
client: "wolai-frontend".into(),
|
client: "mnote-web".into(),
|
||||||
source_kind: None,
|
source_kind: None,
|
||||||
root_uri: None,
|
root_uri: None,
|
||||||
workspace_id: None,
|
workspace_id: None,
|
||||||
@@ -17058,7 +17228,7 @@ mod tests {
|
|||||||
},
|
},
|
||||||
source: RuntimeSourceWire {
|
source: RuntimeSourceWire {
|
||||||
channel: "next-route".into(),
|
channel: "next-route".into(),
|
||||||
client: "wolai-frontend".into(),
|
client: "mnote-web".into(),
|
||||||
source_kind: None,
|
source_kind: None,
|
||||||
root_uri: None,
|
root_uri: None,
|
||||||
workspace_id: None,
|
workspace_id: None,
|
||||||
@@ -17126,7 +17296,7 @@ mod tests {
|
|||||||
},
|
},
|
||||||
source: RuntimeSourceWire {
|
source: RuntimeSourceWire {
|
||||||
channel: "onlyoffice-callback".into(),
|
channel: "onlyoffice-callback".into(),
|
||||||
client: "wolai-frontend".into(),
|
client: "mnote-web".into(),
|
||||||
source_kind: None,
|
source_kind: None,
|
||||||
root_uri: None,
|
root_uri: None,
|
||||||
workspace_id: None,
|
workspace_id: None,
|
||||||
@@ -17204,7 +17374,7 @@ mod tests {
|
|||||||
},
|
},
|
||||||
source: RuntimeSourceWire {
|
source: RuntimeSourceWire {
|
||||||
channel: "next-route".into(),
|
channel: "next-route".into(),
|
||||||
client: "wolai-frontend".into(),
|
client: "mnote-web".into(),
|
||||||
source_kind: None,
|
source_kind: None,
|
||||||
root_uri: None,
|
root_uri: None,
|
||||||
workspace_id: None,
|
workspace_id: None,
|
||||||
@@ -17492,7 +17662,7 @@ mod tests {
|
|||||||
},
|
},
|
||||||
source: RuntimeSourceWire {
|
source: RuntimeSourceWire {
|
||||||
channel: "next-route".into(),
|
channel: "next-route".into(),
|
||||||
client: "wolai-frontend".into(),
|
client: "mnote-web".into(),
|
||||||
source_kind: None,
|
source_kind: None,
|
||||||
root_uri: None,
|
root_uri: None,
|
||||||
workspace_id: None,
|
workspace_id: None,
|
||||||
@@ -17684,7 +17854,7 @@ mod tests {
|
|||||||
},
|
},
|
||||||
source: RuntimeSourceWire {
|
source: RuntimeSourceWire {
|
||||||
channel: "next-route".into(),
|
channel: "next-route".into(),
|
||||||
client: "wolai-frontend".into(),
|
client: "mnote-web".into(),
|
||||||
source_kind: None,
|
source_kind: None,
|
||||||
root_uri: None,
|
root_uri: None,
|
||||||
workspace_id: None,
|
workspace_id: None,
|
||||||
@@ -17741,7 +17911,7 @@ mod tests {
|
|||||||
},
|
},
|
||||||
source: RuntimeSourceWire {
|
source: RuntimeSourceWire {
|
||||||
channel: "next-route".into(),
|
channel: "next-route".into(),
|
||||||
client: "wolai-frontend".into(),
|
client: "mnote-web".into(),
|
||||||
source_kind: None,
|
source_kind: None,
|
||||||
root_uri: None,
|
root_uri: None,
|
||||||
workspace_id: None,
|
workspace_id: None,
|
||||||
@@ -17781,7 +17951,7 @@ mod tests {
|
|||||||
},
|
},
|
||||||
source: RuntimeSourceWire {
|
source: RuntimeSourceWire {
|
||||||
channel: "next-route".into(),
|
channel: "next-route".into(),
|
||||||
client: "wolai-frontend".into(),
|
client: "mnote-web".into(),
|
||||||
source_kind: None,
|
source_kind: None,
|
||||||
root_uri: None,
|
root_uri: None,
|
||||||
workspace_id: None,
|
workspace_id: None,
|
||||||
@@ -17827,7 +17997,7 @@ mod tests {
|
|||||||
},
|
},
|
||||||
source: RuntimeSourceWire {
|
source: RuntimeSourceWire {
|
||||||
channel: "next-route".into(),
|
channel: "next-route".into(),
|
||||||
client: "wolai-frontend".into(),
|
client: "mnote-web".into(),
|
||||||
source_kind: None,
|
source_kind: None,
|
||||||
root_uri: None,
|
root_uri: None,
|
||||||
workspace_id: None,
|
workspace_id: None,
|
||||||
@@ -17952,7 +18122,7 @@ mod tests {
|
|||||||
},
|
},
|
||||||
source: RuntimeSourceWire {
|
source: RuntimeSourceWire {
|
||||||
channel: "next-route".into(),
|
channel: "next-route".into(),
|
||||||
client: "wolai-frontend".into(),
|
client: "mnote-web".into(),
|
||||||
source_kind: None,
|
source_kind: None,
|
||||||
root_uri: None,
|
root_uri: None,
|
||||||
workspace_id: None,
|
workspace_id: None,
|
||||||
@@ -18015,7 +18185,7 @@ mod tests {
|
|||||||
},
|
},
|
||||||
source: RuntimeSourceWire {
|
source: RuntimeSourceWire {
|
||||||
channel: "next-route".into(),
|
channel: "next-route".into(),
|
||||||
client: "wolai-frontend".into(),
|
client: "mnote-web".into(),
|
||||||
source_kind: None,
|
source_kind: None,
|
||||||
root_uri: None,
|
root_uri: None,
|
||||||
workspace_id: None,
|
workspace_id: None,
|
||||||
@@ -18079,7 +18249,7 @@ mod tests {
|
|||||||
},
|
},
|
||||||
source: RuntimeSourceWire {
|
source: RuntimeSourceWire {
|
||||||
channel: "next-route".into(),
|
channel: "next-route".into(),
|
||||||
client: "wolai-frontend".into(),
|
client: "mnote-web".into(),
|
||||||
source_kind: None,
|
source_kind: None,
|
||||||
root_uri: None,
|
root_uri: None,
|
||||||
workspace_id: None,
|
workspace_id: None,
|
||||||
@@ -18146,7 +18316,7 @@ mod tests {
|
|||||||
},
|
},
|
||||||
source: RuntimeSourceWire {
|
source: RuntimeSourceWire {
|
||||||
channel: "next-route".into(),
|
channel: "next-route".into(),
|
||||||
client: "wolai-frontend".into(),
|
client: "mnote-web".into(),
|
||||||
source_kind: None,
|
source_kind: None,
|
||||||
root_uri: None,
|
root_uri: None,
|
||||||
workspace_id: None,
|
workspace_id: None,
|
||||||
@@ -18244,7 +18414,7 @@ mod tests {
|
|||||||
},
|
},
|
||||||
source: RuntimeSourceWire {
|
source: RuntimeSourceWire {
|
||||||
channel: "next-route".into(),
|
channel: "next-route".into(),
|
||||||
client: "wolai-frontend".into(),
|
client: "mnote-web".into(),
|
||||||
source_kind: None,
|
source_kind: None,
|
||||||
root_uri: None,
|
root_uri: None,
|
||||||
workspace_id: None,
|
workspace_id: None,
|
||||||
@@ -18343,7 +18513,7 @@ mod tests {
|
|||||||
},
|
},
|
||||||
source: RuntimeSourceWire {
|
source: RuntimeSourceWire {
|
||||||
channel: "next-route".into(),
|
channel: "next-route".into(),
|
||||||
client: "wolai-frontend".into(),
|
client: "mnote-web".into(),
|
||||||
source_kind: None,
|
source_kind: None,
|
||||||
root_uri: None,
|
root_uri: None,
|
||||||
workspace_id: None,
|
workspace_id: None,
|
||||||
@@ -18456,7 +18626,7 @@ mod tests {
|
|||||||
},
|
},
|
||||||
source: RuntimeSourceWire {
|
source: RuntimeSourceWire {
|
||||||
channel: "next-route".into(),
|
channel: "next-route".into(),
|
||||||
client: "wolai-frontend".into(),
|
client: "mnote-web".into(),
|
||||||
source_kind: None,
|
source_kind: None,
|
||||||
root_uri: None,
|
root_uri: None,
|
||||||
workspace_id: None,
|
workspace_id: None,
|
||||||
@@ -18530,7 +18700,7 @@ mod tests {
|
|||||||
},
|
},
|
||||||
source: RuntimeSourceWire {
|
source: RuntimeSourceWire {
|
||||||
channel: "next-route".into(),
|
channel: "next-route".into(),
|
||||||
client: "wolai-frontend".into(),
|
client: "mnote-web".into(),
|
||||||
source_kind: None,
|
source_kind: None,
|
||||||
root_uri: None,
|
root_uri: None,
|
||||||
workspace_id: None,
|
workspace_id: None,
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ pub enum EditorBlockType {
|
|||||||
Toc,
|
Toc,
|
||||||
PageReference,
|
PageReference,
|
||||||
BlockReference,
|
BlockReference,
|
||||||
|
Resource,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
|||||||
@@ -588,6 +588,18 @@ fn block_to_tiptap_node(
|
|||||||
content: Vec::new(),
|
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 {
|
EditorBlockType::Heading => Ok(TiptapNode::Heading {
|
||||||
attrs: TiptapHeadingAttrs {
|
attrs: TiptapHeadingAttrs {
|
||||||
level: block.props.heading_level.unwrap_or(1),
|
level: block.props.heading_level.unwrap_or(1),
|
||||||
@@ -698,6 +710,21 @@ fn node_to_block(
|
|||||||
child_block_ids: vec![],
|
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 {
|
TiptapNode::Paragraph { attrs, content } => Ok(EditorBlock {
|
||||||
block_id: attrs.block_id.clone().unwrap_or(fallback_block_id),
|
block_id: attrs.block_id.clone().unwrap_or(fallback_block_id),
|
||||||
block_type: EditorBlockType::Paragraph,
|
block_type: EditorBlockType::Paragraph,
|
||||||
|
|||||||
@@ -179,7 +179,7 @@ impl AcpRunBridge {
|
|||||||
/// Map an AcpSessionEvent to an SSE event for the frontend.
|
/// Map an AcpSessionEvent to an SSE event for the frontend.
|
||||||
///
|
///
|
||||||
/// Reference: hermes-vscode-main protocol.ts extractTextContent/parseToolCall/parseToolCallUpdate
|
/// 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> {
|
pub fn acp_event_to_sse(event: AcpSessionEvent) -> Option<SseEvent> {
|
||||||
match event {
|
match event {
|
||||||
AcpSessionEvent::TextDelta { text } => Some(SseEvent {
|
AcpSessionEvent::TextDelta { text } => Some(SseEvent {
|
||||||
@@ -233,6 +233,24 @@ pub fn acp_event_to_sse(event: AcpSessionEvent) -> Option<SseEvent> {
|
|||||||
event: "usage.updated".into(),
|
event: "usage.updated".into(),
|
||||||
data: json!({ "used": used, "size": size }),
|
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 { .. } => {
|
AcpSessionEvent::SessionInfoUpdate { .. } => {
|
||||||
None // Not forwarded to frontend
|
None // Not forwarded to frontend
|
||||||
}
|
}
|
||||||
@@ -292,6 +310,23 @@ mod tests {
|
|||||||
assert_eq!(sse.data["delta"], "internal reasoning");
|
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]
|
#[test]
|
||||||
fn acp_tool_events_keep_detail_for_collapsible_ui() {
|
fn acp_tool_events_keep_detail_for_collapsible_ui() {
|
||||||
let started = acp_event_to_sse(AcpSessionEvent::ToolCall {
|
let started = acp_event_to_sse(AcpSessionEvent::ToolCall {
|
||||||
|
|||||||
@@ -149,7 +149,7 @@ impl AcpClient {
|
|||||||
.take()
|
.take()
|
||||||
.ok_or_else(|| AcpError::Internal("failed to take child stdout".into()))?;
|
.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 reader = BufReader::new(stdout);
|
||||||
|
|
||||||
let pending: Arc<Mutex<HashMap<u64, PendingEntry>>> = Arc::new(Mutex::new(HashMap::new()));
|
let pending: Arc<Mutex<HashMap<u64, PendingEntry>>> = Arc::new(Mutex::new(HashMap::new()));
|
||||||
@@ -159,15 +159,16 @@ impl AcpClient {
|
|||||||
// Start background reader task
|
// Start background reader task
|
||||||
let pending_clone = pending.clone();
|
let pending_clone = pending.clone();
|
||||||
let handler_clone = notification_handler.clone();
|
let handler_clone = notification_handler.clone();
|
||||||
|
let writer_clone = writer.clone();
|
||||||
let child_pid = child.id().unwrap_or(0);
|
let child_pid = child.id().unwrap_or(0);
|
||||||
tokio::spawn(async move {
|
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);
|
info!("ACP reader loop ended (pid={})", child_pid);
|
||||||
});
|
});
|
||||||
|
|
||||||
let client = Self {
|
let client = Self {
|
||||||
child: Some(child),
|
child: Some(child),
|
||||||
writer: Arc::new(Mutex::new(writer)),
|
writer,
|
||||||
pending,
|
pending,
|
||||||
next_id: AtomicU64::new(1),
|
next_id: AtomicU64::new(1),
|
||||||
notification_handler,
|
notification_handler,
|
||||||
@@ -285,6 +286,7 @@ impl AcpClient {
|
|||||||
/// Reference: `acpClient.ts` L120-180 (onData + dispatch)
|
/// Reference: `acpClient.ts` L120-180 (onData + dispatch)
|
||||||
async fn reader_loop(
|
async fn reader_loop(
|
||||||
mut reader: BufReader<ChildStdout>,
|
mut reader: BufReader<ChildStdout>,
|
||||||
|
writer: Arc<Mutex<BufWriter<ChildStdin>>>,
|
||||||
pending: Arc<Mutex<HashMap<u64, PendingEntry>>>,
|
pending: Arc<Mutex<HashMap<u64, PendingEntry>>>,
|
||||||
notification_handler: Arc<NotificationHandlerMutex>,
|
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
|
// Process died or EOF — resolve all pending
|
||||||
@@ -334,6 +336,7 @@ impl AcpClient {
|
|||||||
/// Reference: `acpClient.ts` L160-200 (dispatch)
|
/// Reference: `acpClient.ts` L160-200 (dispatch)
|
||||||
async fn dispatch_message(
|
async fn dispatch_message(
|
||||||
msg: Value,
|
msg: Value,
|
||||||
|
writer: &Arc<Mutex<BufWriter<ChildStdin>>>,
|
||||||
pending: &Arc<Mutex<HashMap<u64, PendingEntry>>>,
|
pending: &Arc<Mutex<HashMap<u64, PendingEntry>>>,
|
||||||
notification_handler: &Arc<NotificationHandlerMutex>,
|
notification_handler: &Arc<NotificationHandlerMutex>,
|
||||||
) {
|
) {
|
||||||
@@ -348,11 +351,32 @@ impl AcpClient {
|
|||||||
// Incoming request from agent (e.g. session/request_permission)
|
// Incoming request from agent (e.g. session/request_permission)
|
||||||
let method = msg["method"].as_str().unwrap_or("unknown").to_string();
|
let method = msg["method"].as_str().unwrap_or("unknown").to_string();
|
||||||
let params = msg.get("params").cloned().unwrap_or(Value::Null);
|
let params = msg.get("params").cloned().unwrap_or(Value::Null);
|
||||||
// For now, reject all incoming requests since we don't need permission dialogs yet.
|
let id = msg.get("id").cloned().unwrap_or(Value::Null);
|
||||||
// Reference: acpClient.ts handleIncomingRequest (L200-220)
|
// 当前还没有权限确认 UI,必须明确拒绝,避免 agent 等待到超时。
|
||||||
warn!("ACP incoming request not handled: {method} (params={params:?})");
|
warn!("ACP incoming request not handled: {method} (params={params:?})");
|
||||||
// If we wanted to reply, we'd need to write back a response...
|
let response = json!({
|
||||||
// For now just log. Phase C will add permission support.
|
"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 {
|
} else if has_id {
|
||||||
// Response to one of our requests
|
// Response to one of our requests
|
||||||
if let Some(id) = msg["id"].as_u64() {
|
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 {
|
impl Drop for AcpClient {
|
||||||
@@ -443,6 +479,48 @@ rl.on('line', (line) => {
|
|||||||
.expect("spawn mock ACP")
|
.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]
|
#[tokio::test]
|
||||||
async fn test_request_response() {
|
async fn test_request_response() {
|
||||||
let client = spawn_mock_acp_server().await;
|
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
|
// spawn already calls initialize; if it fails, the test fails
|
||||||
let _client = spawn_mock_acp_server().await;
|
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.
|
/// Context usage update.
|
||||||
UsageUpdate { used: u64, size: u64 },
|
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).
|
/// Session metadata update (e.g. auto-title).
|
||||||
SessionInfoUpdate { title: String },
|
SessionInfoUpdate { title: String },
|
||||||
/// Plan entries update.
|
/// Plan entries update.
|
||||||
@@ -51,6 +58,16 @@ pub enum AcpSessionEvent {
|
|||||||
Disconnected { reason: String },
|
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.
|
/// Handler for session events.
|
||||||
pub type SessionEventHandler = Arc<dyn Fn(AcpSessionEvent) + Send + Sync + 'static>;
|
pub type SessionEventHandler = Arc<dyn Fn(AcpSessionEvent) + Send + Sync + 'static>;
|
||||||
|
|
||||||
@@ -101,6 +118,34 @@ impl AcpSessionManager {
|
|||||||
let in_prompt_clone = in_prompt.clone();
|
let in_prompt_clone = in_prompt.clone();
|
||||||
|
|
||||||
client.on_notification(move |method, params| {
|
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" {
|
if method != "session/update" {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -193,6 +238,14 @@ impl AcpSessionManager {
|
|||||||
pub async fn run_prompt(
|
pub async fn run_prompt(
|
||||||
&self,
|
&self,
|
||||||
prompt: Vec<ContentBlock>,
|
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> {
|
) -> Result<SessionPromptResult, crate::acp_client::AcpError> {
|
||||||
{
|
{
|
||||||
let mut in_prompt = self.in_prompt.lock().unwrap();
|
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 {
|
let params = SessionPromptParams {
|
||||||
session_id: session_id.clone(),
|
session_id: session_id.clone(),
|
||||||
prompt,
|
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);
|
debug!("ACP session/prompt (session={})", session_id);
|
||||||
|
|||||||
@@ -163,6 +163,18 @@ pub struct ResourceContent {
|
|||||||
pub struct SessionPromptParams {
|
pub struct SessionPromptParams {
|
||||||
pub session_id: String,
|
pub session_id: String,
|
||||||
pub prompt: Vec<ContentBlock>,
|
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)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
|||||||
@@ -430,5 +430,6 @@ fn block_type_name(block_type: &EditorBlockType) -> String {
|
|||||||
EditorBlockType::Toc => "table_of_contents".into(),
|
EditorBlockType::Toc => "table_of_contents".into(),
|
||||||
EditorBlockType::PageReference => "page_reference".into(),
|
EditorBlockType::PageReference => "page_reference".into(),
|
||||||
EditorBlockType::BlockReference => "block_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_page_write_preconditions(context, input, &aggregate)?;
|
||||||
ensure_block_revision_ref(context, input, &block, "blockRevisionRef")?;
|
ensure_block_revision_ref(context, input, &block, "blockRevisionRef")?;
|
||||||
ensure_block_editable(context, &block)?;
|
ensure_block_editable(context, &block)?;
|
||||||
|
ensure_allowed_target(context, input, &Value::Null, &block_id, "replace")?;
|
||||||
let replacement_text = content_to_text(&content);
|
let replacement_text = content_to_text(&content);
|
||||||
let diff = json!([{
|
let diff = json!([{
|
||||||
"op": "replace",
|
"op": "replace",
|
||||||
@@ -155,58 +156,73 @@ pub async fn block_insert_after(
|
|||||||
ensure_page_write_preconditions(context, input, &aggregate)?;
|
ensure_page_write_preconditions(context, input, &aggregate)?;
|
||||||
ensure_block_revision_ref(context, input, &anchor, "anchorRevisionRef")?;
|
ensure_block_revision_ref(context, input, &anchor, "anchorRevisionRef")?;
|
||||||
ensure_block_editable(context, &anchor)?;
|
ensure_block_editable(context, &anchor)?;
|
||||||
let content = input
|
ensure_allowed_target(
|
||||||
.arg_value("content")
|
|
||||||
.or_else(|| input.arg_value("block"))
|
|
||||||
.ok_or_else(|| {
|
|
||||||
WebError::bad_request_code(
|
|
||||||
"mnote_tool_bad_request",
|
|
||||||
"mnote.block.insert_after 缺少 content",
|
|
||||||
)
|
|
||||||
.with_context(context)
|
|
||||||
})?;
|
|
||||||
let new_block_id = format!("ai_block_{}", context.trace.request_id.replace('-', "_"));
|
|
||||||
let new_block = build_insert_block(&new_block_id, &content);
|
|
||||||
let diff = json!([{
|
|
||||||
"op": "insert_after",
|
|
||||||
"anchorBlockId": anchor_block_id,
|
|
||||||
"after": anchor.get("text").cloned().unwrap_or(Value::Null),
|
|
||||||
"block": new_block
|
|
||||||
}]);
|
|
||||||
if input.dry_run.unwrap_or(false) {
|
|
||||||
return Ok(dry_run_result(
|
|
||||||
input,
|
|
||||||
&aggregate,
|
|
||||||
"block_insert_after",
|
|
||||||
diff,
|
|
||||||
false,
|
|
||||||
));
|
|
||||||
}
|
|
||||||
let current_content = current_body_content(&aggregate);
|
|
||||||
let command = EditorCommand::InsertBlockAfter(EditorInsertBlockAfter {
|
|
||||||
after_block_id: anchor_block_id.clone(),
|
|
||||||
block: build_editor_block(&new_block_id, &content),
|
|
||||||
});
|
|
||||||
let (next_content, delta_opt) = compute_next_content_via_actor(
|
|
||||||
state,
|
|
||||||
&aggregate,
|
|
||||||
input,
|
|
||||||
¤t_content,
|
|
||||||
&command,
|
|
||||||
"block_insert_after",
|
|
||||||
)?;
|
|
||||||
let mut result = execute_page_body_save(
|
|
||||||
state,
|
|
||||||
context,
|
context,
|
||||||
input,
|
input,
|
||||||
next_content,
|
&Value::Null,
|
||||||
vec![json!({
|
&anchor_block_id,
|
||||||
"blockId": new_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": build_insert_block(block_id, content)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
if input.dry_run.unwrap_or(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 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: after_block_id.clone(),
|
||||||
|
block: build_editor_block(block_id, content),
|
||||||
|
});
|
||||||
|
let (content_after_command, command_delta) = compute_next_content_via_actor(
|
||||||
|
state,
|
||||||
|
&aggregate,
|
||||||
|
input,
|
||||||
|
&next_content,
|
||||||
|
&command,
|
||||||
|
"block_insert_after",
|
||||||
|
)?;
|
||||||
|
next_content = content_after_command;
|
||||||
|
delta_opt = command_delta;
|
||||||
|
changed_blocks.push(json!({
|
||||||
|
"blockId": block_id,
|
||||||
"op": "insert_after",
|
"op": "insert_after",
|
||||||
"anchorBlockId": anchor_block_id
|
"anchorBlockId": after_block_id
|
||||||
})],
|
}));
|
||||||
)
|
after_block_id = block_id.clone();
|
||||||
.await?;
|
}
|
||||||
|
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);
|
merge_block_delta(&mut result, delta_opt);
|
||||||
Ok(result)
|
Ok(result)
|
||||||
}
|
}
|
||||||
@@ -224,6 +240,7 @@ pub async fn block_delete(
|
|||||||
ensure_page_write_preconditions(context, input, &aggregate)?;
|
ensure_page_write_preconditions(context, input, &aggregate)?;
|
||||||
ensure_block_revision_ref(context, input, &block, "blockRevisionRef")?;
|
ensure_block_revision_ref(context, input, &block, "blockRevisionRef")?;
|
||||||
ensure_block_editable(context, &block)?;
|
ensure_block_editable(context, &block)?;
|
||||||
|
ensure_allowed_target(context, input, &Value::Null, &block_id, "delete")?;
|
||||||
let leaf = block
|
let leaf = block
|
||||||
.get("children")
|
.get("children")
|
||||||
.and_then(Value::as_array)
|
.and_then(Value::as_array)
|
||||||
@@ -298,6 +315,14 @@ pub async fn block_move_after(
|
|||||||
ensure_page_write_preconditions(context, input, &aggregate)?;
|
ensure_page_write_preconditions(context, input, &aggregate)?;
|
||||||
ensure_block_revision_ref(context, input, &block, "blockRevisionRef")?;
|
ensure_block_revision_ref(context, input, &block, "blockRevisionRef")?;
|
||||||
ensure_block_revision_ref(context, input, &anchor, "anchorRevisionRef")?;
|
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 same_parent = block.get("parentBlockId") == anchor.get("parentBlockId");
|
||||||
let leaf = block
|
let leaf = block
|
||||||
.get("children")
|
.get("children")
|
||||||
@@ -413,6 +438,7 @@ pub async fn doc_apply_block_ops(
|
|||||||
)
|
)
|
||||||
.with_context(context));
|
.with_context(context));
|
||||||
}
|
}
|
||||||
|
ensure_page_write_preconditions(context, input, &aggregate)?;
|
||||||
|
|
||||||
let blocks = block_projection_blocks(&aggregate);
|
let blocks = block_projection_blocks(&aggregate);
|
||||||
let mut next_content = current_body_content(&aggregate);
|
let mut next_content = current_body_content(&aggregate);
|
||||||
@@ -431,6 +457,13 @@ pub async fn doc_apply_block_ops(
|
|||||||
"replace" | "block_replace" => {
|
"replace" | "block_replace" => {
|
||||||
let block = resolve_target_block(context, &blocks, operation, false)?;
|
let block = resolve_target_block(context, &blocks, operation, false)?;
|
||||||
ensure_block_editable(context, &block)?;
|
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 block_id = block_id_of(&block).unwrap_or_default();
|
||||||
let content = operation.get("content").cloned().ok_or_else(|| {
|
let content = operation.get("content").cloned().ok_or_else(|| {
|
||||||
WebError::bad_request_code("mnote_tool_bad_request", "replace 操作缺少 content")
|
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" => {
|
"insert_after" | "block_insert_after" => {
|
||||||
let anchor = resolve_anchor_block(context, &blocks, operation)?;
|
let anchor = resolve_anchor_block(context, &blocks, operation)?;
|
||||||
ensure_block_editable(context, &anchor)?;
|
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();
|
let anchor_block_id = block_id_of(&anchor).unwrap_or_default();
|
||||||
ensure_allowed_target(context, input, operation, &anchor_block_id, "insert_after")?;
|
ensure_allowed_target(context, input, operation, &anchor_block_id, "insert_after")?;
|
||||||
let content = operation.get("content").cloned().ok_or_else(|| {
|
let content = operation.get("content").cloned().ok_or_else(|| {
|
||||||
@@ -511,6 +551,13 @@ pub async fn doc_apply_block_ops(
|
|||||||
"delete" | "block_delete" => {
|
"delete" | "block_delete" => {
|
||||||
let block = resolve_target_block(context, &blocks, operation, false)?;
|
let block = resolve_target_block(context, &blocks, operation, false)?;
|
||||||
ensure_block_editable(context, &block)?;
|
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 block_id = block_id_of(&block).unwrap_or_default();
|
||||||
ensure_allowed_target(context, input, operation, &block_id, "delete")?;
|
ensure_allowed_target(context, input, operation, &block_id, "delete")?;
|
||||||
ensure_leaf_block(context, &block, "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 block = resolve_target_block(context, &blocks, operation, false)?;
|
||||||
let anchor = resolve_anchor_block(context, &blocks, operation)?;
|
let anchor = resolve_anchor_block(context, &blocks, operation)?;
|
||||||
ensure_block_editable(context, &block)?;
|
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")?;
|
ensure_leaf_block(context, &block, "move_after")?;
|
||||||
let block_id = block_id_of(&block).unwrap_or_default();
|
let block_id = block_id_of(&block).unwrap_or_default();
|
||||||
let anchor_block_id = block_id_of(&anchor).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
|
.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() {
|
if !input.has_idempotency_key() {
|
||||||
return Err(WebError::bad_request_code(
|
return Err(WebError::bad_request_code(
|
||||||
"mnote_tool_idempotency_required",
|
"mnote_tool_idempotency_required",
|
||||||
@@ -833,6 +897,37 @@ fn ensure_block_revision_ref(
|
|||||||
Ok(())
|
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> {
|
fn ensure_block_editable(context: &RequestContext, block: &Value) -> Result<(), WebError> {
|
||||||
if block
|
if block
|
||||||
.get("editable")
|
.get("editable")
|
||||||
@@ -851,6 +946,40 @@ fn ensure_block_editable(context: &RequestContext, block: &Value) -> Result<(),
|
|||||||
.with_context(context))
|
.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 {
|
fn missing_write_precondition(context: &RequestContext, name: &'static str) -> WebError {
|
||||||
WebError::bad_request_code(
|
WebError::bad_request_code(
|
||||||
"mnote_tool_write_precondition_required",
|
"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,
|
state: &AppState,
|
||||||
context: &RequestContext,
|
context: &RequestContext,
|
||||||
input: &ToolCallInput,
|
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
|
aggregate
|
||||||
.pointer("/body/content")
|
.pointer("/body/content")
|
||||||
.cloned()
|
.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"] },
|
"content": { "type": ["string", "object", "array"] },
|
||||||
"revision": { "type": ["number", "string"] },
|
"revision": { "type": ["number", "string"] },
|
||||||
"conflictDetectionKey": { "type": "string" },
|
"conflictDetectionKey": { "type": "string" },
|
||||||
"blockRevisionRef": { "type": "string" }
|
"blockRevisionRef": { "type": "string" },
|
||||||
|
"allowedTargetBlockIds": {
|
||||||
|
"type": "array",
|
||||||
|
"items": { "type": "string" }
|
||||||
|
}
|
||||||
}),
|
}),
|
||||||
[
|
[
|
||||||
"blockId",
|
"blockId",
|
||||||
@@ -193,7 +197,7 @@ fn block_replace_tool() -> Value {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn block_insert_after_tool() -> Value {
|
fn block_insert_after_tool() -> Value {
|
||||||
write_tool(
|
let mut tool = write_tool(
|
||||||
"mnote.block.insert_after",
|
"mnote.block.insert_after",
|
||||||
"在指定块后插入新块;真实写入走 Rust page.body.save 链路",
|
"在指定块后插入新块;真实写入走 Rust page.body.save 链路",
|
||||||
["block.write", "page.write"],
|
["block.write", "page.write"],
|
||||||
@@ -201,18 +205,38 @@ fn block_insert_after_tool() -> Value {
|
|||||||
"anchorBlockId": { "type": "string" },
|
"anchorBlockId": { "type": "string" },
|
||||||
"content": { "type": ["string", "object", "array"] },
|
"content": { "type": ["string", "object", "array"] },
|
||||||
"block": { "type": "object" },
|
"block": { "type": "object" },
|
||||||
|
"blocks": {
|
||||||
|
"type": "array",
|
||||||
|
"minItems": 1,
|
||||||
|
"maxItems": 20,
|
||||||
|
"items": { "type": ["string", "object", "array"] }
|
||||||
|
},
|
||||||
"revision": { "type": ["number", "string"] },
|
"revision": { "type": ["number", "string"] },
|
||||||
"conflictDetectionKey": { "type": "string" },
|
"conflictDetectionKey": { "type": "string" },
|
||||||
"anchorRevisionRef": { "type": "string" }
|
"anchorRevisionRef": { "type": "string" },
|
||||||
|
"allowedTargetBlockIds": {
|
||||||
|
"type": "array",
|
||||||
|
"items": { "type": "string" }
|
||||||
|
}
|
||||||
}),
|
}),
|
||||||
[
|
[
|
||||||
"anchorBlockId",
|
"anchorBlockId",
|
||||||
"content",
|
|
||||||
"revision",
|
"revision",
|
||||||
"conflictDetectionKey",
|
"conflictDetectionKey",
|
||||||
"anchorRevisionRef",
|
"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 {
|
fn block_move_after_tool() -> Value {
|
||||||
@@ -226,7 +250,11 @@ fn block_move_after_tool() -> Value {
|
|||||||
"revision": { "type": ["number", "string"] },
|
"revision": { "type": ["number", "string"] },
|
||||||
"conflictDetectionKey": { "type": "string" },
|
"conflictDetectionKey": { "type": "string" },
|
||||||
"blockRevisionRef": { "type": "string" },
|
"blockRevisionRef": { "type": "string" },
|
||||||
"anchorRevisionRef": { "type": "string" }
|
"anchorRevisionRef": { "type": "string" },
|
||||||
|
"allowedTargetBlockIds": {
|
||||||
|
"type": "array",
|
||||||
|
"items": { "type": "string" }
|
||||||
|
}
|
||||||
}),
|
}),
|
||||||
[
|
[
|
||||||
"blockId",
|
"blockId",
|
||||||
@@ -248,7 +276,11 @@ fn block_delete_tool() -> Value {
|
|||||||
"blockId": { "type": "string" },
|
"blockId": { "type": "string" },
|
||||||
"revision": { "type": ["number", "string"] },
|
"revision": { "type": ["number", "string"] },
|
||||||
"conflictDetectionKey": { "type": "string" },
|
"conflictDetectionKey": { "type": "string" },
|
||||||
"blockRevisionRef": { "type": "string" }
|
"blockRevisionRef": { "type": "string" },
|
||||||
|
"allowedTargetBlockIds": {
|
||||||
|
"type": "array",
|
||||||
|
"items": { "type": "string" }
|
||||||
|
}
|
||||||
}),
|
}),
|
||||||
[
|
[
|
||||||
"blockId",
|
"blockId",
|
||||||
@@ -411,11 +443,12 @@ fn available_tool(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn doc_markdown_edit_tool() -> Value {
|
fn doc_markdown_edit_tool() -> Value {
|
||||||
let mut properties = base_identity_properties();
|
let mut tool = write_tool(
|
||||||
if let Value::Object(map) = &mut properties {
|
"mnote.doc.markdown_edit",
|
||||||
map.insert(
|
"通过文本级搜索替换编辑 markdown 内容(AI 编辑主路径)。在线 Convex 文档和本地 .md 文件共用,不需要 blockId。",
|
||||||
"operations".into(),
|
["block.write", "page.write"],
|
||||||
json!({
|
json!({
|
||||||
|
"operations": {
|
||||||
"type": "array",
|
"type": "array",
|
||||||
"description": "搜索替换操作列表",
|
"description": "搜索替换操作列表",
|
||||||
"items": {
|
"items": {
|
||||||
@@ -426,25 +459,24 @@ fn doc_markdown_edit_tool() -> Value {
|
|||||||
},
|
},
|
||||||
"required": ["search", "replace"]
|
"required": ["search", "replace"]
|
||||||
}
|
}
|
||||||
}),
|
},
|
||||||
);
|
"full_content": {
|
||||||
map.insert(
|
|
||||||
"full_content".into(),
|
|
||||||
json!({
|
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "完整修改后的 markdown 文本(替代 operations)"
|
"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!({
|
tool
|
||||||
"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)
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn tool_annotations(
|
fn tool_annotations(
|
||||||
|
|||||||
@@ -20,3 +20,13 @@ pub mod tree_shell;
|
|||||||
pub mod workspace_shell;
|
pub mod workspace_shell;
|
||||||
|
|
||||||
pub use app::{build_app, AppConfig, AppState};
|
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",
|
"/client/sessions",
|
||||||
get(hermes_client::list_sessions).post(hermes_client::create_session),
|
get(hermes_client::list_sessions).post(hermes_client::create_session),
|
||||||
)
|
)
|
||||||
|
.route(
|
||||||
|
"/client/sessions/search",
|
||||||
|
get(hermes_client::search_sessions),
|
||||||
|
)
|
||||||
.route(
|
.route(
|
||||||
"/client/sessions/{session_id}",
|
"/client/sessions/{session_id}",
|
||||||
get(hermes_client::get_session),
|
get(hermes_client::get_session).delete(hermes_client::delete_session),
|
||||||
)
|
)
|
||||||
.route(
|
.route(
|
||||||
"/client/sessions/{session_id}/resume",
|
"/client/sessions/{session_id}/resume",
|
||||||
post(hermes_client::resume_session),
|
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/gateway/health", get(hermes_client::gateway_health))
|
||||||
.route("/client/profiles", get(hermes_client::list_profiles))
|
.route("/client/profiles", get(hermes_client::list_profiles))
|
||||||
.route(
|
.route(
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
use crate::app::AppState;
|
use crate::app::AppState;
|
||||||
use crate::context::RequestContext;
|
use crate::context::RequestContext;
|
||||||
use crate::error::WebError;
|
use crate::error::WebError;
|
||||||
use crate::hermes_tools::{doc, ToolCallInput};
|
use crate::hermes_tools::ToolCallInput;
|
||||||
use axum::extract::{Extension, State};
|
use axum::extract::{Extension, State};
|
||||||
use axum::http::{HeaderMap, StatusCode};
|
use axum::http::{HeaderMap, StatusCode};
|
||||||
use axum::Json;
|
use axum::Json;
|
||||||
@@ -88,6 +88,30 @@ pub async fn block_edit_workflow(
|
|||||||
} else {
|
} else {
|
||||||
context.auth.actor_id.clone()
|
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 {
|
let edit_input = ToolCallInput {
|
||||||
tool_name: "mnote.doc.markdown_edit".into(),
|
tool_name: "mnote.doc.markdown_edit".into(),
|
||||||
workspace_id: Some(workspace_id.clone()),
|
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)),
|
idempotency_key: Some(format!("page_ai_fast_edit_{}", context.trace.request_id)),
|
||||||
dry_run: Some(false),
|
dry_run: Some(false),
|
||||||
capability_scope: Some(vec!["block.write".into(), "page.write".into()]),
|
capability_scope: Some(vec!["block.write".into(), "page.write".into()]),
|
||||||
args: Some(json!({
|
args: Some(markdown_args),
|
||||||
"operations": markdown_operations
|
|
||||||
})),
|
|
||||||
};
|
};
|
||||||
let apply_started = Instant::now();
|
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();
|
let apply_ms = apply_started.elapsed().as_millis();
|
||||||
info!(
|
info!(
|
||||||
trace_id = %trace_id,
|
trace_id = %trace_id,
|
||||||
@@ -128,6 +152,7 @@ pub async fn block_edit_workflow(
|
|||||||
"traceId": trace_id,
|
"traceId": trace_id,
|
||||||
"operations": markdown_operations,
|
"operations": markdown_operations,
|
||||||
"applyResult": apply_result,
|
"applyResult": apply_result,
|
||||||
|
"toolExecution": tool_response,
|
||||||
"message": "已通过页面 markdown 编辑快路径完成写入。",
|
"message": "已通过页面 markdown 编辑快路径完成写入。",
|
||||||
"timingsMs": {
|
"timingsMs": {
|
||||||
"total": started.elapsed().as_millis(),
|
"total": started.elapsed().as_millis(),
|
||||||
@@ -540,6 +565,124 @@ fn yaml_path_value(content: &str, path: &[&str]) -> Option<String> {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::{direct_block_edit_operations, extract_operations_from_model_text};
|
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]
|
#[test]
|
||||||
fn extracts_operations_from_fenced_model_json() {
|
fn extracts_operations_from_fenced_model_json() {
|
||||||
@@ -570,4 +713,137 @@ mod tests {
|
|||||||
assert_eq!(operations[2]["op"], "delete");
|
assert_eq!(operations[2]["op"], "delete");
|
||||||
assert_eq!(operations[2]["matchText"], "第三段");
|
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::context::RequestContext;
|
||||||
use crate::error::WebError;
|
use crate::error::WebError;
|
||||||
use crate::routes::stream_support::{
|
use crate::routes::stream_support::{
|
||||||
build_stream_delta_payload, load_stream_overview, load_stream_snapshot,
|
build_stream_delta_payload, build_stream_push_delta_hint, load_stream_overview,
|
||||||
read_stream_cursor_from_payload, resolve_stream_change, with_stream_kind, StreamChangeKind,
|
load_stream_snapshot, read_stream_cursor_from_payload, resolve_stream_change, with_stream_kind,
|
||||||
StreamSnapshotQuery,
|
StreamChangeKind, StreamSnapshotQuery,
|
||||||
};
|
};
|
||||||
use axum::extract::{Extension, Query, State};
|
use axum::extract::{Extension, Query, State};
|
||||||
use axum::http::{HeaderMap, HeaderName, HeaderValue};
|
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 {
|
if let Some(ref mut rx) = state.stream_delta_rx {
|
||||||
match rx.try_recv() {
|
match rx.try_recv() {
|
||||||
Ok(payload) => {
|
Ok(payload) => {
|
||||||
let hint = json!({
|
let hint = build_stream_push_delta_hint(
|
||||||
"kind": "delta",
|
&payload,
|
||||||
"hint": "command_committed",
|
&state.context.trace.request_id,
|
||||||
"commandName": payload.get("commandName"),
|
&state.context.trace.trace_id,
|
||||||
"commandId": payload.get("commandId"),
|
);
|
||||||
"workspaceId": payload.get("workspaceId"),
|
|
||||||
"requestId": payload.get("requestId"),
|
|
||||||
"traceId": payload.get("traceId"),
|
|
||||||
});
|
|
||||||
return Some((Ok(stream_event("delta", &hint)), Some(state)));
|
return Some((Ok(stream_event("delta", &hint)), Some(state)));
|
||||||
}
|
}
|
||||||
Err(tokio::sync::broadcast::error::TryRecvError::Empty) => {}
|
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() {
|
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)));
|
return Some((Ok(stream_event("heartbeat", &json!({}))), Some(state)));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -289,6 +344,8 @@ mod tests {
|
|||||||
use crate::routes::stream_support::StreamSnapshotQuery;
|
use crate::routes::stream_support::StreamSnapshotQuery;
|
||||||
use axum::body::{to_bytes, Body};
|
use axum::body::{to_bytes, Body};
|
||||||
use axum::http::{Request, StatusCode};
|
use axum::http::{Request, StatusCode};
|
||||||
|
use std::time::Duration;
|
||||||
|
use tokio::time::timeout;
|
||||||
use tower::util::ServiceExt;
|
use tower::util::ServiceExt;
|
||||||
|
|
||||||
fn app() -> axum::Router {
|
fn app() -> axum::Router {
|
||||||
@@ -395,6 +452,31 @@ mod tests {
|
|||||||
assert!(text.contains("\"revision\""));
|
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]
|
#[test]
|
||||||
fn live_poll_query_drops_bridge_pagination_cursor() {
|
fn live_poll_query_drops_bridge_pagination_cursor() {
|
||||||
let query = StreamSnapshotQuery {
|
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(
|
pub async fn build_stream_delta_payload(
|
||||||
config: &AppConfig,
|
config: &AppConfig,
|
||||||
context: &RequestContext,
|
context: &RequestContext,
|
||||||
@@ -1042,7 +1054,7 @@ mod tests {
|
|||||||
"id": "clog_2",
|
"id": "clog_2",
|
||||||
"command_id": "cmd_2",
|
"command_id": "cmd_2",
|
||||||
"created_at": "2026-04-25T10:00:02Z",
|
"created_at": "2026-04-25T10:00:02Z",
|
||||||
"command_name": "tree.resource.delete",
|
"command_name": "tree.resource.archive",
|
||||||
"payload": {
|
"payload": {
|
||||||
"streamDelta": {
|
"streamDelta": {
|
||||||
"op": "remove_asset",
|
"op": "remove_asset",
|
||||||
|
|||||||
@@ -2018,12 +2018,17 @@ fn build_tree_shell_html(
|
|||||||
|
|
||||||
const getFileTreeRowDocumentId = (item) => {
|
const getFileTreeRowDocumentId = (item) => {
|
||||||
if (!item) return "";
|
if (!item) return "";
|
||||||
if (item.resourceMeta?.documentId) return item.resourceMeta.documentId;
|
|
||||||
if (item.rowKind === "document" || item.rowKind === "markdown") return item.nodeId;
|
if (item.rowKind === "document" || item.rowKind === "markdown") return item.nodeId;
|
||||||
if (item.rowKind === "index") return item.nodeId.replace(/^index:/, "");
|
if (item.rowKind === "index") return item.nodeId.replace(/^index:/, "");
|
||||||
return "";
|
return "";
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getFileTreeRowOwnerDocumentId = (item) => {
|
||||||
|
if (!item) return "";
|
||||||
|
if (item.resourceMeta?.documentId) return item.resourceMeta.documentId;
|
||||||
|
return getFileTreeRowDocumentId(item);
|
||||||
|
};
|
||||||
|
|
||||||
const getFileTreeRowAssetId = (item) => {
|
const getFileTreeRowAssetId = (item) => {
|
||||||
if (!item) return "";
|
if (!item) return "";
|
||||||
if (item.resourceMeta?.assetId) return item.resourceMeta.assetId;
|
if (item.resourceMeta?.assetId) return item.resourceMeta.assetId;
|
||||||
@@ -2084,6 +2089,7 @@ fn build_tree_shell_html(
|
|||||||
rowKind: "root",
|
rowKind: "root",
|
||||||
nodeId: null,
|
nodeId: null,
|
||||||
documentId: null,
|
documentId: null,
|
||||||
|
ownerDocumentId: null,
|
||||||
assetId: null,
|
assetId: null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -2092,6 +2098,7 @@ fn build_tree_shell_html(
|
|||||||
rowKind: normalizeText(row.dataset.rowKind, "document"),
|
rowKind: normalizeText(row.dataset.rowKind, "document"),
|
||||||
nodeId: normalizeText(row.dataset.nodeId) || null,
|
nodeId: normalizeText(row.dataset.nodeId) || null,
|
||||||
documentId: normalizeText(row.dataset.documentId) || null,
|
documentId: normalizeText(row.dataset.documentId) || null,
|
||||||
|
ownerDocumentId: normalizeText(row.dataset.ownerDocumentId) || null,
|
||||||
assetId: normalizeText(row.dataset.assetId) || null,
|
assetId: normalizeText(row.dataset.assetId) || null,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
@@ -2934,13 +2941,14 @@ fn build_tree_shell_html(
|
|||||||
const targetItem = targetRowId ? fileTreeRowById.get(targetRowId) : null;
|
const targetItem = targetRowId ? fileTreeRowById.get(targetRowId) : null;
|
||||||
const target = targetItem
|
const target = targetItem
|
||||||
? {
|
? {
|
||||||
rowId: targetItem.rowId,
|
rowId: targetItem.rowId,
|
||||||
rowKind: targetItem.rowKind,
|
rowKind: targetItem.rowKind,
|
||||||
nodeId: targetItem.nodeId,
|
nodeId: targetItem.nodeId,
|
||||||
documentId: getFileTreeRowDocumentId(targetItem) || null,
|
documentId: getFileTreeRowDocumentId(targetItem) || null,
|
||||||
assetId: getFileTreeRowAssetId(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") {
|
if (sourceKind === "local_folder") {
|
||||||
const pasted = await executeLocalFileTreeInternalDrop(
|
const pasted = await executeLocalFileTreeInternalDrop(
|
||||||
target,
|
target,
|
||||||
@@ -3030,7 +3038,12 @@ fn build_tree_shell_html(
|
|||||||
if (event.key === "F2") {
|
if (event.key === "F2") {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
const rowId = item?.rowId || fileTreeFocusedRowId;
|
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;
|
return;
|
||||||
}
|
}
|
||||||
if (event.key === "ArrowDown") {
|
if (event.key === "ArrowDown") {
|
||||||
@@ -3057,7 +3070,11 @@ fn build_tree_shell_html(
|
|||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
if (item) {
|
if (item) {
|
||||||
const documentId = getFileTreeRowDocumentId(item);
|
const documentId = getFileTreeRowDocumentId(item);
|
||||||
if (documentId) handleNavigate(documentId);
|
if (documentId) {
|
||||||
|
handleNavigate(documentId);
|
||||||
|
} else {
|
||||||
|
openHydratedFileTreeItem(item);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -3219,7 +3236,7 @@ fn build_tree_shell_html(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
normalizedItems.slice().forEach((item) => {
|
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) {
|
if (removedNodeIds.has(item.nodeId) || itemDocumentId === normalizedDocumentId) {
|
||||||
removeTreeItemEverywhere(item);
|
removeTreeItemEverywhere(item);
|
||||||
changed = true;
|
changed = true;
|
||||||
@@ -4149,8 +4166,7 @@ fn build_tree_shell_html(
|
|||||||
rowKind === "markdown" ||
|
rowKind === "markdown" ||
|
||||||
rowKind === "document" ||
|
rowKind === "document" ||
|
||||||
rowKind === "folder" ||
|
rowKind === "folder" ||
|
||||||
rowKind === "index" ||
|
rowKind === "index";
|
||||||
(localSource && rowKind === "asset");
|
|
||||||
const canCopyCut =
|
const canCopyCut =
|
||||||
rowKind === "markdown" ||
|
rowKind === "markdown" ||
|
||||||
rowKind === "document" ||
|
rowKind === "document" ||
|
||||||
@@ -4282,7 +4298,9 @@ fn build_tree_shell_html(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (kind === "rename") {
|
if (kind === "rename") {
|
||||||
if (target.rowId) beginInlineRename("filetree", target.rowId);
|
if (target.rowId && getFileTreeRowDocumentId(item)) {
|
||||||
|
beginInlineRename("filetree", target.rowId);
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (kind === "copy" || kind === "cut") {
|
if (kind === "copy" || kind === "cut") {
|
||||||
@@ -4639,8 +4657,14 @@ fn build_tree_shell_html(
|
|||||||
const id = inlineRenameState.id;
|
const id = inlineRenameState.id;
|
||||||
const item = renameMode === "filetree" ? fileTreeRowById.get(id) : itemById.get(id);
|
const item = renameMode === "filetree" ? fileTreeRowById.get(id) : itemById.get(id);
|
||||||
const documentId = renameMode === "filetree"
|
const documentId = renameMode === "filetree"
|
||||||
? getFileTreeRowDocumentId(item) || item?.nodeId || id
|
? getFileTreeRowDocumentId(item)
|
||||||
: id;
|
: id;
|
||||||
|
if (!documentId) {
|
||||||
|
inlineRenameState = { mode: null, id: null, committing: false };
|
||||||
|
setLastAction("当前资源暂不支持重命名", "error");
|
||||||
|
renderTree();
|
||||||
|
return;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const result = await sendCommand({
|
const result = await sendCommand({
|
||||||
action: "rename",
|
action: "rename",
|
||||||
@@ -4953,6 +4977,7 @@ fn build_tree_shell_html(
|
|||||||
|
|
||||||
const openHydratedFileTreeItem = (item) => {
|
const openHydratedFileTreeItem = (item) => {
|
||||||
const documentId = getFileTreeRowDocumentId(item);
|
const documentId = getFileTreeRowDocumentId(item);
|
||||||
|
const ownerDocumentId = getFileTreeRowOwnerDocumentId(item);
|
||||||
const assetId = getFileTreeRowAssetId(item);
|
const assetId = getFileTreeRowAssetId(item);
|
||||||
if (item.rowKind === "document" || item.rowKind === "index") {
|
if (item.rowKind === "document" || item.rowKind === "index") {
|
||||||
handleNavigate(documentId || item.nodeId);
|
handleNavigate(documentId || item.nodeId);
|
||||||
@@ -4960,12 +4985,12 @@ fn build_tree_shell_html(
|
|||||||
}
|
}
|
||||||
setLastAction(`准备打开资源 ${assetId || item.rowId}`);
|
setLastAction(`准备打开资源 ${assetId || item.rowId}`);
|
||||||
postToHost("tree.asset.open", {
|
postToHost("tree.asset.open", {
|
||||||
documentId: documentId || null,
|
documentId: ownerDocumentId || null,
|
||||||
assetId: assetId || null,
|
assetId: assetId || null,
|
||||||
objectIdentity: item.resourceMeta?.objectIdentity || null,
|
objectIdentity: item.resourceMeta?.objectIdentity || null,
|
||||||
target: { documentId: documentId || null },
|
target: { documentId: ownerDocumentId || null },
|
||||||
payload: {
|
payload: {
|
||||||
documentId: documentId || null,
|
documentId: ownerDocumentId || null,
|
||||||
assetId: assetId || null,
|
assetId: assetId || null,
|
||||||
rowId: item.rowId,
|
rowId: item.rowId,
|
||||||
rowKind: item.rowKind,
|
rowKind: item.rowKind,
|
||||||
@@ -5122,6 +5147,7 @@ fn build_tree_shell_html(
|
|||||||
const bindFileTreeRowEvents = (row, item) => {
|
const bindFileTreeRowEvents = (row, item) => {
|
||||||
if (!(row instanceof HTMLElement) || !item) return;
|
if (!(row instanceof HTMLElement) || !item) return;
|
||||||
const documentId = getFileTreeRowDocumentId(item) || null;
|
const documentId = getFileTreeRowDocumentId(item) || null;
|
||||||
|
const ownerDocumentId = getFileTreeRowOwnerDocumentId(item) || null;
|
||||||
const assetId = getFileTreeRowAssetId(item) || null;
|
const assetId = getFileTreeRowAssetId(item) || null;
|
||||||
row.dataset.active = String(
|
row.dataset.active = String(
|
||||||
item.rowKind === "document" && documentId === currentActiveDocumentId
|
item.rowKind === "document" && documentId === currentActiveDocumentId
|
||||||
@@ -5130,6 +5156,7 @@ fn build_tree_shell_html(
|
|||||||
row.dataset.rowId = item.rowId;
|
row.dataset.rowId = item.rowId;
|
||||||
row.dataset.rowKind = item.rowKind;
|
row.dataset.rowKind = item.rowKind;
|
||||||
row.dataset.documentId = documentId || "";
|
row.dataset.documentId = documentId || "";
|
||||||
|
row.dataset.ownerDocumentId = ownerDocumentId || "";
|
||||||
row.dataset.assetId = assetId || "";
|
row.dataset.assetId = assetId || "";
|
||||||
row.dataset.objectIdentity = item.resourceMeta?.objectIdentity
|
row.dataset.objectIdentity = item.resourceMeta?.objectIdentity
|
||||||
? JSON.stringify(item.resourceMeta.objectIdentity)
|
? JSON.stringify(item.resourceMeta.objectIdentity)
|
||||||
@@ -5577,6 +5604,7 @@ fn build_tree_shell_html(
|
|||||||
|
|
||||||
const openFileTreeItem = (item) => {
|
const openFileTreeItem = (item) => {
|
||||||
const documentId = getFileTreeRowDocumentId(item);
|
const documentId = getFileTreeRowDocumentId(item);
|
||||||
|
const ownerDocumentId = getFileTreeRowOwnerDocumentId(item);
|
||||||
const assetId = getFileTreeRowAssetId(item);
|
const assetId = getFileTreeRowAssetId(item);
|
||||||
if (item.rowKind === "document" || item.rowKind === "index" || item.rowKind === "markdown") {
|
if (item.rowKind === "document" || item.rowKind === "index" || item.rowKind === "markdown") {
|
||||||
handleNavigate(documentId || item.nodeId);
|
handleNavigate(documentId || item.nodeId);
|
||||||
@@ -5584,12 +5612,12 @@ fn build_tree_shell_html(
|
|||||||
}
|
}
|
||||||
setLastAction(`准备打开资源 ${assetId || item.rowId}`);
|
setLastAction(`准备打开资源 ${assetId || item.rowId}`);
|
||||||
postToHost("tree.asset.open", {
|
postToHost("tree.asset.open", {
|
||||||
documentId: documentId || null,
|
documentId: ownerDocumentId || null,
|
||||||
assetId: assetId || null,
|
assetId: assetId || null,
|
||||||
objectIdentity: item.resourceMeta?.objectIdentity || null,
|
objectIdentity: item.resourceMeta?.objectIdentity || null,
|
||||||
target: { documentId: documentId || null },
|
target: { documentId: ownerDocumentId || null },
|
||||||
payload: {
|
payload: {
|
||||||
documentId: documentId || null,
|
documentId: ownerDocumentId || null,
|
||||||
assetId: assetId || null,
|
assetId: assetId || null,
|
||||||
rowId: item.rowId,
|
rowId: item.rowId,
|
||||||
rowKind: item.rowKind,
|
rowKind: item.rowKind,
|
||||||
@@ -5649,6 +5677,7 @@ fn build_tree_shell_html(
|
|||||||
const children = getSiblings(item.nodeId);
|
const children = getSiblings(item.nodeId);
|
||||||
const hasBranches = canExpandFileTreeRow(item) && children.length > 0;
|
const hasBranches = canExpandFileTreeRow(item) && children.length > 0;
|
||||||
const documentId = getFileTreeRowDocumentId(item) || null;
|
const documentId = getFileTreeRowDocumentId(item) || null;
|
||||||
|
const ownerDocumentId = getFileTreeRowOwnerDocumentId(item) || null;
|
||||||
const assetId = getFileTreeRowAssetId(item) || null;
|
const assetId = getFileTreeRowAssetId(item) || null;
|
||||||
|
|
||||||
const row = document.createElement("div");
|
const row = document.createElement("div");
|
||||||
@@ -5661,6 +5690,7 @@ fn build_tree_shell_html(
|
|||||||
row.dataset.rowId = item.rowId;
|
row.dataset.rowId = item.rowId;
|
||||||
row.dataset.rowKind = item.rowKind;
|
row.dataset.rowKind = item.rowKind;
|
||||||
row.dataset.documentId = documentId || "";
|
row.dataset.documentId = documentId || "";
|
||||||
|
row.dataset.ownerDocumentId = ownerDocumentId || "";
|
||||||
row.dataset.assetId = assetId || "";
|
row.dataset.assetId = assetId || "";
|
||||||
row.dataset.objectIdentity = item.resourceMeta?.objectIdentity
|
row.dataset.objectIdentity = item.resourceMeta?.objectIdentity
|
||||||
? JSON.stringify(item.resourceMeta.objectIdentity)
|
? JSON.stringify(item.resourceMeta.objectIdentity)
|
||||||
@@ -5769,15 +5799,17 @@ fn build_tree_shell_html(
|
|||||||
|
|
||||||
const actions = document.createElement("div");
|
const actions = document.createElement("div");
|
||||||
actions.className = "tree-actions";
|
actions.className = "tree-actions";
|
||||||
actions.appendChild(
|
if (getFileTreeRowDocumentId(item)) {
|
||||||
createActionButton(
|
actions.appendChild(
|
||||||
ICONS.edit,
|
createActionButton(
|
||||||
"filetree-action-rename",
|
ICONS.edit,
|
||||||
`重命名 ${item.title}`,
|
"filetree-action-rename",
|
||||||
() => beginInlineRename("filetree", item.rowId),
|
`重命名 ${item.title}`,
|
||||||
false,
|
() => beginInlineRename("filetree", item.rowId),
|
||||||
),
|
false,
|
||||||
);
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
actions.appendChild(
|
actions.appendChild(
|
||||||
createActionButton(
|
createActionButton(
|
||||||
ICONS.more,
|
ICONS.more,
|
||||||
@@ -7143,6 +7175,11 @@ mod tests {
|
|||||||
assert!(html.contains("tree.filetree.external-drop"));
|
assert!(html.contains("tree.filetree.external-drop"));
|
||||||
assert!(html.contains("\"rowKind\":\"asset_folder\""));
|
assert!(html.contains("\"rowKind\":\"asset_folder\""));
|
||||||
assert!(html.contains("\"resourceMeta\""));
|
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("dragover"));
|
||||||
assert!(html.contains("hydrateInitialFileTree"));
|
assert!(html.contains("hydrateInitialFileTree"));
|
||||||
assert!(html.contains("const usedRustInitialRenderer = hydrateInitialRenderer();"));
|
assert!(html.contains("const usedRustInitialRenderer = hydrateInitialRenderer();"));
|
||||||
@@ -7897,7 +7934,7 @@ mod tests {
|
|||||||
payload["result"]["documentId"],
|
payload["result"]["documentId"],
|
||||||
Value::String("page_child".into())
|
Value::String("page_child".into())
|
||||||
);
|
);
|
||||||
assert_eq!(payload["result"]["sortOrder"], Value::from(1));
|
assert_eq!(payload["result"]["sortOrder"], Value::Null);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
payload["result"]["execution"]["deletedCount"],
|
payload["result"]["execution"]["deletedCount"],
|
||||||
Value::from(1)
|
Value::from(1)
|
||||||
|
|||||||
@@ -1210,6 +1210,18 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
|
|||||||
titleEndpoint: '/api/documents/title',
|
titleEndpoint: '/api/documents/title',
|
||||||
editorHostKind: 'leptos_tiptap_island',
|
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 documentSessionRegistry = new Map();
|
||||||
const localFolderEventRegistry = 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);
|
const nextRevision = Number.isInteger(nextBody.revision) ? nextBody.revision : revisionFromConflictKey(nextConflictKey);
|
||||||
session.latestAggregate = nextAggregate;
|
session.latestAggregate = nextAggregate;
|
||||||
|
syncPageAggregateScript(session, nextAggregate);
|
||||||
session.title = nextAggregate?.head?.title || session.title;
|
session.title = nextAggregate?.head?.title || session.title;
|
||||||
session.currentTiptapDocument = nextTiptapDocument;
|
session.currentTiptapDocument = nextTiptapDocument;
|
||||||
session.currentSerialized = nextSerialized;
|
session.currentSerialized = nextSerialized;
|
||||||
@@ -1891,6 +1904,7 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
|
|||||||
sourceKind,
|
sourceKind,
|
||||||
rootUri: runtimeDescriptor.bootstrap.rootUri,
|
rootUri: runtimeDescriptor.bootstrap.rootUri,
|
||||||
saveEndpoint: runtimeDescriptor.bootstrap.saveEndpoint || '/api/documents/save',
|
saveEndpoint: runtimeDescriptor.bootstrap.saveEndpoint || '/api/documents/save',
|
||||||
|
pageAggregateScriptId: runtimeDescriptor.bootstrap.pageAggregateScriptId || '__MNOTE_PAGE_AGGREGATE__',
|
||||||
latestAggregate: runtimeDescriptor.aggregate,
|
latestAggregate: runtimeDescriptor.aggregate,
|
||||||
title: runtimeDescriptor.aggregate.head?.title || '无标题',
|
title: runtimeDescriptor.aggregate.head?.title || '无标题',
|
||||||
currentTiptapDocument: tiptapDocument,
|
currentTiptapDocument: tiptapDocument,
|
||||||
@@ -3082,6 +3096,7 @@ mod tests {
|
|||||||
assert!(html.contains("mnote.tree_live_bootstrap.v1"));
|
assert!(html.contains("mnote.tree_live_bootstrap.v1"));
|
||||||
assert!(html.contains("/api/tree/events"));
|
assert!(html.contains("/api/tree/events"));
|
||||||
assert!(html.contains("data-mnote-tree-live-transport"));
|
assert!(html.contains("data-mnote-tree-live-transport"));
|
||||||
|
assert!(html.contains("syncPageAggregateScript(session, nextAggregate);"));
|
||||||
assert!(!html.contains("mnote-web-document-shell"));
|
assert!(!html.contains("mnote-web-document-shell"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
use crate::app::AppState;
|
use crate::app::AppState;
|
||||||
use crate::context::RequestContext;
|
use crate::context::RequestContext;
|
||||||
use crate::error::WebError;
|
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::ws::{Message, WebSocket, WebSocketUpgrade};
|
||||||
use axum::extract::{Extension, Query, State};
|
use axum::extract::{Extension, Query, State};
|
||||||
use axum::response::Response;
|
use axum::response::Response;
|
||||||
@@ -41,14 +43,15 @@ async fn handle_socket(
|
|||||||
delta_result = stream_delta_rx.recv() => {
|
delta_result = stream_delta_rx.recv() => {
|
||||||
match delta_result {
|
match delta_result {
|
||||||
Ok(delta) => {
|
Ok(delta) => {
|
||||||
let notify = json!({
|
if socket
|
||||||
"kind": "delta",
|
.send(serialize_delta_message(
|
||||||
"data": delta,
|
&delta,
|
||||||
"requestId": context.trace.request_id,
|
&context.trace.request_id,
|
||||||
"traceId": context.trace.trace_id,
|
&context.trace.trace_id,
|
||||||
"workspaceId": delta.get("workspaceId").and_then(Value::as_str).unwrap_or(""),
|
))
|
||||||
});
|
.await
|
||||||
if socket.send(Message::Text(notify.to_string().into())).await.is_err() {
|
.is_err()
|
||||||
|
{
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -130,6 +133,14 @@ fn serialize_snapshot_message(payload: &Value) -> Message {
|
|||||||
Message::Text(payload.to_string().into())
|
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 {
|
fn serialize_resync_message(payload: &Value) -> Message {
|
||||||
Message::Text(
|
Message::Text(
|
||||||
json!({
|
json!({
|
||||||
@@ -156,7 +167,10 @@ fn is_resync_request(text: &str) -> bool {
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
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 axum::extract::ws::Message;
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
|
|
||||||
@@ -183,4 +197,29 @@ mod tests {
|
|||||||
};
|
};
|
||||||
assert!(resync_text.contains("\"kind\":\"resync\""));
|
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: '',
|
pageAiSkillQuery: '',
|
||||||
pageAiSkillError: '',
|
pageAiSkillError: '',
|
||||||
pageAiSessions: [],
|
pageAiSessions: [],
|
||||||
pageAiActiveSessionId: ''
|
pageAiActiveSessionId: '',
|
||||||
|
pageAiSessionSearchQuery: '',
|
||||||
|
pageAiSessionSearchResults: [],
|
||||||
|
pageAiSessionSearchTimer: 0,
|
||||||
|
pageAiSessionError: '',
|
||||||
|
pageAiPermissionRequests: []
|
||||||
};
|
};
|
||||||
|
|
||||||
function closestAction(target, selector) {
|
function closestAction(target, selector) {
|
||||||
return target && typeof target.closest === 'function' ? target.closest(selector) : null;
|
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) {
|
function escapeHtml(value) {
|
||||||
return String(value == null ? '' : value)
|
return String(value == null ? '' : value)
|
||||||
.replace(/&/g, '&')
|
.replace(/&/g, '&')
|
||||||
@@ -227,20 +246,7 @@ const SIDEBAR_TREE_JS: &str = r##"
|
|||||||
function currentPageAiContextSnapshot() {
|
function currentPageAiContextSnapshot() {
|
||||||
var aggregate = currentPageAggregate();
|
var aggregate = currentPageAggregate();
|
||||||
var body = aggregate.body || {};
|
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 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 {
|
return {
|
||||||
aggregate: aggregate,
|
aggregate: aggregate,
|
||||||
body: body,
|
body: body,
|
||||||
@@ -3930,6 +3936,30 @@ const SIDEBAR_TREE_JS: &str = r##"
|
|||||||
return 'hermes_page_ai_session:' + currentDocumentId();
|
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) {
|
function pageAiNewSession(title) {
|
||||||
var now = Date.now();
|
var now = Date.now();
|
||||||
return {
|
return {
|
||||||
@@ -3939,10 +3969,126 @@ const SIDEBAR_TREE_JS: &str = r##"
|
|||||||
acpRuntime: pageUiState.pageAiAcpRuntime || '',
|
acpRuntime: pageUiState.pageAiAcpRuntime || '',
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
|
source: 'local',
|
||||||
|
usage: null,
|
||||||
|
status: 'idle',
|
||||||
messages: []
|
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) {
|
function pageAiNormalizeSessions(sessions) {
|
||||||
return (Array.isArray(sessions) ? sessions : [])
|
return (Array.isArray(sessions) ? sessions : [])
|
||||||
.slice(0, 20)
|
.slice(0, 20)
|
||||||
@@ -3952,8 +4098,13 @@ const SIDEBAR_TREE_JS: &str = r##"
|
|||||||
title: String(session && session.title || '').trim() || '新会话',
|
title: String(session && session.title || '').trim() || '新会话',
|
||||||
profile: String(session && session.profile || pageAiCurrentProfile()).trim() || 'default',
|
profile: String(session && session.profile || pageAiCurrentProfile()).trim() || 'default',
|
||||||
acpRuntime: String(session && session.acpRuntime || '').trim(),
|
acpRuntime: String(session && session.acpRuntime || '').trim(),
|
||||||
createdAt: Number(session && session.createdAt || Date.now()),
|
createdAt: pageAiTimestamp(session && session.createdAt),
|
||||||
updatedAt: Number(session && session.updatedAt || Date.now()),
|
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) : []
|
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() {
|
function pageAiLoadSessions() {
|
||||||
if (pageUiState.pageAiSessions.length && pageUiState.pageAiActiveSessionId) return;
|
if (pageUiState.pageAiSessions.length && pageUiState.pageAiActiveSessionId) return;
|
||||||
try {
|
try {
|
||||||
@@ -3996,6 +4184,135 @@ const SIDEBAR_TREE_JS: &str = r##"
|
|||||||
pageUiState.pageAiMessages = [];
|
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() {
|
function pageAiPersistSessions() {
|
||||||
document.documentElement.setAttribute('data-mnote-page-ai-session-owner', 'hermes');
|
document.documentElement.setAttribute('data-mnote-page-ai-session-owner', 'hermes');
|
||||||
document.documentElement.setAttribute('data-mnote-page-ai-session-key', pageAiStorageKey());
|
document.documentElement.setAttribute('data-mnote-page-ai-session-key', pageAiStorageKey());
|
||||||
@@ -4049,12 +4366,18 @@ const SIDEBAR_TREE_JS: &str = r##"
|
|||||||
async function pageAiRestoreHermesSession() {
|
async function pageAiRestoreHermesSession() {
|
||||||
var current = pageAiCurrentSession();
|
var current = pageAiCurrentSession();
|
||||||
if (!current || !String(current.id || '').startsWith('mnote_')) return;
|
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',
|
method: 'POST',
|
||||||
headers: { 'accept': 'application/json' }
|
headers: { 'accept': 'application/json' }
|
||||||
});
|
});
|
||||||
if (!response.ok) return;
|
if (!response.ok) return;
|
||||||
var payload = await response.json().catch(function(){ return null; });
|
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 session = payload && (payload.session || (payload.upstream && payload.upstream.session) || payload);
|
||||||
var messages = session && Array.isArray(session.messages) ? session.messages : [];
|
var messages = session && Array.isArray(session.messages) ? session.messages : [];
|
||||||
pageAiApplyRuntimeState(payload && payload.runtime);
|
pageAiApplyRuntimeState(payload && payload.runtime);
|
||||||
@@ -4101,6 +4424,12 @@ const SIDEBAR_TREE_JS: &str = r##"
|
|||||||
pageAiPersistSessions();
|
pageAiPersistSessions();
|
||||||
renderPageAiControls();
|
renderPageAiControls();
|
||||||
renderPageAiConversation();
|
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() {
|
function pageAiStartNewSession() {
|
||||||
@@ -4114,6 +4443,71 @@ const SIDEBAR_TREE_JS: &str = r##"
|
|||||||
renderPageAiConversation();
|
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) {
|
function pageAiProviderLabel(provider) {
|
||||||
if (provider === 'codex') return 'Codex';
|
if (provider === 'codex') return 'Codex';
|
||||||
if (provider === 'claudecode') return 'ClaudeCode';
|
if (provider === 'claudecode') return 'ClaudeCode';
|
||||||
@@ -4970,7 +5364,10 @@ const SIDEBAR_TREE_JS: &str = r##"
|
|||||||
var sessionNode = drawer.querySelector('[data-page-ai-session-status]');
|
var sessionNode = drawer.querySelector('[data-page-ai-session-status]');
|
||||||
if (sessionNode instanceof HTMLElement) {
|
if (sessionNode instanceof HTMLElement) {
|
||||||
var session = pageAiCurrentSession();
|
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]');
|
var modelNode = drawer.querySelector('[data-page-ai-model-status]');
|
||||||
if (modelNode instanceof HTMLElement) modelNode.textContent = pageAiCurrentModelLabel();
|
if (modelNode instanceof HTMLElement) modelNode.textContent = pageAiCurrentModelLabel();
|
||||||
@@ -5024,6 +5421,15 @@ const SIDEBAR_TREE_JS: &str = r##"
|
|||||||
skillError.textContent = pageUiState.pageAiSkillError || '';
|
skillError.textContent = pageUiState.pageAiSkillError || '';
|
||||||
skillError.hidden = !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]');
|
var skillSearch = drawer.querySelector('[data-page-ai-skill-search]');
|
||||||
if (skillSearch instanceof HTMLInputElement && document.activeElement !== skillSearch) {
|
if (skillSearch instanceof HTMLInputElement && document.activeElement !== skillSearch) {
|
||||||
skillSearch.value = pageUiState.pageAiSkillQuery;
|
skillSearch.value = pageUiState.pageAiSkillQuery;
|
||||||
@@ -5290,6 +5696,11 @@ const SIDEBAR_TREE_JS: &str = r##"
|
|||||||
'<div class="wolai-page-ai-settings-head">' +
|
'<div class="wolai-page-ai-settings-head">' +
|
||||||
'<button type="button" class="wolai-page-ai-back" data-page-ai-tab="chat">← 聊天</button>' +
|
'<button type="button" class="wolai-page-ai-back" data-page-ai-tab="chat">← 聊天</button>' +
|
||||||
'</div>' +
|
'</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>' +
|
'<div class="wolai-page-ai-conversation" data-page-ai-conversation></div>' +
|
||||||
'</section>' +
|
'</section>' +
|
||||||
'</div>' +
|
'</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]');
|
var conversation = drawer.querySelector('[data-page-ai-panel="' + cssEscape(pageUiState.pageAiPage || 'chat') + '"] [data-page-ai-conversation]');
|
||||||
if (!(conversation instanceof HTMLElement)) return;
|
if (!(conversation instanceof HTMLElement)) return;
|
||||||
if (pageUiState.pageAiPage === 'history') {
|
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>';
|
conversation.innerHTML = '<div class="wolai-page-ai-empty">尚未产生历史会话。</div>';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
conversation.innerHTML = pageUiState.pageAiSessions.map(function(session) {
|
conversation.innerHTML = historyRows.map(function(session) {
|
||||||
var preview = Array.isArray(session.messages) && session.messages.length
|
var preview = Array.isArray(session.messages) && session.messages.length
|
||||||
? session.messages.slice(-1)[0].content
|
? session.messages.slice(-1)[0].content
|
||||||
: '暂无消息';
|
: (session.snippet || session.preview || '暂无消息');
|
||||||
var active = session.id === pageUiState.pageAiActiveSessionId;
|
var active = session.id === pageUiState.pageAiActiveSessionId;
|
||||||
|
var usage = pageAiUsageSummary(session.usage);
|
||||||
|
var meta = [session.source === 'acp' ? 'Convex' : '本地', session.status, usage].filter(Boolean).join(' · ');
|
||||||
return '' +
|
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 wolai-page-ai-message--history' + (active ? ' is-active' : '') + '" data-page-ai-session-row="' + escapeHtml(session.id) + '">' +
|
||||||
'<div class="wolai-page-ai-message-role">会话</div>' +
|
'<button type="button" class="wolai-page-ai-message-text" data-page-ai-session="' + escapeHtml(session.id) + '">' +
|
||||||
'<div class="wolai-page-ai-message-text"><strong>' + escapeHtml(session.title || '新会话') + '</strong><br />' + escapeHtml(preview) + '</div>' +
|
'<strong>' + escapeHtml(session.title || '新会话') + '</strong><br />' +
|
||||||
'</button>';
|
'<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('');
|
}).join('');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -5376,6 +5800,30 @@ const SIDEBAR_TREE_JS: &str = r##"
|
|||||||
'</div>' +
|
'</div>' +
|
||||||
'</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"' : '';
|
var streamingAttr = item.streaming ? ' data-page-ai-streaming="true"' : '';
|
||||||
return '' +
|
return '' +
|
||||||
'<div class="wolai-page-ai-message wolai-page-ai-message--' + escapeHtml(item.role || 'assistant') + '"' + streamingAttr + '>' +
|
'<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(),
|
pageAiLoadProfileMemory(),
|
||||||
pageAiLoadSkills(),
|
pageAiLoadSkills(),
|
||||||
pageAiLoadTools(),
|
pageAiLoadTools(),
|
||||||
pageAiLoadGatewayHealth()
|
pageAiLoadGatewayHealth(),
|
||||||
|
pageAiLoadBackendSessions()
|
||||||
]).then(function() {
|
]).then(function() {
|
||||||
renderPageAiControls();
|
renderPageAiControls();
|
||||||
}).catch(function() {}).then(function() {
|
}).catch(function() {}).then(function() {
|
||||||
@@ -5687,10 +6136,54 @@ const SIDEBAR_TREE_JS: &str = r##"
|
|||||||
if (pageUiState.pageAiStoppedRunIds[runId]) return;
|
if (pageUiState.pageAiStoppedRunIds[runId]) return;
|
||||||
assistantText = pageAiAppendStreamingAssistantDelta(runId, pageAiDecodeDeltaText(payloadText));
|
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') {
|
if (eventName === 'run.completed') {
|
||||||
try {
|
try {
|
||||||
var completed = JSON.parse(payloadText || 'null');
|
var completed = JSON.parse(payloadText || 'null');
|
||||||
if (completed && completed.output) assistantText = String(completed.output || '');
|
if (completed && completed.output) assistantText = String(completed.output || '');
|
||||||
|
if (completed && completed.usage) {
|
||||||
|
var completedSession = pageAiCurrentSession();
|
||||||
|
if (completedSession) completedSession.usage = completed.usage;
|
||||||
|
}
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
pageAiSetRunStatus('completed', runId);
|
pageAiSetRunStatus('completed', runId);
|
||||||
}
|
}
|
||||||
@@ -6181,6 +6674,13 @@ const SIDEBAR_TREE_JS: &str = r##"
|
|||||||
if (activeTreeContextMenu && activeTreeContextMenu.contains(e.target)) return;
|
if (activeTreeContextMenu && activeTreeContextMenu.contains(e.target)) return;
|
||||||
if (activeTreeContextMenu) closeTreeContextMenu();
|
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]');
|
var attachmentAction = closestAction(e.target, '[data-attachment-action]');
|
||||||
if (attachmentAction) {
|
if (attachmentAction) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -6362,6 +6862,46 @@ const SIDEBAR_TREE_JS: &str = r##"
|
|||||||
return;
|
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]');
|
var pageAiSession = closestAction(e.target, '[data-page-ai-session]');
|
||||||
if (pageAiSession) {
|
if (pageAiSession) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -6396,6 +6936,12 @@ const SIDEBAR_TREE_JS: &str = r##"
|
|||||||
pageUiState.pageAiPage = pageUiState.pageAiPage === 'history' ? 'chat' : 'history';
|
pageUiState.pageAiPage = pageUiState.pageAiPage === 'history' ? 'chat' : 'history';
|
||||||
renderPageAiControls();
|
renderPageAiControls();
|
||||||
renderPageAiConversation();
|
renderPageAiConversation();
|
||||||
|
if (pageUiState.pageAiPage === 'history') {
|
||||||
|
void pageAiLoadBackendSessions().catch(function(error) {
|
||||||
|
pageUiState.pageAiSessionError = error instanceof Error ? error.message : String(error);
|
||||||
|
renderPageAiControls();
|
||||||
|
});
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -6661,6 +7207,18 @@ const SIDEBAR_TREE_JS: &str = r##"
|
|||||||
renderPageAiControls();
|
renderPageAiControls();
|
||||||
return;
|
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]');
|
var memoryEditor = closestAction(event.target, '[data-page-ai-memory-editor]');
|
||||||
if (memoryEditor instanceof HTMLTextAreaElement) {
|
if (memoryEditor instanceof HTMLTextAreaElement) {
|
||||||
var section = memoryEditor.getAttribute('data-page-ai-memory-editor') || '';
|
var section = memoryEditor.getAttribute('data-page-ai-memory-editor') || '';
|
||||||
@@ -6681,6 +7239,9 @@ const SIDEBAR_TREE_JS: &str = r##"
|
|||||||
void pageAiLoadProfiles();
|
void pageAiLoadProfiles();
|
||||||
if (next !== 'reasonix') void pageAiLoadProfileMemory();
|
if (next !== 'reasonix') void pageAiLoadProfileMemory();
|
||||||
void pageAiLoadSkills();
|
void pageAiLoadSkills();
|
||||||
|
void pageAiLoadBackendSessions().catch(function(error) {
|
||||||
|
pageUiState.pageAiSessionError = error instanceof Error ? error.message : String(error);
|
||||||
|
});
|
||||||
renderPageAiControls();
|
renderPageAiControls();
|
||||||
renderPageAiProviderButtons();
|
renderPageAiProviderButtons();
|
||||||
return;
|
return;
|
||||||
@@ -7343,7 +7904,7 @@ pub fn PageLayout(
|
|||||||
<div class="mnote-main">
|
<div class="mnote-main">
|
||||||
<header class="wolai-topbar" data-testid="wolai-topbar">
|
<header class="wolai-topbar" data-testid="wolai-topbar">
|
||||||
<div class="wolai-topbar-left">
|
<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="页面路径">
|
<nav class="wolai-breadcrumb" aria-label="页面路径">
|
||||||
<span class="wolai-breadcrumb-root">{ws_name.clone()}</span>
|
<span class="wolai-breadcrumb-root">{ws_name.clone()}</span>
|
||||||
<span class="wolai-breadcrumb-separator" aria-hidden="true">"›"</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"));
|
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]
|
#[test]
|
||||||
fn sidebar_workspace_source_switch_remembers_real_cloud_workspace_before_local_folder() {
|
fn sidebar_workspace_source_switch_remembers_real_cloud_workspace_before_local_folder() {
|
||||||
assert!(
|
assert!(
|
||||||
@@ -7593,6 +8207,9 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn tree_live_controller_marks_transport_and_closes_source_on_pagehide() {
|
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-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("data-mnote-tree-live-transport"));
|
||||||
assert!(TREE_LIVE_CONTROLLER_JS.contains("pagehide"));
|
assert!(TREE_LIVE_CONTROLLER_JS.contains("pagehide"));
|
||||||
assert!(TREE_LIVE_CONTROLLER_JS.contains("__mnoteTreeLiveEventSource"));
|
assert!(TREE_LIVE_CONTROLLER_JS.contains("__mnoteTreeLiveEventSource"));
|
||||||
|
|||||||
@@ -224,6 +224,11 @@ a:hover {
|
|||||||
background: var(--wolai-bg);
|
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-workspace-shell {
|
||||||
--wolai-bg-sidebar: #F7F7F6;
|
--wolai-bg-sidebar: #F7F7F6;
|
||||||
--wolai-bg-selected: #F8E6E7;
|
--wolai-bg-selected: #F8E6E7;
|
||||||
@@ -3376,7 +3381,6 @@ body {
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
border: 1px solid rgba(27, 28, 28, 0.08);
|
border: 1px solid rgba(27, 28, 28, 0.08);
|
||||||
text-align: left;
|
text-align: left;
|
||||||
cursor: pointer;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.wolai-page-ai-message--history.is-active {
|
.wolai-page-ai-message--history.is-active {
|
||||||
@@ -3399,6 +3403,39 @@ body {
|
|||||||
white-space: pre-wrap;
|
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 {
|
.wolai-page-ai-tool-details {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
@@ -3670,6 +3707,7 @@ mod tests {
|
|||||||
assert!(MNOTE_CSS.contains(".mnote-sidebar"));
|
assert!(MNOTE_CSS.contains(".mnote-sidebar"));
|
||||||
assert!(MNOTE_CSS.contains(".mnote-main"));
|
assert!(MNOTE_CSS.contains(".mnote-main"));
|
||||||
assert!(MNOTE_CSS.contains(".mnote-content"));
|
assert!(MNOTE_CSS.contains(".mnote-content"));
|
||||||
|
assert!(MNOTE_CSS.contains("[data-mnote-sidebar-collapsed=\"true\"]"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -3713,7 +3751,7 @@ mod tests {
|
|||||||
fn mnote_css_is_reasonably_sized() {
|
fn mnote_css_is_reasonably_sized() {
|
||||||
// 至少 2000 字符才能包含完整样式
|
// 至少 2000 字符才能包含完整样式
|
||||||
assert!(MNOTE_CSS.len() > 2000);
|
assert!(MNOTE_CSS.len() > 2000);
|
||||||
// 当前整合了工作区壳、编辑器样式、树菜单和双 pane 布局,仍保持在单文件可审阅范围内。
|
// 当前整合了工作区壳、编辑器样式、树菜单、页面 AI 和双 pane 布局,仍保持在单文件可审阅范围内。
|
||||||
assert!(MNOTE_CSS.len() < 70000);
|
assert!(MNOTE_CSS.len() < 90000);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -83,8 +83,14 @@ fn render_filetree_row(
|
|||||||
} else {
|
} else {
|
||||||
String::new()
|
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!(
|
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),
|
node_id = escape_html(&row.node_id),
|
||||||
aria_level = row.depth + 1,
|
aria_level = row.depth + 1,
|
||||||
expanded_attr = if row.expandable && row.expanded { "true" } else { "false" },
|
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_id = escape_html(&row.row_id),
|
||||||
row_kind = escape_html(&row.row_kind),
|
row_kind = escape_html(&row.row_kind),
|
||||||
parent_attr = parent_attr,
|
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()),
|
asset_id = escape_html(row.asset_id.as_deref().unwrap_or_default()),
|
||||||
object_identity = escape_html(row.object_identity.as_deref().unwrap_or_default()),
|
object_identity = escape_html(row.object_identity.as_deref().unwrap_or_default()),
|
||||||
selected = row.selected,
|
selected = row.selected,
|
||||||
@@ -208,6 +215,7 @@ mod tests {
|
|||||||
assert!(!html.contains("data-testid=\"filetree-index-row\""));
|
assert!(!html.contains("data-testid=\"filetree-index-row\""));
|
||||||
assert!(!html.contains("index.md"));
|
assert!(!html.contains("index.md"));
|
||||||
assert!(html.contains("data-doc-id=\"page_root\""));
|
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-asset-id=\"mind_1\""));
|
||||||
assert!(html.contains("data-object-identity=\"{"objectKind":"mindmap""));
|
assert!(html.contains("data-object-identity=\"{"objectKind":"mindmap""));
|
||||||
assert!(html.contains("tree-children"));
|
assert!(html.contains("tree-children"));
|
||||||
|
|||||||
@@ -171,7 +171,7 @@ mod tests {
|
|||||||
},
|
},
|
||||||
source: SourcePayload {
|
source: SourcePayload {
|
||||||
channel: "next-route".into(),
|
channel: "next-route".into(),
|
||||||
client: "wolai-frontend".into(),
|
client: "mnote-web".into(),
|
||||||
},
|
},
|
||||||
target: Some(TargetRef {
|
target: Some(TargetRef {
|
||||||
workspace_id: Some("ws_1".into()),
|
workspace_id: Some("ws_1".into()),
|
||||||
@@ -210,7 +210,7 @@ mod tests {
|
|||||||
},
|
},
|
||||||
source: SourcePayload {
|
source: SourcePayload {
|
||||||
channel: "next-route".into(),
|
channel: "next-route".into(),
|
||||||
client: "wolai-frontend".into(),
|
client: "mnote-web".into(),
|
||||||
},
|
},
|
||||||
target: Some(TargetRef {
|
target: Some(TargetRef {
|
||||||
workspace_id: Some("ws_1".into()),
|
workspace_id: Some("ws_1".into()),
|
||||||
@@ -257,7 +257,7 @@ mod tests {
|
|||||||
},
|
},
|
||||||
source: SourcePayload {
|
source: SourcePayload {
|
||||||
channel: "next-route".into(),
|
channel: "next-route".into(),
|
||||||
client: "wolai-frontend".into(),
|
client: "mnote-web".into(),
|
||||||
},
|
},
|
||||||
target: Some(TargetRef {
|
target: Some(TargetRef {
|
||||||
workspace_id: Some("ws_1".into()),
|
workspace_id: Some("ws_1".into()),
|
||||||
@@ -294,7 +294,7 @@ mod tests {
|
|||||||
},
|
},
|
||||||
source: SourcePayload {
|
source: SourcePayload {
|
||||||
channel: "next-route".into(),
|
channel: "next-route".into(),
|
||||||
client: "wolai-frontend".into(),
|
client: "mnote-web".into(),
|
||||||
},
|
},
|
||||||
target: Some(TargetRef {
|
target: Some(TargetRef {
|
||||||
workspace_id: Some("ws_1".into()),
|
workspace_id: Some("ws_1".into()),
|
||||||
@@ -334,7 +334,7 @@ mod tests {
|
|||||||
},
|
},
|
||||||
source: SourcePayload {
|
source: SourcePayload {
|
||||||
channel: "next-route".into(),
|
channel: "next-route".into(),
|
||||||
client: "wolai-frontend".into(),
|
client: "mnote-web".into(),
|
||||||
},
|
},
|
||||||
target: Some(TargetRef {
|
target: Some(TargetRef {
|
||||||
workspace_id: Some("ws_1".into()),
|
workspace_id: Some("ws_1".into()),
|
||||||
@@ -374,7 +374,7 @@ mod tests {
|
|||||||
},
|
},
|
||||||
source: SourcePayload {
|
source: SourcePayload {
|
||||||
channel: "next-route".into(),
|
channel: "next-route".into(),
|
||||||
client: "wolai-frontend".into(),
|
client: "mnote-web".into(),
|
||||||
},
|
},
|
||||||
target: Some(TargetRef {
|
target: Some(TargetRef {
|
||||||
workspace_id: Some("ws_1".into()),
|
workspace_id: Some("ws_1".into()),
|
||||||
@@ -413,7 +413,7 @@ mod tests {
|
|||||||
};
|
};
|
||||||
let base_source = SourcePayload {
|
let base_source = SourcePayload {
|
||||||
channel: "next-route".into(),
|
channel: "next-route".into(),
|
||||||
client: "wolai-frontend".into(),
|
client: "mnote-web".into(),
|
||||||
};
|
};
|
||||||
|
|
||||||
let create_request = build_write_request(
|
let create_request = build_write_request(
|
||||||
@@ -594,7 +594,7 @@ mod tests {
|
|||||||
},
|
},
|
||||||
source: SourcePayload {
|
source: SourcePayload {
|
||||||
channel: "next-route".into(),
|
channel: "next-route".into(),
|
||||||
client: "wolai-frontend".into(),
|
client: "mnote-web".into(),
|
||||||
},
|
},
|
||||||
target: Some(TargetRef {
|
target: Some(TargetRef {
|
||||||
workspace_id: Some("ws_1".into()),
|
workspace_id: Some("ws_1".into()),
|
||||||
@@ -651,7 +651,7 @@ mod tests {
|
|||||||
},
|
},
|
||||||
source: SourcePayload {
|
source: SourcePayload {
|
||||||
channel: "next-route".into(),
|
channel: "next-route".into(),
|
||||||
client: "wolai-frontend".into(),
|
client: "mnote-web".into(),
|
||||||
},
|
},
|
||||||
target: Some(TargetRef {
|
target: Some(TargetRef {
|
||||||
workspace_id: Some("ws_1".into()),
|
workspace_id: Some("ws_1".into()),
|
||||||
@@ -694,7 +694,7 @@ mod tests {
|
|||||||
},
|
},
|
||||||
source: SourcePayload {
|
source: SourcePayload {
|
||||||
channel: "next-route".into(),
|
channel: "next-route".into(),
|
||||||
client: "wolai-frontend".into(),
|
client: "mnote-web".into(),
|
||||||
},
|
},
|
||||||
target: Some(TargetRef {
|
target: Some(TargetRef {
|
||||||
workspace_id: Some("ws_1".into()),
|
workspace_id: Some("ws_1".into()),
|
||||||
@@ -745,7 +745,7 @@ mod tests {
|
|||||||
},
|
},
|
||||||
source: SourcePayload {
|
source: SourcePayload {
|
||||||
channel: "next-route".into(),
|
channel: "next-route".into(),
|
||||||
client: "wolai-frontend".into(),
|
client: "mnote-web".into(),
|
||||||
},
|
},
|
||||||
target: Some(TargetRef {
|
target: Some(TargetRef {
|
||||||
workspace_id: Some("ws_1".into()),
|
workspace_id: Some("ws_1".into()),
|
||||||
@@ -796,7 +796,7 @@ mod tests {
|
|||||||
},
|
},
|
||||||
source: SourcePayload {
|
source: SourcePayload {
|
||||||
channel: "onlyoffice-callback".into(),
|
channel: "onlyoffice-callback".into(),
|
||||||
client: "wolai-frontend".into(),
|
client: "mnote-web".into(),
|
||||||
},
|
},
|
||||||
target: Some(TargetRef {
|
target: Some(TargetRef {
|
||||||
workspace_id: Some("ws_1".into()),
|
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 { createInterface } from 'node:readline';
|
||||||
import { stdin, stdout } from 'node:process';
|
import { stdin, stdout } from 'node:process';
|
||||||
import { randomUUID } from 'node:crypto';
|
import { randomUUID } from 'node:crypto';
|
||||||
|
import { AsyncLocalStorage } from 'node:async_hooks';
|
||||||
import { readFileSync, existsSync } from 'node:fs';
|
import { readFileSync, existsSync } from 'node:fs';
|
||||||
import { homedir } from 'node:os';
|
import { homedir } from 'node:os';
|
||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
@@ -33,6 +34,97 @@ function debugLog(message) {
|
|||||||
if (DEBUG) process.stderr.write(`${message}\n`);
|
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。
|
// 读取 DeepSeek API key:先环境变量,再 Reasonix 官方 JSON 配置,最后兼容旧 YAML。
|
||||||
function loadApiKey() {
|
function loadApiKey() {
|
||||||
if (process.env.DEEPSEEK_API_KEY) return process.env.DEEPSEEK_API_KEY;
|
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) {
|
async function callMnoteTool(toolName, args) {
|
||||||
const url = `${MNOTE_WEB_URL}/api/hermes/tools/mnote/call`;
|
const url = `${MNOTE_WEB_URL}/api/hermes/tools/mnote/call`;
|
||||||
|
const payload = buildMnoteToolPayload(toolName, args, toolContextStorage.getStore() || {});
|
||||||
const response = await fetch(url, {
|
const response = await fetch(url, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: {
|
||||||
body: JSON.stringify({
|
'Content-Type': 'application/json',
|
||||||
toolName,
|
'x-mnote-actor-id': payload.actorId,
|
||||||
args,
|
'x-mnote-workspace-id': payload.workspaceId,
|
||||||
workspaceId: args.workspaceId || 'default',
|
},
|
||||||
}),
|
body: JSON.stringify(payload),
|
||||||
});
|
});
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const text = await response.text().catch(() => '');
|
const text = await response.text().catch(() => '');
|
||||||
@@ -357,6 +450,15 @@ onRequest('session/prompt', async (params) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
session.aborter = new AbortController();
|
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 stopReason = 'end_turn';
|
||||||
let hasAssistantOutput = false;
|
let hasAssistantOutput = false;
|
||||||
let hasToolCall = false;
|
let hasToolCall = false;
|
||||||
@@ -386,7 +488,8 @@ onRequest('session/prompt', async (params) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
for await (const ev of session.loop.step(text)) {
|
await toolContextStorage.run(toolContext, async () => {
|
||||||
|
for await (const ev of session.loop.step(text)) {
|
||||||
if (session.aborter?.signal.aborted) {
|
if (session.aborter?.signal.aborted) {
|
||||||
stopReason = 'cancelled';
|
stopReason = 'cancelled';
|
||||||
break;
|
break;
|
||||||
@@ -480,7 +583,8 @@ onRequest('session/prompt', async (params) => {
|
|||||||
if (ev.stats) {
|
if (ev.stats) {
|
||||||
emitUsage(session.id, ev.stats.inputTokens || 0, ev.stats.cacheHitTokens || 0);
|
emitUsage(session.id, ev.stats.inputTokens || 0, ev.stats.cacheHitTokens || 0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = err.message || String(err);
|
const message = err.message || String(err);
|
||||||
const stack = err.stack || '';
|
const stack = err.stack || '';
|
||||||
|
|||||||
@@ -1,9 +1,18 @@
|
|||||||
#!/usr/bin/env node
|
#!/usr/bin/env node
|
||||||
const { spawn } = require("child_process");
|
const { spawn } = require("child_process");
|
||||||
|
const fs = require("fs");
|
||||||
|
const path = require("path");
|
||||||
|
|
||||||
process.env.CONVEX_TMPDIR = "/mnt/Data1T/mnote/.convex-tmp";
|
process.env.CONVEX_TMPDIR = "/mnt/Data1T/mnote/.convex-tmp";
|
||||||
|
|
||||||
const adminKey = "mnote-local|01cedce68c51e168c6aacb282a90f7d233f56eddfabb07944a1d9dd9506f73ba888fc7daff";
|
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 = [
|
const args = [
|
||||||
"deploy",
|
"deploy",
|
||||||
@@ -14,7 +23,7 @@ const args = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
const child = spawn("npx", ["convex", ...args], {
|
const child = spawn("npx", ["convex", ...args], {
|
||||||
cwd: "/mnt/Data1T/mnote/wolai-frontend",
|
cwd: deployCwd,
|
||||||
stdio: "inherit",
|
stdio: "inherit",
|
||||||
env: { ...process.env, CONVEX_TMPDIR: "/mnt/Data1T/mnote/.convex-tmp" },
|
env: { ...process.env, CONVEX_TMPDIR: "/mnt/Data1T/mnote/.convex-tmp" },
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -16,6 +16,15 @@ const {
|
|||||||
} = require("./tree-shell-smoke-helpers");
|
} = require("./tree-shell-smoke-helpers");
|
||||||
|
|
||||||
const OUT_DIR = path.join(process.cwd(), "tmp", "page-aggregate-refresh-persistence-smoke");
|
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) {
|
async function fetchPageAggregate(requestContext, workspaceId, documentId) {
|
||||||
const payload = await requestJson(
|
const payload = await requestJson(
|
||||||
@@ -27,6 +36,37 @@ async function fetchPageAggregate(requestContext, workspaceId, documentId) {
|
|||||||
return payload.result;
|
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) {
|
function findBlockWithText(aggregate, text) {
|
||||||
const blocks = aggregate?.body?.blockDocument?.blocks;
|
const blocks = aggregate?.body?.blockDocument?.blocks;
|
||||||
if (!Array.isArray(blocks)) return null;
|
if (!Array.isArray(blocks)) return null;
|
||||||
@@ -289,7 +329,11 @@ async function main() {
|
|||||||
const bodyText = `Page Aggregate refresh body ${suffix}`;
|
const bodyText = `Page Aggregate refresh body ${suffix}`;
|
||||||
const evidencePath = path.join(OUT_DIR, `${suffix}.json`);
|
const evidencePath = path.join(OUT_DIR, `${suffix}.json`);
|
||||||
const screenshotPath = path.join(OUT_DIR, `${suffix}.png`);
|
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 context = await browser.newContext({ viewport: { width: 1440, height: 960 } });
|
||||||
const page = await context.newPage();
|
const page = await context.newPage();
|
||||||
const createdIds = [];
|
const createdIds = [];
|
||||||
@@ -325,6 +369,20 @@ async function main() {
|
|||||||
title,
|
title,
|
||||||
bodyText,
|
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 page.reload({ waitUntil: "commit", timeout: UI_TIMEOUT_MS });
|
||||||
await waitForPageTitleInput(page);
|
await waitForPageTitleInput(page);
|
||||||
@@ -354,6 +412,20 @@ async function main() {
|
|||||||
title,
|
title,
|
||||||
bodyText,
|
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 });
|
await page.screenshot({ path: screenshotPath, fullPage: false });
|
||||||
const evidence = {
|
const evidence = {
|
||||||
@@ -383,12 +455,23 @@ async function main() {
|
|||||||
conflictDetectionKey: beforeReloadAggregate.body?.conflictDetectionKey ?? null,
|
conflictDetectionKey: beforeReloadAggregate.body?.conflictDetectionKey ?? null,
|
||||||
pageOptions: beforeReloadAggregate.layout?.pageOptions ?? null,
|
pageOptions: beforeReloadAggregate.layout?.pageOptions ?? null,
|
||||||
blockProjectionVersion: beforeReloadAggregate.body?.blockProjectionVersion ?? null,
|
blockProjectionVersion: beforeReloadAggregate.body?.blockProjectionVersion ?? null,
|
||||||
|
pageSubtree: pageSubtreeSummary(beforeReloadAggregate),
|
||||||
},
|
},
|
||||||
matchedBlock: {
|
matchedBlock: {
|
||||||
blockId: beforeReloadBlock.blockId,
|
blockId: beforeReloadBlock.blockId,
|
||||||
text: beforeReloadBlock.text,
|
text: beforeReloadBlock.text,
|
||||||
revisionRef: beforeReloadBlock.revisionRef,
|
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,
|
runtime: beforeReloadRuntime,
|
||||||
},
|
},
|
||||||
afterReload: {
|
afterReload: {
|
||||||
@@ -398,12 +481,23 @@ async function main() {
|
|||||||
conflictDetectionKey: afterReloadAggregate.body?.conflictDetectionKey ?? null,
|
conflictDetectionKey: afterReloadAggregate.body?.conflictDetectionKey ?? null,
|
||||||
pageOptions: afterReloadAggregate.layout?.pageOptions ?? null,
|
pageOptions: afterReloadAggregate.layout?.pageOptions ?? null,
|
||||||
blockProjectionVersion: afterReloadAggregate.body?.blockProjectionVersion ?? null,
|
blockProjectionVersion: afterReloadAggregate.body?.blockProjectionVersion ?? null,
|
||||||
|
pageSubtree: pageSubtreeSummary(afterReloadAggregate),
|
||||||
},
|
},
|
||||||
matchedBlock: {
|
matchedBlock: {
|
||||||
blockId: afterReloadBlock.blockId,
|
blockId: afterReloadBlock.blockId,
|
||||||
text: afterReloadBlock.text,
|
text: afterReloadBlock.text,
|
||||||
revisionRef: afterReloadBlock.revisionRef,
|
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,
|
title: afterReloadTitle,
|
||||||
editorText: afterReloadEditorText,
|
editorText: afterReloadEditorText,
|
||||||
runtime: afterReloadRuntime,
|
runtime: afterReloadRuntime,
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ const {
|
|||||||
} = require("./tree-shell-smoke-helpers");
|
} = require("./tree-shell-smoke-helpers");
|
||||||
|
|
||||||
const OUT_DIR = path.join(process.cwd(), "tmp", "page-block-ai-conflict-idempotency-smoke");
|
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) {
|
async function callMnoteTool(request, payload) {
|
||||||
return requestJson(request, "/api/hermes/tools/mnote/call", {
|
return requestJson(request, "/api/hermes/tools/mnote/call", {
|
||||||
@@ -87,7 +88,11 @@ async function main() {
|
|||||||
};
|
};
|
||||||
await fs.mkdir(OUT_DIR, { recursive: true });
|
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 context = await browser.newContext({ viewport: { width: 1280, height: 900 } });
|
||||||
const page = await context.newPage();
|
const page = await context.newPage();
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ const {
|
|||||||
} = require("./tree-shell-smoke-helpers");
|
} = require("./tree-shell-smoke-helpers");
|
||||||
|
|
||||||
const OUT_DIR = path.join(process.cwd(), "tmp", "page-block-ai-tools-smoke");
|
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) {
|
function blockById(blocks, blockId) {
|
||||||
return blocks.find((block) => block.blockId === blockId);
|
return blocks.find((block) => block.blockId === blockId);
|
||||||
@@ -26,6 +27,16 @@ function blockTexts(blocks) {
|
|||||||
return blocks.map((block) => block.text);
|
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) {
|
async function waitForVisibleTexts(page, expectedTexts) {
|
||||||
await page.waitForFunction(
|
await page.waitForFunction(
|
||||||
({ expected }) => {
|
({ 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) {
|
async function fetchBlocks(request, target, suffix, label, actorId) {
|
||||||
const response = await callMnoteTool(request, {
|
const response = await callMnoteTool(request, {
|
||||||
toolName: "mnote.doc.fetch",
|
toolName: "mnote.doc.fetch",
|
||||||
@@ -96,8 +117,14 @@ async function main() {
|
|||||||
steps: [],
|
steps: [],
|
||||||
};
|
};
|
||||||
await fs.mkdir(OUT_DIR, { recursive: true });
|
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 context = await browser.newContext({ viewport: { width: 1280, height: 900 } });
|
||||||
const page = await context.newPage();
|
const page = await context.newPage();
|
||||||
|
|
||||||
@@ -111,9 +138,28 @@ async function main() {
|
|||||||
await renameDocument(context.request, target.workspaceId, target.documentId, title);
|
await renameDocument(context.request, target.workspaceId, target.documentId, title);
|
||||||
|
|
||||||
const initialContent = [
|
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_1", type: "paragraph", content: [{ type: "text", text: `第一段 ${suffix}` }] },
|
||||||
{ id: "p_2", 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: "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, {
|
const seed = await callMnoteTool(context.request, {
|
||||||
toolName: "mnote.page.save",
|
toolName: "mnote.page.save",
|
||||||
@@ -136,16 +182,139 @@ async function main() {
|
|||||||
evidence.steps.push({ name: "seed", commandName: seed.result.commandName });
|
evidence.steps.push({ name: "seed", commandName: seed.result.commandName });
|
||||||
|
|
||||||
let snapshot = await fetchBlocks(context.request, target, suffix, "initial", actorId);
|
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 p1 = blockById(snapshot.blocks, "p_1");
|
||||||
const p2 = blockById(snapshot.blocks, "p_2");
|
const p2 = blockById(snapshot.blocks, "p_2");
|
||||||
const p3 = blockById(snapshot.blocks, "p_3");
|
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");
|
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({
|
evidence.steps.push({
|
||||||
name: "doc.fetch.initial",
|
name: "doc.fetch.initial",
|
||||||
revision: snapshot.revision,
|
revision: snapshot.revision,
|
||||||
conflictDetectionKey: snapshot.conflictDetectionKey,
|
conflictDetectionKey: snapshot.conflictDetectionKey,
|
||||||
blockIds: snapshot.blocks.map((block) => block.blockId),
|
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, {
|
const blockFetch = await callMnoteTool(context.request, {
|
||||||
@@ -159,15 +328,180 @@ async function main() {
|
|||||||
traceId: `trace_block_${suffix}`,
|
traceId: `trace_block_${suffix}`,
|
||||||
capabilityScope: ["block.read"],
|
capabilityScope: ["block.read"],
|
||||||
args: {
|
args: {
|
||||||
blockId: "p_2",
|
blockId: findP2.result.matches[0].blockId,
|
||||||
|
includeChildren: true,
|
||||||
contextBefore: 1,
|
contextBefore: 1,
|
||||||
contextAfter: 1,
|
contextAfter: 1,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
assert.equal(blockFetch.result.block.blockId, "p_2", "block.fetch 应读取目标块");
|
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.before[0].blockId, "p_1", "block.fetch before 应来自同父级");
|
||||||
assert.equal(blockFetch.result.context.after[0].blockId, "p_3", "block.fetch after 应来自同父级");
|
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 replacedText = `第二段已替换 ${suffix}`;
|
||||||
const replace = await callMnoteTool(context.request, {
|
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");
|
assert.equal(replace.result.commandName, "page.body.save", "block.replace 必须走 page.body.save");
|
||||||
snapshot = await fetchBlocks(context.request, target, suffix, "after_replace", actorId);
|
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 应回读新文本");
|
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 insertedText = `插入段 ${suffix}`;
|
||||||
const anchorAfterReplace = blockById(snapshot.blocks, "p_1");
|
const anchorAfterReplace = blockById(snapshot.blocks, "p_1");
|
||||||
@@ -211,7 +562,11 @@ async function main() {
|
|||||||
capabilityScope: ["block.write"],
|
capabilityScope: ["block.write"],
|
||||||
args: {
|
args: {
|
||||||
anchorBlockId: "p_1",
|
anchorBlockId: "p_1",
|
||||||
content: insertedText,
|
content: {
|
||||||
|
type: "todo",
|
||||||
|
props: { checked: false },
|
||||||
|
content: [{ type: "text", text: insertedText }],
|
||||||
|
},
|
||||||
revision: snapshot.revision,
|
revision: snapshot.revision,
|
||||||
conflictDetectionKey: snapshot.conflictDetectionKey,
|
conflictDetectionKey: snapshot.conflictDetectionKey,
|
||||||
anchorRevisionRef: anchorAfterReplace.revisionRef,
|
anchorRevisionRef: anchorAfterReplace.revisionRef,
|
||||||
@@ -221,7 +576,73 @@ async function main() {
|
|||||||
assert(insertedBlockId.startsWith("ai_block_"), "插入块 id 必须由 Rust/mnote 侧生成");
|
assert(insertedBlockId.startsWith("ai_block_"), "插入块 id 必须由 Rust/mnote 侧生成");
|
||||||
snapshot = await fetchBlocks(context.request, target, suffix, "after_insert", actorId);
|
snapshot = await fetchBlocks(context.request, target, suffix, "after_insert", actorId);
|
||||||
assert.equal(blockById(snapshot.blocks, insertedBlockId).text, insertedText, "插入后 doc.fetch 应回读新块");
|
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 moveBlock = blockById(snapshot.blocks, "p_3");
|
||||||
const moveAnchor = blockById(snapshot.blocks, "p_1");
|
const moveAnchor = blockById(snapshot.blocks, "p_1");
|
||||||
@@ -269,14 +690,31 @@ async function main() {
|
|||||||
snapshot = await fetchBlocks(context.request, target, suffix, "after_move", actorId);
|
snapshot = await fetchBlocks(context.request, target, suffix, "after_move", actorId);
|
||||||
const order = snapshot.blocks.map((block) => block.blockId);
|
const order = snapshot.blocks.map((block) => block.blockId);
|
||||||
assert(order.indexOf("p_3") === order.indexOf("p_1") + 1, "移动后 p_3 必须紧跟 p_1");
|
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 openDocument(page, target.workspaceId, target.documentId);
|
||||||
await waitForVisibleTexts(page, [replacedText, insertedText, `第三段 ${suffix}`]);
|
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`);
|
const screenshotPath = path.join(OUT_DIR, `${suffix}-page.png`);
|
||||||
await page.screenshot({ path: screenshotPath, fullPage: true });
|
await page.screenshot({ path: screenshotPath, fullPage: true });
|
||||||
evidence.screenshot = screenshotPath;
|
evidence.screenshot = screenshotPath;
|
||||||
evidence.visibleTextCheck = [replacedText, insertedText, `第三段 ${suffix}`];
|
evidence.visibleTextCheck = [replacedText, insertedText, `第三段 ${suffix}`];
|
||||||
|
evidence.afterRefreshBlockIds = aggregateBlocks(afterRefreshAggregate).map((block) => block.blockId);
|
||||||
evidence.finalTexts = blockTexts(snapshot.blocks);
|
evidence.finalTexts = blockTexts(snapshot.blocks);
|
||||||
evidence.ok = true;
|
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