chore: 收口 review 执行清单与 runtime 验证
- 补齐 design/10-review 执行清单、验收标准与相关设计治理记录 - 迁移已完成的 tree、mindmap、runtime fallback、AI kernel 等设计和缺陷条目 - 推进 Rust Web runtime、tree/sidebar、page aggregate、mindmap 与 OnlyOffice 路由侧验证支撑 - 增加 task177-task180 smoke/audit 脚本及前端相关测试覆盖
This commit is contained in:
@@ -0,0 +1,183 @@
|
||||
# 4-25 [done][bug] 树命令新建/删除后强制刷新导致卡顿 v1
|
||||
|
||||
> 更新时间:2026-05-14
|
||||
>
|
||||
> 分类归属:
|
||||
> - `04-tree-domain/done`
|
||||
> - 关联缺陷:`bugs/05-editor-mainline/done/5-11-mindmap-ghost-assets-and-tree-command-latency-v1.md`
|
||||
>
|
||||
> 用户反馈:
|
||||
> - “删除页面和新建页面都很慢,不知道为什么这么卡。”
|
||||
|
||||
## 1. 问题定义
|
||||
|
||||
当前页面树/文件树的新建页面、删除页面等命令,在服务端命令成功后仍会触发整页刷新或完整树刷新。用户感知是:新建和删除并不是局部更新,而是卡一下、等整套页面壳或树壳重新加载。
|
||||
|
||||
这属于 `tree command -> projection update -> UI apply` 链路问题,应归到 `04-tree-domain`,而不是只作为编辑器 UI 问题处理。
|
||||
|
||||
## 2. 真实现象
|
||||
|
||||
用户反馈:
|
||||
|
||||
1. 新建页面慢。
|
||||
2. 删除页面慢。
|
||||
3. 卡顿与同轮 mindmap 文件树重复增长一起出现,怀疑树刷新链路被频繁触发。
|
||||
|
||||
期望结果:
|
||||
|
||||
1. `tree.node.create` 成功后,树 UI 局部插入新节点并进入重命名/导航状态。
|
||||
2. `tree.node.archive` 成功后,树 UI 局部移除节点或移动到回收站 projection。
|
||||
3. 只有 delta 无法应用、projection 缺失、SSE 断线或数据不一致时,才进入 resync/reload fallback。
|
||||
4. 普通新建/删除不应默认整页 reload。
|
||||
|
||||
## 3. 证据
|
||||
|
||||
### 3.1 Rust tree shell 有硬刷新路径
|
||||
|
||||
`rust/crates/mnote-web/src/routes/tree.rs:2976` 到 `:2986`:
|
||||
|
||||
- `scheduleRefresh()` 在 80ms 后执行 `window.location.reload()`。
|
||||
- 如果带 `renameRowId`,则通过 `window.location.assign(url.toString())` 重新加载。
|
||||
|
||||
调用点:
|
||||
|
||||
- 文件树删除:`rust/crates/mnote-web/src/routes/tree.rs:2791` 到 `:2824`
|
||||
- 新建页面:`rust/crates/mnote-web/src/routes/tree.rs:4309` 到 `:4340`
|
||||
- 重命名:`rust/crates/mnote-web/src/routes/tree.rs:4373` 到 `:4406`
|
||||
- 移动:`rust/crates/mnote-web/src/routes/tree.rs:4448` 到 `:4502`
|
||||
|
||||
这条路径会让 tree shell 命令体验明显慢于纯 delta / reducer 更新。
|
||||
|
||||
### 3.2 主页面已有 tree live controller,但命令后仍刷新
|
||||
|
||||
主页面已经监听 tree live:
|
||||
|
||||
- `rust/crates/mnote-web/src/ssr/pages/layout.rs:3881` 到 `:3917` 处理 `tree:snapshot` / `tree:delta` / `tree:resync`
|
||||
- `rust/crates/mnote-web/src/ssr/pages/layout.rs:3939` 到 `:4050` 启动 `/api/tree/events` EventSource
|
||||
|
||||
也就是说,命令完成后理论上可以由 delta/resync 更新 projection;当前硬 reload 与 live projection 机制重复。
|
||||
|
||||
### 3.3 命令本身已有 delta hint
|
||||
|
||||
Bridge runtime 对树命令生成 stream delta hint:
|
||||
|
||||
- `tree.node.create`:`rust/crates/bridge-runtime/src/lib.rs:9545` 到 `:9592`
|
||||
- `tree.node.archive`:`rust/crates/bridge-runtime/src/lib.rs:9655` 到 `:9702`
|
||||
|
||||
当前前端没有把这些结果作为默认局部更新来源,而是在 tree shell 中继续 reload。
|
||||
|
||||
### 3.4 React Sidebar 路径也存在重刷链
|
||||
|
||||
子代理只读调查发现 React Sidebar 路径也存在重刷:
|
||||
|
||||
- 新建页面:`wolai-frontend/src/components/sidebar/sidebar.tsx:1799`、`:1847` 附近在 command 后 `await refreshTree()` 再跳转。
|
||||
- 删除页面:`wolai-frontend/src/components/sidebar/sidebar.tsx:2148` 附近删除后等待 command、`refreshTree()`、广播文档变化/跳转。
|
||||
- Next API 的 `api/tree/commands` create/delete 还会执行 workspace/default scaffold、bridge mutation、artifact 记录等多段流程。
|
||||
|
||||
这说明卡顿不只来自单个 reload,而是命令后刷新链路偏重。
|
||||
|
||||
### 3.5 tree stream overview 查询曾经全量扫描 workspace 日志
|
||||
|
||||
`/api/tree/events` 的 live 轮询依赖 `bridgeLogs:listWorkspaceOverview` 读取最新 command/domain event 窗口。2026-05-13 复核发现,该查询原先对 `command_logs` 和 `domain_events` 都是按 workspace 全量 `.collect()` 后再在内存中过滤、排序和分页;日志量增长后,这会让每轮 SSE 轮询成本随历史日志线性增长。
|
||||
|
||||
本轮已完成代码侧收口:
|
||||
|
||||
- `command_logs` 增加 `workspace_id + created_at + id` 及 status/page/block 常用过滤索引。
|
||||
- `domain_events` 增加 `workspace_id + created_at + id` 及 status/aggregate 常用过滤索引。
|
||||
- `listWorkspaceOverview` 改为按索引读取有界窗口,不再全量 collect 后分页。
|
||||
|
||||
这只能降低 tree live polling 的背景成本,并不等于新建/删除的强刷新问题已经修完。React Sidebar 的 `refreshTree()` 和 Rust tree shell 的 `scheduleRefresh()` 仍需单独收口。
|
||||
|
||||
### 3.6 2026-05-14 tree live 局部 apply 进展
|
||||
|
||||
本轮已完成主页面 Rust 3000 下的部分局部 apply 收口:
|
||||
|
||||
- `rust/crates/mnote-web/src/transport/convex.rs`:tree create / rename / archive / restore / move 及 compat documents mutation 发往 legacy Convex mutation 前剥离 artifact-only 字段,修复 `documents:softDelete` 因 `streamDeltaHint` / `domainEventPlan` 等额外字段触发 validator 502 的问题。
|
||||
- `rust/crates/mnote-web/src/ssr/pages/layout.rs`:`tree:delta` 的 `move_document` 会局部更新页面树与 File Tree 行的 `data-parent-id`;`remove_document` 会局部移除页面树与 File Tree 行。
|
||||
- 已通过:`cargo test -p mnote-web convex_command_args_strips -- --nocapture`。
|
||||
- 已通过:`cargo test -p mnote-web sidebar_tree_runtime -- --nocapture`。
|
||||
- 已通过:`node scripts/task177-tree-move-archive-live-smoke.js`,覆盖 move 后页面树 / File Tree 行父节点更新、archive 后两棵树局部移除,以及文档页头保持稳定。
|
||||
|
||||
这说明 move / archive 的 Rust 3000 主页面 live reducer 已有可验证进展;当时普通新建/删除、React Sidebar `refreshTree()` 与耗时阈值仍待后续章节继续收口。后续 3.7-3.9 已补齐这些流转条件。
|
||||
|
||||
### 3.7 2026-05-14 Rust tree shell create / delete no-reload 验收
|
||||
|
||||
本轮已完成 Rust `/tree` debug shell 的普通新建 / 删除强刷新收口:
|
||||
|
||||
- `rust/crates/mnote-web/src/routes/tree.rs`:新增 `addTreeItemLocally()` / `applyCreatedDocumentLocally()`,`convex_workspace` create 成功后直接插入本地树项并进入 inline rename;只有本地 apply 失败时才 fallback 到 `scheduleRefresh()`。
|
||||
- `rust/crates/mnote-web/src/routes/tree.rs`:新增 `removeTreeItemEverywhere()` / `applyRemovedDocumentLocally()`,`convex_workspace` delete 成功后从本地 projection 移除对应页面及子项;只有删除后仍能在本地 row map 中看到目标行时才 fallback `scheduleRefresh()`。
|
||||
- `scripts/task179-tree-create-delete-no-reload-smoke.js`:真实浏览器覆盖 `/tree?mode=filetree` 的 create + delete,以及 `/tree?mode=page` 的 create;断言操作后 URL 不变、对应 mode 内无新增顶层 navigation。
|
||||
- 已通过:`cargo test -p mnote-web tree_shell_returns_interactive_html_document -- --nocapture`。
|
||||
- 已通过:`cargo test -p mnote-web tree_shell -- --nocapture`。
|
||||
- 已通过:`node scripts/task179-tree-create-delete-no-reload-smoke.js`,最新结果 `ok=true`;`filetree.createMs=120`、`filetree.deleteMs=178`、`page.createMs=120`、两个 mode 的 `navigationEvents.length=0`。
|
||||
|
||||
### 3.8 2026-05-14 React Sidebar 直接 create / delete 收口进展
|
||||
|
||||
React Sidebar 的直接新建 / 删除路径已完成一层去阻塞:
|
||||
|
||||
- `wolai-frontend/src/components/sidebar/sidebar.tsx`:`handleCreate()` 继续使用 `createDocumentCommand()` 结果本地 `setTree()` 插入节点并导航,但不再同步 `await refreshTree()`。
|
||||
- `wolai-frontend/src/components/sidebar/sidebar.tsx`:新增 `removeDocumentsFromTree()`,`handleDelete()` 与 `handleDeleteResourceSelection()` 在 delete command 成功后本地移除页面节点,不再同步等待整树 refetch;附件删除仍保留 asset 专用刷新链。
|
||||
- `wolai-frontend/src/components/sidebar/sidebar-delete-preflight-source.test.ts`:新增源级回归断言,防止页面 create/delete 成功路径重新出现同步 `await refreshTree()`。
|
||||
- 已按 TDD 验证:新增测试先失败,修改后通过。
|
||||
- 已通过:`pnpm test -- src/components/sidebar/sidebar-delete-preflight-source.test.ts`(Vitest 实际执行 112 个测试文件、459 个测试)。
|
||||
- 已通过:`pnpm exec eslint src/components/sidebar/sidebar.tsx src/components/sidebar/sidebar-delete-preflight-source.test.ts`,无 error;`sidebar.tsx` 保留既有 warning。
|
||||
|
||||
### 3.9 2026-05-14 Rust-family React shell mutation 本地 apply 收口
|
||||
|
||||
Rust-family React shell 的 host mutation 回调已从“命令成功后默认整树刷新”改为可局部 apply:
|
||||
|
||||
- `wolai-frontend/src/components/sidebar/tree-shell-dom-host.tsx`:`tree.node.created` / `tree.node.renamed` / `tree.subtree.moved` 成功后把 command result 中的 `documentId`、`parentId`、`title`、`sortOrder`、`workspaceId`、`execution` 传回 Sidebar。
|
||||
- `wolai-frontend/src/components/sidebar/tree-shell-iframe-host.tsx`:legacy iframe bridge 同步转发 mutation payload 中的局部 apply 字段,保持兼容。
|
||||
- `wolai-frontend/src/components/sidebar/sidebar.tsx`:`handleTreeShellMutation` 对 create / rename / move 分别本地插入、重命名、移动页面树;只有未知事件或缺少必要字段时保留 `refreshTree()` 兜底。
|
||||
- `wolai-frontend/src/components/sidebar/sidebar-delete-preflight-source.test.ts`:新增 RED -> GREEN 源级回归断言,防止 `onTreeMutation -> refreshTree()` 重新退化成默认整树 refetch。
|
||||
- 已通过:`pnpm test -- src/components/sidebar/sidebar-delete-preflight-source.test.ts`(Vitest 实际执行 112 个测试文件、460 个测试)。
|
||||
- 已通过:`pnpm exec eslint src/components/sidebar/sidebar.tsx src/components/sidebar/sidebar-delete-preflight-source.test.ts src/components/sidebar/tree-shell-host.tsx src/components/sidebar/tree-shell-surface.tsx src/components/sidebar/tree-shell-dom-host.tsx src/components/sidebar/tree-shell-iframe-host.tsx`,无 error;保留既有 warning。
|
||||
|
||||
## 4. 当前判断
|
||||
|
||||
当前慢的核心不是“Convex 一定慢”,而是树命令执行后缺少轻量本地 apply:
|
||||
|
||||
1. 命令 result / delta hint 已经具备局部更新信息。
|
||||
2. UI 仍经常走 `refreshTree()`、`window.location.reload()` 或 full snapshot。
|
||||
3. `resync_required` 类事件会触发完整 workspace snapshot,结构性变化越多,越容易造成卡顿。
|
||||
4. tree stream overview 全量日志扫描已在代码侧改为有界窗口查询,并已有 `task123` / `task120` / `task177` 证明 Rust 3000 下可输出并消费相关 live 事件。
|
||||
5. move / archive 已具备局部 DOM reducer 证据。
|
||||
6. Rust `/tree` debug shell 的 `convex_workspace` 普通新建 / 删除已经通过无 reload 与耗时 smoke。
|
||||
7. React Sidebar 直接 create/delete 成功路径已改成本地更新,不再同步等待 `refreshTree()`。
|
||||
8. Rust-family React shell 的 create / rename / move host mutation 回调已改成本地 apply,不再默认触发整树 refetch;未知事件或缺字段才保留刷新兜底。
|
||||
|
||||
## 5. 建议验证
|
||||
|
||||
修复前先补一条计时 smoke:
|
||||
|
||||
1. 登录真实测试账号。
|
||||
2. 新建 `TEST-TREE-LATENCY-<timestamp>` 页面。
|
||||
3. 记录 `POST /api/tree/commands` 响应耗时。
|
||||
4. 记录下一次 `tree:delta` / `tree:resync` 到达耗时。
|
||||
5. 断言过程中是否发生 `window.location.reload()` 或顶层 navigation。
|
||||
6. 删除该测试页面,重复同样计时。
|
||||
7. 输出新建/删除从点击到 DOM 稳定的总耗时。
|
||||
|
||||
## 6. 建议修复方向
|
||||
|
||||
1. `tree.node.create` 成功后直接用 command result 插入本地节点,并只在后台等待 live delta 校准。
|
||||
2. `tree.node.archive` 成功后直接从当前 projection 移除节点,并只在后台等待 live delta 校准。
|
||||
3. `scheduleRefresh()` 改成显式 fallback,只有 reducer 无法应用时才调用。
|
||||
4. React Sidebar 的 `refreshTree()` 应去重、节流,并避免与 SSE/full snapshot 同时触发。
|
||||
5. smoke 增加无 reload 断言和耗时阈值。
|
||||
6. tree stream 查询模型已完成第一步优化;后续若 polling-backed SSE 继续作为长期实现,还需要补连接数、日志量、延迟边界测试。
|
||||
|
||||
## 7. 流转条件
|
||||
|
||||
当前状态:`done`
|
||||
|
||||
Rust tree shell 的 create / delete 强刷新子项已关闭;React Sidebar 直接 create/delete 同步刷新等待已关闭;Rust-family React shell 的 create / rename / move host mutation 回调已改为本地 apply,不再默认整树 refetch。
|
||||
|
||||
只有满足以下条件后才能移动到 `bugs/04-tree-domain/done/`:
|
||||
|
||||
1. [x] 新建页面普通路径不再默认整页 reload。Rust `/tree` `convex_workspace` 的 File Tree 与 Page Tree create 已由 `task179` 验证。
|
||||
2. [x] 删除页面普通路径不再默认整页 reload。Rust `/tree` `convex_workspace` 的 File Tree delete 已由 `task179` 验证。
|
||||
3. [x] command result / tree delta 能局部更新页面树与文件树 projection。create 走 `applyCreatedDocumentLocally()`;delete 走 `applyRemovedDocumentLocally()`;move/archive 由 `task177` 验证主页面与 File Tree DOM reducer。
|
||||
4. [x] fallback reload 只在明确异常条件下触发,并有可观测标记。当前 create/delete 仅在本地 apply 失败或目标行未移除时 fallback `scheduleRefresh()`。
|
||||
5. [x] smoke 记录新建/删除耗时,并通过无 reload 断言。`task179` 最新记录 `filetree.createMs=120`、`filetree.deleteMs=178`、`page.createMs=120`,对应 mode 内 navigation 为 0。
|
||||
6. [x] Rust-family React shell 的 `onTreeMutation -> refreshTree()` 不再在 create / rename / move 成功后默认整树 refetch。`handleTreeShellMutation` 已按 create / rename / move 本地 apply,未知事件或缺字段才刷新兜底,并由 `sidebar-delete-preflight-source.test.ts` 防回归。
|
||||
@@ -1,117 +0,0 @@
|
||||
# 4-25 [process][bug] 树命令新建/删除后强制刷新导致卡顿 v1
|
||||
|
||||
> 更新时间:2026-05-13
|
||||
>
|
||||
> 分类归属:
|
||||
> - `04-tree-domain/process`
|
||||
> - 关联缺陷:`bugs/05-editor-mainline/process/5-11-mindmap-ghost-assets-and-tree-command-latency-v1.md`
|
||||
>
|
||||
> 用户反馈:
|
||||
> - “删除页面和新建页面都很慢,不知道为什么这么卡。”
|
||||
|
||||
## 1. 问题定义
|
||||
|
||||
当前页面树/文件树的新建页面、删除页面等命令,在服务端命令成功后仍会触发整页刷新或完整树刷新。用户感知是:新建和删除并不是局部更新,而是卡一下、等整套页面壳或树壳重新加载。
|
||||
|
||||
这属于 `tree command -> projection update -> UI apply` 链路问题,应归到 `04-tree-domain`,而不是只作为编辑器 UI 问题处理。
|
||||
|
||||
## 2. 真实现象
|
||||
|
||||
用户反馈:
|
||||
|
||||
1. 新建页面慢。
|
||||
2. 删除页面慢。
|
||||
3. 卡顿与同轮 mindmap 文件树重复增长一起出现,怀疑树刷新链路被频繁触发。
|
||||
|
||||
期望结果:
|
||||
|
||||
1. `tree.node.create` 成功后,树 UI 局部插入新节点并进入重命名/导航状态。
|
||||
2. `tree.node.archive` 成功后,树 UI 局部移除节点或移动到回收站 projection。
|
||||
3. 只有 delta 无法应用、projection 缺失、SSE 断线或数据不一致时,才进入 resync/reload fallback。
|
||||
4. 普通新建/删除不应默认整页 reload。
|
||||
|
||||
## 3. 证据
|
||||
|
||||
### 3.1 Rust tree shell 有硬刷新路径
|
||||
|
||||
`rust/crates/mnote-web/src/routes/tree.rs:2976` 到 `:2986`:
|
||||
|
||||
- `scheduleRefresh()` 在 80ms 后执行 `window.location.reload()`。
|
||||
- 如果带 `renameRowId`,则通过 `window.location.assign(url.toString())` 重新加载。
|
||||
|
||||
调用点:
|
||||
|
||||
- 文件树删除:`rust/crates/mnote-web/src/routes/tree.rs:2791` 到 `:2824`
|
||||
- 新建页面:`rust/crates/mnote-web/src/routes/tree.rs:4309` 到 `:4340`
|
||||
- 重命名:`rust/crates/mnote-web/src/routes/tree.rs:4373` 到 `:4406`
|
||||
- 移动:`rust/crates/mnote-web/src/routes/tree.rs:4448` 到 `:4502`
|
||||
|
||||
这条路径会让 tree shell 命令体验明显慢于纯 delta / reducer 更新。
|
||||
|
||||
### 3.2 主页面已有 tree live controller,但命令后仍刷新
|
||||
|
||||
主页面已经监听 tree live:
|
||||
|
||||
- `rust/crates/mnote-web/src/ssr/pages/layout.rs:3881` 到 `:3917` 处理 `tree:snapshot` / `tree:delta` / `tree:resync`
|
||||
- `rust/crates/mnote-web/src/ssr/pages/layout.rs:3939` 到 `:4050` 启动 `/api/tree/events` EventSource
|
||||
|
||||
也就是说,命令完成后理论上可以由 delta/resync 更新 projection;当前硬 reload 与 live projection 机制重复。
|
||||
|
||||
### 3.3 命令本身已有 delta hint
|
||||
|
||||
Bridge runtime 对树命令生成 stream delta hint:
|
||||
|
||||
- `tree.node.create`:`rust/crates/bridge-runtime/src/lib.rs:9545` 到 `:9592`
|
||||
- `tree.node.archive`:`rust/crates/bridge-runtime/src/lib.rs:9655` 到 `:9702`
|
||||
|
||||
当前前端没有把这些结果作为默认局部更新来源,而是在 tree shell 中继续 reload。
|
||||
|
||||
### 3.4 React Sidebar 路径也存在重刷链
|
||||
|
||||
子代理只读调查发现 React Sidebar 路径也存在重刷:
|
||||
|
||||
- 新建页面:`wolai-frontend/src/components/sidebar/sidebar.tsx:1799`、`:1847` 附近在 command 后 `await refreshTree()` 再跳转。
|
||||
- 删除页面:`wolai-frontend/src/components/sidebar/sidebar.tsx:2148` 附近删除后等待 command、`refreshTree()`、广播文档变化/跳转。
|
||||
- Next API 的 `api/tree/commands` create/delete 还会执行 workspace/default scaffold、bridge mutation、artifact 记录等多段流程。
|
||||
|
||||
这说明卡顿不只来自单个 reload,而是命令后刷新链路偏重。
|
||||
|
||||
## 4. 当前判断
|
||||
|
||||
当前慢的核心不是“Convex 一定慢”,而是树命令执行后缺少轻量本地 apply:
|
||||
|
||||
1. 命令 result / delta hint 已经具备局部更新信息。
|
||||
2. UI 仍经常走 `refreshTree()`、`window.location.reload()` 或 full snapshot。
|
||||
3. `resync_required` 类事件会触发完整 workspace snapshot,结构性变化越多,越容易造成卡顿。
|
||||
|
||||
## 5. 建议验证
|
||||
|
||||
修复前先补一条计时 smoke:
|
||||
|
||||
1. 登录真实测试账号。
|
||||
2. 新建 `TEST-TREE-LATENCY-<timestamp>` 页面。
|
||||
3. 记录 `POST /api/tree/commands` 响应耗时。
|
||||
4. 记录下一次 `tree:delta` / `tree:resync` 到达耗时。
|
||||
5. 断言过程中是否发生 `window.location.reload()` 或顶层 navigation。
|
||||
6. 删除该测试页面,重复同样计时。
|
||||
7. 输出新建/删除从点击到 DOM 稳定的总耗时。
|
||||
|
||||
## 6. 建议修复方向
|
||||
|
||||
1. `tree.node.create` 成功后直接用 command result 插入本地节点,并只在后台等待 live delta 校准。
|
||||
2. `tree.node.archive` 成功后直接从当前 projection 移除节点,并只在后台等待 live delta 校准。
|
||||
3. `scheduleRefresh()` 改成显式 fallback,只有 reducer 无法应用时才调用。
|
||||
4. React Sidebar 的 `refreshTree()` 应去重、节流,并避免与 SSE/full snapshot 同时触发。
|
||||
5. smoke 增加无 reload 断言和耗时阈值。
|
||||
|
||||
## 7. 流转条件
|
||||
|
||||
当前状态:`process`
|
||||
|
||||
只有满足以下条件后才能移动到 `bugs/04-tree-domain/done/`:
|
||||
|
||||
1. [ ] 新建页面普通路径不再默认整页 reload。
|
||||
2. [ ] 删除页面普通路径不再默认整页 reload。
|
||||
3. [ ] command result / tree delta 能局部更新页面树与文件树 projection。
|
||||
4. [ ] fallback reload 只在明确异常条件下触发,并有可观测标记。
|
||||
5. [ ] smoke 记录新建/删除耗时,并通过无 reload 断言。
|
||||
+293
@@ -0,0 +1,293 @@
|
||||
# 5-11 [done][bug] Mindmap 幽灵附件增长与树命令卡顿 v1
|
||||
|
||||
> 更新时间:2026-05-14
|
||||
>
|
||||
> 分类归属:
|
||||
> - `05-editor-mainline/process`
|
||||
> - 涉及边界:`04-tree-domain/tree command + file_tree projection`、`06-mindmap/runtime save + resource relation`
|
||||
>
|
||||
> 用户证据:
|
||||
> - `/mnt/Data1T/mnote/tmp/image copy 94.png`
|
||||
> - `/mnt/Data1T/mnote/tmp/image copy 93.png`
|
||||
|
||||
## 1. 问题定义
|
||||
|
||||
用户在页面中只是修改了一下 mindmap,文件树/资源树下却陆续出现多个 `mindmap-mindmap...` 附件行。截图显示同一页面 `新页面3` 下有 `index.md`,并且 mindmap 附件从 2 条增长到 4 条。
|
||||
|
||||
同一轮反馈还指出:删除页面和新建页面都很慢,表现为树操作后明显卡顿。
|
||||
|
||||
这不是单纯的图标显示问题。当前症状同时暴露两条链路风险:
|
||||
|
||||
1. mindmap 编辑/初始化/保存链路会多次向资产层广播同一个资源存在,且缺少“页面正文 block 与 mindmap asset 关系唯一”的硬约束。
|
||||
2. tree shell 的页面新建、删除、重命名、移动仍在命令成功后强制整页刷新,和当前 live projection/SSE 机制重复,导致用户感知卡顿。
|
||||
|
||||
## 2. 真实现象
|
||||
|
||||
已观察到的用户现象:
|
||||
|
||||
1. 新页面下初始只有 `index.md` 和少量 mindmap 附件。
|
||||
2. 用户只是编辑 mindmap,不是主动新建 mindmap。
|
||||
3. 等一会儿或再次修改后,同一页面下又出现新的 `mindmap-mindmap...` 行。
|
||||
4. 页面新建和删除动作响应慢,像是页面/树整体重新加载。
|
||||
|
||||
期望结果:
|
||||
|
||||
1. 一个页面内的一个 mindmap block 只对应一个稳定 `mindmapId` 和一个 file tree asset row。
|
||||
2. mindmap 保存只能更新既有资源,不应创建新的 mindmap 资产行。
|
||||
3. file tree projection 应按 `{documentId, blockId, assetId}` 或明确 object identity 去重。
|
||||
4. 页面新建/删除成功后应优先消费 command result / tree delta 更新局部投影,不应默认整页 reload。
|
||||
|
||||
## 3. 初步调查证据
|
||||
|
||||
### 3.1 mindmap 资产广播存在多入口
|
||||
|
||||
`wolai-frontend/src/components/editor/blocks/MindmapBlock.tsx` 中同一个 mindmap 资源至少有三处会触发资产刷新广播:
|
||||
|
||||
- 保存成功后广播 `emitAssetsChanged(docId, { id: mindmapId, asset_type: "mindmap", ... })`:`MindmapBlock.tsx:1499` 到 `:1534`
|
||||
- 初始同步 `createOnly: true` 成功后广播:`MindmapBlock.tsx:1591` 到 `:1621`
|
||||
- mindmap 实例就绪后立即广播:`MindmapBlock.tsx:1636` 到 `:1647`
|
||||
|
||||
另一个 legacy/compat block wrapper 也会在 mount 时执行 `createOnly` 并广播资产:`MindmapBlock.tsx:3573` 到 `:3612`。
|
||||
|
||||
这些广播本身用 `id = mindmapId`,理论上同 ID 会被 sidebar 本地 state 去重;但只要保存/转换链路让同一视觉 mindmap 换了新的 `mindmapId`,就会生成新的资产行。
|
||||
|
||||
### 3.2 mindmapId 仍可能由时间戳生成
|
||||
|
||||
当前 `leptos-tiptap` 插入 mindmap 时使用:
|
||||
|
||||
- `rust/spikes/leptos-tiptap-spike/src/lib.rs:5681` 到 `:5689`
|
||||
|
||||
这里 `next_mindmap_id()` 生成 `mindmap_{Date.now()}`,并写入 paragraph attrs 的 `mindmapId`。如果后续转换、保存、重新挂载中丢失原 attrs,fallback 会用新的 block identity / 新插入节点创建新的 `mindmapId`,资产层就会认为这是另一个 mindmap。
|
||||
|
||||
相关转换锚点:
|
||||
|
||||
- `rust/crates/mnote-web/src/routes/web_shell.rs:818` 到 `:838`:legacy block 转 TipTap 时写入 `mindmapId`
|
||||
- `rust/crates/mnote-web/src/routes/web_shell.rs:983` 到 `:1009`:TipTap 节点转 editor block 时若 attrs 缺失则用 blockId fallback
|
||||
- `wolai-frontend/src/lib/documents/tiptap-content-converter.ts:188` 到 `:213`:`mindmapReferenceProps` 会在缺少 `mindmapId` 时 fallback 到 blockId
|
||||
- `wolai-frontend/src/lib/documents/tiptap-content-converter.ts:410` 到 `:418`:editor block 转 TipTap 时把 mindmap props 写回 paragraph attrs
|
||||
|
||||
当前缺少一条回归断言:连续编辑同一个 mindmap 后,保存前后 `mindmapId` 必须保持不变,且 file tree 下同一页面 mindmap asset 数量不增长。
|
||||
|
||||
### 3.3 Convex mindmaps 表允许同页多 mindmap,但缺少 block 关系唯一约束
|
||||
|
||||
`wolai-frontend/convex/schema.ts:164` 到 `:183` 定义 mindmaps 表,并以 `(document_id, mindmap_id)` 做查询索引。注意这里是普通索引,不是唯一约束。
|
||||
|
||||
`wolai-frontend/convex/mindmaps.ts` 的 `put` 对同一 `(document_id, mindmap_id)` 是幂等 patch/insert,但它并不知道页面正文中的哪个 block 才是唯一来源。也就是说:
|
||||
|
||||
- 同一个 `mindmapId` 重复保存不会多插入。
|
||||
- 如果历史或并发路径已经写出同一 `(document_id, mindmap_id)` 的多行,`put` 当前用 `.first()` 只会 patch 第一行,剩余重复行仍会被后续 list/projection 展示。
|
||||
- 但如果前端生成了新的 `mindmapId`,后端会按合法新 mindmap 插入。
|
||||
- file tree 会把同一 document 下所有 active mindmaps 映射为资产行。
|
||||
|
||||
对应映射:
|
||||
|
||||
- `wolai-frontend/convex/mindmaps.ts:206` 到 `:245`:`put` 查询 `by_doc_mindmap` 后 `.first()`,不存在则 insert
|
||||
- `wolai-frontend/convex/sidebar.ts:79` 到 `:100`:`mindmap_id` 被映射为 `id` 和 `mindmap-{mindmap_id}.json`
|
||||
- `wolai-frontend/convex/sidebar.ts:193` 到 `:220`:所有 active mindmaps 都进入 `mindmap_assets`
|
||||
- `rust/crates/bridge-runtime/src/lib.rs:7569` 到 `:7647`:file tree projection 从 `mindmap_assets` 构建资源行,并只按 asset id 去重
|
||||
|
||||
因此,当前有两个需要实测区分的分支:
|
||||
|
||||
1. 同一视觉对象被保存成多个不同 `mindmapId`,每个都被合法展示为一个资源。
|
||||
2. 数据表中已经存在同一 `(document_id, mindmap_id)` 多行,`.first()` 更新掩盖重复行,sidebar list 把重复行全部暴露出来。
|
||||
|
||||
两者都会在截图中表现为同一页面下多个 `mindmap-mindmap...` 行。
|
||||
|
||||
### 3.4 Rust / Next API 都会把 mindmap 保存转成 tree resync
|
||||
|
||||
保存路径还会触发树刷新:
|
||||
|
||||
- Next API `POST /api/mindmap/[docId]/[mindmapId]` 构造 `mindmaps.put` 或 `mindmap.command.apply`:`wolai-frontend/src/app/api/mindmap/[docId]/[mindmapId]/route.ts:229` 到 `:254`
|
||||
- Rust API 普通保存也包装为 `mindmaps.put` 并携带 `createOnly`:`rust/crates/mnote-web/src/routes/mindmap_api.rs:177` 到 `:200`
|
||||
- Bridge runtime 给 `mindmaps.put` 生成 `resync_required` 树事件:`rust/crates/bridge-runtime/src/lib.rs:9273` 到 `:9326`
|
||||
|
||||
这意味着 mindmap 每次保存都会推动资源树重新读 projection;如果底层 mindmaps 数据已经重复,保存/刷新会把重复行显性化。
|
||||
|
||||
### 3.5 mindmap 节点编辑 artifact 语义过宽
|
||||
|
||||
当前 `mindmaps.put` / `mindmap.command.apply` 已经会进入 artifact 链路,但普通导图节点编辑生成的是 tree resource 语义:
|
||||
|
||||
- `tree.resource.mindmap.put`
|
||||
- `tree.resource.mindmap.updated`
|
||||
- `streamDelta: resync_required`
|
||||
|
||||
这会把“导图 object 内部内容变化”提升成“资源树需要重新同步”。从 `design/10-review/05-tree.md` 的对象边界看,mindmap 是页面内 block 关联的独立 object / asset editor,不是 `index.md` 正文替身;普通节点文字、结构、样式变化应属于 mindmap object 内容事件,而不是 file tree 资源关系事件。
|
||||
|
||||
因此,artifact 本身仍然需要,但语义需要分层:
|
||||
|
||||
1. 修改 mindmap 节点、文字、样式、折叠状态:应记录 `mindmap.content.updated` / `mindmap.node.updated` 这类 object 内容事件,不应默认触发 file tree resource resync。
|
||||
2. 创建、挂载、解绑、删除、移动、重命名 mindmap 资源:才应记录 `tree.resource.*` / `tree.asset.*` 事件,并影响 file tree projection。
|
||||
3. `resync_required` 只能作为无法应用精确 delta 时的保守 fallback,不应成为普通节点编辑的默认输出。
|
||||
|
||||
当前过宽 artifact 会放大本 bug:只要底层存在 ghost mindmap 行,或同一视觉 mindmap 被重新分配了新的 `mindmapId`,普通编辑触发的 tree resync 就会把重复资源更快暴露到文件树,并带来额外刷新成本。
|
||||
|
||||
### 3.6 次级入口收口进展
|
||||
|
||||
2026-05-14 已完成 `design/10-review` 第 9 项的入口一致性收口:
|
||||
|
||||
- Mindmap toolbar `export` 不再映射到未受控的 `EXPORT` runtime command,UI state 继续禁用该入口。
|
||||
- 旧 `/api/mindmap-ai/expand-node` Next route 从“有效请求稳定 501”改为显式 410 退役边界,并指向 AI Agent 内置 `mindmap_expand_node` 工具。
|
||||
- legacy `MindmapSidebar` 中直接调用旧 route 的补完入口已从渲染层关闭。
|
||||
- `mindmap.command.apply` 保存 facade 已通过 Rust 单测确认仍存在,未回退到 Page Aggregate body 保存链。
|
||||
|
||||
这组收口只解决“能力入口与实现不一致”的次级问题,不代表 ghost mindmap 资产增长、普通节点编辑 artifact 语义过宽、file tree 重复资源行这几个核心缺陷已经完成。
|
||||
|
||||
### 3.7 2026-05-14 ghost asset 止血进展
|
||||
|
||||
本轮已确认并修复一条会制造幽灵 mindmap asset 的高风险链路:
|
||||
|
||||
- 根因一:`resolveCurrentDocumentId()` 原先优先读取 `window.location.pathname`。切页或打开文件树后,旧 mindmap NodeView 的保存 / 刷新 URL 可能被当前页面路径覆盖成其他 document,导致同一个视觉 mindmap 在错误页面下创建 asset。
|
||||
- 根因二:TipTap NodeView 构造时会立即 `fetchScene("initial")`。刚从其他页面切回原页面时,NodeView root 可能尚未插入带 `data-document-id` 的 DOM;旧逻辑会退回 URL document id,进而向错误页面创建同名 asset。
|
||||
- `design/05-editor-mainline/reference-code/leptos-tiptap/tiptap/src/extensions/tiptap_paragraph.ts`:`resolveCurrentDocumentId()` 改为优先使用 NodeView anchor 所属的 `[data-document-id]`;当 anchor 存在但尚未连接到 DOM 时返回 `null`,交给已有 retry 机制等待 DOM 归位,不再退回旧 URL。
|
||||
- `design/05-editor-mainline/reference-code/leptos-tiptap/tiptap/src/extensions/tiptap_paragraph.test.ts`:补充 NodeView 所属文档优先、无 anchor 时回退 URL、未连接 anchor 不回退旧 URL三类用例。
|
||||
- `wolai-frontend/src/lib/documents/tiptap-content-converter.test.ts`:补充 mindmapId 多轮转换稳定性回归用例。
|
||||
- `scripts/task169-mindmap-realtime-smoke.js`:增强 File Tree mindmap row 去重与 object identity 断言,等待 runtime view settle 后再做 live 对比。
|
||||
|
||||
已通过验证:
|
||||
|
||||
- `node --import tsx --test src/extensions/tiptap_paragraph.test.ts`,4 个测试通过。
|
||||
- `npm run typecheck`,目录:`design/05-editor-mainline/reference-code/leptos-tiptap/tiptap`。
|
||||
- `npm run build`,目录:`design/05-editor-mainline/reference-code/leptos-tiptap/tiptap`,并同步生成物到 leptos-tiptap reference / spike generated JS。
|
||||
- `pnpm test -- src/lib/documents/tiptap-content-converter.test.ts`,Vitest 实际执行 112 个测试文件、458 个测试,通过。
|
||||
- `node scripts/task169-mindmap-realtime-smoke.js`,结果 `ok=true`、`failures=[]`;同一页面下最终 File Tree mindmap row 数量为 1,`mindmapId=mindmap_1778695090474`,object identity 仍指向同一 `objectKind:"mindmap"`。
|
||||
|
||||
### 3.8 2026-05-14 普通 mindmap 编辑 artifact 语义收窄
|
||||
|
||||
本轮已完成普通 mindmap 内容编辑的 artifact 语义收窄:
|
||||
|
||||
- `rust/crates/bridge-runtime/src/lib.rs`:`mindmap.command.apply` 改为产出 `mindmap.content.updated` domain event 与 `noop` stream delta,不再默认产出 `tree.resource.mindmap.updated + resync_required`。
|
||||
- `rust/crates/bridge-runtime/src/lib.rs`:`mindmaps.put` 按 `createOnly` 分流;`createOnly=true` 仍表示资源创建 / 挂载,保留 `tree.resource.mindmap.put + resync_required`;`createOnly=false` 表示既有 mindmap blob 内容更新,改为 `mindmap.content.updated + noop`。
|
||||
- `rust/crates/mnote-web/src/routes/mindmap_api.rs`:新增 route 单测覆盖 `mindmap.command.apply` 响应中的 object event 与 noop delta。
|
||||
- `scripts/task169-mindmap-realtime-smoke.js`:新增真实浏览器 artifact 断言,拦截普通 `mindmap.command.apply` 响应,禁止 `tree.resource.*` event 与 `resync_required` delta。
|
||||
|
||||
已通过验证:
|
||||
|
||||
- RED:`cargo test -p bridge-runtime mindmaps_put_existing_content_update_uses_object_event_without_tree_resync -- --nocapture` 修改前失败,旧行为为 `resync_required`。
|
||||
- RED:`cargo test -p mnote-web mindmap_command_apply_returns_object_artifacts_without_tree_resync -- --nocapture` 修改前失败,旧行为为 `tree.resource.mindmap.put`。
|
||||
- GREEN:`cargo test -p bridge-runtime mindmap -- --nocapture`,17 个 mindmap 相关测试通过。
|
||||
- GREEN:`cargo test -p mnote-web mindmap -- --nocapture`,6 个 mindmap 相关测试通过。
|
||||
- 已通过:`node --check scripts/task169-mindmap-realtime-smoke.js`。
|
||||
- 已通过:`node scripts/task169-mindmap-realtime-smoke.js`,最新结果 `ok=true`、`failures=[]`;6 条可解析普通 `mindmap.command.apply` 响应均为 `eventType=mindmap.content.updated`、`streamOp=noop`,另有 1 条 Playwright response body 读取失败被记录为 `skippedReadFailures=1`;同一页面最终仍只有 1 条 mindmap asset row,`mindmapId=mindmap_1778698542703`。
|
||||
|
||||
仍未完成:
|
||||
|
||||
- 本轮未执行历史幽灵副本清理;若后续需要清理 Convex 中未被正文引用的旧 mindmap 行,必须先经用户确认并保留清理前后证据。
|
||||
|
||||
### 3.9 2026-05-14 历史幽灵副本只读审计入口
|
||||
|
||||
本轮新增只读审计脚本,用于在清理前生成候选证据,但不删除任何用户数据:
|
||||
|
||||
- `scripts/task180-mindmap-ghost-candidate-audit.js`:支持从 `/api/sidebar`、导出的 sidebar JSON、`/api/tree/projections/file?workspaceId=<workspaceId>` 或导出的 file projection JSON 读取数据,报告同页多 active mindmap、重复 active asset id、缺失 document 的 active mindmap asset、以及 file tree 中同一 mindmap object 的重复 row。
|
||||
- `scripts/task180-mindmap-ghost-candidate-audit.test.js`:覆盖候选识别逻辑,确认脚本只输出 candidate summary;同时覆盖 Rust file projection 的 `{ ok, result: { items } }` 包装形态和直接 `items` 形态。
|
||||
- 已通过:`node scripts/task180-mindmap-ghost-candidate-audit.test.js`。
|
||||
- 已通过:`node --check scripts/task180-mindmap-ghost-candidate-audit.js` 与 `node --check scripts/task180-mindmap-ghost-candidate-audit.test.js`。
|
||||
- 已通过真实只读审计:`node scripts/task180-mindmap-ghost-candidate-audit.js --url 'http://127.0.0.1:3000/api/tree/projections/file?workspaceId=tree_1777430834634_3' --output tmp/task180-mindmap-ghost-candidate-audit/runtime-file-projection-result.json`。结果 `ok=true`、`readOnly=true`、`fileTreeMindmapRows=10`、`duplicateFileTreeMindmapRows=0`、`candidateCount=0`。
|
||||
- 已确认 endpoint 可用:`/api/tree/projections/file?workspaceId=tree_1777430834634_3` 返回 `200 OK`,projection item 数量为 90。
|
||||
|
||||
该脚本只解决“清理前证据如何生成”的问题,不代表历史数据已经清理。真正删除 Convex 历史幽灵副本仍需用户明确授权,并应另行保留清理前后审计结果。
|
||||
需要注意:file projection 输入只包含投影行,能审计 File Tree 内同一 mindmap object 的重复 row;它不包含 `mindmapAssets` 与 `documents` 全量数据,因此不能单独判断同页多 active mindmap、重复 active asset id 或 orphan asset,这三类仍需 `/api/sidebar` 或等价导出的完整数据。
|
||||
|
||||
## 4. 页面新建/删除慢的证据
|
||||
|
||||
tree shell 在多处命令成功后调用 `scheduleRefresh()`,而 `scheduleRefresh()` 的实现是 80ms 后整页 reload 或带 `renameRowId` 重新 assign:
|
||||
|
||||
- `rust/crates/mnote-web/src/routes/tree.rs:2976` 到 `:2986`
|
||||
|
||||
调用点包括:
|
||||
|
||||
- 文件树删除:`rust/crates/mnote-web/src/routes/tree.rs:2791` 到 `:2824`
|
||||
- 新建页面:`rust/crates/mnote-web/src/routes/tree.rs:4309` 到 `:4340`
|
||||
- 重命名:`rust/crates/mnote-web/src/routes/tree.rs:4373` 到 `:4406`
|
||||
- 移动:`rust/crates/mnote-web/src/routes/tree.rs:4448` 到 `:4502`
|
||||
|
||||
与此同时,主页面已经有 tree live controller 接收 `snapshot` / `delta` / `resync`:
|
||||
|
||||
- `rust/crates/mnote-web/src/ssr/pages/layout.rs:3881` 到 `:3917`
|
||||
- `rust/crates/mnote-web/src/ssr/pages/layout.rs:3939` 到 `:4050`
|
||||
|
||||
这会造成两种低效叠加:
|
||||
|
||||
1. 命令结果本身已经返回 `tree.node.created` / `tree.node.archived` 等 delta hint。
|
||||
2. 前端仍然整页刷新,重新加载 sidebar、file tree、workspace shell、编辑器 runtime。
|
||||
|
||||
这解释了“新建页面和删除页面都很慢”的用户感知。
|
||||
|
||||
2026-05-14 已有部分 tree command live apply 进展:
|
||||
|
||||
- `rust/crates/mnote-web/src/transport/convex.rs` 已剥离发往 legacy Convex mutation 的 artifact-only 字段,修复 archive 命令因 validator 额外字段触发 502 的问题。
|
||||
- `rust/crates/mnote-web/src/ssr/pages/layout.rs` 已补 `move_document` / `remove_document` 的主页面 DOM reducer。
|
||||
- 已通过:`node scripts/task177-tree-move-archive-live-smoke.js`,覆盖 move 后页面树 / File Tree 行父节点更新、archive 后两棵树局部移除,以及文档页头稳定。
|
||||
|
||||
这只降低了 tree command 卡顿链路的一部分风险,不代表 mindmap ghost asset 增长已经修复,也不代表普通新建/删除的无 reload 与耗时阈值已经完成。
|
||||
|
||||
## 5. 当前判断
|
||||
|
||||
当前根因尚需实测最终确认,但静态代码已经能支持以下判断:
|
||||
|
||||
1. mindmap ghost asset 增长的高概率根因是 `mindmapId` 稳定性没有被端到端锁死。只要编辑/保存/重挂载过程中 attrs 丢失或重新创建 block,就会插入新 `mindmap_{timestamp}`,后端会合法保存,file tree 会合法展示。
|
||||
2. 另一个可疑根因是 `mindmaps` 表没有唯一约束,`put` 只 patch `.first()`,无法清除或阻止同键重复行。
|
||||
3. 资产广播入口过多会放大问题。它们让新 mindmapId 或重复数据几乎立即进入 sidebar 本地 state 和 Convex projection,用户就会看到“自己又新增了一个”。
|
||||
4. file tree projection 只按 `asset.id` 去重,无法识别“同一 document + 同一正文 block 的多个 mindmapId 其实是幽灵副本”,也无法处理同 id 多行投影的上游异常。
|
||||
5. 普通 mindmap 节点编辑当前仍会走 artifact,但语义已从 `tree.resource.* + resync_required` 收窄为 `mindmap.content.updated + noop`,不再默认推动 file tree projection resync。
|
||||
6. 页面新建/删除慢不是 Convex 单点问题,曾由 tree shell 命令成功后强制 reload 与 Sidebar 整树刷新链路放大。该慢操作已单独归入 `04-tree-domain` 跟踪,并在 `bugs/04-tree-domain/done/4-25-tree-command-create-delete-reload-latency-v1.md` 完成 create / delete no-reload、React Sidebar 直接 create/delete 本地更新,以及 Rust-family host mutation 本地 apply;本文只保留关联证据。
|
||||
7. 2026-05-14 已修复 NodeView document id 误归属导致的 ghost asset 增长高风险链路;真实 `task169` 已证明连续编辑后同一页面下只保留 1 条 mindmap asset row。
|
||||
8. 2026-05-14 已收窄普通 mindmap 内容编辑 artifact 语义;剩余边界主要是历史幽灵副本清理必须显式授权,不能在本 bug 修复中擅自删除用户数据。
|
||||
|
||||
## 6. 建议复现与验证
|
||||
|
||||
建议补一条失败优先 smoke,先不要直接修代码:
|
||||
|
||||
1. 使用真实测试账号登录 `http://localhost:3000/auth`。
|
||||
2. 新建测试页面,记录 `documentId`。
|
||||
3. 插入一个 mindmap,记录首个 `mindmapId`。
|
||||
4. 连续修改 mindmap 中心主题或新增节点 3 次,每次等待保存完成。
|
||||
5. 切到文件树,统计该页面下 `asset_type=mindmap` 的行数和 `data-mnote-object-identity`。
|
||||
6. 刷新页面后再次统计。
|
||||
7. 断言同一页面下 mindmap asset 数量仍为 1,且 `mindmapId` 未变化。
|
||||
8. 检查 `bridgeLogs:listWorkspaceOverview` 中连续 mindmap 节点编辑产生的 artifact,确认普通内容编辑不应默认产生 `tree.resource.* + resync_required`。
|
||||
9. 同一脚本计时新建页面、删除页面从点击到 DOM 稳定的耗时,并记录是否触发 `window.location.reload()` / navigation。
|
||||
|
||||
建议同时检查 Convex 数据:
|
||||
|
||||
- `mindmaps` 中同一 `document_id` 下是否出现多个 `mindmap_id`。
|
||||
- 新增出来的 `mindmap_id` 是否都形如 `mindmap_<timestamp>`。
|
||||
- 页面正文 TipTap JSON 中是否只有一个 mindmap paragraph,且 attrs.mindmapId 是否随保存变化。
|
||||
|
||||
## 7. 建议修复方向
|
||||
|
||||
### 阶段一:防止继续增长
|
||||
|
||||
1. mindmap block 创建时生成一次稳定 `blockId` 与 `mindmapId`,后续保存、转换、重挂载必须保留。
|
||||
2. `documents.save` / TipTap converter 对 `mnoteBlockType=mindmap` 增加 contract:缺少 `mindmapId` 时不得静默新建时间戳 ID,应先从 block identity / object identity 恢复,恢复不了则报可观测错误。
|
||||
3. `mindmaps.put` 或上层 command 增加可选 `blockId` / `blockAssetRelation`,对同一 `{documentId, blockId}` 已有关联 mindmap 时拒绝插入第二个 mindmapId。
|
||||
4. asset 广播入口收口:保存成功、initial createOnly、实例就绪不应都伪造 asset;前端应优先消费 kernel/file tree projection 返回的 object identity。
|
||||
5. 收窄普通 mindmap 节点编辑的 artifact:内容编辑记录 `mindmap.content.updated` / `mindmap.node.updated`,只有资源关系变化才记录 `tree.resource.*`。
|
||||
6. 普通 mindmap 节点编辑不应默认输出 `resync_required`;只有缺少精确 delta、projection 丢失或 reducer 无法应用时才触发保守 resync。
|
||||
|
||||
### 阶段二:清理幽灵副本
|
||||
|
||||
1. 提供只读诊断脚本:列出同一页面下多个 mindmapId、对应 updated_at、正文引用的 mindmapId。
|
||||
2. 只有在用户确认后,才可清理未被页面正文引用的 mindmap 行。
|
||||
3. 清理必须进入 bug 修复 checklist,不能在本缺陷说明阶段直接删除数据。
|
||||
|
||||
### 阶段三:树命令性能收口
|
||||
|
||||
1. `tree.node.create` 成功后用 command result / delta 局部插入节点,而不是 reload。
|
||||
2. `tree.node.archive` 成功后用 `remove_document` delta 局部移除节点。
|
||||
3. 只有 projection 丢失、SSE 断线或 reducer 无法应用时才走 resync/reload fallback。
|
||||
4. smoke 增加“无整页 reload”断言和耗时阈值。
|
||||
|
||||
## 8. 流转条件
|
||||
|
||||
当前状态:`done`
|
||||
|
||||
只有满足以下条件后才能移动到 `bugs/05-editor-mainline/done/`:
|
||||
|
||||
1. [x] 已有 smoke 能稳定覆盖 ghost asset 防回归。`task169` 增强后断言同一页面最终只保留 1 条 mindmap asset row,并检查 object identity。
|
||||
2. [x] 同一 mindmap 连续编辑保存后,`mindmapId` 和 file tree object identity 保持稳定。`task169` 最新结果中 `mindmapId=mindmap_1778698542703`,最终 File Tree mindmap row 只剩同一 object identity。
|
||||
3. [x] 同一页面下未主动新建多个 mindmap 时,file tree 只出现一个 mindmap asset row。`task169` 结果 `ok=true`、`failures=[]`。
|
||||
4. [x] 普通 mindmap 节点编辑产生 object 内容 artifact,不默认产生 `tree.resource.* + resync_required`。`task169` 最新结果已验证 6 条可解析普通 `mindmap.command.apply` 均为 `mindmap.content.updated + noop`;1 条 Playwright response body 读取失败只计入采样跳过,不作为应用语义失败。
|
||||
5. [x] 只有 mindmap 创建、挂载、解绑、删除、移动、重命名这类资源关系变化才影响 file tree projection。`mindmaps.put createOnly=true` 保留资源事件;`createOnly=false` 与 `mindmap.command.apply` 改为 object 内容事件。
|
||||
6. [x] 新建页面和删除页面不再默认整页 reload,或至少有明确 fallback 条件。`task179` 已验证 Rust `/tree` `convex_workspace` File Tree create/delete 与 Page Tree create 对应 mode 内 navigation 为 0。
|
||||
7. [x] 浏览器实测记录新建/删除耗时,并明显低于当前整页 reload 体验。`task179` 记录 `filetree.createMs=142`、`filetree.deleteMs=180`、`page.createMs=126`。
|
||||
8. [x] 历史幽灵副本的只读候选审计已完成,且当前真实 File Tree projection 未发现 duplicate row。本轮已新增 `task180` 只读候选审计脚本,并补齐 `/api/tree/projections/file` 只读投影输入支持;已完成真实只读审计,candidateCount=0,因此本轮不需要执行历史数据清理,也不会删除用户数据。
|
||||
-193
@@ -1,193 +0,0 @@
|
||||
# 5-11 [process][bug] Mindmap 幽灵附件增长与树命令卡顿 v1
|
||||
|
||||
> 更新时间:2026-05-13
|
||||
>
|
||||
> 分类归属:
|
||||
> - `05-editor-mainline/process`
|
||||
> - 涉及边界:`04-tree-domain/tree command + file_tree projection`、`06-mindmap/runtime save + resource relation`
|
||||
>
|
||||
> 用户证据:
|
||||
> - `/mnt/Data1T/mnote/tmp/image copy 94.png`
|
||||
> - `/mnt/Data1T/mnote/tmp/image copy 93.png`
|
||||
|
||||
## 1. 问题定义
|
||||
|
||||
用户在页面中只是修改了一下 mindmap,文件树/资源树下却陆续出现多个 `mindmap-mindmap...` 附件行。截图显示同一页面 `新页面3` 下有 `index.md`,并且 mindmap 附件从 2 条增长到 4 条。
|
||||
|
||||
同一轮反馈还指出:删除页面和新建页面都很慢,表现为树操作后明显卡顿。
|
||||
|
||||
这不是单纯的图标显示问题。当前症状同时暴露两条链路风险:
|
||||
|
||||
1. mindmap 编辑/初始化/保存链路会多次向资产层广播同一个资源存在,且缺少“页面正文 block 与 mindmap asset 关系唯一”的硬约束。
|
||||
2. tree shell 的页面新建、删除、重命名、移动仍在命令成功后强制整页刷新,和当前 live projection/SSE 机制重复,导致用户感知卡顿。
|
||||
|
||||
## 2. 真实现象
|
||||
|
||||
已观察到的用户现象:
|
||||
|
||||
1. 新页面下初始只有 `index.md` 和少量 mindmap 附件。
|
||||
2. 用户只是编辑 mindmap,不是主动新建 mindmap。
|
||||
3. 等一会儿或再次修改后,同一页面下又出现新的 `mindmap-mindmap...` 行。
|
||||
4. 页面新建和删除动作响应慢,像是页面/树整体重新加载。
|
||||
|
||||
期望结果:
|
||||
|
||||
1. 一个页面内的一个 mindmap block 只对应一个稳定 `mindmapId` 和一个 file tree asset row。
|
||||
2. mindmap 保存只能更新既有资源,不应创建新的 mindmap 资产行。
|
||||
3. file tree projection 应按 `{documentId, blockId, assetId}` 或明确 object identity 去重。
|
||||
4. 页面新建/删除成功后应优先消费 command result / tree delta 更新局部投影,不应默认整页 reload。
|
||||
|
||||
## 3. 初步调查证据
|
||||
|
||||
### 3.1 mindmap 资产广播存在多入口
|
||||
|
||||
`wolai-frontend/src/components/editor/blocks/MindmapBlock.tsx` 中同一个 mindmap 资源至少有三处会触发资产刷新广播:
|
||||
|
||||
- 保存成功后广播 `emitAssetsChanged(docId, { id: mindmapId, asset_type: "mindmap", ... })`:`MindmapBlock.tsx:1499` 到 `:1534`
|
||||
- 初始同步 `createOnly: true` 成功后广播:`MindmapBlock.tsx:1591` 到 `:1621`
|
||||
- mindmap 实例就绪后立即广播:`MindmapBlock.tsx:1636` 到 `:1647`
|
||||
|
||||
另一个 legacy/compat block wrapper 也会在 mount 时执行 `createOnly` 并广播资产:`MindmapBlock.tsx:3573` 到 `:3612`。
|
||||
|
||||
这些广播本身用 `id = mindmapId`,理论上同 ID 会被 sidebar 本地 state 去重;但只要保存/转换链路让同一视觉 mindmap 换了新的 `mindmapId`,就会生成新的资产行。
|
||||
|
||||
### 3.2 mindmapId 仍可能由时间戳生成
|
||||
|
||||
当前 `leptos-tiptap` 插入 mindmap 时使用:
|
||||
|
||||
- `rust/spikes/leptos-tiptap-spike/src/lib.rs:5681` 到 `:5689`
|
||||
|
||||
这里 `next_mindmap_id()` 生成 `mindmap_{Date.now()}`,并写入 paragraph attrs 的 `mindmapId`。如果后续转换、保存、重新挂载中丢失原 attrs,fallback 会用新的 block identity / 新插入节点创建新的 `mindmapId`,资产层就会认为这是另一个 mindmap。
|
||||
|
||||
相关转换锚点:
|
||||
|
||||
- `rust/crates/mnote-web/src/routes/web_shell.rs:818` 到 `:838`:legacy block 转 TipTap 时写入 `mindmapId`
|
||||
- `rust/crates/mnote-web/src/routes/web_shell.rs:983` 到 `:1009`:TipTap 节点转 editor block 时若 attrs 缺失则用 blockId fallback
|
||||
- `wolai-frontend/src/lib/documents/tiptap-content-converter.ts:188` 到 `:213`:`mindmapReferenceProps` 会在缺少 `mindmapId` 时 fallback 到 blockId
|
||||
- `wolai-frontend/src/lib/documents/tiptap-content-converter.ts:410` 到 `:418`:editor block 转 TipTap 时把 mindmap props 写回 paragraph attrs
|
||||
|
||||
当前缺少一条回归断言:连续编辑同一个 mindmap 后,保存前后 `mindmapId` 必须保持不变,且 file tree 下同一页面 mindmap asset 数量不增长。
|
||||
|
||||
### 3.3 Convex mindmaps 表允许同页多 mindmap,但缺少 block 关系唯一约束
|
||||
|
||||
`wolai-frontend/convex/schema.ts:164` 到 `:183` 定义 mindmaps 表,并以 `(document_id, mindmap_id)` 做查询索引。注意这里是普通索引,不是唯一约束。
|
||||
|
||||
`wolai-frontend/convex/mindmaps.ts` 的 `put` 对同一 `(document_id, mindmap_id)` 是幂等 patch/insert,但它并不知道页面正文中的哪个 block 才是唯一来源。也就是说:
|
||||
|
||||
- 同一个 `mindmapId` 重复保存不会多插入。
|
||||
- 如果历史或并发路径已经写出同一 `(document_id, mindmap_id)` 的多行,`put` 当前用 `.first()` 只会 patch 第一行,剩余重复行仍会被后续 list/projection 展示。
|
||||
- 但如果前端生成了新的 `mindmapId`,后端会按合法新 mindmap 插入。
|
||||
- file tree 会把同一 document 下所有 active mindmaps 映射为资产行。
|
||||
|
||||
对应映射:
|
||||
|
||||
- `wolai-frontend/convex/mindmaps.ts:206` 到 `:245`:`put` 查询 `by_doc_mindmap` 后 `.first()`,不存在则 insert
|
||||
- `wolai-frontend/convex/sidebar.ts:79` 到 `:100`:`mindmap_id` 被映射为 `id` 和 `mindmap-{mindmap_id}.json`
|
||||
- `wolai-frontend/convex/sidebar.ts:193` 到 `:220`:所有 active mindmaps 都进入 `mindmap_assets`
|
||||
- `rust/crates/bridge-runtime/src/lib.rs:7569` 到 `:7647`:file tree projection 从 `mindmap_assets` 构建资源行,并只按 asset id 去重
|
||||
|
||||
因此,当前有两个需要实测区分的分支:
|
||||
|
||||
1. 同一视觉对象被保存成多个不同 `mindmapId`,每个都被合法展示为一个资源。
|
||||
2. 数据表中已经存在同一 `(document_id, mindmap_id)` 多行,`.first()` 更新掩盖重复行,sidebar list 把重复行全部暴露出来。
|
||||
|
||||
两者都会在截图中表现为同一页面下多个 `mindmap-mindmap...` 行。
|
||||
|
||||
### 3.4 Rust / Next API 都会把 mindmap 保存转成 tree resync
|
||||
|
||||
保存路径还会触发树刷新:
|
||||
|
||||
- Next API `POST /api/mindmap/[docId]/[mindmapId]` 构造 `mindmaps.put` 或 `mindmap.command.apply`:`wolai-frontend/src/app/api/mindmap/[docId]/[mindmapId]/route.ts:229` 到 `:254`
|
||||
- Rust API 普通保存也包装为 `mindmaps.put` 并携带 `createOnly`:`rust/crates/mnote-web/src/routes/mindmap_api.rs:177` 到 `:200`
|
||||
- Bridge runtime 给 `mindmaps.put` 生成 `resync_required` 树事件:`rust/crates/bridge-runtime/src/lib.rs:9273` 到 `:9326`
|
||||
|
||||
这意味着 mindmap 每次保存都会推动资源树重新读 projection;如果底层 mindmaps 数据已经重复,保存/刷新会把重复行显性化。
|
||||
|
||||
## 4. 页面新建/删除慢的证据
|
||||
|
||||
tree shell 在多处命令成功后调用 `scheduleRefresh()`,而 `scheduleRefresh()` 的实现是 80ms 后整页 reload 或带 `renameRowId` 重新 assign:
|
||||
|
||||
- `rust/crates/mnote-web/src/routes/tree.rs:2976` 到 `:2986`
|
||||
|
||||
调用点包括:
|
||||
|
||||
- 文件树删除:`rust/crates/mnote-web/src/routes/tree.rs:2791` 到 `:2824`
|
||||
- 新建页面:`rust/crates/mnote-web/src/routes/tree.rs:4309` 到 `:4340`
|
||||
- 重命名:`rust/crates/mnote-web/src/routes/tree.rs:4373` 到 `:4406`
|
||||
- 移动:`rust/crates/mnote-web/src/routes/tree.rs:4448` 到 `:4502`
|
||||
|
||||
与此同时,主页面已经有 tree live controller 接收 `snapshot` / `delta` / `resync`:
|
||||
|
||||
- `rust/crates/mnote-web/src/ssr/pages/layout.rs:3881` 到 `:3917`
|
||||
- `rust/crates/mnote-web/src/ssr/pages/layout.rs:3939` 到 `:4050`
|
||||
|
||||
这会造成两种低效叠加:
|
||||
|
||||
1. 命令结果本身已经返回 `tree.node.created` / `tree.node.archived` 等 delta hint。
|
||||
2. 前端仍然整页刷新,重新加载 sidebar、file tree、workspace shell、编辑器 runtime。
|
||||
|
||||
这解释了“新建页面和删除页面都很慢”的用户感知。
|
||||
|
||||
## 5. 当前判断
|
||||
|
||||
当前根因尚需实测最终确认,但静态代码已经能支持以下判断:
|
||||
|
||||
1. mindmap ghost asset 增长的高概率根因是 `mindmapId` 稳定性没有被端到端锁死。只要编辑/保存/重挂载过程中 attrs 丢失或重新创建 block,就会插入新 `mindmap_{timestamp}`,后端会合法保存,file tree 会合法展示。
|
||||
2. 另一个可疑根因是 `mindmaps` 表没有唯一约束,`put` 只 patch `.first()`,无法清除或阻止同键重复行。
|
||||
3. 资产广播入口过多会放大问题。它们让新 mindmapId 或重复数据几乎立即进入 sidebar 本地 state 和 Convex projection,用户就会看到“自己又新增了一个”。
|
||||
4. file tree projection 只按 `asset.id` 去重,无法识别“同一 document + 同一正文 block 的多个 mindmapId 其实是幽灵副本”,也无法处理同 id 多行投影的上游异常。
|
||||
5. 页面新建/删除慢不是 Convex 单点问题,当前 tree shell 命令成功后仍强制 reload,是明确的性能和体验缺陷。该慢操作应单独归入 `04-tree-domain` 跟踪,本文只保留关联证据。
|
||||
|
||||
## 6. 建议复现与验证
|
||||
|
||||
建议补一条失败优先 smoke,先不要直接修代码:
|
||||
|
||||
1. 使用真实测试账号登录 `http://localhost:3000/auth`。
|
||||
2. 新建测试页面,记录 `documentId`。
|
||||
3. 插入一个 mindmap,记录首个 `mindmapId`。
|
||||
4. 连续修改 mindmap 中心主题或新增节点 3 次,每次等待保存完成。
|
||||
5. 切到文件树,统计该页面下 `asset_type=mindmap` 的行数和 `data-mnote-object-identity`。
|
||||
6. 刷新页面后再次统计。
|
||||
7. 断言同一页面下 mindmap asset 数量仍为 1,且 `mindmapId` 未变化。
|
||||
8. 同一脚本计时新建页面、删除页面从点击到 DOM 稳定的耗时,并记录是否触发 `window.location.reload()` / navigation。
|
||||
|
||||
建议同时检查 Convex 数据:
|
||||
|
||||
- `mindmaps` 中同一 `document_id` 下是否出现多个 `mindmap_id`。
|
||||
- 新增出来的 `mindmap_id` 是否都形如 `mindmap_<timestamp>`。
|
||||
- 页面正文 TipTap JSON 中是否只有一个 mindmap paragraph,且 attrs.mindmapId 是否随保存变化。
|
||||
|
||||
## 7. 建议修复方向
|
||||
|
||||
### 阶段一:防止继续增长
|
||||
|
||||
1. mindmap block 创建时生成一次稳定 `blockId` 与 `mindmapId`,后续保存、转换、重挂载必须保留。
|
||||
2. `documents.save` / TipTap converter 对 `mnoteBlockType=mindmap` 增加 contract:缺少 `mindmapId` 时不得静默新建时间戳 ID,应先从 block identity / object identity 恢复,恢复不了则报可观测错误。
|
||||
3. `mindmaps.put` 或上层 command 增加可选 `blockId` / `blockAssetRelation`,对同一 `{documentId, blockId}` 已有关联 mindmap 时拒绝插入第二个 mindmapId。
|
||||
4. asset 广播入口收口:保存成功、initial createOnly、实例就绪不应都伪造 asset;前端应优先消费 kernel/file tree projection 返回的 object identity。
|
||||
|
||||
### 阶段二:清理幽灵副本
|
||||
|
||||
1. 提供只读诊断脚本:列出同一页面下多个 mindmapId、对应 updated_at、正文引用的 mindmapId。
|
||||
2. 只有在用户确认后,才可清理未被页面正文引用的 mindmap 行。
|
||||
3. 清理必须进入 bug 修复 checklist,不能在本缺陷说明阶段直接删除数据。
|
||||
|
||||
### 阶段三:树命令性能收口
|
||||
|
||||
1. `tree.node.create` 成功后用 command result / delta 局部插入节点,而不是 reload。
|
||||
2. `tree.node.archive` 成功后用 `remove_document` delta 局部移除节点。
|
||||
3. 只有 projection 丢失、SSE 断线或 reducer 无法应用时才走 resync/reload fallback。
|
||||
4. smoke 增加“无整页 reload”断言和耗时阈值。
|
||||
|
||||
## 8. 流转条件
|
||||
|
||||
当前状态:`process`
|
||||
|
||||
只有满足以下条件后才能移动到 `bugs/05-editor-mainline/done/`:
|
||||
|
||||
1. [ ] 已有 smoke 能稳定复现当前 mindmap 资产增长问题,或能证明 Convex 数据中存在同页多 mindmap 幽灵副本。
|
||||
2. [ ] 同一 mindmap 连续编辑保存后,`mindmapId` 和 file tree object identity 保持稳定。
|
||||
3. [ ] 同一页面下未主动新建多个 mindmap 时,file tree 只出现一个 mindmap asset row。
|
||||
4. [ ] 新建页面和删除页面不再默认整页 reload,或至少有明确 fallback 条件。
|
||||
5. [ ] 浏览器实测记录新建/删除耗时,并明显低于当前整页 reload 体验。
|
||||
6. [ ] 若清理历史幽灵副本,必须经用户确认,并保留清理前后证据。
|
||||
@@ -3,10 +3,11 @@
|
||||
> 状态说明:
|
||||
> - 本稿定义的 default gate 已在当前主线代码中成立:`mnote-web` 是 `3000` owner,Next 仅保留 legacy compat/debug 边界
|
||||
> - `MNOTE_WEB_ENABLE_LEGACY_NEXT_COMPAT` 默认关闭与 `task117` guard 已落地,故迁入 `done/`
|
||||
> - 2026-05-13 追加说明:本文中“长期 agent 执行面收口到 `mnote-cli`”是历史完成口径;当前 AI 长期方向已由 `design/07-ai/process/7-3-page-ai-hermes-panel-and-mnote-plugin-v1.md` 覆盖为 Hermes 页面内客户端 + mnote Hermes skill/plugin
|
||||
|
||||
## 目标
|
||||
|
||||
将 Next App Router 从 3000 默认主链降级为显式 legacy/debug/internal 兼容边界。默认首页、文档页、搜索页、导图页、tree SSE 与 AI bridge host 均由 `mnote-web` 承接;长期 agent 执行面收口到 `mnote-cli`。
|
||||
将 Next App Router 从 3000 默认主链降级为显式 legacy/debug/internal 兼容边界。默认首页、文档页、搜索页、导图页、tree SSE 与 AI bridge host 均由 `mnote-web` 承接;AI 会话与编排长期归 Hermes,mnote 通过 Hermes skill/plugin 暴露业务工具。
|
||||
|
||||
## 当前 owner
|
||||
|
||||
@@ -15,7 +16,7 @@
|
||||
- `/search`:Rust Web `search::shell`,Search projection owner `rust-kernel`。
|
||||
- `/mindmap/{doc_id}/{mindmap_id}`:Rust Web `mindmap_shell::mindmap_object_shell`,Mindmap projection owner `rust-kernel`。
|
||||
- `/api/tree/events`:Rust Web SSE,stream owner `rust-web`。
|
||||
- `/api/hermes/bridge`:Rust Web 兼容 AI bridge;仅作为过渡边界,不是长期 agent 执行面。
|
||||
- `/api/hermes/bridge`:Rust Web 兼容 AI bridge;仅作为过渡边界,不是新的 Hermes 页面内客户端代理。
|
||||
- `/api/compat/next/*`:legacy compat boundary,仅用于迁移期兼容与调试。
|
||||
|
||||
## Gate
|
||||
@@ -23,12 +24,12 @@
|
||||
- `MNOTE_WEB_ENABLE_LEGACY_NEXT_COMPAT` 默认关闭;只有显式设置为 `1/true/yes` 才允许 fallback proxy。
|
||||
- 默认主路径不得返回 `x-mnote-legacy-upstream: next-app-router`。
|
||||
- 导图、搜索、文档页 contract 不得再把 `next-app-router` 声明为主 runtime。
|
||||
- `/api/ai-agent/run` 当前仍保留兼容 route,但长期应降为 `mnote-cli` host / adapter,结构化写入必须经 Rust runtime,外置 agent 不得拥有第二执行面。
|
||||
- `/api/ai-agent/run` 当前仍保留兼容 route,但长期应退出页面 AI 主路径;新的页面 AI 应走 Hermes client proxy,结构化写入必须经 mnote Hermes plugin -> Rust runtime / kernel。
|
||||
|
||||
## 删除条件
|
||||
|
||||
- 删除 `/api/compat/next/sidebar`:Sidebar、workspace shell、file tree smoke 均证明 Rust projection 可覆盖默认入口。
|
||||
- 删除 `/api/compat/next/ai-agent/run`:AI 面板默认请求统一 CLI host,legacy 调用方清零;Hermes 仅保留为可插拔外置 agent。
|
||||
- 删除 `/api/compat/next/ai-agent/run`:AI 面板默认请求统一 Hermes client proxy,legacy 调用方清零;`mnote-cli` 只可作为 mnote plugin 内部适配器或调试入口。
|
||||
- 删除 fallback proxy:`task117` 默认关闭 legacy compat 后覆盖首页、文档页、搜索页、导图页与 tree SSE。
|
||||
|
||||
## 验收命令
|
||||
|
||||
+10
-5
@@ -1,4 +1,4 @@
|
||||
# 3-15 [process] Runtime Fallback 退场 Checklist v1
|
||||
# 3-15 [done] Runtime Fallback 退场 Checklist v1
|
||||
|
||||
> 更新时间:2026-05-09
|
||||
>
|
||||
@@ -9,7 +9,12 @@
|
||||
> - `/mnt/Data1T/mnote/design/03-rust-web/process/3-3-rust-web-tree-realtime-event-stream-v1.md`
|
||||
> - `/mnt/Data1T/mnote/design/05-editor-mainline/process/5-5-page-aggregate-single-truth-alignment-v1.md`
|
||||
> - `/mnt/Data1T/mnote/design/05-editor-mainline/process/5-6-page-aggregate-alignment-checklist-v1.md`
|
||||
> - `/mnt/Data1T/mnote/design/07-ai/process/7-phase7-ai-kernel-projection-plan-v4.md`
|
||||
> - `/mnt/Data1T/mnote/design/07-ai/process/7-3-page-ai-hermes-panel-and-mnote-plugin-v1.md`
|
||||
>
|
||||
> 2026-05-13 口径修正:
|
||||
> - 本清单中“删除 `provider=hermes` 直连分支”的结论仍然有效,含义是旧 `/api/ai-agent/run` 不再把 Hermes 当 fallback/provider 分支。
|
||||
> - 新长期方向不是恢复旧 provider 分支,而是新增正式 Hermes 页面内客户端与 mnote Hermes skill/plugin。
|
||||
> - 因此“退场旧 direct Hermes fallback”和“页面 AI 长期调用 Hermes”不冲突。
|
||||
|
||||
## 1. 目标
|
||||
|
||||
@@ -59,14 +64,14 @@
|
||||
- [x] 把 `BlockNote` 从“系统默认可退回编辑器”降为 `recycle` 参考实现
|
||||
- [x] 活跃设计稿中不再把 `BlockNote` 写成系统 fallback,只允许写成历史对照链
|
||||
|
||||
### 4.2 AI provider 直连分支
|
||||
### 4.2 旧 AI provider 直连分支
|
||||
|
||||
- [x] 删除 Next `/api/ai-agent/run` 中 `provider=codex` 直连分支
|
||||
- [x] 删除 Next `/api/ai-agent/run` 中 `provider=hermes` 直连分支
|
||||
- [x] 删除 Rust `compat` 中代理到 legacy Next AI route 的优先分支
|
||||
- [x] 删除 Rust `compat` 中 direct Hermes 分支
|
||||
- [x] 删除 Rust `compat` 中 document AI orchestrator fallback 分支
|
||||
- [x] 统一只保留 `mnote-cli` host 作为默认主执行入口
|
||||
- [x] 旧 `/api/ai-agent/run` 不再作为页面 AI 主执行入口;历史 `mnote-cli` host 只保留为兼容/内部适配,不再代表长期页面 AI 主线
|
||||
- [x] 次要 provider 请求改为明确失败,不再静默降级
|
||||
|
||||
### 4.3 legacy Next compat / alias / debug 壳
|
||||
@@ -133,7 +138,7 @@
|
||||
### 8.2 必须消失
|
||||
|
||||
- [x] 默认运行时不再自动触发 `BlockNote` fallback
|
||||
- [x] 默认 AI 运行时不再直连 `Hermes` / `Codex` / 旧 orchestrator compat
|
||||
- [x] 默认 AI 运行时不再通过旧 `/api/ai-agent/run` provider 分支直连 `Hermes` / `Codex` / 旧 orchestrator compat;新的 Hermes 页面内客户端应走正式 Hermes client proxy
|
||||
- [x] 默认 gateway 不再把 legacy Next 当作未迁路径的总兜底
|
||||
- [x] `/api/documents/page` 不再在 runtime 中组装 TS fallback projection
|
||||
|
||||
@@ -120,6 +120,8 @@
|
||||
|
||||
### 3.2 Hermes / AI tool registry 归属仍需继续收口
|
||||
|
||||
2026-05-13 口径更新:本文记录的是 `3104` 退役阶段的历史边界判断,不再代表当前 AI 长期编排主线。当前 AI 长期方向已改为 Hermes 页面内客户端 + mnote Hermes skill/plugin;旧 `openai-agents-python` 编排层与旧 `/api/hermes/bridge` 只作为历史或兼容路径看待。
|
||||
|
||||
当前前端 AI route 与后端 `openai-agents-python` 编排层已经不再通过 `MNOTE_WEB_BASE_URL` 回源 `mnote-web /api/hermes/bridge` 获取 runtime tool 结果,而是走后端 orchestration + 本地 `bridge-runtime`。
|
||||
|
||||
这意味着:
|
||||
@@ -302,12 +304,12 @@
|
||||
目标:
|
||||
|
||||
- AI runtime tool bridge 不再强依赖 `MNOTE_WEB_BASE_URL`
|
||||
- `openai-agents-python` 编排层与工具桥接边界收口到正式 AI 服务面
|
||||
- 历史 `openai-agents-python` 编排层不再被描述为长期 AI 服务面;新的正式边界是 Hermes client proxy + mnote Hermes plugin
|
||||
|
||||
推荐方向:
|
||||
|
||||
- 由 `wolai-backend:8000` 持有 AI orchestration 正式入口
|
||||
- Rust `bridge-runtime` 作为内核语义执行层
|
||||
- 由 Hermes 持有 session / run / tool event / usage / model 编排
|
||||
- Rust `bridge-runtime` 作为 mnote 业务语义执行层,经 Hermes skill/plugin 暴露
|
||||
- `3000` 只负责页面与同源 API 边界,不再额外绕回 `3104`
|
||||
|
||||
当前状态:
|
||||
|
||||
@@ -47,7 +47,7 @@
|
||||
- [x] `Phase 2` 文档阅读态分离已经有实质代码
|
||||
- [x] `Phase 3` 主 Sidebar 已出现 `kernelSidebarTree` 消费接缝
|
||||
- [x] `Phase 4` 搜索已做 host/runtime 拆分,并开始返回 `nodeId` / `subtreeRootId` / `evidence`
|
||||
- [x] `Phase 5` AI 面板已做 host/runtime 拆分,并开始向 AI bridge 传递 `node` / `subtree` / `outline` / `evidence`;长期执行面应收口到 `mnote-cli`,Web 只作为 CLI host / client,`openai-agents-python` / Hermes / Codex 只作为可插拔外置 agent
|
||||
- [x] `Phase 5` AI 面板已做 host/runtime 拆分,并开始向 AI bridge 传递 `node` / `subtree` / `outline` / `evidence`;2026-05-13 起长期方向改为 Hermes 页面内客户端 + mnote Hermes skill/plugin,旧 CLI host 只保留为历史/兼容证据
|
||||
- [x] `Phase 6` Mindmap 独立页已去掉 `editorStub` 主入口
|
||||
- [x] `Phase 7` 阅读态已直接消费 `pageSubtree`,`BlockNote` 也已不是文档页默认唯一入口
|
||||
|
||||
@@ -107,7 +107,7 @@
|
||||
| Phase 2 | 文档阅读页 server-first 化 | `PARTIAL` 接近 `DONE` | 阅读态/编辑态已明显分离,但仍有旧链回退和大量客户端状态集中在 `DocumentContent` |
|
||||
| Phase 3 | Sidebar / 树结构 Rust 化 | `PARTIAL` 偏早期 | 主 Sidebar 已以 `kernelSidebarTree` 作为主树来源,但整体仍是超大客户端组件 |
|
||||
| Phase 4 | 搜索 Rust 化与 island 化 | `PARTIAL` | host/runtime 懒加载拆分已做,结果形状也开始带 `nodeId` / `subtreeRootId` / `evidence`,但仍未完成 server-first 搜索页 |
|
||||
| Phase 5 | AI 面板 bridge island 化 | `PARTIAL` | host/runtime 拆分已做,且已开始把 `node` / `subtree` / `outline` / `evidence` 送入 AI bridge;长期执行统一落在 `mnote-cli`,Web 面板只做 host / adapter,但 runtime 仍重,协议也未统一到真正“最小壳” |
|
||||
| Phase 5 | AI 面板 Hermes client island 化 | `PARTIAL` | host/runtime 拆分已做,且已开始把 `node` / `subtree` / `outline` / `evidence` 送入 AI bridge;后续应把 Web 面板退成 Hermes 页面内客户端,把 mnote 能力注册为 Hermes skill/plugin,但 runtime 仍重,协议也未统一到 Hermes session/run/tool contract |
|
||||
| Phase 6 | Mindmap 独立对象化 | `PARTIAL` | 独立页脱离 editor stub 主入口,并补出 `standalone` / `documentBridge` 边界,但仍是客户端重壳 |
|
||||
| Phase 7 | BlockNote 孤岛化 | `PARTIAL` | 阅读态已直接消费 `pageSubtree`,编辑器按需挂载,但外围 drawer/panel 仍集中在同一内容组件 |
|
||||
| Phase 8 | 旧前端壳下线 | `PARTIAL` 接近 `DONE` | 3000 gateway / 文档 shell / tree realtime / Search / AI bridge / Mindmap object shell 已由 `mnote-web` 持有;Next App Router 降为 legacy compat / island bundle source,旧链彻底删除仍待后续 |
|
||||
@@ -290,10 +290,15 @@
|
||||
|
||||
---
|
||||
|
||||
## 10. Phase 5:AI 面板进一步收口为 `mnote-cli` host / 纯桥接 island
|
||||
## 10. Phase 5:AI 面板收口为 Hermes 页面内客户端 / mnote plugin bridge
|
||||
|
||||
**当前状态:`PARTIAL`**
|
||||
|
||||
> 2026-05-13 口径更新:
|
||||
> - 本阶段原先把页面 AI 继续收口为 `mnote-cli host / client`,现在已被 `design/07-ai/process/7-3-page-ai-hermes-panel-and-mnote-plugin-v1.md` 覆盖。
|
||||
> - 新长期方向是:页面 AI 面板使用 Leptos 实现 Hermes 页面内客户端;Hermes session/message/tool event/usage/model 是 AI 会话真相;mnote 只通过 Hermes skill/plugin 暴露页面、树、artifact、edge 等业务能力。
|
||||
> - `mnote-cli` 只能作为 plugin 内部适配器或调试入口,不能再被写成页面 AI 唯一长期执行面。
|
||||
|
||||
### 10.1 已落地事实
|
||||
|
||||
- [x] 文档页 AI 已拆为 host + runtime
|
||||
@@ -301,24 +306,24 @@
|
||||
- [DocumentAiAgentPanel.runtime.tsx](/mnt/Data1T/mnote/wolai-frontend/src/components/editor/DocumentAiAgentPanel.runtime.tsx)
|
||||
- [x] Mindmap / OnlyOffice 也有类似 runtime 拆分
|
||||
- [x] `GlobalAiAgentHost` 已不在 `(app)/layout.tsx` 主布局中挂载
|
||||
- [x] 文档页 AI 已开始把 `node` / `subtree` / `outline` / `evidence` 传给 AI bridge;长期执行面应收口到 `mnote-cli`,页面 AI 只保留为 CLI host / client,`openai-agents-python` / Hermes / Codex 只作为可插拔外置 agent
|
||||
- [x] 文档页 AI 已开始把 `node` / `subtree` / `outline` / `evidence` 传给 AI bridge;后续应改为 Hermes run/session context,由 Hermes 通过 mnote skill/plugin 回读和写入业务事实
|
||||
- [DocumentAiAgentPanel.runtime.tsx](/mnt/Data1T/mnote/wolai-frontend/src/components/editor/DocumentAiAgentPanel.runtime.tsx)
|
||||
- [route.ts](/mnt/Data1T/mnote/wolai-frontend/src/app/api/ai-agent/run/route.ts)
|
||||
|
||||
### 10.2 当前还没完成
|
||||
|
||||
- [ ] runtime 组件仍然非常重
|
||||
- [ ] “最小会话协议、统一流式协议、页面上下文协议”还更多体现在组件内部,不是系统级协议层
|
||||
- [ ] 还不能说 AI 面板已经变成“纯桥接壳”
|
||||
- [ ] “最小会话协议、统一流式协议、页面上下文协议”还更多体现在组件内部,不是 Hermes client proxy / plugin tool contract
|
||||
- [ ] 还不能说 AI 面板已经变成“只调用 Hermes 的页面内客户端”
|
||||
- [ ] 页面级 AI adapter 仍然很大,只是改成了懒加载
|
||||
- [ ] AI 已能消费 `node` / `subtree` / `outline` / `evidence` 上下文,但还不是统一的 `mnote-cli` kernel-first tool 协议;Web 面板也还没有彻底退成单纯 host / adapter
|
||||
- [ ] AI 已能消费 `node` / `subtree` / `outline` / `evidence` 上下文,但还没有进入稳定 Hermes session/run context 与 mnote plugin tool 协议;Web 面板也还没有彻底退成 Hermes client
|
||||
|
||||
### 10.3 v2 后续任务
|
||||
|
||||
- [ ] 继续下沉 runtime 内状态与协议
|
||||
- [ ] 把会话、tool event、client action 收口到共享协议层
|
||||
- [ ] 让 runtime 再减重,而不只是拆文件
|
||||
- [ ] 把当前上下文注入进一步收口为更稳定的 kernel node / subtree / edge bridge
|
||||
- [ ] 移除页面 AI 对旧 `/api/ai-agent/run` 主路径的长期依赖,改走正式 Hermes client proxy
|
||||
- [ ] 把会话、message、tool event、usage、model 选择交给 Hermes session 存储
|
||||
- [ ] 把 mnote 页面、树、artifact、edge 能力注册为 Hermes skill/plugin tools
|
||||
- [ ] 把当前上下文注入进一步收口为 Hermes run context + 稳定 kernel node / subtree / edge tool bridge
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
- Rust 内核和 Web 承载层如何分工
|
||||
- 前端哪些部分应该退出当前重 React 壳
|
||||
- `axum`、`Leptos` 这类 Rust Web 方案是否值得采用
|
||||
- `mnote-cli` 应如何成为唯一长期 agent 执行面
|
||||
- 页面 AI 应如何作为 Hermes 页面内客户端接入,并让 mnote 通过 Hermes skill/plugin 暴露业务能力
|
||||
- 默认主编辑器已经切到页面内 `leptos-tiptap` island 后,剩余重编辑兼容链应如何继续收口
|
||||
|
||||
---
|
||||
@@ -57,7 +57,8 @@
|
||||
- **业务执行面:现有 Rust workspace 继续作为唯一业务真执行面**
|
||||
- **Web 承载层:`axum`**
|
||||
- **页面渲染模型:`Leptos Islands`**
|
||||
- **AI 执行面:`mnote-cli`**
|
||||
- **AI 会话与编排面:Hermes**
|
||||
- **mnote AI 能力面:Hermes skill/plugin -> Rust runtime / kernel**
|
||||
- **最后保留的重编辑兼容孤岛:少量编辑器 runtime(当前默认主编辑器已是页面内 `leptos-tiptap` island,`BlockNote` 仅保留为 `recycle/` 历史参考副本)**
|
||||
|
||||
一句话概括:
|
||||
@@ -180,7 +181,7 @@
|
||||
- 文档查询与聚合层
|
||||
- 树结构装配层
|
||||
- 搜索服务层
|
||||
- AI bridge / CLI host 层;长期 agent 执行面只收口到 `mnote-cli`,Hermes 仅作为历史兼容 bridge / 外置 adapter
|
||||
- Hermes client proxy 与 mnote tool bridge 层;页面 AI 会话真相归 Hermes,mnote 业务工具最终回到 Rust runtime / kernel
|
||||
- 页面 SSR 外壳承载层
|
||||
- 流式更新、通知、事件推送层
|
||||
|
||||
@@ -413,14 +414,15 @@ Leptos Islands 很适合承接下面这类长期目标:
|
||||
|
||||
目标:
|
||||
|
||||
- 继续桥接统一 CLI host
|
||||
- 不再承担前端本地 orchestration
|
||||
- 作为 Hermes 页面内客户端运行
|
||||
- 不再承担前端本地 orchestration 或私有会话存储
|
||||
- 不再成为常驻大壳的一部分
|
||||
|
||||
归位:
|
||||
|
||||
- 面板只保留最小 UI 与上下文桥接
|
||||
- 工具执行全部走 `mnote-cli` + Rust tools
|
||||
- 面板只保留 Hermes chat/session/run/tool event 的页面内子集 UI 与当前页面上下文桥接
|
||||
- Hermes 持有 session / message / usage / model 真相
|
||||
- mnote 通过 Hermes skill/plugin 暴露页面、树、artifact、edge 工具,最终执行回到 Rust runtime / kernel
|
||||
- 面板按页面需要懒挂载成单独 island
|
||||
|
||||
### 9.5 Mindmap
|
||||
|
||||
+14
-12
@@ -5,6 +5,7 @@
|
||||
> 状态说明:
|
||||
> - 本稿对应的页面设置 `popover`、页面 AI `drawer`、入口位置与最小接线方案已在当前 `mnote-web` 文档壳中落地,故迁入 `done/`
|
||||
> - 本稿中的 deferred 项继续由后续独立任务推进,不影响这一轮“页面设置 + 页面 AI 交互壳”完成判定
|
||||
> - 2026-05-13 追加说明:本文中 `CLI-first` / `mnote-cli host` 是 2026-05-06 完成时的历史接线口径;当前 AI 长期方向已由 `design/07-ai/process/7-3-page-ai-hermes-panel-and-mnote-plugin-v1.md` 覆盖为 Hermes 页面内客户端 + mnote Hermes skill/plugin
|
||||
>
|
||||
> 关联文档:
|
||||
> - `/mnt/Data1T/mnote/ARCHITECTURE.md`
|
||||
@@ -13,7 +14,7 @@
|
||||
> - `/mnt/Data1T/mnote/design/05-editor-mainline/process/5-6-page-aggregate-alignment-checklist-v1.md`
|
||||
> - `/mnt/Data1T/mnote/design/05-editor-mainline/process/5-7-wolai-page-tree-main-editor-experience-restoration-v1.md`
|
||||
> - `/mnt/Data1T/mnote/design/05-editor-mainline/process/5-9-wolai-aline-continuous-checklist-v1.md`
|
||||
> - `/mnt/Data1T/mnote/design/07-ai/process/7-phase7-ai-kernel-projection-plan-v4.md`
|
||||
> - `/mnt/Data1T/mnote/design/07-ai/process/7-3-page-ai-hermes-panel-and-mnote-plugin-v1.md`
|
||||
> - `/mnt/Data1T/mnote/design/08-wolai-aline-test-flow/process/wolai-aline-test-flow-v1.md`
|
||||
|
||||
## 1. 文档目的
|
||||
@@ -28,7 +29,7 @@
|
||||
|
||||
1. 页面设置入口与面板形态
|
||||
2. 页面级 AI 入口与右侧抽屉
|
||||
3. 两者与现有 `Page Aggregate` / `mnote-cli` 主线的接线方式
|
||||
3. 两者与现有 `Page Aggregate` / Hermes 面板主线的接线方式
|
||||
|
||||
一句话收口:
|
||||
|
||||
@@ -45,7 +46,7 @@
|
||||
- 页面设置 UI 已存在:`PageOptionsSidebar`
|
||||
- 页面设置写链已存在:`page.layout.updateOptions`
|
||||
- 页面 AI host 已存在:`DocumentAiAgentPanel`
|
||||
- AI 执行面已收口到 `mnote-cli` host / client
|
||||
- 历史实现已具备 `mnote-cli` host / client 最小返回链;后续应替换为 Hermes 页面内客户端
|
||||
- 评论 drawer、历史 drawer、分享 dialog 都已有各自最小实现
|
||||
|
||||
但当前现网文档页仍然存在两个明显错位:
|
||||
@@ -141,14 +142,15 @@
|
||||
|
||||
> **把现有设置项放进更接近 Wolai 的容器,同时继续显式区分“已正式接通”和“仅保存字段/待接通”。**
|
||||
|
||||
### 4.2 页面 AI 继续服从 `CLI-first`
|
||||
### 4.2 页面 AI 继续服从 Hermes 面板主线
|
||||
|
||||
页面 AI 界面可以继续做产品壳,但它不能重新变成独立执行面。
|
||||
|
||||
当前长期方向已经明确:
|
||||
当前长期方向已经改为:
|
||||
|
||||
- `mnote-cli` 是唯一长期 agent 执行面
|
||||
- Web AI 面板只是 host / client
|
||||
- Hermes 是 AI session/message/tool event/usage/model 的会话真相
|
||||
- Web AI 面板只是页面内 Hermes client
|
||||
- mnote 通过 Hermes skill/plugin 暴露页面、树、artifact、edge 等业务工具
|
||||
|
||||
因此本轮 AI 界面改造只处理:
|
||||
|
||||
@@ -326,7 +328,7 @@ Wolai 里页面设置和 AI 的第一感受,首先是:
|
||||
|
||||
本轮不改:
|
||||
|
||||
- `mnote-cli` 执行链
|
||||
- 历史 `mnote-cli` 执行链
|
||||
- 工具注册表
|
||||
- 页面 AI 读写命令族
|
||||
- AI 输出协议
|
||||
@@ -510,11 +512,11 @@ Rust `mnote-web` 当前在文档壳中已有顶栏占位按钮与右下角浮动
|
||||
|
||||
### 9.3 页面 AI 重复造轮子的风险
|
||||
|
||||
如果为了像 Wolai 而新做一层 AI drawer runtime,会直接偏离 `CLI-first`。
|
||||
如果为了像 Wolai 而在 mnote 里新做一层独立 AI drawer runtime,会直接偏离当前 Hermes 面板主线。
|
||||
|
||||
本轮约束:
|
||||
|
||||
> **页面 AI 只换壳,不换执行面。**
|
||||
> **页面 AI 交互壳可继续沿用本轮成果,执行主线后续切到 Hermes client proxy + mnote plugin。**
|
||||
|
||||
### 9.4 范围失控风险
|
||||
|
||||
@@ -535,11 +537,11 @@ Rust `mnote-web` 当前在文档壳中已有顶栏占位按钮与右下角浮动
|
||||
- 页面设置入口、容器、关闭语义已和 Wolai 基线一致
|
||||
- 页面 AI 入口、容器、关闭语义已和 Wolai 基线一致
|
||||
- 页面设置仍继续走 `page.layout.updateOptions`
|
||||
- 页面 AI 仍继续走 `DocumentAiAgentPanel -> mnote-cli`
|
||||
- 页面 AI 交互壳保留右下角入口 + 右侧抽屉;后续执行主线切到 Hermes client proxy + mnote plugin
|
||||
- 没有新增第二套页面设置真相
|
||||
- 没有新增第二套页面 AI 执行面
|
||||
- 评论、协作、成员、演示模式没有被混入首批范围
|
||||
|
||||
本轮完成后的正确口径应是:
|
||||
|
||||
> **文档页“页面设置”和“AI 界面”的交互壳开始按 Wolai 收口,但页面域单一真源与 CLI-first AI 的主线保持不变。**
|
||||
> **文档页“页面设置”和“AI 界面”的交互壳开始按 Wolai 收口;页面域单一真源保持不变,AI 长期主线改为 Hermes 面板 + mnote plugin。**
|
||||
|
||||
+10
-8
@@ -5,6 +5,7 @@
|
||||
> 状态说明:
|
||||
> - 本清单覆盖的页面设置 A/B、页面 AI C、集成护栏 D 均已完成并有 `task160/161/162` 证据,故迁入 `done/`
|
||||
> - `X1-X6` 属于明确 deferred 项,不构成这轮完成阻塞
|
||||
> - 2026-05-13 追加说明:本文保留 `task161/162` 对旧 `/api/ai-agent/run -> mnote-cli` 返回链的历史验证证据;AI 长期方向已由 `design/07-ai/process/7-3-page-ai-hermes-panel-and-mnote-plugin-v1.md` 改为 Hermes 页面内客户端 + mnote Hermes skill/plugin
|
||||
>
|
||||
> 本清单服务于:
|
||||
> - `/mnt/Data1T/mnote/design/05-editor-mainline/done/5-10-wolai-page-settings-and-ai-surface-alignment-v1.md`
|
||||
@@ -231,9 +232,9 @@ GREEN:
|
||||
| C4 | GREEN | 顶栏 `页面AI` 降级 | `task161` 已断言顶栏不再保留 `页面AI` 主入口 |
|
||||
| C5 | GREEN | 页面 AI 容器类型 | `task161` 已断言页面 AI 以右侧抽屉打开、无遮罩、不跳新页面 |
|
||||
| C6 | GREEN | 页面 AI 关闭语义 | `task161` 已断言 `Esc`、点击空白无效,右上角 `X` 有效 |
|
||||
| C7 | GREEN | 页面 AI 页面级绑定 | `task161` 已断言 `/api/ai-agent/run` 请求体携带当前 `documentId` 与 `pageOptions` |
|
||||
| C8 | GREEN | 页面 AI 执行面不分裂 | 真实 `/api/ai-agent/run` 已改为 `Next 兼容优先 -> 本地 mnote-cli fallback -> 旧 orchestrator 兜底`;`task162` 已验证真实页面可返回文本结果 |
|
||||
| C9 | GREEN | AI chrome 与 Wolai 接近 | `task161` 已断言标题、输入区、`新会话 / 历史会话 / mnote-cli` chrome 可见 |
|
||||
| C7 | GREEN | 页面 AI 页面级绑定 | `task161` 已断言历史 `/api/ai-agent/run` 请求体携带当前 `documentId` 与 `pageOptions`;后续同类上下文应进入 Hermes run/session context |
|
||||
| C8 | GREEN | 页面 AI 历史返回链不分裂 | 真实 `/api/ai-agent/run` 曾改为 `Next 兼容优先 -> 本地 mnote-cli fallback -> 旧 orchestrator 兜底`;`task162` 已验证真实页面可返回文本结果;长期主线已改为 Hermes client proxy + mnote plugin |
|
||||
| C9 | GREEN | AI chrome 与 Wolai 接近 | `task161` 已断言标题、输入区、`新会话 / 历史会话 / mnote-cli` chrome 可见;后续文案应改为 Hermes session / model / history |
|
||||
|
||||
### 6.1 本阶段 smoke 建议
|
||||
|
||||
@@ -286,8 +287,8 @@ GREEN:
|
||||
- `Esc` 和点击空白不会关闭
|
||||
- 右上角关闭按钮可关闭
|
||||
- 抽屉标题、输入区、会话/模型 chrome 可见
|
||||
- 发送动作继续命中 `/api/ai-agent/run`
|
||||
- 请求体继续携带当前 `documentId` 与 `pageOptions`
|
||||
- 发送动作在历史实现中继续命中 `/api/ai-agent/run`
|
||||
- 历史请求体继续携带当前 `documentId` 与 `pageOptions`;后续应转为 Hermes run/session context
|
||||
|
||||
新增证据:
|
||||
|
||||
@@ -306,6 +307,7 @@ GREEN:
|
||||
- 同时补了认证头显式转发,避免被 Next 侧 307 `/auth` 拦截
|
||||
- 页面 AI drawer 默认 provider 改为 `hermes`
|
||||
- 新增 `task162-wolai-page-ai-real-response-smoke.js`,并在真实后端端口 `41327` 上验证:页面 AI 不再显示 `page_ai_failed_502` / “没有返回文本结果”,而是会回填真实文本
|
||||
- 2026-05-13 口径更新:上述修复只证明历史页面 AI 抽屉可返回文本,不再作为后续 AI 长期执行面依据;新的页面 AI 应直接调用 Hermes client proxy,不再通过 `/api/ai-agent/run` 的 provider / fallback 分支模拟 Hermes
|
||||
|
||||
### 6.3 task162 页面 AI 真实返回执行记录
|
||||
|
||||
@@ -325,7 +327,7 @@ GREEN:
|
||||
| ID | 状态 | 任务 | 验收要点 |
|
||||
| --- | --- | --- | --- |
|
||||
| D1 | GREEN | 不新造页面设置真相 | 页面设置继续围绕 `currentPageOptions -> /api/documents/options -> page.layout.updateOptions` |
|
||||
| D2 | GREEN | 不新造 AI 执行面 | 页面 AI 继续围绕 `/api/ai-agent/run -> mnote-cli` |
|
||||
| D2 | GREEN | 不新造 AI 执行面 | 历史实现围绕 `/api/ai-agent/run -> mnote-cli` 保持单链;后续不在 mnote 内新造 AI 执行面,而是调用 Hermes 并通过 mnote plugin 写回业务事实 |
|
||||
| D3 | GREEN | 文档页 owner 行为合同固定 | `task160/task161` 已断言 owner 态页面设置和 AI 都不跳新页面、不改 URL |
|
||||
| D4 | GREEN | mobile / 窄屏退化 | `task160/task161` 已断言移动视口下 popover / drawer 不超出视口 |
|
||||
| D5 | GREEN | React compat 与 Rust SSR 边界 | 本轮实现仅落在 `mnote-web` Rust SSR 壳、脚本与样式;未再把入口回接 React compat |
|
||||
@@ -355,9 +357,9 @@ GREEN:
|
||||
- 页面设置 `popover` 行为已通过本地 smoke 和 Wolai 基线复核
|
||||
- 页面 AI 抽屉行为已通过本地 smoke 和 Wolai 基线复核
|
||||
- 页面设置仍然围绕 `page.layout.updateOptions`
|
||||
- 页面 AI 仍然围绕 `/api/ai-agent/run -> mnote-cli`
|
||||
- 页面 AI 历史验证仍然围绕 `/api/ai-agent/run -> mnote-cli`;后续执行主线改为 Hermes client proxy + mnote plugin
|
||||
- deferred 项没有被误报为已完成
|
||||
|
||||
本轮完成后的正确口径:
|
||||
|
||||
> **页面设置与页面 AI 的交互壳已开始对齐 Wolai,但评论、协作、成员、演示模式仍是后续独立任务。**
|
||||
> **页面设置与页面 AI 的交互壳已开始对齐 Wolai;AI 后续执行主线改为 Hermes 面板 + mnote plugin,评论、协作、成员、演示模式仍是后续独立任务。**
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# 5-5-1 [done] Page Aggregate Contract v1
|
||||
|
||||
> 更新时间:2026-04-22
|
||||
> 更新时间:2026-05-14
|
||||
>
|
||||
> 关联文档:
|
||||
> - `/mnt/Data1T/mnote/design/05-editor-mainline/process/5-5-page-aggregate-single-truth-alignment-v1.md`
|
||||
@@ -92,13 +92,13 @@
|
||||
说明:
|
||||
|
||||
- 这层不是“页面设置面板 UI state”,而是页面设置的持久化真相。
|
||||
- 其中只有一部分已经进入 `leptos-tiptap` island 运行时:
|
||||
- 以下字段已经进入 `leptos-tiptap` island runtime payload:
|
||||
- `wideLayout`
|
||||
- `smallText`
|
||||
- `layoutDensity`
|
||||
- 下面两项当前只完成字段贯通,不可描述成“正式支持”:
|
||||
- `showHeadingNumbers`
|
||||
- `embedDefaultBlockId`
|
||||
- 其中 `showHeadingNumbers` / `embedDefaultBlockId` 已进入 runtime payload,但深层语义仍是阶段性桥接:前者还不能描述成完整 heading 编号渲染闭环,后者还不能描述成完整嵌入默认落点闭环。
|
||||
|
||||
### 2.4 `page_body`
|
||||
|
||||
@@ -152,6 +152,24 @@
|
||||
|
||||
- 统计不是页面身份,也不是布局或正文真相,但它是 page aggregate 的附属部分。
|
||||
|
||||
### 2.7 `source` / provenance
|
||||
|
||||
作用:
|
||||
|
||||
> **标识这份 `PageAggregateProjection` 的真实构建来源。**
|
||||
|
||||
当前允许值:
|
||||
|
||||
- `KernelProjection`:底层已经由 Rust kernel 原生 page aggregate projection 产出。
|
||||
- `CompatMetaContentJoin`:对外已经是 Rust `mnote.page_aggregate.v1` 契约,但底层仍由 Rust runtime adapter 消费 `documents:getMeta + documents:getContent` substrate 后聚合。
|
||||
- `Fixture`:仅允许在测试或显式 dev fixture 场景出现。
|
||||
|
||||
说明:
|
||||
|
||||
- 当前 `/api/page-aggregate/:id` 主读链属于 `CompatMetaContentJoin`,不是完整 `KernelProjection`。
|
||||
- `source_label()` / `x-mnote-page-aggregate-owner` 必须与实际来源一致:`KernelProjection -> rust-kernel`,`CompatMetaContentJoin -> compat-join`,`Fixture -> fixture`。
|
||||
- 前端 loader 只消费 `mnote.page_aggregate.v1` 契约,不应因为来源是 `CompatMetaContentJoin` 而恢复 TS runtime builder 主链。
|
||||
|
||||
## 3. 哪些字段是 aggregate truth,哪些只是 UI state
|
||||
|
||||
### 3.1 Aggregate Truth
|
||||
@@ -194,7 +212,7 @@
|
||||
- 标题输入中的 debounce 缓冲态
|
||||
- 正文 host 内的未保存 dirty 态
|
||||
- 只影响单次交互的面板/菜单开关
|
||||
- `showHeadingNumbers/embedDefaultBlockId` 的“字段已贯通,但深语义未完成”阶段性桥接逻辑
|
||||
- `showHeadingNumbers/embedDefaultBlockId` 的“runtime payload 已贯通,但深语义未完成”阶段性桥接逻辑
|
||||
|
||||
约束:
|
||||
|
||||
@@ -222,11 +240,11 @@
|
||||
|
||||
- 树标题与页头标题已经证明消费同一份更新后的 projection
|
||||
- AI 写入口已经完全脱离 editor bridge,正式执行 page body command
|
||||
- `showHeadingNumbers` 真正进入 heading 渲染语义
|
||||
- `embedDefaultBlockId` 真正进入嵌入默认落点逻辑
|
||||
- `showHeadingNumbers` 完整进入 heading 编号渲染语义
|
||||
- `embedDefaultBlockId` 完整进入嵌入默认落点逻辑
|
||||
|
||||
## 7. 当前可对外口径
|
||||
|
||||
当前可以准确描述为:
|
||||
|
||||
> **文档页已开始消费统一 `Page Aggregate`,标题/页面设置/正文保存也开始按 `page_head / page_layout / page_body` 收口;但树域投影统一与 AI 正式 page body 写入口仍未完全完成。**
|
||||
> **文档页已开始消费统一 `Page Aggregate`,标题/页面设置/正文保存也开始按 `page_head / page_layout / page_body` 收口;当前 Rust route 主读链已成立,但 `/api/page-aggregate/:id` 底层仍是 `CompatMetaContentJoin`,树域投影统一与 AI 正式 page body 写入口也仍未完全完成。**
|
||||
|
||||
@@ -115,6 +115,8 @@
|
||||
- 页面本地 aggregate state reducer
|
||||
- preferred sidebar snapshot / 页头标题补偿链
|
||||
|
||||
需要特别区分“Rust route 主读链”和“完整 kernel-native projection”:当前 `/api/page-aggregate/:id` 已经由 Rust route / Rust runtime adapter 对外提供稳定 `mnote.page_aggregate.v1` 契约,前端不再恢复 TS runtime builder 主链;但底层 substrate 仍主要来自 `documents:getMeta + documents:getContent` 的兼容聚合。因此当前 projection provenance 应标记为 `CompatMetaContentJoin`,不能写成已经完全由 Rust kernel 原生投影闭环的 `KernelProjection`。
|
||||
|
||||
这意味着当前已经不再是:
|
||||
|
||||
- `page.tsx` 手工拉 `meta + content` 再现场拼 props
|
||||
|
||||
@@ -81,6 +81,8 @@
|
||||
|
||||
补充:当前 `page-aggregate-loader.ts`、文档页 SSR 入口与 `DocumentContent` 的内容重试补拉都已经直接消费 Rust `/api/page-aggregate/:id` 返回的 `PageAggregateProjection`,不再由 `page.tsx` 手工拼 `meta + content`。Next `/api/documents/page` 已降级为显式 `410` 的 compat 边界;`page-aggregate-loader.ts` 现在只校验并消费这条 Rust 正式读链,不再在 runtime 中回退到 TS builder。与此同时,`storage-convex-bridge` 与 `bridge-runtime` 已开始接受 `page.head.updateTitle / page.layout.updateOptions / page.body.save` 这组 page command family 的命名口径,因此这里按“读取契约已进入 Rust-first,写入命令面已开始收口”勾选完成。
|
||||
|
||||
补充:这里的“Rust-first”只表示 `/api/page-aggregate/:id` 的对外 route、契约校验和前端消费主链已经收口到 Rust,不表示底层已经完全 kernel-native。2026-05-13 复核后,当前 route 仍由 Rust runtime adapter 消费 `documents:getMeta + documents:getContent` substrate 构建聚合快照,因此 response `source` / `x-mnote-page-aggregate-owner` 应反映为 `CompatMetaContentJoin` / `compat-join`。只有底层真实改为 kernel 原生 page aggregate projection 后,才应标记为 `KernelProjection` / `rust-kernel`。
|
||||
|
||||
### 4.3 退出标准
|
||||
|
||||
- [x] 后续再讨论标题、页面设置、正文保存时,能够直接定位到它属于 `page_head / page_layout / page_body / page_tree` 的哪一层。
|
||||
@@ -113,6 +115,8 @@
|
||||
|
||||
补充:当前首屏 SSR 与客户端内容重试补拉都已经直接走 `/api/page-aggregate/:id -> PageAggregateProjection`,因此“页面明显由 `meta + content` 两次查询拼起来”的入口级痕迹已经消失;但新增字段仍可能需要继续补 loader / route / island 消费链,所以第二项继续保留未完成。
|
||||
|
||||
补充:入口级痕迹消失不等于来源语义消失。当前前端 loader 仍只接受 `mnote.page_aggregate.v1`,不会恢复 TS builder 主链;但 Rust response 的 provenance 必须继续暴露真实构建来源,避免把兼容 join 误验收成 kernel-native projection。
|
||||
|
||||
补充:本轮已新增 `page-aggregate-client-state.ts`,并让 `DocumentContent` 把原先分散维护的 `title / options / content / serverContentSnapshot / serverPageSubtreeSnapshot / contentRevision / conflictDetectionKey` 继续收口为同一份 client aggregate state reducer;标题的 draft / committed / persisted 语义也已并入这份 reducer,不再额外维护独立 `usePageHeadTitle` hook 或 `serverPageSubtreeTitle` 双轨状态。这里代表“页面本地 `head/body/layout/tree` 真相已经开始统一”,不等于页面设置运行时分类、AI 对页面设置面的正式入口都已闭环,因此本阶段不提前宣称聚合完成。对应最小回归测试为 `page-aggregate-client-state.test.ts`、`document-content.test.ts`。
|
||||
|
||||
---
|
||||
@@ -160,7 +164,7 @@
|
||||
- [x] 对未支持项给出显式降级说明,而不是仅保存字段。
|
||||
- [x] 让“页面设置有值但编辑器内部没变化”这类状态在产品上消失。
|
||||
|
||||
补充:本轮已把 AI 页面设置结构化结果接入 `DocumentContent` 现有的 `patch_page_options + page.layout.updateOptions` 正式链路,页面设置不再只能靠人类点击 inspector 才能进入同一条页面命令面。同时,AI 正式写回白名单已限制为 `page-option-semantics.ts` 中 `runtimeSupport === "wired"` 的字段,`protectEditing / showStructure / showBlockRefCount` 继续排除在正式写回外,避免把 planned / ui_only 选项误描述为稳定能力。`mnote-cli host` 侧也已新增最小服务端分支:命中页面设置 patch 时,直接执行 `page.layout.updateOptions` 并产出结构化 `tool_result(action=update_page_options)`,不再只把整段结果退化成 assistant 文本。对应最小回归测试为 `DocumentAiAgentPanel.runtime.test.tsx`、`mnote-cli-agent-host.test.ts`。
|
||||
补充:本轮已把 AI 页面设置结构化结果接入 `DocumentContent` 现有的 `patch_page_options + page.layout.updateOptions` 正式链路,页面设置不再只能靠人类点击 inspector 才能进入同一条页面命令面。同时,AI 正式写回白名单已限制为 `page-option-semantics.ts` 中 `runtimeSupport === "wired"` 的字段,`protectEditing / showStructure / showBlockRefCount` 继续排除在正式写回外,避免把 planned / ui_only 选项误描述为稳定能力。历史 `mnote-cli host` 侧也已新增最小服务端分支:命中页面设置 patch 时,直接执行 `page.layout.updateOptions` 并产出结构化 `tool_result(action=update_page_options)`,不再只把整段结果退化成 assistant 文本。2026-05-13 起后续主线改为 Hermes tool call -> mnote skill/plugin -> `page.layout.updateOptions`,该 host 分支只保留为过渡证据。对应最小回归测试为 `DocumentAiAgentPanel.runtime.test.tsx`、`mnote-cli-agent-host.test.ts`。
|
||||
补充:本轮继续把 Inspector 产品态与这份分类对齐:`showHeadingNumbers / embedDefaultBlockId` 已按“正式接通项”更新文案,不再继续显示“已保存字段但未接通”;`showBlockRefCount` 作为纯占位项也不再继续暴露可点击的假开关,而是明确显示 `待接线`。对应最小回归测试为 `page-options-sidebar.test.tsx`、`page-option-semantics.test.ts`。
|
||||
|
||||
### 6.4 退出标准
|
||||
@@ -230,7 +234,7 @@
|
||||
|
||||
补充:本轮继续把 AI 面板的读取边界开始收口到统一页面本地快照:`DocumentContent` 不再向 `DocumentAiAgentPanel` 透传 `getLatestBlocks / getLatestPageSubtree / getLatestPersistedMeta` 三组分散 getter,而是改为一份 `getLatestPageAggregateSnapshot`,由 `page-aggregate-client-state` 导出 `blocks / pageSubtree / persistedMeta / pageOptions`。这代表 AI 面板已开始消费同一份页面本地 aggregate state,而不是继续拼接独立局部真相;但页面设置写工具本身仍未进入 Hermes 正式 tool surface,因此这里仍然只算“开始收口”,不提前打满。
|
||||
|
||||
补充:本轮还把 `pageOptions` 与 `editorRuntimePageOptions` 一并带入 `/api/ai-agent/run -> buildHermesInstructions`,因此 AI 在服务端至少能看到“当前页面设置是什么”以及“哪些设置已经进入 island runtime payload”,不再只依赖正文块快照和树快照来猜测页面语义。对应回归测试为 `src/app/api/ai-agent/run/route.test.ts`。这推进的是 AI 读侧上下文,不等于页面设置写工具已经具备正式入口。
|
||||
补充:本轮还把 `pageOptions` 与 `editorRuntimePageOptions` 一并带入历史 `/api/ai-agent/run -> buildHermesInstructions`,因此 AI 在服务端至少能看到“当前页面设置是什么”以及“哪些设置已经进入 island runtime payload”,不再只依赖正文块快照和树快照来猜测页面语义。对应回归测试为 `src/app/api/ai-agent/run/route.test.ts`。2026-05-13 起同类上下文后续应改为 Hermes run/session context,并由 Hermes 通过 mnote plugin 回读最新 page aggregate;这推进的是 AI 读侧上下文,不等于页面设置写工具已经具备正式入口。
|
||||
|
||||
### 8.2 与主编辑区的关系
|
||||
|
||||
@@ -240,27 +244,27 @@
|
||||
|
||||
补充:进入这一步后,当前最大的真实 blocker 已经明确下来,不再继续靠口头描述模糊处理:
|
||||
|
||||
- `/api/ai-agent/run` 当前默认主路仍是 `mnote-cli host`
|
||||
- `mnote-cli host` 目前没有真正的模型/tool 执行环,而是 `cargo ... tool run --tool-name doc_get --mode explain-plan` 的说明型入口
|
||||
- 因此,当前新增的 `page_options_patch -> page.layout.updateOptions -> structured tool_result` 仍然只是“主路内的最小结构化兼容分支”,不是完整正式 tool surface
|
||||
- 页面 AI 历史实现仍残留 `/api/ai-agent/run` / `mnote-cli host` 路径
|
||||
- Hermes 页面内客户端、Hermes session 真相和 mnote skill/plugin tool surface 还没有在页面 AI 主链落地
|
||||
- 因此,当前新增的 `page_options_patch -> page.layout.updateOptions -> structured tool_result` 仍然只是“历史主路内的最小结构化兼容分支”,不是完整正式 Hermes tool surface
|
||||
|
||||
这意味着 `8.2` 后续完成标准必须至少包含:
|
||||
|
||||
1. 页面设置进入真正的 agent/tool 执行环,而不是继续靠 host 内部的自然语言/最小规则分支识别
|
||||
2. 页面设置结构化结果由正式 tool 调用产出,而不是只由 host 自己补一条兼容 `tool_result`
|
||||
1. 页面设置进入 Hermes tool call -> mnote plugin -> Rust runtime 的正式执行环,而不是继续靠 host 内部的自然语言/最小规则分支识别
|
||||
2. 页面设置结构化结果由正式 mnote plugin tool 调用产出,而不是只由 host 自己补一条兼容 `tool_result`
|
||||
3. 树标题 / 页头 / 页面设置三条 AI 写回链在同一条正式 page aggregate command family 中闭环,并补 smoke 验证
|
||||
|
||||
### 8.3 退出标准
|
||||
|
||||
- [x] AI 写入口已经可以被明确描述为“操作 page aggregate command family”,而不是“绕过系统写编辑器”。
|
||||
|
||||
注:当前 `/api/ai-agent/run` 在 `Hermes tool.completed` 后,会优先尝试把 `slash_run / doc_insert_blocks / doc_replace_range` 恢复成 `mnote-web bridge-runtime` 的结构化 `tool_result`,不再只把 Hermes 事件当作薄日志。其后:
|
||||
注:历史 `/api/ai-agent/run` 在 `Hermes tool.completed` 后,会优先尝试把 `slash_run / doc_insert_blocks / doc_replace_range` 恢复成 `mnote-web bridge-runtime` 的结构化 `tool_result`,不再只把 Hermes 事件当作薄日志。2026-05-13 起这只保留为过渡证据;新的主线应由 Hermes 直接发起 mnote plugin tool call,再由 Rust runtime / kernel 返回 tool result。其后:
|
||||
|
||||
- `doc_insert_blocks / doc_replace_range` 继续按 `page.body.save` 语义落到 `/api/documents/save`,再正式回显主编辑区 island。
|
||||
- `slash_run(rename current page)` 会把结构化结果回接到当前页 `DocumentContent` 的同一条标题提交链,并继续广播 `emitDocumentsChanged(documentId)`,因此页头标题与树标题不再靠 AI 面板内部本地状态各自漂移。
|
||||
- 当前 AI 面板已经能消费结构化 `update_page_options` 结果,并把 `pageOptionsPatch` 回接到当前页 `DocumentContent` 的同一条 `patch_page_options + page.layout.updateOptions` 提交链;同时只允许 `runtimeSupport === "wired"` 的字段进入正式写回,避免 planned / ui_only 页面设置混入主链。对应最小回归测试为 `DocumentAiAgentPanel.runtime.test.tsx`、`document-content.test.ts`。
|
||||
- `mnote-cli host` 现已能在命中页面设置 patch 时直接执行 `page.layout.updateOptions`,并向前端回放结构化 `tool_call/tool_result` 事件;这意味着“服务端完全没有页面设置结构化写回结果”的状态已经结束。
|
||||
- 但 `pageOptions` 仍没有进入 Hermes 正式 tool surface,当前服务端 patch 识别也仍是最小规则分支而不是完整模型工具编排,因此“页面设置类 AI 命令”仍不能算整条线已闭环;`8.2` 的最后一项继续保留未完成,避免误判为整条线已经闭环。
|
||||
- 历史 `mnote-cli host` 现已能在命中页面设置 patch 时直接执行 `page.layout.updateOptions`,并向前端回放结构化 `tool_call/tool_result` 事件;这意味着“服务端完全没有页面设置结构化写回结果”的状态已经结束。
|
||||
- 但 `pageOptions` 仍没有进入 mnote Hermes plugin 的正式 tool surface,当前服务端 patch 识别也仍是最小规则分支而不是完整 Hermes tool 编排,因此“页面设置类 AI 命令”仍不能算整条线已闭环;`8.2` 的最后一项继续保留未完成,避免误判为整条线已经闭环。
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -391,8 +391,8 @@ GREEN:
|
||||
- E26 执行前口径纠偏:2026-05-02 已按 `5-2`、`5-4`、`5-5`、`5-6` 和 `5-7` 设计确认,E26 不是“前端引入 Tiptap UniqueID 即完成”。正式块身份必须来自 Rust `EditorBlock.block_id`,经 Tiptap bridge 映射为 `data-block-id` 供浏览器 runtime 使用,再通过 `page.body.save` 写回 Convex-backed 持久化底座;复制锚点和 hash 定位只能消费这个真源 id。Tiptap `UniqueID` 可以参考其节点身份策略,但不得生成长期块 id、不得绕过 Rust `EditorBlockDocument`、不得绕过 Page Aggregate / `page.body.save`。
|
||||
- E26 执行边界:实现与 smoke 必须把 `EditorBlockDocument -> Tiptap attrs.blockId/data-block-id -> DOM 锚点 -> page.body.save -> /api/documents/content -> reload/hash 定位` 串成一条链。允许在浏览器 runtime 内用 `UniqueID` 或等价插件帮助定位节点,但只能读取/补齐已有 Rust block id;若某块缺少正式 id,应通过 Rust/保存适配链生成并持久化,而不是让前端临时 id 成为长期合同。Convex 只验证持久化结果可读可刷新,不作为语义真源。
|
||||
- E26 最小真源闭环:2026-05-02 已完成第一段 anchor 切片。`leptos-tiptap` paragraph/heading/blockquote/codeBlock/image/table 的 `blockId` 渲染同时输出 `data-block-id` 与 DOM `id`,浏览器 hash 命中走 `id=:blockId` + CSS `:target`,不是手工给 ProseMirror DOM 写临时 class;Rust runtime 复制链接和 hash 滚动继续只消费 `EditorBlock.block_id` 派生的 `attrs.blockId`。`mnote-web` 保存 payload 不再发送空 `content: []`,而是派生 `editorDocument`、legacy `content`、`tiptapDocument` 和 `blockCount` 写回 Convex-backed 持久化底座;reload 侧 legacy block 恢复继续补 `attrs.blockId`。本地 smoke `node scripts/task154-e26-anchor-smoke.js` 已覆盖保存请求、`/api/documents/content`、复制链接、DOM `id/:target` 和 reload/hash 定位,截图目录:`/mnt/Data1T/mnote/tmp/wolai-editor-parity/task154-e26-anchor-local-smoke`。剩余:列表项/分割线等更多块类型的统一 anchor attr 覆盖、Wolai 视觉细节、块引用/页面引用预览与移动端行为另拆后续。
|
||||
- E27 执行前口径纠偏:AI 编辑能力包不是“补 AI 菜单文案”或“点击后显示 feedback”。最小闭环必须从 `leptos-tiptap` 的 slash / 块菜单入口出发,携带当前 `documentId`、`workspaceId`、Rust `blockId`、selection 摘要和 Tiptap JSON 快照,调用 mnote 现有 AI 真源边界。2026-05-05 起长期口径进一步修正:`/api/ai-agent/run` 不应长期绑定 `openai-agents-python` sidecar,而应收口为 `mnote-cli` 唯一长期 agent 执行面上的 host / adapter;`openai-agents-python`、`Hermes`、`Codex` 只允许作为可插拔外置 agent。结构化写入仍必须回到 Rust tool / `page.body.save` / Convex-backed content 链。E27 第一刀已完成的 sidecar 路径只作为过渡基线,不宣称是长期主线。
|
||||
- E27 AI agent 入口与写入闭环:2026-05-02 已完成主路径纠偏,修正此前把 `/api/hermes/bridge` 当作 E27 主入口的误导口径。块菜单 `AI 助理` 现在从当前 `leptos-tiptap` editor 读取 `documentId`、`workspaceId`、Rust `blockId`、selection state、selected text 与 `tiptapDocument` 快照,发起 `/api/ai-agent/run` 请求,payload 固定 `scope=document`、`stream=true`、`options.ai.provider=online`,并显示 `mnote-leptos-tiptap-ai-status` 的 `idle/pending/ready/error` 状态;请求上下文标记 `source=leptos-tiptap-island`、`action=ask_ai`,Next sidecar adapter 已透传 `workspaceId / selectedBlockId / selection / tiptapDocument` 等 block 级上下文给 `openai-agents-python`。本地 smoke `node scripts/task155-e27-ai-edit-smoke.js` 已先 RED 于 Hermes 主入口,再 GREEN 覆盖请求 URL、payload 真源字段和 ready 状态;`node scripts/task156-e27-ai-writeback-smoke.js` 已先 RED 于只返回不写入,再 GREEN 覆盖 SSE `doc_replace_range` tool_result -> 编辑器改写 -> `/api/documents/save` -> `/api/documents/content` 真源读回 -> reload 后页面读回。截图目录:`/mnt/Data1T/mnote/tmp/wolai-editor-parity/task156-e27-ai-writeback-local-smoke`。剩余:Wolai AI 菜单视觉基线断流未完成、slash AI/selection toolbar AI、Improve/continue/regenerate/summarize/translate 子命令、SSE 分段 UI 状态、`doc_insert_blocks` 多块插入和标题 `slash_run` 应用另拆后续。
|
||||
- E27 执行前口径纠偏:AI 编辑能力包不是“补 AI 菜单文案”或“点击后显示 feedback”。最小闭环必须从 `leptos-tiptap` 的 slash / 块菜单入口出发,携带当前 `documentId`、`workspaceId`、Rust `blockId`、selection 摘要和 Tiptap JSON 快照,进入 Hermes run/session context;Hermes 通过 mnote skill/plugin 调用稳定工具,再由 Rust runtime / `page.body.save` / Convex-backed content 链写回。2026-05-13 起长期口径进一步修正:`/api/ai-agent/run`、`openai-agents-python` sidecar 和 `mnote-cli host` 都只作为历史/兼容路径,不再代表页面 AI 长期主入口;`mnote-cli` 只可作为 mnote plugin 内部适配器或调试入口。E27 第一刀已完成的 sidecar / `/api/ai-agent/run` 路径只作为过渡基线,不宣称是长期主线。
|
||||
- E27 AI agent 入口与写入闭环:2026-05-02 已完成历史主路径纠偏,修正此前把 `/api/hermes/bridge` 当作 E27 主入口的误导口径。块菜单 `AI 助理` 当时从当前 `leptos-tiptap` editor 读取 `documentId`、`workspaceId`、Rust `blockId`、selection state、selected text 与 `tiptapDocument` 快照,发起 `/api/ai-agent/run` 请求,payload 固定 `scope=document`、`stream=true`、`options.ai.provider=online`,并显示 `mnote-leptos-tiptap-ai-status` 的 `idle/pending/ready/error` 状态;请求上下文标记 `source=leptos-tiptap-island`、`action=ask_ai`,Next sidecar adapter 已透传 `workspaceId / selectedBlockId / selection / tiptapDocument` 等 block 级上下文给 `openai-agents-python`。本地 smoke `node scripts/task155-e27-ai-edit-smoke.js` 已先 RED 于 Hermes 主入口,再 GREEN 覆盖请求 URL、payload 真源字段和 ready 状态;`node scripts/task156-e27-ai-writeback-smoke.js` 已先 RED 于只返回不写入,再 GREEN 覆盖 SSE `doc_replace_range` tool_result -> 编辑器改写 -> `/api/documents/save` -> `/api/documents/content` 真源读回 -> reload 后页面读回。截图目录:`/mnt/Data1T/mnote/tmp/wolai-editor-parity/task156-e27-ai-writeback-local-smoke`。剩余:把入口改为 Hermes client proxy,把上下文作为 Hermes run/session context 传入,把写入工具注册为 mnote Hermes plugin,Wolai AI 菜单视觉基线、slash AI/selection toolbar AI、Improve/continue/regenerate/summarize/translate 子命令、Hermes streaming UI 状态、`doc_insert_blocks` 多块插入和标题写入另拆后续。
|
||||
- E27 Auth 真源纠偏:2026-05-02 补充确认,AI 编辑和在线 smoke 的用户身份必须以真实 Convex Auth 为主线;不同账号的数据隔离依赖 Convex `getAuthUserId(ctx)` 解析真实 identity subject,并由 workspace/member 权限链校验。`/auth` 复用既有 Convex Auth 页面和统一测试账号 `test@example.com` / `Test123456`;`/api/auth/session`、`/api/auth/whoami`、AI orchestrator 转发和 Convex transport 均应真实 token / forwarded actor 优先。`DEV_USER_ID` / admin acting identity 只允许作为本地底层 fallback 或显式 `MNOTE_DEV_AUTH=1` 联调模式,不能作为 E27 在线验收、多账号隔离或正式数据真源。
|
||||
- E27 当前推进备注:2026-05-03 主线程先切到 E28 Mention / Emoji,E27 暂停在 online smoke 模型网关层排障状态。当前已确认 `3000 -> 8000` orchestrator 主链可达,本地 `task155/task156` 通过;剩余在线阻塞点在 `20128 /v1/responses` 上游模型/渠道可用性,以及后续真实 actor 收口。恢复 E27 时应从这两点继续,不要回退成“主入口未迁完”。
|
||||
- E28/E29 暂停与 E30 先行口径:2026-05-03 主线程先暂停 E28 Mention / Emoji 与 E29 Comment / History,转入 E30 Menu / Floating 状态机能力包。E28 已有 Wolai Hermes baseline 显示正文输入 `@` 当前弹出提醒/会议/成员候选,并可插入成员 mention,不是页面引用搜索;证据目录为 `/mnt/Data1T/mnote/tmp/wolai-editor-parity/task157-e28-wolai-mention-baseline/`,恢复 E28 时需先重新定 `@mention` 口径,不要按“页面引用第一刀”继续。E29 在评论/历史后端边界未重新确认前保持暂停。E30 第一刀只收敛已有 slash、块菜单、二级菜单、selection/image/table floating toolbar 的打开互斥、Esc、外部点击、方向键、Enter 与层级收起,不扩新业务命令。
|
||||
@@ -418,7 +418,7 @@ GREEN:
|
||||
| E24 | PARTIAL | Image / Media 能力包 | 已完成图片 `/tp` 最小真源闭环与图片 toolbar 小闭环:slash `媒体与附件` 分组入口调用 `leptos-tiptap` 官方 `set_image(TiptapImageResource)`,启用 `TiptapExtension::Image`,保存链新增 `EditorBlockType::Image` / `TiptapNode::Image` 并保留 `props.tiptapImage`,`mnote-web` reload 恢复 `<img>`;点击图片后出现 `image-floating-toolbar`,删除入口本切片保持禁用,左/中/右对齐通过官方 Image 扩展 `addAttributes("data-align")` + `updateAttributes("image")` 持久化,同源下载通过隐藏 `<a download>` 触发且不改文档;`task152-e24-image-smoke.js` 覆盖入口、实际图片加载、toolbar、同源下载、删除入口禁用态、居中对齐、保存请求、content API 和刷新恢复;剩余上传、最近上传、caption、resize、replace、跨源下载 fallback、删除保存链、移动端 toolbar、失败态 |
|
||||
| E25 | PARTIAL | TOC 能力包 | 已完成 `/toc` 最小真源闭环:`leptos-tiptap` 新增真实 `tocNode` schema/command,slash `页面目录 /toc` 插入真实节点,NodeView 从当前 headings 派生目录项,支持点击定位、标题显示开关、保存请求、content API 与 reload 恢复;`task153-e25-toc-smoke.js` 覆盖入口、动态 heading 更新、hash 定位和 `props.tiptapTocNode`。剩余 TOC sidebar、active heading 高亮、完整块菜单入口、官方 TableOfContents v3 数据源评估、移动端与像素级视觉 |
|
||||
| E26 | PARTIAL | UniqueID / Anchor 能力包 | 已完成最小真源闭环:Rust `EditorBlock.block_id` -> Tiptap `attrs.blockId` -> DOM `data-block-id` + `id` -> 复制链接 hash -> `page.body.save` / `/api/documents/content` -> reload 后 `:target` 定位;`task154-e26-anchor-smoke.js` 覆盖保存、复制、reload/hash 和截图。Tiptap `UniqueID` 仍只作为参考/辅助口径,不作为正式块 id;剩余更多块类型 anchor 覆盖、Wolai 视觉细节、引用预览和移动端行为。 |
|
||||
| E27 | PARTIAL | AI 编辑能力包 | 已完成块菜单 `AI 助理` 主路径与最小写入闭环:点击后发起 `/api/ai-agent/run`,payload 使用 `scope=document`、`stream=true`、`options.ai.provider=online`,携带 `documentId`、`workspaceId`、Rust `blockId`、selection、selected text、Tiptap 快照和 `action=ask_ai`;历史基线里 `Hermes` 只作为 `/api/ai-agent/run` 内部 fallback。2026-05-05 起长期口径改为:`/api/ai-agent/run` 后续应收口为 `mnote-cli` 唯一长期 agent 执行面上的 host / adapter,`Hermes`、`Codex`、`openai-agents-python` 仅为可插拔外置 agent;`task155-e27-ai-edit-smoke.js` 与 `task156-e27-ai-writeback-smoke.js` 保留为过渡主链行为基线。Auth 验收必须复用 Convex Auth 测试账号,真实 token / actor 优先,`dev identity` 仅为本地 fallback。当前主线程已于 2026-05-03 先切 E28;E27 暂停在 online smoke 的 `20128 /v1/responses` 模型网关排障与真实 actor 收口。剩余 slash AI/selection toolbar AI、Improve/continue/regenerate/summarize/translate、Wolai 视觉基线、SSE 分段 UI、`doc_insert_blocks` 多块插入与标题写入。 |
|
||||
| E27 | PARTIAL | AI 编辑能力包 | 已完成历史块菜单 `AI 助理` 主路径与最小写入闭环:点击后发起 `/api/ai-agent/run`,payload 使用 `scope=document`、`stream=true`、`options.ai.provider=online`,携带 `documentId`、`workspaceId`、Rust `blockId`、selection、selected text、Tiptap 快照和 `action=ask_ai`;`task155-e27-ai-edit-smoke.js` 与 `task156-e27-ai-writeback-smoke.js` 保留为过渡行为基线。2026-05-13 起长期口径改为:页面 AI 入口应调用 Hermes client proxy,Hermes session/message/tool event/usage/model 是会话真相,mnote 通过 Hermes skill/plugin 执行 `page.body.save`、标题、页面设置、artifact、edge 等业务工具;`mnote-cli` 仅可作为 plugin 内部适配器。Auth 验收必须复用 Convex Auth 测试账号,真实 token / actor 优先,`dev identity` 仅为本地 fallback。当前主线程已于 2026-05-03 先切 E28;E27 暂停在 online smoke 的 `20128 /v1/responses` 模型网关排障与真实 actor 收口。剩余 Hermes client proxy 接入、mnote plugin tool 注册、slash AI/selection toolbar AI、Improve/continue/regenerate/summarize/translate、Wolai 视觉基线、Hermes streaming UI、`doc_insert_blocks` 多块插入与标题写入。 |
|
||||
| E28 | PAUSED | Mention / Emoji 能力包 | 2026-05-03 暂停;Wolai baseline 已纠偏:正文 `@` 当前是提醒/会议/成员候选入口,不是页面引用搜索,恢复时先重定 mention/emoji 口径,再决定 Tiptap JSON 与 mnote 保存链 |
|
||||
| E29 | PAUSED | Comment / History 能力包 | 2026-05-03 暂停;评论/历史入口仍需后端边界和 Wolai 基线复核,待 E30 统一菜单状态机后再接入,避免继续复制独立弹层逻辑 |
|
||||
| E30 | PARTIAL | Menu / Floating 状态机能力包 | 2026-05-03 第一刀已完成本地统一状态机 smoke:`task158` 覆盖 slash、块菜单/二级菜单、selection toolbar/color panel、image floating toolbar、table toolbar/options 的互斥打开、Esc 统一关闭、方向键 active,以及打开块菜单时关闭 image toolbar;Wolai 只读基线已确认 selection/type menu 和 table popper 的 Esc/外部点击/URL 不变,slash 与块菜单二级菜单在只读条件下不稳定,后续仍需 editable-test 复核 Enter、外部点击和更多层级收起 |
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
# 6 [process] Mindmap Phase 6 KMind/simple-mind-map Parity Detail Checklist v1
|
||||
# 6 [done] Mindmap Phase 6 KMind/simple-mind-map Parity Detail Checklist v1
|
||||
|
||||
> 日期:2026-05-11
|
||||
>
|
||||
@@ -10,7 +10,8 @@
|
||||
> 状态说明:
|
||||
> - 本稿对应 `Phase 7 v2` 的“文档页 AI 最小闭环”已完成,故迁入 `done/`
|
||||
> - 本稿完成不等于整个 `Phase 7 v2` 已完成;结构化知识写链仍以后续阶段继续推进
|
||||
> - 2026-05-05 追加说明:本稿记录的是 `openai-agents-python` sidecar 作为过渡主链的完成状态,不代表当前长期方向;长期口径已由 `/mnt/Data1T/mnote/design/07-ai/process/7-phase7-ai-kernel-projection-plan-v4.md` 收口为 `mnote-cli` 是唯一长期 agent 执行面
|
||||
> - 2026-05-05 追加说明:本稿记录的是 `openai-agents-python` sidecar 作为过渡主链的完成状态,不代表当前长期方向;当时长期口径曾由 `/mnt/Data1T/mnote/design/old/07-ai/process/7-phase7-ai-kernel-projection-plan-v4.md` 收口为 `mnote-cli` 是唯一长期 agent 执行面
|
||||
> - 2026-05-13 追加说明:`mnote-cli` 唯一长期 agent 执行面口径已被 `/mnt/Data1T/mnote/design/07-ai/process/7-3-page-ai-hermes-panel-and-mnote-plugin-v1.md` 覆盖;新的长期方向是页面 AI 面板仅作为 Hermes 页面内客户端,Hermes 持有 AI 会话真相,mnote 通过 Hermes skill/plugin 暴露业务工具
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -12,8 +12,14 @@
|
||||
>
|
||||
> 2026-05-05 追加说明:
|
||||
> - 本稿的对象模型、artifact 写链与 kernel 边界仍然有效
|
||||
> - 但触发与执行口径已被 `/mnt/Data1T/mnote/design/07-ai/process/7-phase7-ai-kernel-projection-plan-v4.md` 覆盖
|
||||
> - 当前凡是提到“页面 AI 面板触发”的位置,都应理解为“页面 AI 面板作为 `mnote-cli` host 触发”,而不是独立内置编排主线
|
||||
> - 当时触发与执行口径曾被 `/mnt/Data1T/mnote/design/old/07-ai/process/7-phase7-ai-kernel-projection-plan-v4.md` 覆盖
|
||||
> - 该 `mnote-cli` host 口径现已被 2026-05-13 的 Hermes 面板主线覆盖
|
||||
>
|
||||
> 2026-05-13 追加说明:
|
||||
> - 本稿的对象模型、artifact 写链、projection-only `AI Artifacts` 分组与 kernel 边界继续有效
|
||||
> - 触发与执行口径改由 `/mnt/Data1T/mnote/design/07-ai/process/7-3-page-ai-hermes-panel-and-mnote-plugin-v1.md` 覆盖
|
||||
> - 当前凡是提到“页面 AI 面板触发”或“页面 AI host 固定动作”的位置,都应理解为:
|
||||
> 页面 AI 面板作为 Hermes 页面内客户端发起意图,Hermes 通过 mnote skill/plugin 调用正式 artifact 工具,最终写入仍回到 Rust runtime / kernel
|
||||
|
||||
---
|
||||
|
||||
@@ -43,7 +49,7 @@
|
||||
- `ai_note node`
|
||||
- `reference edge`
|
||||
2. 只允许围绕**当前文档页**创建,不做跨页、跨工作区写链
|
||||
3. 触发方式不是自然语言,不做自动推断,只允许页面 AI host 暴露的固定动作触发;当前页面 AI 面板只是 `mnote-cli` host 的一个页面内入口
|
||||
3. 第一版允许页面 AI 面板提供固定快捷入口,但快捷入口本质是向 Hermes 发送意图;Hermes 通过 mnote skill/plugin 调用 artifact 工具,不再由页面 AI host 或 `mnote-cli` host 私有触发
|
||||
4. 点击按钮后直接创建,不走“先预览再确认”的两阶段流
|
||||
5. `summary node` 默认单例覆盖更新;`ai_note node` 每次新建
|
||||
6. 两者都落成**可编辑的页面型节点**
|
||||
@@ -278,21 +284,25 @@
|
||||
|
||||
## 6. 触发方式与产品入口
|
||||
|
||||
### 6.1 只允许固定按钮触发
|
||||
### 6.1 允许固定按钮触发,但必须经过 Hermes
|
||||
|
||||
第一版不走自然语言触发。
|
||||
|
||||
不支持:
|
||||
|
||||
- “帮我顺手创建一个摘要节点”
|
||||
- “看起来像摘要就自动生成”
|
||||
- “只要语义明确就直接落库”
|
||||
|
||||
只支持页面 AI host 面板里的两个固定按钮:
|
||||
第一版允许页面 AI 面板提供两个固定快捷按钮:
|
||||
|
||||
- `创建 Summary`
|
||||
- `创建 AI Note`
|
||||
|
||||
但按钮不得绕过 Hermes 或 mnote plugin 直接写入。正确链路是:
|
||||
|
||||
```text
|
||||
Leptos 页面 AI 面板
|
||||
-> Hermes session/run
|
||||
-> mnote Hermes skill/plugin
|
||||
-> Rust runtime / kernel
|
||||
-> artifact node / reference edge
|
||||
```
|
||||
|
||||
自然语言触发可以作为后续能力加入,但第一版验收仍以固定按钮或固定 tool intent 为准,避免 artifact 写链质量和权限边界同时失控。
|
||||
|
||||
### 6.2 点击后直接创建
|
||||
|
||||
第一版点击按钮后,直接创建,不走预览确认。
|
||||
@@ -307,6 +317,7 @@
|
||||
- 正式写链成立
|
||||
- kernel node / edge 成立
|
||||
- 文件树 projection 成立
|
||||
- Hermes tool call -> mnote plugin -> Rust kernel 的边界成立
|
||||
|
||||
而不是先做复杂的人机审核流。
|
||||
|
||||
|
||||
@@ -0,0 +1,427 @@
|
||||
# 7-3 [process] 页面 AI Hermes 面板与 mnote Plugin 主线方案 v1
|
||||
|
||||
> 更新时间:2026-05-13
|
||||
>
|
||||
> 上位依据:
|
||||
> - `/mnt/Data1T/mnote/ARCHITECTURE.md`
|
||||
> - `/mnt/Data1T/mnote/design/01-05-current-priority-overview.md`
|
||||
> - `/mnt/Data1T/mnote/design/03-rust-web/process/3-rust-web-long-term-architecture-v1.md`
|
||||
> - `/mnt/Data1T/mnote/design/05-editor-mainline/process/5-5-page-aggregate-single-truth-alignment-v1.md`
|
||||
> - `/mnt/Data1T/mnote/design/05-editor-mainline/process/5-6-page-aggregate-alignment-checklist-v1.md`
|
||||
> - `/mnt/Data1T/mnote/design/05-editor-mainline/done/5-10-wolai-page-settings-and-ai-surface-alignment-v1.md`
|
||||
> - `/mnt/Data1T/mnote/design/05-editor-mainline/reference-code/hermes-web-ui-0.5.18`
|
||||
> - `/mnt/Data1T/mnote/design/07-ai/process/7-2-phase7-structured-artifact-write-chain-v1.md`
|
||||
>
|
||||
> 覆盖关系:
|
||||
> - 覆盖 `/mnt/Data1T/mnote/design/old/07-ai/process/7-phase7-ai-kernel-projection-plan-v4.md`
|
||||
> 中“`mnote-cli` 是唯一长期 agent 执行面”的口径。
|
||||
> - 保留 `v4` 中“Web 不应拥有第二套工具注册表、结构化写入必须回到 Rust runtime / kernel”的判断。
|
||||
> - 保留 `7-2` 中 `summary node / ai_note node / reference edge` 的对象模型和写入边界,
|
||||
> 但触发方改为 Hermes tool call,而不是页面 AI host 私有按钮或 `mnote-cli` host。
|
||||
|
||||
---
|
||||
|
||||
## 1. 文档目的
|
||||
|
||||
这份稿只回答一个问题:
|
||||
|
||||
> **未来页面 AI 的长期主语到底是谁。**
|
||||
|
||||
当前冻结答案是:
|
||||
|
||||
> **页面 AI 面板只是 Hermes 的页面内客户端;Hermes session/message/tool event/usage/model 才是会话真相;mnote 通过 Hermes skill/plugin 暴露业务能力。**
|
||||
|
||||
这不是把 mnote 的业务真相交给 Hermes。长期边界必须分清:
|
||||
|
||||
- Hermes 负责 AI 编排、会话、模型、tool call 调度和聊天历史。
|
||||
- mnote 负责页面、树、正文、artifact、edge、projection 和审计事实。
|
||||
- 页面 AI 面板只负责在文档页里打开一个 Hermes 客户端。
|
||||
|
||||
一句话收口:
|
||||
|
||||
> **AI 会话归 Hermes,mnote 能力归 Rust kernel;页面 AI 面板只连接两者。**
|
||||
|
||||
---
|
||||
|
||||
## 2. 为什么要从 CLI-first 改为 Hermes-first
|
||||
|
||||
`CLI-first` 解决了一个真实问题:避免 Web 前端继续拥有私有 AI 编排、私有工具注册表和私有写链。
|
||||
|
||||
但它也带来了新的错位:
|
||||
|
||||
- 页面 AI 面板开始伪装成 `mnote-cli` 图形客户端。
|
||||
- Hermes、Codex、`openai-agents-python` 被统一压成“外置 agent”,但实际用户希望页面 AI 就是 Hermes 面板。
|
||||
- 真实会话能力、模型选择、tool event、thinking、usage、历史搜索这些已经是 Hermes 的强项,mnote 自己再做一套会重复。
|
||||
- 当前运行态已经出现冲突:页面壳默认发送 `provider=hermes`,而 `/api/ai-agent/run` 又按旧退场口径返回 `ai_provider_bridge_unavailable`。
|
||||
|
||||
因此新的主线不是恢复旧的 Web 私有 AI 编排,而是:
|
||||
|
||||
> **把页面 AI 从 `mnote-cli host` 改成 Hermes client,把 mnote 能力从 Web 私有 tool 改成 Hermes 可发现、可调用的 skill/plugin。**
|
||||
|
||||
---
|
||||
|
||||
## 3. 长期分层
|
||||
|
||||
### 3.1 Hermes
|
||||
|
||||
Hermes 负责:
|
||||
|
||||
- session 创建、恢复、重命名、删除、搜索
|
||||
- message 存储与 conversation history
|
||||
- model / provider / profile 选择
|
||||
- streaming 事件、thinking / reasoning、usage
|
||||
- tool call 调度、排队、取消、恢复
|
||||
- skill / plugin 的发现和启停
|
||||
|
||||
Hermes 不负责:
|
||||
|
||||
- 直接写 mnote 的 Convex 表
|
||||
- 直接构造第二套 page aggregate
|
||||
- 直接决定页面树、文件树、artifact、edge 的事实结构
|
||||
|
||||
### 3.2 mnote Rust kernel / runtime
|
||||
|
||||
mnote Rust 负责:
|
||||
|
||||
- `Page Aggregate`
|
||||
- `page.*` command
|
||||
- `tree.*` command
|
||||
- `kernel.*` query / edge
|
||||
- `summary node / ai_note node / reference edge`
|
||||
- projection、audit、idempotency、workspace / actor scope
|
||||
|
||||
Rust 不负责:
|
||||
|
||||
- 存储 Hermes 聊天历史
|
||||
- 维护 Hermes session 列表
|
||||
- 重做 Hermes 模型、profile、usage 管理
|
||||
|
||||
### 3.3 页面 AI Leptos 面板
|
||||
|
||||
页面 AI 面板负责:
|
||||
|
||||
- 右下角入口与右侧抽屉壳层继续服从 Wolai 对齐结果
|
||||
- 用 Leptos 实现 Hermes chat 的页面内子集
|
||||
- 调用 Hermes session / run / stream API
|
||||
- 把当前页面上下文作为 Hermes run 的输入或 session workspace context
|
||||
- 展示 Hermes 返回的 message、reasoning、tool event、error、usage
|
||||
|
||||
页面 AI 面板不负责:
|
||||
|
||||
- 自己保存聊天真相
|
||||
- 自己维护工具注册表
|
||||
- 自己执行页面写入
|
||||
- 自己 fallback 到 `mnote-cli` 或旧 sidecar
|
||||
|
||||
### 3.4 mnote Hermes skill/plugin
|
||||
|
||||
mnote 需要作为 Hermes skill/plugin 暴露能力。
|
||||
|
||||
第一版建议能力分组:
|
||||
|
||||
- `mnote.page.get`
|
||||
- `mnote.page.save`
|
||||
- `mnote.page.update_title`
|
||||
- `mnote.page.update_options`
|
||||
- `mnote.tree.create`
|
||||
- `mnote.tree.move`
|
||||
- `mnote.search.documents`
|
||||
- `mnote.artifact.create_summary`
|
||||
- `mnote.artifact.create_ai_note`
|
||||
- `mnote.kernel.attach_reference`
|
||||
|
||||
这些工具可以由 plugin 内部调用:
|
||||
|
||||
- Rust Web 同源 tool bridge
|
||||
- `mnote-cli` JSON adapter
|
||||
- 或后续更稳定的 Rust plugin bridge
|
||||
|
||||
但对 Hermes 来说,它们必须表现为一组稳定 Hermes tools,而不是页面前端私有函数。
|
||||
|
||||
---
|
||||
|
||||
## 4. 会话真相
|
||||
|
||||
页面 AI 的会话真相固定在 Hermes。
|
||||
|
||||
mnote 不保存:
|
||||
|
||||
- 聊天消息列表
|
||||
- assistant 文本历史
|
||||
- thinking / reasoning 历史
|
||||
- tool event 完整展开状态
|
||||
- session 标题、分组、usage
|
||||
|
||||
mnote 可以保存:
|
||||
|
||||
- 结构化写入产生的 audit
|
||||
- artifact node
|
||||
- reference edge
|
||||
- page/body/title/options 的正式变更
|
||||
- 与一次 Hermes tool call 对应的 request / trace / actor / reason
|
||||
|
||||
也就是说,mnote 只保存“对 mnote 事实源造成影响的结果”,不复制 Hermes 的聊天数据库。
|
||||
|
||||
---
|
||||
|
||||
## 5. 页面上下文进入 Hermes 的方式
|
||||
|
||||
页面 AI 面板打开时,mnote 应提供最小上下文包:
|
||||
|
||||
- `workspaceId`
|
||||
- `documentId`
|
||||
- 页面标题
|
||||
- `pageAggregate` 摘要
|
||||
- 当前选区 / blockId / selected text
|
||||
- 页面设置
|
||||
- 当前用户 actor / capability 摘要
|
||||
|
||||
上下文传入 Hermes 有两种可接受方式:
|
||||
|
||||
1. 作为 run input / instructions 的结构化上下文。
|
||||
2. 作为 Hermes session workspace context,由 mnote panel 在创建或恢复 session 时设置。
|
||||
|
||||
第一版优先采用简单方式:
|
||||
|
||||
> 页面 AI 面板每次发起 run 时附带当前页面上下文摘要;Hermes 如需读取最新正文,再通过 `mnote.page.get` 工具回读。
|
||||
|
||||
这样可以避免把 page aggregate 大对象长期塞进 Hermes session,也避免 stale context 变成事实源。
|
||||
|
||||
---
|
||||
|
||||
## 6. API 与路由边界
|
||||
|
||||
### 6.1 退役 `/api/ai-agent/run` 主路径
|
||||
|
||||
`/api/ai-agent/run` 不再作为页面 AI 的长期主入口。
|
||||
|
||||
允许状态:
|
||||
|
||||
- 暂时保留为 legacy compat,明确返回旧接口退场信息
|
||||
- 或只用于旧 smoke / 对照验证
|
||||
|
||||
禁止状态:
|
||||
|
||||
- 页面 AI 新实现继续向它发送 `provider=hermes`
|
||||
- `/api/ai-agent/run` 继续作为 Hermes 面板的主代理
|
||||
- 它继续持有 mnote 私有工具注册表或执行编排
|
||||
|
||||
### 6.2 新增 Hermes client proxy
|
||||
|
||||
浏览器不应直接暴露 Hermes API key。
|
||||
|
||||
建议在 `mnote-web` 中提供同源薄代理:
|
||||
|
||||
- `/api/hermes/client/sessions`
|
||||
- `/api/hermes/client/runs`
|
||||
- `/api/hermes/client/events`
|
||||
- `/api/hermes/client/models`
|
||||
- `/api/hermes/client/tools`
|
||||
|
||||
这层只做:
|
||||
|
||||
- auth / cookie / token 转发
|
||||
- 同源安全边界
|
||||
- 页面上下文最小注入
|
||||
- 错误码标准化
|
||||
|
||||
这层不做:
|
||||
|
||||
- session 真相存储
|
||||
- message 真相存储
|
||||
- tool 执行编排
|
||||
- 旧 provider fallback
|
||||
|
||||
### 6.3 mnote tool bridge
|
||||
|
||||
Hermes 调用 mnote 工具时,应进入窄桥:
|
||||
|
||||
```text
|
||||
Hermes tool call
|
||||
-> mnote Hermes plugin
|
||||
-> mnote-web /api/hermes/tools/mnote/*
|
||||
-> Rust runtime / kernel command/query
|
||||
-> Hermes tool result
|
||||
```
|
||||
|
||||
第一版不要求一次性冻结最终 URL,但要求协议字段稳定:
|
||||
|
||||
- `toolName`
|
||||
- `arguments`
|
||||
- `workspaceId`
|
||||
- `documentId`
|
||||
- `actor`
|
||||
- `sessionId`
|
||||
- `traceId`
|
||||
- `idempotencyKey`
|
||||
- `dryRun`
|
||||
- `capabilityScope`
|
||||
|
||||
---
|
||||
|
||||
## 7. Leptos 面板参考范围
|
||||
|
||||
参考 `hermes-web-ui-0.5.18`,但只采用页面内必要子集。
|
||||
|
||||
第一版采用:
|
||||
|
||||
- Chat session list
|
||||
- Message list
|
||||
- Chat input
|
||||
- streaming delta
|
||||
- thinking / reasoning 展开
|
||||
- tool started / tool completed 展开
|
||||
- model selector
|
||||
- error / retry / abort
|
||||
- session search 可后置
|
||||
|
||||
第一版不采用:
|
||||
|
||||
- 平台 Channels 管理
|
||||
- Jobs / Cron 管理
|
||||
- Profiles 管理全页面
|
||||
- Logs 全页面
|
||||
- Files 全浏览器
|
||||
- Terminal
|
||||
- Group Chat
|
||||
- Hermes 全局 Settings
|
||||
|
||||
这些能力属于 Hermes 管理台,不属于 mnote 页面 AI 抽屉。
|
||||
|
||||
实现要求:
|
||||
|
||||
- 使用 Leptos island 实现,不引入 Vue / Naive UI。
|
||||
- 保留 Wolai 对齐的右下角入口和右侧 drawer 容器。
|
||||
- 文案和视觉以 mnote 文档页密度为准,不照搬 Hermes Web UI 的整站导航。
|
||||
|
||||
---
|
||||
|
||||
## 8. 结构化 Artifact 写链
|
||||
|
||||
`7-2` 的对象模型继续成立:
|
||||
|
||||
- `summary node`
|
||||
- `ai_note node`
|
||||
- `reference edge`
|
||||
- `AI Artifacts` projection-only 分组
|
||||
|
||||
但触发方改为 Hermes tool call:
|
||||
|
||||
- 用户可以在 Hermes 面板中自然语言要求总结当前页。
|
||||
- Hermes 决定调用 `mnote.artifact.create_summary`。
|
||||
- 或页面面板提供快捷按钮,但按钮本质也是向 Hermes 发送意图,不是绕过 Hermes 直接写 mnote。
|
||||
|
||||
第一版允许两个快捷入口:
|
||||
|
||||
- `创建 Summary`
|
||||
- `创建 AI Note`
|
||||
|
||||
但它们必须走:
|
||||
|
||||
```text
|
||||
Leptos panel -> Hermes session/run -> mnote plugin tool call -> Rust kernel
|
||||
```
|
||||
|
||||
不得走:
|
||||
|
||||
```text
|
||||
Leptos panel -> /api/documents/* 直接写 artifact
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. 权限与安全
|
||||
|
||||
Hermes 可以调度工具,但 mnote 必须做最终授权。
|
||||
|
||||
每个 mnote tool call 至少校验:
|
||||
|
||||
- 当前 actor 是否登录
|
||||
- actor 是否属于 workspace
|
||||
- 当前页面是否允许 AI 读取
|
||||
- 当前页面是否允许 AI 写入
|
||||
- 当前工具是否允许写入页面 / 树 / artifact
|
||||
- 是否需要 `dryRun` 或确认
|
||||
|
||||
默认第一版:
|
||||
|
||||
- 读当前页:允许
|
||||
- 写当前页:按页面 capability
|
||||
- 创建 summary / ai_note:按当前页 artifact capability
|
||||
- 跨页写入:默认禁止
|
||||
- 跨 workspace:禁止
|
||||
|
||||
---
|
||||
|
||||
## 10. 迁移步骤
|
||||
|
||||
### Phase A:设计口径统一
|
||||
|
||||
- 新增本稿作为 07-ai 当前主线。
|
||||
- 将 `7-v4 CLI-first` 移入 `design/old/07-ai/process/` 并标记 `[recycle]`。
|
||||
- 更新仍引用 `mnote-cli` 唯一长期 agent 执行面的活跃设计稿。
|
||||
|
||||
### Phase B:薄 Hermes client proxy
|
||||
|
||||
- 在 `mnote-web` 补 Hermes client proxy。
|
||||
- 页面 AI 面板不再调用 `/api/ai-agent/run`。
|
||||
- 浏览器不直接持有 Hermes API key。
|
||||
|
||||
### Phase C:Leptos Hermes 面板最小子集
|
||||
|
||||
- 实现 session 创建 / 恢复。
|
||||
- 实现 run / stream / abort。
|
||||
- 实现 message / reasoning / tool event 展示。
|
||||
- 保留 Wolai 右侧抽屉壳。
|
||||
|
||||
### Phase D:mnote Hermes plugin
|
||||
|
||||
- 暴露 mnote tool manifest。
|
||||
- 先接 `mnote.page.get`、`mnote.page.save`、`mnote.artifact.create_summary`、`mnote.artifact.create_ai_note`。
|
||||
- 工具结果回到 Hermes tool event。
|
||||
|
||||
### Phase E:退役旧面板执行链
|
||||
|
||||
- `/api/ai-agent/run` 从主路径移除。
|
||||
- 旧 React `DocumentAiAgentPanel.runtime` 降为历史参考或 compat。
|
||||
- `provider=hermes/codex/claudecode` 502 不再出现在新页面 AI 主链。
|
||||
|
||||
---
|
||||
|
||||
## 11. 验收标准
|
||||
|
||||
第一版完成时必须满足:
|
||||
|
||||
- 页面 AI 抽屉打开后可创建或恢复 Hermes session。
|
||||
- Hermes session 存储中能看到页面 AI 的消息历史。
|
||||
- mnote 本地不复制聊天消息真相。
|
||||
- 发送消息后,事件流来自 Hermes run。
|
||||
- tool call 展示使用 Hermes tool event。
|
||||
- Hermes 可调用至少一个只读 mnote 工具读取当前页面。
|
||||
- Hermes 可调用至少一个写入工具,经 Rust runtime 写回当前页或创建 artifact。
|
||||
- 页面刷新后,AI 会话从 Hermes 恢复,而不是从 mnote 本地 state 恢复。
|
||||
- 结构化写入产生的 artifact / edge 能在 mnote projection 中验证。
|
||||
|
||||
---
|
||||
|
||||
## 12. 禁止项
|
||||
|
||||
- 不再新增 Web 私有 AI tool registry。
|
||||
- 不再把 `mnote-cli` 写成页面 AI 唯一长期执行面。
|
||||
- 不把 Hermes 聊天历史复制进 mnote page aggregate。
|
||||
- 不让 Hermes plugin 直接写 Convex。
|
||||
- 不在 Leptos 面板里重做 Hermes 后台管理台。
|
||||
- 不把 `AI Artifacts` 做成真实 kernel node。
|
||||
- 不绕过 Rust runtime 创建 artifact / edge。
|
||||
|
||||
---
|
||||
|
||||
## 13. 最终冻结口径
|
||||
|
||||
> **页面 AI 是 Hermes 面板,不是 mnote-cli 面板。**
|
||||
|
||||
> **Hermes 持有 AI 会话真相,mnote 持有业务对象真相。**
|
||||
|
||||
> **mnote 通过 Hermes skill/plugin 暴露工具,工具最终回到 Rust runtime / kernel。**
|
||||
|
||||
> **Leptos 负责页面内 Hermes 客户端体验,不负责 AI 编排。**
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
## 结论
|
||||
|
||||
总体方向与当前主线基本一致:Mindmap 已明确降为 `tree-first graph kernel` 的视图/编辑挂件,AI 主执行面正在向 `mnote-cli` 收口,OnlyOffice 仍是独立页面型编辑器边界,SiYuan 参考稿没有发现被提升为上位架构来源的证据。
|
||||
总体方向与当前主线基本一致:Mindmap 已明确降为 `tree-first graph kernel` 的视图/编辑挂件;AI 长期方向已从 `mnote-cli` 收口改为 Hermes 页面内客户端 + mnote Hermes skill/plugin;OnlyOffice 仍是独立页面型编辑器边界,SiYuan 参考稿没有发现被提升为上位架构来源的证据。
|
||||
|
||||
主要风险集中在三处:OnlyOffice 在 `mnote-web` 主入口里的 callback/forcesave 仍是 no-op,而 legacy Next route 已有真实写回链;Mindmap 的导出 action 在 schema/action map 中暴露,但 bridge 安全命令集不支持;Mindmap AI 补完 route 当前硬返回 501,和 Rust tool 已登记的能力面不闭合。
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
| F-01 | 实现偏差 / 风险 | P1 | OnlyOffice `mnote-web` 主入口已挂载 callback/forcesave,但当前实现不写回,只返回成功或 noop,可能让 3000 主链下的 OnlyOffice 保存丢失。 | `rust/crates/mnote-web/src/routes/mod.rs:78-84` 挂载 `/api/onlyoffice/callback` 与 `/api/onlyoffice/forcesave`;`rust/crates/mnote-web/src/routes/onlyoffice.rs:717-759` callback 只记录日志并返回 `{error:0}`,forcesave 返回 `mnote-web-rust-noop`;而 `wolai-frontend/src/app/api/onlyoffice/callback/route.ts:93-192` 有真实 Convex 写回链。 | 优先把 Rust route 接到 `onlyoffice_prepare_callback` / media asset writeback,或显式把该路由代理到 legacy Next,避免主入口 shadow 掉真实写回。 |
|
||||
| F-02 | 未完成 | P2 | Mindmap `export` 动作暴露给 UI action map,但 simple-mind-map 安全执行器不允许 `EXPORT`,默认路径可能显示能力却执行失败。 | `wolai-frontend/src/lib/mindmap/mindmap-action-map.ts:65` 将 `export` 映射为 runtimeCommand `EXPORT`;`wolai-frontend/src/lib/mindmap/simple-mind-map-bridge.ts:119-129` 的 `SIMPLE_MIND_MAP_SAFE_COMMANDS` 不包含 `EXPORT`。 | 要么把 `EXPORT` 加入安全命令并补 smoke,要么在 UI state 中继续禁用导出并标注为延期。 |
|
||||
| F-03 | 未完成 / 方向变化 | P2 | Mindmap AI 补完 route 当前直接 501,后续大段旧 Supabase/在线 AI 实现被注释;但 Rust tool registry 已登记 `mindmap_expand_node`,形成“工具存在、产品入口不可用”的断层。 | `wolai-frontend/src/app/api/mindmap-ai/expand-node/route.ts:90-97` 校验后直接返回 `501`;同文件后续注释块仍保留旧 Supabase/AI 逻辑;`rust/crates/core-protocol/src/tool.rs:231-240` 与 `rust/crates/bridge-runtime/src/lib.rs:3803-3812` 已有 `mindmap_expand_node`。 | 若该能力仍在 Phase 6/7 范围内,应按 CLI/Rust bridge 路线重接;若延期,应把 route 标成 retired/debug,避免前端或测试误以为可用。 |
|
||||
| F-04 | 方向变化 / 文档滞后 | P2 | AI 主 Web route 已收口到 `mnote-cli` host,但 `wolai-backend` 仍暴露 `openai_agents_python` 文档 agent route 与旧工具面;是否仍部署为可访问入口需进一步验证。 | `wolai-frontend/src/app/api/ai-agent/run/route.ts:36-57` 明确拒绝 codex/hermes/claudecode 并进入 `startMnoteCliAgentHostRun`;`wolai-backend/app/routers/ai_agent.py:34-57` 仍暴露 `/ai-agent/health`、`/ai-agent/document/run`,health 返回 `bridge: openai_agents_python`;`wolai-backend/app/services/ai_document_agent.py:1174-1470` 仍指令 agent 使用 `doc_insert_blocks`、`doc_replace_range`、`slash_run`。 | 在设计或代码注释中明确该后端只作为可插拔外置 agent/对照链;若不再使用,补退场计划和访问边界,尤其确认 `mnote_ai_orchestrator_api_key` 未配置时的开放行为。 |
|
||||
| F-04 | 方向变化 / 文档滞后 | P2 | AI 主 Web route 曾收口到 `mnote-cli` host,但新主线已改为 Hermes 页面内客户端;`wolai-backend` 仍暴露 `openai_agents_python` 文档 agent route 与旧工具面,旧 route / sidecar / host 的退场关系需重新明确。 | `wolai-frontend/src/app/api/ai-agent/run/route.ts:36-57` 曾明确拒绝 codex/hermes/claudecode 并进入 `startMnoteCliAgentHostRun`;`wolai-backend/app/routers/ai_agent.py:34-57` 仍暴露 `/ai-agent/health`、`/ai-agent/document/run`,health 返回 `bridge: openai_agents_python`;`wolai-backend/app/services/ai_document_agent.py:1174-1470` 仍指令 agent 使用 `doc_insert_blocks`、`doc_replace_range`、`slash_run`。 | 以 `design/07-ai/process/7-3-page-ai-hermes-panel-and-mnote-plugin-v1.md` 为上位依据,明确页面 AI 改走 Hermes client proxy,mnote 能力通过 Hermes skill/plugin 暴露;`openai-agents-python` 和 `mnote-cli host` 只保留为历史/兼容/内部适配路径并补退场计划。 |
|
||||
| F-05 | 文档治理风险 | P3 | `design/90-reference` 符合“参考资料”目录定位,但内容仍带有问答式残留,容易被后续 worker 误用为正式设计结论。 | `design/README.md` 明确 `90-reference/` 不参与 process/done 状态判断;`design/90-reference/90-1-filetree.md` 末尾保留“需要我给你...”类对话尾巴;`design/90-reference/90-2-yemianshu.md` 同样保留示例请求口吻。 | 低优先级清理为中性参考笔记,并在引用时强制以 `ARCHITECTURE.md` 和主线设计为上位依据。 |
|
||||
|
||||
## 证据
|
||||
@@ -31,9 +31,9 @@
|
||||
|
||||
### AI
|
||||
|
||||
- CLI-first 入口基本对齐:`wolai-frontend/src/app/api/ai-agent/run/route.ts:36-57` 将默认执行入口限定到 `mnote-cli host`,拒绝旧 provider。
|
||||
- 结构化 artifact 设计仍有未完成项:`design/07-ai/process/7-phase7-ai-kernel-projection-plan-v4.md` 中“后续权限收口”仍未勾选,说明 AI 写链权限模型尚未完全闭环。
|
||||
- 旧后端 agent 仍存在:`wolai-backend/app/routers/ai_agent.py:47-57` 仍提供 streaming run;这可作为外置 agent,但需要避免被误认为长期默认主编排。
|
||||
- 旧 CLI-first 入口曾基本对齐:`wolai-frontend/src/app/api/ai-agent/run/route.ts:36-57` 将默认执行入口限定到 `mnote-cli host`,拒绝旧 provider;但该口径已被 2026-05-13 的 Hermes 面板主线覆盖。
|
||||
- 结构化 artifact 设计应继续保留对象模型,但触发方改为 Hermes tool call -> mnote plugin -> Rust runtime / kernel,见 `design/07-ai/process/7-3-page-ai-hermes-panel-and-mnote-plugin-v1.md` 与 `design/07-ai/process/7-2-phase7-structured-artifact-write-chain-v1.md`。
|
||||
- 旧后端 agent 仍存在:`wolai-backend/app/routers/ai_agent.py:47-57` 仍提供 streaming run;这只能作为历史/兼容/对照链,不能被误认为长期默认主编排。
|
||||
|
||||
### OnlyOffice
|
||||
|
||||
@@ -55,8 +55,8 @@
|
||||
|
||||
1. P1:补齐或明确代理 OnlyOffice Rust callback/forcesave 写回链,避免主入口下保存成功但内容未持久化。
|
||||
2. P2:收口 Mindmap action 可用性,先修 `export` 映射与安全命令不一致,再继续扩展 UI 能力。
|
||||
3. P2:处理 Mindmap AI route:要么按 Rust bridge/CLI-first 重接 `mindmap_expand_node`,要么显式退役该 Next route。
|
||||
4. P2:明确 `wolai-backend` AI agent 的外置/对照定位与访问边界,避免与 `mnote-cli` 唯一执行面口径冲突。
|
||||
3. P2:处理 Mindmap AI route:要么按 Hermes plugin / Rust bridge 重接 `mindmap_expand_node`,要么显式退役该 Next route。
|
||||
4. P2:明确 `wolai-backend` AI agent、旧 `/api/ai-agent/run` 与 `mnote-cli host` 的历史/兼容定位和访问边界,避免与 Hermes 面板 + mnote plugin 新主线冲突。
|
||||
5. P3:清理 `design/90-reference` 的问答残留,并在引用规范里再次强调它不是 process/done 设计稿。
|
||||
|
||||
## 本次修改文件
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
> 执行状态:本文是 10-review 阶段的树域判断记录,不再作为待办清单直接执行。Resource Tree / File Tree / Page Tree 真源合同已由 `/mnt/Data1T/mnote/design/04-tree-domain/done/4-24-resource-tree-filetree-pagetree-source-contract-checklist-v1.md` 承接;主编辑区 Object Tab、mindmap object editor、`index.md` 隔离已由 `/mnt/Data1T/mnote/design/05-editor-mainline/done/5-12-main-editor-object-tab-resource-alignment-checklist-v1.md` 承接。后续只需按 `06-execution-checklist-and-acceptance.md` 保留 smoke 防回归。
|
||||
|
||||
结论
|
||||
我基本同意你的方向,但要把“文件树为根源”说得更精确:不应让“文件树 UI 组件”成为真源,而应让 Rust kernel 中的 workspace resource tree / file tree projection 背后
|
||||
的资源层级 成为页面、附件、mindmap、OnlyOffice 等对象的组织根源。页面树则是从这棵资源/对象树派生出的导航投影,类似快捷方式、收藏视图或文档视图。
|
||||
|
||||
@@ -0,0 +1,438 @@
|
||||
# 10-review 顺序执行清单与验收标准
|
||||
|
||||
> 更新时间:2026-05-14
|
||||
>
|
||||
> 上游依据:
|
||||
> - `/mnt/Data1T/mnote/design/10-review/README.md`
|
||||
> - `/mnt/Data1T/mnote/design/10-review/01-rust-kernel-web-review.md`
|
||||
> - `/mnt/Data1T/mnote/design/10-review/02-frontend-editor-tree-review.md`
|
||||
> - `/mnt/Data1T/mnote/design/10-review/03-convex-realtime-storage-review.md`
|
||||
> - `/mnt/Data1T/mnote/design/10-review/04-secondary-domains-and-design-governance-review.md`
|
||||
> - `/mnt/Data1T/mnote/design/10-review/05-tree.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`
|
||||
|
||||
## 0. 执行前提
|
||||
|
||||
`05-tree.md` 中关于 Resource Tree / File Tree / Page Tree、ObjectIdentity、mindmap 与 `index.md` 隔离的核心事项已经由 `4-24` 与 `5-12` 承接到 `done/`。后续执行不要重复重做该主线,只需要保留防回归验证。
|
||||
|
||||
本清单按依赖顺序执行。原则是先修会影响主链一致性和数据可信度的 P0,再处理 realtime / projection / compat 收口,最后做次级域和文档治理。
|
||||
|
||||
## 1. P0:修正 Rust page write artifact 一致性
|
||||
|
||||
状态:已完成第 1 项代码侧与 Rust 3000 验证。Rust `/api/documents/title` 与 `/api/documents/options` 已切到 artifact 记录路径;`page.layout.updateOptions` 已补 tree domain event / conservative resync artifact;Rust 3000 下已直接验证标题更新后 `bridge.workspace.overview` 可读到 `page.head.updateTitle` command log 与 `tree.node.renamed` domain event,`/api/tree/events` 也能输出包含新标题的 stream 事件。完整 File Tree 标题同步仍归入第 6 项继续处理。
|
||||
|
||||
目标:让 3000 主入口下的页面标题、页面设置、正文保存拥有一致 side effect,确保 tree stream 能看到相关变更。
|
||||
|
||||
建议修改范围:
|
||||
|
||||
- `rust/crates/mnote-web/src/routes/documents.rs`
|
||||
- `rust/crates/mnote-web/src/routes/command_support.rs`
|
||||
- `rust/crates/mnote-web/src/transport/convex.rs`
|
||||
- 必要时补充 `wolai-frontend/convex/bridgeLogs.ts` 查询侧验证
|
||||
|
||||
执行项:
|
||||
|
||||
- [x] 将 Rust `/api/documents/title` 的命令执行切到 artifact 记录路径,或统一复用正式 page/tree command route。
|
||||
- [x] 将 Rust `/api/documents/options` 的命令执行切到 artifact 记录路径,或统一复用正式 page/tree command route。
|
||||
- [x] 明确 artifact 写失败策略:主 mutation 成功但 artifact 失败时至少要有可观测错误,并触发保守 resync 或明确降级。
|
||||
- [x] 补回归测试:标题更新后,`bridgeLogs:listWorkspaceOverview` 能看到对应 command/domain event。
|
||||
|
||||
验收标准:
|
||||
|
||||
- [x] `cargo test -p mnote-web documents_title -- --nocapture` 或等价标题 route 测试通过。
|
||||
- [x] `cargo test -p mnote-web documents_options -- --nocapture` 或等价页面设置 route 测试通过。
|
||||
- [x] 手动或脚本验证:更新标题后 `/api/tree/events` 输出 delta 或 resync,不需要刷新整页才能看到 sidebar/breadcrumb 变化。
|
||||
- [x] Rust route 与 Next legacy adapter 对同一 page write 的 canonical command / artifacts 语义一致。
|
||||
|
||||
证据:
|
||||
|
||||
- `rust/crates/mnote-web/src/routes/documents.rs`:`title` / `options` 使用 `execute_runtime_command_via_convex_with_artifacts`,响应 `meta.artifacts` / `meta.artifactError`。
|
||||
- `rust/crates/bridge-runtime/src/lib.rs`:`page.layout.updateOptions` 产生 `page.layout.options_updated` domain event 与 `resync_required` stream delta。
|
||||
- `rust/crates/mnote-web/src/transport/convex.rs`:发送给 legacy `documents:updateOptions` 时剥离 artifact-only 字段。
|
||||
- 已通过:Rust 3000 目标脚本创建临时页面后调用 `/api/documents/title`,再查 `/api/bridge/workspace?targetPageId=<documentId>&aggregateType=page&aggregateId=<documentId>`,确认返回 `command_name=page.head.updateTitle` 与 `event_type=tree.node.renamed`。
|
||||
- 已通过:Rust 3000 目标脚本创建临时页面后调用 `/api/documents/title`,再查 `/api/tree/events?workspaceId=<workspaceId>&maxPolls=1&pollMs=250`,确认 stream 文本包含新标题且包含 `snapshot` / `delta` / `resync` 事件。
|
||||
- 已通过:`node scripts/task123-rust-web-tree-live-stream-consumer-smoke.js`。
|
||||
- 部分通过:`node scripts/task110-page-title-single-truth-smoke.js` 在 Rust 3000 下已通过页头、breadcrumb、sidebar、page tree 标题一致性检查;当前失败点为 File Tree 标题行,继续归入第 6 项处理。
|
||||
- 已通过:`pnpm test -- src/app/api/documents/route-adapters.test.ts src/lib/documents/page-write-command-adapter.test.ts`(Vitest 实际执行 112 个测试文件、457 个测试),Next `documents/title` route 与 page write adapter 继续生成 `page.head.updateTitle` canonical command 并进入 Rust artifact writer。
|
||||
- 已通过:`cargo test -p mnote-web documents_ -- --nocapture`。
|
||||
- 已通过:`cargo test -p bridge-runtime page_head_update_title_command_plan_uses_canonical_page_command -- --nocapture`。
|
||||
- 已通过:`cargo test -p bridge-runtime page_layout_update_options_command_plan_uses_canonical_page_command -- --nocapture`。
|
||||
- 已通过:`cargo test -p bridge-runtime document_options_command_plan_maps_to_documents_update_options -- --nocapture`。
|
||||
- 已通过:`cargo test -p mnote-web convex_command_args_strips_page_options_artifacts_for_legacy_mutation -- --nocapture`。
|
||||
|
||||
## 2. P0:移除或显式隔离假 fallback 数据
|
||||
|
||||
状态:已完成代码侧收口与负向单测。Search 默认不再返回内置固定业务结果;workspace shell projection 默认进入 degraded empty;Sidebar/File Tree dev fallback 仅在 `allow_dev_fixtures` 开启时可用,并带隐藏 debug/dev 标识。
|
||||
|
||||
目标:失败、超时、Convex 不可用时,不再返回看起来像真实业务数据的 fixture / fallback。
|
||||
|
||||
建议修改范围:
|
||||
|
||||
- `rust/crates/mnote-web/src/routes/search.rs`
|
||||
- `rust/crates/mnote-web/src/routes/web_shell.rs`
|
||||
- `rust/crates/mnote-web/src/routes/snapshot_support.rs`
|
||||
|
||||
执行项:
|
||||
|
||||
- [x] 优先移除或强门禁 Search 的 `fallback_search_dataset`。
|
||||
- [x] 对 workspace shell projection 加载失败的最小合成数据做决策:返回明确 degraded/empty/error,不能伪装成真实 workspace projection。
|
||||
- [x] 对 `allow_dev_fixtures` 相关 sidebar/filetree fallback 增加明确 debug/dev 标识,并确认 3000 默认启动链不会误开。
|
||||
- [x] 增加负向 smoke:Convex/query 不可用时,Search、Sidebar、File Tree 不返回固定假业务数据。
|
||||
|
||||
验收标准:
|
||||
|
||||
- [x] 关闭或破坏 Convex query 后,Search 不返回内置固定结果。
|
||||
- [x] 关闭或破坏 projection 加载后,workspace/sidebar/filetree 响应带明确错误、空状态或 degraded 标记。
|
||||
- [x] 3000 默认入口不会出现 fixture workspace、fixture document、fixture filetree row。
|
||||
- [x] 相关负向测试或 smoke 在失败场景下能稳定断言“不返回假数据”。
|
||||
|
||||
证据:
|
||||
|
||||
- `rust/crates/mnote-web/src/routes/search.rs`:Convex/search query 失败时,默认返回空结果与 `meta.degraded=true`;仅 `allow_dev_fixtures` 开启时使用 `fallback_search_dataset`。
|
||||
- `rust/crates/mnote-web/src/routes/web_shell.rs`:workspace shell projection 失败时,默认返回 degraded empty dataset;Sidebar/File Tree dev fallback HTML 增加 `data-mnote-dev-fixture` 标识。
|
||||
- `rust/crates/mnote-web/src/workspace_shell.rs`:workspace shell projection/render 支持 `degraded` / `devFixture` 标识。
|
||||
- 已通过:`cargo test -p mnote-web search_ -- --nocapture`。
|
||||
- 已通过:`cargo test -p mnote-web sidebar_and_filetree_do_not_return_dev_fixtures_by_default -- --nocapture`。
|
||||
- 已通过:`cargo test -p mnote-web workspace_shell_sidebar_html_marks_degraded_and_dev_fixture_states -- --nocapture`。
|
||||
- 已通过:`cargo test -p mnote-web document_shell_returns_page_aggregate_snapshot -- --nocapture`。
|
||||
|
||||
## 3. P1:补齐 OnlyOffice Rust callback / forcesave 写回链
|
||||
|
||||
状态:已完成代码侧与真实 Rust 3000 smoke。Rust `/api/onlyoffice/callback` 与 `/api/onlyoffice/forcesave` 已改为显式代理 legacy Next 写回链;未配置 legacy Next 时返回明确失败/unsupported,不再静默 no-op 成功;`task174` 已验证 OnlyOffice 附件打开链路与 docKey。
|
||||
|
||||
目标:Rust Web 主入口下 OnlyOffice 保存不能出现“返回成功但未写回”的情况。
|
||||
|
||||
建议修改范围:
|
||||
|
||||
- `rust/crates/mnote-web/src/routes/onlyoffice.rs`
|
||||
- `rust/crates/adapter-onlyoffice/`
|
||||
- `wolai-frontend/src/app/api/onlyoffice/callback/route.ts` 作为 legacy 写回参考
|
||||
- `scripts/task174-rust-onlyoffice-attachment-open-smoke.js`
|
||||
|
||||
执行项:
|
||||
|
||||
- [x] 决定 Rust route 直接写回,还是显式代理到 legacy Next 写回链。
|
||||
- [x] 如果直接写回,接入 media asset / storage 写回与 callback 状态处理。(本轮选择显式代理 legacy Next 写回链,直接写回不适用。)
|
||||
- [x] forcesave 不再返回纯 `mnote-web-rust-noop` 成功语义;若仍未支持,必须返回明确 unsupported/degraded。
|
||||
- [x] 补真实浏览器 smoke:打开 OnlyOffice 附件、触发保存、重新打开后内容或版本可验证。
|
||||
|
||||
验收标准:
|
||||
|
||||
- [x] `/api/onlyoffice/callback` 在需要写回的 status 下会产生真实持久化写入或明确失败。
|
||||
- [x] `/api/onlyoffice/forcesave` 不再静默 no-op 成功。
|
||||
- [x] `node scripts/task174-rust-onlyoffice-attachment-open-smoke.js` 或等价 smoke 通过。
|
||||
- [x] 3000 主链不会 shadow 掉 legacy Next 的真实写回能力。
|
||||
|
||||
证据:
|
||||
|
||||
- `rust/crates/mnote-web/src/routes/onlyoffice.rs`:callback / forcesave 代理到 `legacy_next_base_url` 的 Next `/api/onlyoffice/*` 写回链;缺少 legacy 写回链时返回明确失败。
|
||||
- 已通过:`cargo test -p mnote-web onlyoffice_ -- --nocapture`。
|
||||
- 已通过:`node scripts/task174-rust-onlyoffice-attachment-open-smoke.js`,输出 `ok=true`,创建临时 docx attachment,打开 `/onlyoffice` 编辑 URL,并返回稳定 `docKey`。
|
||||
|
||||
## 4. P1:收口 Page Aggregate provenance
|
||||
|
||||
状态:已完成代码侧与文档侧收口。`/api/page-aggregate/:id` 继续保持 Rust `mnote.page_aggregate.v1` 主读链,但当底层由 `documents:getMeta + documents:getContent` substrate 构建时,response `source` 与 owner header 已明确标为 `CompatMetaContentJoin` / `compat-join`,不再误写成完整 `KernelProjection`。
|
||||
|
||||
目标:Page Aggregate 读链保持 Rust-first,但来源标识必须反映真实构建路径,避免把 `meta/content join` 写成完整 kernel-native projection。
|
||||
|
||||
建议修改范围:
|
||||
|
||||
- `rust/crates/core-protocol/src/page_aggregate.rs`
|
||||
- `rust/crates/bridge-runtime/src/lib.rs`
|
||||
- `rust/crates/mnote-web/src/routes/web_shell.rs`
|
||||
- `wolai-frontend/src/lib/documents/page-aggregate-loader.ts`
|
||||
- `design/05-editor-mainline/process/5-5-page-aggregate-single-truth-alignment-v1.md`
|
||||
- `design/05-editor-mainline/process/5-6-page-aggregate-alignment-checklist-v1.md`
|
||||
|
||||
执行项:
|
||||
|
||||
- [x] 明确 `PageAggregateSource::KernelProjection` 与 `CompatMetaContentJoin` 的边界。
|
||||
- [x] 如果 route 仍由 `getMeta + getContent` 构建,返回 source 或 response header 应反映真实来源。
|
||||
- [x] 文档更新为“Rust route 主读链已成立,底层仍是 Rust runtime adapter 消费 meta/content substrate”。
|
||||
- [x] 保留或删除 `PageAggregateSource::Fixture` 前,先确认测试和本地 fixture 依赖。(本轮保留,仅用于测试或显式 dev fixture 语义。)
|
||||
|
||||
验收标准:
|
||||
|
||||
- [x] `/api/page-aggregate/:id` 返回的 source/provenance 与实际构建路径一致。
|
||||
- [x] 文档中不再出现“Page Aggregate 已完全 kernel-native projection 闭环”的过满表述。
|
||||
- [x] 前端 loader 仍只消费 `mnote.page_aggregate.v1`,不恢复 TS runtime builder 主链。
|
||||
- [x] Page Aggregate provenance 变更有 Rust 单测或 loader 单测覆盖。
|
||||
|
||||
证据:
|
||||
|
||||
- `rust/crates/bridge-runtime/src/lib.rs`:`page.aggregate.get` 根据 query data 是否包含 `meta + content` 选择 `CompatMetaContentJoin`,否则保留 `KernelProjection`。
|
||||
- `rust/crates/mnote-web/src/routes/web_shell.rs`:`/api/page-aggregate/:id` 测试断言 `x-mnote-page-aggregate-owner=compat-join` 且 `result.source=CompatMetaContentJoin`。
|
||||
- `design/05-editor-mainline/process/5-5-page-aggregate-single-truth-alignment-v1.md`:补充 Rust route 主读链与完整 kernel-native projection 的边界。
|
||||
- `design/05-editor-mainline/process/5-6-page-aggregate-alignment-checklist-v1.md`:补充 `CompatMetaContentJoin` / `KernelProjection` 验收口径。
|
||||
- `design/05-editor-mainline/done/5-5-1-page-aggregate-contract-v1.md`:新增 `source` / provenance 契约说明。
|
||||
- 已通过:`cargo test -p bridge-runtime page_aggregate_get_query_executes_into_core_projection -- --nocapture`。
|
||||
- 已通过:`cargo test -p mnote-web page_aggregate_endpoint_returns_snapshot_contract -- --nocapture`。
|
||||
|
||||
## 5. P1:优化 tree stream 的 Convex 查询模型
|
||||
|
||||
状态:已完成代码侧与基础 Rust 3000 smoke。`bridgeLogs:listWorkspaceOverview` 已从全量 workspace collect 后内存分页,改为基于 `workspace_id + created_at + id` 及常用过滤组合索引的有界窗口查询;Rust `/api/tree/events` 的 snapshot/delta/resync 协议未改,polling-backed SSE 仍按“最新窗口 + Rust cursor 比较”工作,当前定位为过渡实现。`task123` 和标题更新目标脚本已证明 Rust 3000 stream 可输出 snapshot / title event;完整浏览器 tree integration smoke 仍因 sidebar row 可见性失败,归入第 6 项继续处理。
|
||||
|
||||
目标:让 `/api/tree/events` 可以长期承载主链,避免 SSE 轮询叠加全量日志扫描。
|
||||
|
||||
建议修改范围:
|
||||
|
||||
- `wolai-frontend/convex/bridgeLogs.ts`
|
||||
- `wolai-frontend/convex/schema.ts`
|
||||
- `rust/crates/mnote-web/src/routes/sse.rs`
|
||||
- `rust/crates/mnote-web/src/routes/stream_support.rs`
|
||||
- `wolai-frontend/src/lib/tree-stream/`
|
||||
|
||||
执行项:
|
||||
|
||||
- [x] 为 `command_logs` 增加按 `workspace_id + created_at/id` 的查询路径。
|
||||
- [x] 为 `domain_events` 增加按 `workspace_id + created_at/id` 或 command cursor 的查询路径。
|
||||
- [x] `listWorkspaceOverview` 不再每轮 collect 全量 workspace 日志后内存分页。
|
||||
- [x] 明确 polling-backed SSE 是过渡实现还是长期实现;若长期使用,补连接数、日志量、延迟边界测试。(本轮明确为过渡实现,live 轮询仍查最新窗口并由 Rust 做 cursor 比较。)
|
||||
|
||||
验收标准:
|
||||
|
||||
- [x] 大量历史日志存在时,overview 查询仍按 cursor / limit 返回,不全量 collect。
|
||||
- [x] `/api/tree/events` 的 snapshot/delta/resync 协议不破坏现有前端消费。
|
||||
- [x] `pnpm --dir wolai-frontend test -- tree-stream` 或等价 tree-stream 测试通过。
|
||||
- [x] 至少一条 smoke 覆盖标题/树变更后 SSE 能正常输出增量或保守 resync。
|
||||
|
||||
证据:
|
||||
|
||||
- `wolai-frontend/convex/schema.ts`:`command_logs` 增加 `by_workspace_created_at`、`by_workspace_status_created_at`、`by_workspace_target_page_created_at`、`by_workspace_target_block_created_at`;`domain_events` 增加 `by_workspace_created_at`、`by_workspace_status_created_at`、`by_workspace_aggregate_created_at`。
|
||||
- `wolai-frontend/convex/bridgeLogs.ts`:`listWorkspaceOverview` 改为 `fetchCommandLogWindow` / `fetchDomainEventWindow`,通过索引 `order("desc").take(scanLimit)` 和 cursor 上界查询,移除对 workspace 全量日志的 `.collect()`。
|
||||
- `rust/crates/mnote-web/src/routes/sse.rs`:live poll 语义保持不变,仍清空 bridge pagination cursor 后查最新窗口,再由 Rust 比较当前 stream cursor。
|
||||
- 已通过:`pnpm test -- src/lib/tree-stream`(Vitest 实际执行 111 个测试文件、452 个测试)。
|
||||
- 已通过:`cargo test -p mnote-web stream_ -- --nocapture`。
|
||||
- 已通过:`cargo test -p mnote-web tree_realtime_route_returns_rust_web_owned_snapshot_event -- --nocapture`。
|
||||
- 已通过:`pnpm exec eslint convex/bridgeLogs.ts convex/schema.ts` 无错误;保留既有 `any` 风格警告。
|
||||
- 已通过:`node scripts/task123-rust-web-tree-live-stream-consumer-smoke.js`。
|
||||
- 已通过:Rust 3000 目标脚本验证 `/api/documents/title` 后 `/api/tree/events?maxPolls=1&pollMs=250` 输出包含新标题的 stream 事件。
|
||||
- 未通过:`node scripts/task120-rust-web-tree-integration-smoke.js` 在等待 child sidebar row 可见时超时,完整浏览器 tree integration 继续归入第 6 项处理。
|
||||
- 受既有问题阻塞:`pnpm exec tsc --noEmit --pretty false` 仍被 `.next` 旧生成文件、Convex documents 类型和 mindmap/OnlyOffice 等无关类型错误阻塞;本轮新增 `bridgeLogs.ts` / `schema.ts` 未出现在错误列表中。
|
||||
|
||||
## 6. P1:统一 Sidebar live cache
|
||||
|
||||
状态:代码侧完成,真实浏览器 create / rename / move / archive / purge 主链已通过。`usePreferredSidebarSnapshot` 已从“sync key 与 initial 对比”的特殊规则,改为基于 `getSidebarDataFreshness()` 的 version 仲裁,并保留 tree stream cursor 作为 live 元信息;`AppLayoutShell`、Sidebar、Breadcrumb、SearchPalette 继续消费同一份 preferred snapshot。Rust 3000 下已修复 `tree:delta` 的 title-only patch 被误当成空 projection 渲染的问题,`task110` 已通过页头、breadcrumb、sidebar、page tree、File Tree 标题一致性检查;`task120` 已通过 child sidebar row 可见性、rename live delta、reload 与 purge 后消失检查;`task177` 已覆盖 move / archive live DOM reducer,确认页面树与 File Tree 行局部更新后文档页头仍稳定。
|
||||
|
||||
目标:减少 `initial / query / tree_stream` 三源 freshness 选择,把 Sidebar、Breadcrumb、文档页头收口到统一 live cache / projection version 仲裁。
|
||||
|
||||
建议修改范围:
|
||||
|
||||
- `wolai-frontend/src/components/app-layout-shell.tsx`
|
||||
- `wolai-frontend/src/components/sidebar/use-preferred-sidebar-snapshot.ts`
|
||||
- `wolai-frontend/src/components/sidebar/sidebar.tsx`
|
||||
- `wolai-frontend/src/lib/tree-stream/`
|
||||
|
||||
执行项:
|
||||
|
||||
- [x] 定义 live snapshot 的 version/cursor 仲裁字段。
|
||||
- [x] `usePreferredSidebarSnapshot` 从 sync key 比较推进到 cursor/version 比较。
|
||||
- [x] Sidebar、Breadcrumb、文档页头统一消费同一 preferred/live snapshot。
|
||||
- [x] 保留 initial snapshot 只作为首屏启动输入,不能长期压过更新后的 stream/query。
|
||||
|
||||
验收标准:
|
||||
|
||||
- [x] 新建、重命名、移动、归档页面后,Sidebar、Breadcrumb、文档页头无互相打架或回退闪烁。
|
||||
- [x] `usePreferredSidebarSnapshot` 有单测覆盖 query、initial、tree_stream 三者的新旧仲裁。
|
||||
- [x] 真实浏览器 smoke 覆盖至少一个 tree command 后 Sidebar 与文档页头一致。
|
||||
|
||||
证据:
|
||||
|
||||
- `wolai-frontend/src/components/sidebar/use-preferred-sidebar-snapshot.ts`:输出 `version` 与 `cursor`,以 `getSidebarDataFreshness()` 比较 initial/query/tree_stream;stream fallback 时不参与仲裁,同版本时优先 live stream。
|
||||
- `wolai-frontend/src/components/app-layout-shell.tsx`:向 preferred snapshot 仲裁传入 `treeStream.cursor`,Sidebar、Breadcrumb、SearchPalette 仍消费同一 preferred data。
|
||||
- `wolai-frontend/src/components/sidebar/sidebar.tsx`:Sidebar 内部 fallback 路径也传入 `treeStream.cursor`。
|
||||
- `wolai-frontend/src/components/sidebar/use-preferred-sidebar-snapshot.test.tsx`:新增 query 比旧 stream 更新时优先 query、stream 与 query 同版本时优先 stream 并暴露 cursor 的回归用例。
|
||||
- 已通过:`pnpm test -- src/components/sidebar/use-preferred-sidebar-snapshot.test.tsx src/components/app-layout-shell.test.tsx src/components/sidebar/sidebar-sync.test.tsx`(Vitest 实际执行 111 个测试文件、454 个测试)。
|
||||
- 已通过:`pnpm exec eslint src/components/sidebar/use-preferred-sidebar-snapshot.ts src/components/sidebar/use-preferred-sidebar-snapshot.test.tsx src/components/app-layout-shell.tsx src/components/sidebar/sidebar.tsx` 无错误;`sidebar.tsx` 保留既有 warning。
|
||||
- 已通过:`cargo test -p mnote-web tree_realtime_route_returns_rust_web_owned_snapshot_event -- --nocapture`。
|
||||
- `rust/crates/mnote-web/src/ssr/pages/layout.rs`:`renderSidebarSnapshot` 只在 payload 明确带 projection items 时重渲染 Sidebar;单文档 `upsert_document` title patch 改为只调用 `updateTitleEverywhere()`,避免 rename delta 把 Sidebar 渲染成空树。
|
||||
- `rust/crates/mnote-web/src/ssr/pages/layout.rs`:新增 `move_document` / `remove_document` 的小型 DOM reducer,移动时更新页面树与 File Tree 行的父节点,归档时局部移除对应行。
|
||||
- `rust/crates/mnote-web/src/transport/convex.rs`:tree create / rename / archive / restore / move 及 compat documents mutation 发送给 legacy Convex mutation 前剥离 artifact-only 字段,避免 validator 因 `streamDeltaHint` / `domainEventPlan` 返回 502。
|
||||
- 已通过:`cargo test -p mnote-web sidebar_tree_runtime -- --nocapture`。
|
||||
- 已通过:`cargo test -p mnote-web convex_command_args_strips -- --nocapture`。
|
||||
- 已通过:`node scripts/task110-page-title-single-truth-smoke.js` 在 Rust 3000 下通过页头、breadcrumb、sidebar、page tree、File Tree 标题一致性检查。
|
||||
- 已通过:`node scripts/task120-rust-web-tree-integration-smoke.js` 在 Rust 3000 下通过 root/child row、active 切换、rename live delta、reload 与 purge 检查。
|
||||
- 已通过:`node scripts/task112-tree-rust-family-regression-smoke.js`,但当前页面选项壳缺少 move/embed 入口导致 picker 部分 skipped:`move_embed_entry_missing_in_current_page_options_shell`。
|
||||
- 已通过:`node scripts/task177-tree-move-archive-live-smoke.js`,覆盖 `move_document` live event 后页面树 / File Tree 行 `data-parent-id` 更新,以及 `remove_document` live event 后两棵树局部移除归档行;文档页头和标题输入保持根页面标题稳定。
|
||||
- 已通过:`node scripts/task179-tree-create-delete-no-reload-smoke.js`,覆盖 Rust `/tree` `convex_workspace` 下 File Tree create/delete 与 Page Tree create;最新结果 `ok=true`,`filetree.createMs=120`、`filetree.deleteMs=178`、`page.createMs=120`,对应 mode 内 `navigationEvents.length=0`,URL 前后一致。
|
||||
|
||||
## 7. P1:明确 `pageSubtree` 与本地正文编辑关系
|
||||
|
||||
状态:已完成。策略已固定为:server `pageSubtree` 仍是正式 projection 来源;当本地标题草稿或正文快照与 server snapshot 不一致时,前端允许用当前本地 content 生成临时 `pageSubtree`,并通过 `pageSubtreeSource=local` 暴露给 AI context,避免继续复用旧 server subtree 或让 AI 长期失去结构上下文。React `DocumentAiAgentPanel` 侧已有 client state / runtime 单测覆盖;Rust 3000 当前文档页使用全局浮动页面 AI,本轮已补同等 local `pageSubtree` context 构造与真实浏览器 smoke。
|
||||
|
||||
目标:用户正常编辑正文后,AI 面板、阅读视图、结构视图不能因为本地 content 与 server snapshot 不同而长期失去结构上下文。
|
||||
|
||||
建议修改范围:
|
||||
|
||||
- `wolai-frontend/src/components/editor/page-aggregate-client-state.ts`
|
||||
- `wolai-frontend/src/components/editor/document-content.tsx`
|
||||
- `wolai-frontend/src/lib/documents/page-subtree.ts`
|
||||
- `rust/crates/core-protocol/src/page_aggregate.rs`
|
||||
|
||||
执行项:
|
||||
|
||||
- [x] 决定 `pageSubtree` 是 server-only projection,还是允许本地临时 projection。
|
||||
- [x] 标记本地临时 projection 与 server projection 的来源差异,避免把本地临时结构误认为 kernel projection。
|
||||
- [x] AI 面板读取最新 page aggregate snapshot 时,能拿到本地编辑后的结构上下文。
|
||||
- [x] 真实编辑器 smoke 验证本地正文编辑后阅读态结构 / AI context 不丢失。
|
||||
|
||||
验收标准:
|
||||
|
||||
- [x] 本地正文或标题草稿变化后,不再继续复用旧 server `pageSubtree`。
|
||||
- [x] 本地正文或标题草稿变化后,可生成临时 `pageSubtree` 供阅读态 / AI context 使用。
|
||||
- [x] AI context 明确带 `pageSubtreeSource`,可区分 `server` / `local` / `none`。
|
||||
- [x] 浏览器实测:编辑正文标题或 heading 后,结构面板 / AI context 能看到本地最新结构。
|
||||
|
||||
证据:
|
||||
|
||||
- `wolai-frontend/src/components/editor/page-aggregate-client-state.ts`:新增 `PageAggregateClientPageSubtreeSource`,server 快照未变时返回 `source=server`;本地 content/title 草稿变化时用 `buildPageSubtreeProjection` 生成 `source=local`。
|
||||
- `wolai-frontend/src/components/editor/DocumentAiAgentPanel.tsx`:`PageAggregateAiSnapshot` 增加 `pageSubtreeSource`。
|
||||
- `wolai-frontend/src/components/editor/DocumentAiAgentPanel.runtime.tsx`:AI request context 增加 `pageSubtreeSource`。
|
||||
- `wolai-frontend/src/components/editor/page-aggregate-client-state.test.ts`:覆盖本地正文/标题变化后生成临时 `pageSubtree`,以及 server snapshot 下 `pageSubtreeSource=server`。
|
||||
- `rust/crates/mnote-web/src/ssr/pages/layout.rs`:Rust 3000 全局浮动页面 AI 在发送 `/api/ai-agent/run` 前读取当前 Leptos/Tiptap 编辑器 DOM;当本地内容与 server aggregate 正文不同,生成 `source=local` 的临时 subtree / outline / evidence,并在 context 中发送 `pageSubtreeSource=local`。
|
||||
- 已通过:`pnpm test -- src/components/editor/page-aggregate-client-state.test.ts src/components/editor/DocumentAiAgentPanel.runtime.test.tsx src/components/editor/document-content.test.ts`(Vitest 实际执行 111 个测试文件、454 个测试)。
|
||||
- 已通过:`node scripts/task178-page-ai-local-subtree-context-smoke.js`,Rust 3000 下打开真实文档页,把编辑器内容改成本地 heading 后打开页面 AI,并拦截 `/api/ai-agent/run` 请求确认 `context.pageSubtreeSource=local`、`documentBlocks` / `outline` / `subtree.stats.headingCount` 均包含本地最新 heading。
|
||||
- 已通过:`pnpm exec eslint src/components/editor/page-aggregate-client-state.ts src/components/editor/page-aggregate-client-state.test.ts src/components/editor/DocumentAiAgentPanel.tsx src/components/editor/DocumentAiAgentPanel.runtime.tsx` 无错误;`DocumentAiAgentPanel.runtime.tsx` 保留既有 `any` warning。
|
||||
|
||||
## 8. P2:清理或标注 stale mapping / legacy compat
|
||||
|
||||
状态:已完成。`/api/documents/page` 与 `/api/mnote-web/stream` 已经明确退场为 410 compat 边界;`storage-convex-bridge` 不再把 `page.aggregate.get` 映射到不存在的 Convex `documents:getPageAggregate`,而是显式落到 `queries:unknown` 边界。Rust `/api/page-aggregate/:id` 当前先读取 `documents.meta.get` / `documents.content.get`,再交给 `bridge-runtime` 的 `page.aggregate.get` 组装 core projection,不走缺失 Convex function。
|
||||
|
||||
目标:保留的 compat 都必须是显式迁移/调试边界;不再让后续实现误把 stale mapping 当主链。
|
||||
|
||||
建议修改范围:
|
||||
|
||||
- `rust/crates/storage-convex-bridge/src/mapping.rs`
|
||||
- `rust/crates/mnote-web/src/app.rs`
|
||||
- `rust/crates/mnote-web/src/routes/gateway.rs`
|
||||
- `rust/crates/mnote-web/src/routes/documents.rs`
|
||||
- `wolai-frontend/src/lib/documents/page-aggregate-builder.ts`
|
||||
- `wolai-frontend/src/app/api/documents/page/route.ts`
|
||||
- `wolai-frontend/src/app/api/mnote-web/stream/route.ts`
|
||||
|
||||
执行项:
|
||||
|
||||
- [x] 处理 `page.aggregate.get -> documents:getPageAggregate`:实现、移除或标注为 future/stale。
|
||||
- [x] legacy Next proxy 保留时,命名与注释统一为 explicit migration/debug boundary。
|
||||
- [x] TS `page-aggregate-builder` 保留为测试/adapter/reference,不得重新接回 runtime 主链。
|
||||
- [x] 410 compat route 保持退场语义,并补负向测试防止重新启用。
|
||||
|
||||
验收标准:
|
||||
|
||||
- [x] 全仓没有生产路径调用不存在的 `documents:getPageAggregate`。
|
||||
- [x] 默认 3000 主链不走 legacy Next proxy。
|
||||
- [x] `/api/documents/page` 与 `/api/mnote-web/stream` 继续保持明确退场响应。
|
||||
- [x] 文档和代码注释不会把 compat/fallback 描述成当前主路径。
|
||||
|
||||
证据:
|
||||
|
||||
- `rust/crates/storage-convex-bridge/src/mapping.rs`:`page.aggregate.get` 显式标注为 stale/future compat placeholder。
|
||||
- `wolai-frontend/src/app/api/documents/page/route.ts`:返回 410,提示直接使用 `/api/page-aggregate/:documentId`。
|
||||
- `wolai-frontend/src/app/api/mnote-web/stream/route.ts`:返回 410,提示直接使用 `/api/tree/events`。
|
||||
- `design/10-review/03-convex-realtime-storage-review.md`:已明确指出 `documents:getPageAggregate` 目前缺少 Convex 实现,属于方向滞后或 future/stale 口径。
|
||||
- `rust/crates/storage-convex-bridge/src/mapping.rs`:`page.aggregate.get` 改为 `queries:unknown`,并注释说明 page aggregate 主读链由 Rust route + `bridge-runtime` 组装,避免误触发不存在的 `documents:getPageAggregate`。
|
||||
- 已通过:`rg -n "documents:getPageAggregate|getPageAggregate" rust wolai-frontend src -g '!node_modules' -g '!target'`,仅剩 mapping 注释说明,无生产调用。
|
||||
- 已通过:`cargo test -p storage-convex-bridge -- --nocapture`。
|
||||
- 已通过:`cargo test -p bridge-runtime page_aggregate_get_query_executes_into_core_projection -- --nocapture`。
|
||||
|
||||
## 9. P2:处理 Mindmap 次级未闭合项
|
||||
|
||||
状态:已完成。Mindmap `export` 不再映射到未受控的 `EXPORT` runtime command,UI state 继续禁用该入口;旧 `/api/mindmap-ai/expand-node` Next route 已显式退役为 410,并指向 AI Agent 内置 `mindmap_expand_node` 工具;legacy MindmapSidebar 中调用旧 route 的补完入口已从渲染层关闭。mindmap 内容保存主链仍保持 `mindmap.command.apply`,没有回退到 Page Aggregate body 保存链;真实 3000 `task169` 已通过,证明 mindmap 与 `index.md` 隔离未回归。
|
||||
|
||||
目标:`05-tree.md` 的 mindmap 污染 `index.md` 已完成;剩余处理能力入口与实现不一致的问题。
|
||||
|
||||
建议修改范围:
|
||||
|
||||
- `wolai-frontend/src/lib/mindmap/mindmap-action-map.ts`
|
||||
- `wolai-frontend/src/lib/mindmap/simple-mind-map-bridge.ts`
|
||||
- `wolai-frontend/src/app/api/mindmap-ai/expand-node/route.ts`
|
||||
- `rust/crates/core-protocol/src/tool.rs`
|
||||
- `rust/crates/bridge-runtime/src/lib.rs`
|
||||
|
||||
执行项:
|
||||
|
||||
- [x] 处理 Mindmap `EXPORT`:加入安全命令并补 smoke,或在 UI state 中禁用并标注延期。
|
||||
- [x] 处理 Mindmap AI expand route:按 Hermes plugin / Rust bridge 重接,或显式退役该 route。
|
||||
- [x] 保持 mindmap 内容保存继续走 `mindmap.command.apply`,不得回退到 Page Aggregate body 保存链。
|
||||
|
||||
验收标准:
|
||||
|
||||
- [x] UI 不展示无法执行的 Mindmap export 能力,或 export 能真实执行并有测试覆盖。
|
||||
- [x] `/api/mindmap-ai/expand-node` 不再表现为“产品入口存在但稳定 501”而无说明。
|
||||
- [x] `node scripts/task169-mindmap-realtime-smoke.js` 继续通过,证明 mindmap 与 `index.md` 隔离未回归。
|
||||
|
||||
证据:
|
||||
|
||||
- `wolai-frontend/src/lib/mindmap/mindmap-action-map.ts`:`export` 改为 `localView`,不再下发 `runtimeCommand: "EXPORT"`。
|
||||
- `wolai-frontend/src/lib/mindmap/mindmap-ui-state.ts`:`unsupportedActionIds` 继续包含 `export`,UI state 中该入口保持禁用。
|
||||
- `wolai-frontend/src/app/api/mindmap-ai/expand-node/route.ts`:旧 Next route 返回 410,`x-mnote-compat-boundary=mindmap-expand-node-route-retired`,并指向 `/api/ai-agent/run` + `mindmap_expand_node`。
|
||||
- `wolai-frontend/src/components/editor/blocks/MindmapSidebar.tsx`:legacy 侧栏中调用 `/api/mindmap-ai/expand-node` 的补完入口通过 `MINDMAP_LEGACY_EXPAND_NODE_ENTRY_ENABLED=false` 从渲染层关闭。
|
||||
- 已通过:`pnpm test -- src/lib/mindmap/mindmap-action-map.test.ts src/lib/mindmap/mindmap-ui-state.test.ts src/lib/mindmap/simple-mind-map-bridge.test.ts src/app/api/mindmap-ai/expand-node/route.test.ts`(Vitest 实际执行 112 个测试文件、457 个测试)。
|
||||
- 已通过:`pnpm exec eslint src/lib/mindmap/mindmap-action-map.ts src/lib/mindmap/mindmap-action-map.test.ts src/lib/mindmap/mindmap-ui-state.test.ts src/app/api/mindmap-ai/expand-node/route.ts src/app/api/mindmap-ai/expand-node/route.test.ts src/components/editor/blocks/MindmapSidebar.tsx` 无错误;保留 `MindmapSidebar.tsx` 既有 `AiPanel` 未使用 warning。
|
||||
- 已通过:`cargo test -p bridge-runtime mindmap_command_apply_plan_uses_kernel_command_facade -- --nocapture`,确认 mindmap 内容保存仍走 `mindmap.command.apply` / `mindmaps:applyCommand` facade。
|
||||
- 已通过:`node scripts/task169-mindmap-realtime-smoke.js`(Rust 3000 已启动,脚本退出码 0)。最终结果 `ok=true`、`failures=[]`,同一页面最终 File Tree mindmap row 数量为 1,`mindmapId=mindmap_1778695090474`,object identity 仍为同一 `objectKind:"mindmap"`。
|
||||
- 已通过:`cargo test -p bridge-runtime mindmap -- --nocapture`,17 个 mindmap 相关测试通过;普通 `mindmap.command.apply` 改为 `mindmap.content.updated + noop`,`mindmaps.put createOnly=false` 也不再默认 tree resync。
|
||||
- 已通过:`cargo test -p mnote-web mindmap -- --nocapture`,6 个 mindmap 相关测试通过,覆盖 Rust `/api/mindmap/:docId/:mindmapId` 的 object artifact 响应。
|
||||
- 已通过:`node scripts/task169-mindmap-realtime-smoke.js` 最新结果 `ok=true`、`failures=[]`;6 条可解析普通 `mindmap.command.apply` 响应均为 `eventType=mindmap.content.updated`、`streamOp=noop`,另有 1 条 Playwright response body 读取失败被记录为 `skippedReadFailures=1`;同一页面最终仍只有 1 条 mindmap asset row,`mindmapId=mindmap_1778698542703`。
|
||||
|
||||
## 10. P3:文档治理与回填
|
||||
|
||||
状态:已完成文档侧收口。`10-review/README.md` 已指向本执行清单;`05-tree.md` 已标明 Resource Tree / ObjectIdentity / mindmap 与 `index.md` 隔离主线由 `4-24` / `5-12` 承接完成;`5-5-1` 已修正 `showHeadingNumbers` / `embedDefaultBlockId` 的 runtime payload 口径;`90-reference` 的对话式尾巴已清理,`design/README.md` 已补充参考目录不能覆盖主线设计的引用边界。
|
||||
|
||||
目标:让设计目录继续反映真实主线,不把参考资料或过时清单误当成当前架构。
|
||||
|
||||
建议修改范围:
|
||||
|
||||
- `design/10-review/README.md`
|
||||
- `design/10-review/05-tree.md`
|
||||
- `design/05-editor-mainline/done/5-5-1-page-aggregate-contract-v1.md`
|
||||
- `design/90-reference/`
|
||||
|
||||
执行项:
|
||||
|
||||
- [x] 在 `05-tree.md` 中补充最终采用的 object editor / object tab 方案摘要,并说明核心执行已由 `4-24` / `5-12` 完成。
|
||||
- [x] 修正 `5-5-1` 中 `showHeadingNumbers` / `embedDefaultBlockId` 已接入 runtime payload 的滞后描述。
|
||||
- [x] 清理 `design/90-reference` 问答式残留,保留为中性参考笔记。
|
||||
- [x] 在引用规范中再次强调 `90-reference` 不参与 process/done 状态判断,不是上位架构来源。
|
||||
|
||||
验收标准:
|
||||
|
||||
- [x] `design/10-review/README.md` 能指向本执行清单。
|
||||
- [x] `05-tree.md` 不再让后续 worker 误以为 Resource Tree / ObjectIdentity / mindmap 止血仍未执行。
|
||||
- [x] `design/90-reference` 的内容不再保留“需要我给你...”这类对话尾巴。
|
||||
- [x] 主线文档对 Page Aggregate、tree realtime、ObjectIdentity 的描述与当前代码事实一致。
|
||||
|
||||
证据:
|
||||
|
||||
- `design/10-review/README.md`:补充执行入口,指向 `06-execution-checklist-and-acceptance.md`,并说明 `05-tree.md` 核心主线已由 `4-24` / `5-12` 承接。
|
||||
- `design/10-review/05-tree.md`:开头增加执行状态,明确本文不再作为待办清单直接执行。
|
||||
- `design/05-editor-mainline/done/5-5-1-page-aggregate-contract-v1.md`:`showHeadingNumbers` / `embedDefaultBlockId` 改为“runtime payload 已贯通,但深语义未完成”的口径。
|
||||
- `design/90-reference/90-1-filetree.md`、`design/90-reference/90-2-yemianshu.md`:删除“需要我给你...”对话尾巴。
|
||||
- `design/README.md`:补充 `90-reference/` 只能作为生态资料或背景材料,不能覆盖主线设计稿。
|
||||
- `design/03-rust-web/done/3-15-runtime-fallback-retirement-checklist-v1.md`:从 `process/` 迁入 `done/`,文件自身 checkbox 已全部完成,且与第 2 / 第 8 项 fallback、compat 退场证据一致。
|
||||
- `design/06-mindmap/done/6-mindmap-phase6-kmind-parity-detail-checklist-v1.md`:从 `process/` 迁入 `done/`,文件自身 checkbox 已全部完成,并已有 `task167-mindmap-kmind-parity-smoke.js` 等 Phase 6 KMind parity 证据。
|
||||
- 已验证:`rg -n "需要我给你|我可以|你要不要|是否需要|请告诉我|如果你愿意|要我|我来" design/90-reference` 无匹配。
|
||||
- 已完成只读状态审计:`design/03-rust-web/process/3-3-rust-web-tree-realtime-event-stream-v1.md`、`design/05-editor-mainline/process/5-5-page-aggregate-single-truth-alignment-v1.md`、`design/05-editor-mainline/process/5-6-page-aggregate-alignment-checklist-v1.md`、`design/06-mindmap/process/6-mindmap-kernel-phase6-projection-editor-v1.md`、`design/06-mindmap/process/6-mindmap-phase6-leptos-ui-shell-reuse-checklist-v1.md`、`design/07-ai/process/7-3-page-ai-hermes-panel-and-mnote-plugin-v1.md` 仍有未完成项或长期主线尾项,继续保留在 `process/`;`design/old/**` 不纳入活跃 process/done 迁移判断。
|
||||
|
||||
## 11. 全局 Done Gate
|
||||
|
||||
全部清单完成前,不应把 10-review 状态描述为“单一真源已完全闭环”。可以描述为:
|
||||
|
||||
> Rust 已经持有主语义主导权,Page Aggregate、tree command、tree realtime、Resource/ObjectIdentity 主线已经切入;剩余工作集中在 artifact 一致性、fallback 退场、realtime 查询模型、provenance 口径和 compat 边界收口。
|
||||
|
||||
全部完成的最低验收:
|
||||
|
||||
- [x] P0 两项全部完成,且有自动化测试或真实 smoke 证据。
|
||||
- [x] `05-tree.md` 已执行事项通过 `task169` 或等价 smoke 防回归。
|
||||
- [x] `/api/page-aggregate/:id` provenance 与真实来源一致。
|
||||
- [x] `/api/tree/events` 不依赖全量日志扫描作为长期查询模型。
|
||||
- [x] 3000 默认主链不返回假业务数据、不走 legacy proxy、不静默 no-op 保存。
|
||||
- [x] 设计文档中的 `process/`、`done/` 状态与真实代码和验收证据一致。
|
||||
|
||||
## 12. 关联缺陷验收状态
|
||||
|
||||
本执行清单本身已完成;与本轮用户反馈直接相关的两个 bug 文档状态如下:
|
||||
|
||||
- `bugs/04-tree-domain/done/4-25-tree-command-create-delete-reload-latency-v1.md`:Rust `/tree` debug shell 的 create / delete 强刷新子项已关闭,`task179` 已通过无 reload 与耗时验收;React Sidebar 直接 create/delete 已改为本地更新,不再同步等待 `refreshTree()`;Rust-family React shell 的 create / rename / move host mutation 回调也已改为本地 apply,不再默认整树 refetch。文档已迁入 `done/`。
|
||||
- `bugs/05-editor-mainline/done/5-11-mindmap-ghost-assets-and-tree-command-latency-v1.md`:ghost mindmap asset 增长的 NodeView document id 误归属链路已止血,`task169` 已通过同一页面单 mindmap row 与 object identity 稳定验收;普通 mindmap 节点编辑 artifact 语义已收窄为 `mindmap.content.updated + noop`;已新增 `task180` 只读候选审计脚本用于清理前取证,并支持 `/api/tree/projections/file` 的只读投影输入以审计 File Tree duplicate row。真实 File Tree projection 审计已确认当前 workspace 下 `fileTreeMindmapRows=10`、`duplicateFileTreeMindmapRows=0`、`candidateCount=0`。历史清理候选为 0,因此本轮不执行数据删除;文档已迁入 `done/`。
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
本目录汇总当前设计与实现的偏差审查结果。四份分域报告分别覆盖 Rust kernel/web、前端编辑器与树体验、Convex/realtime/storage、次级域与设计治理。
|
||||
|
||||
执行入口以 [顺序执行清单与验收标准](./06-execution-checklist-and-acceptance.md) 为准;`05-tree.md` 的 Resource Tree / File Tree / Page Tree、ObjectIdentity、mindmap 与 `index.md` 隔离主线已经由 `4-24` 与 `5-12` 承接到 `done/`,后续只保留防回归验证。
|
||||
|
||||
## 结论
|
||||
|
||||
当前主线方向总体没有跑偏,但实现仍停留在“主链已切、收口未完”的状态。最需要继续收口的是:
|
||||
@@ -36,6 +38,7 @@
|
||||
- [前端编辑器 / 树体验实现偏差审查](./02-frontend-editor-tree-review.md)
|
||||
- [Convex / Realtime / Storage 实现偏差审查](./03-convex-realtime-storage-review.md)
|
||||
- [次级域与设计治理实现偏差审查](./04-secondary-domains-and-design-governance-review.md)
|
||||
- [顺序执行清单与验收标准](./06-execution-checklist-and-acceptance.md)
|
||||
|
||||
## 统一判断
|
||||
|
||||
|
||||
@@ -79,6 +79,4 @@ yy - 复制,p - 粘贴
|
||||
:q - 退出
|
||||
```
|
||||
|
||||
如果你需要将文件树组件集成到自己的Rust项目中,建议根据应用类型(终端/GUI)选择对应的组件库,或参考filetree的实现方式进行定制开发。
|
||||
|
||||
需要我给你一份在 Ratatui 中快速集成 tui-file-explorer 实现 VSCode 风格单面板文件树的最小可运行代码示例吗?
|
||||
如果需要将文件树组件集成到 Rust 项目中,建议根据应用类型(终端/GUI)选择对应的组件库,或参考 filetree 的实现方式进行定制开发。
|
||||
|
||||
@@ -143,5 +143,3 @@ fn main() -> eframe::Result<()> {
|
||||
### 七、总结
|
||||
|
||||
Rust生态中已有丰富的工具可构建Notion风格页面树,从底层数据结构到完整应用全覆盖。若需快速开发,可选择现成组件库或应用;若需高度定制,可基于树结构库+UI框架组合实现,充分发挥Rust的性能与安全优势。
|
||||
|
||||
需要我给你一份在 Tauri 中结合 Rust 后端与前端实现可拖拽 Notion 风格页面树的最小可运行示例吗?
|
||||
|
||||
+2
-1
@@ -1,6 +1,6 @@
|
||||
# design 设计稿索引
|
||||
|
||||
> 更新时间:2026-05-11
|
||||
> 更新时间:2026-05-14
|
||||
>
|
||||
> 状态口径以当前仓库真实代码为准:
|
||||
> - `[done]`:对应阶段或收口目标已经在当前主线代码中成立
|
||||
@@ -51,6 +51,7 @@
|
||||
|
||||
- `90-reference/`
|
||||
- 参考资料,不参与 `[done]/[process]/[recycle]` 状态判断
|
||||
- 引用时只能作为生态资料或背景材料,不能覆盖 `ARCHITECTURE.md`、`AGENTS.md`、`01-05` 当前优先级或对应主线 `process/` / `done/` 设计稿
|
||||
- `old/`
|
||||
- 已废弃或被替代的历史稿件,标题统一标记 `[recycle]`
|
||||
- 每个大类继续按 `process/` 与 `done/` 分层
|
||||
|
||||
+11
-1
@@ -1,7 +1,17 @@
|
||||
# 7 [process] mnote Kernel Phase 7 CLI-First 外置 Agent 统一执行面方案 v4
|
||||
# 7 [recycle] mnote Kernel Phase 7 CLI-First 外置 Agent 统一执行面方案 v4
|
||||
|
||||
> 更新时间:2026-05-09
|
||||
>
|
||||
> 回收说明(2026-05-13):
|
||||
> - 本稿的 `mnote-cli` 唯一长期 agent 执行面口径已被
|
||||
> `/mnt/Data1T/mnote/design/07-ai/process/7-3-page-ai-hermes-panel-and-mnote-plugin-v1.md`
|
||||
> 覆盖。
|
||||
> - 后续长期方向改为:页面 AI 面板只是 Hermes 的页面内客户端;
|
||||
> Hermes session/message/tool event/usage/model 才是会话真相;
|
||||
> mnote 通过 Hermes skill/plugin 暴露页面、树、artifact 与 edge 能力。
|
||||
> - 本稿仅作为历史决策记录保留。`mnote-cli` 仍可作为 mnote plugin 内部适配器,
|
||||
> 但不再是页面 AI 的唯一长期执行面。
|
||||
>
|
||||
> 上位依据:
|
||||
> - `/mnt/Data1T/mnote/ARCHITECTURE.md`
|
||||
> - `/mnt/Data1T/mnote/design/01-05-current-priority-overview.md`
|
||||
@@ -5443,6 +5443,14 @@ fn build_page_aggregate_projection_result(
|
||||
})
|
||||
}
|
||||
|
||||
fn page_aggregate_source_for_data(data: &Value) -> PageAggregateSource {
|
||||
if data.get("meta").is_some() && data.get("content").is_some() {
|
||||
PageAggregateSource::CompatMetaContentJoin
|
||||
} else {
|
||||
PageAggregateSource::KernelProjection
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_mindmap_from_value(data: &Value) -> Result<MindmapTreeNode, BridgeError> {
|
||||
if data.is_null() {
|
||||
return Ok(default_mindmap_tree());
|
||||
@@ -8322,11 +8330,12 @@ fn execute_query_result(
|
||||
}
|
||||
"page.aggregate.get" => {
|
||||
let payload: PageAggregateQueryPayload = parse_payload(query_wire.payload)?;
|
||||
let source = page_aggregate_source_for_data(&data);
|
||||
let result = build_page_aggregate_projection_result(
|
||||
&data,
|
||||
&payload.document_id,
|
||||
payload.workspace_id.as_deref(),
|
||||
PageAggregateSource::KernelProjection,
|
||||
source,
|
||||
)?;
|
||||
serde_json::to_value(result).map_err(|error| {
|
||||
BridgeError::transport(format!("page.aggregate.get result 序列化失败: {error}"))
|
||||
@@ -9032,6 +9041,22 @@ fn execute_command(
|
||||
args_json: json!({
|
||||
"id": payload.document_id,
|
||||
"options": Value::Object(options_json),
|
||||
"streamDeltaHint": tree_resync_required_hint(
|
||||
"page.layout.updateOptions",
|
||||
json!({
|
||||
"documentId": payload.document_id,
|
||||
}),
|
||||
),
|
||||
"domainEventHint": tree_domain_event_hint("page.layout.options_updated"),
|
||||
"domainEventPlan": tree_domain_event_plan(
|
||||
"page.layout.options_updated",
|
||||
tree_resync_required_hint(
|
||||
"page.layout.updateOptions",
|
||||
json!({
|
||||
"documentId": payload.document_id,
|
||||
}),
|
||||
),
|
||||
),
|
||||
}),
|
||||
}))
|
||||
}
|
||||
@@ -9272,6 +9297,22 @@ fn execute_command(
|
||||
}
|
||||
"mindmaps.put" => {
|
||||
let payload: MindmapPutCommandPayload = parse_payload(command_wire.payload.clone())?;
|
||||
let create_only = payload.create_only.unwrap_or(false);
|
||||
let (event_type, stream_delta_hint) = if create_only {
|
||||
(
|
||||
"tree.resource.mindmap.put",
|
||||
tree_stream_delta_hint("resync_required", json!({
|
||||
"reason": "mindmap.put",
|
||||
"documentId": payload.document_id.clone(),
|
||||
"blockId": payload.mindmap_id.clone(),
|
||||
})),
|
||||
)
|
||||
} else {
|
||||
(
|
||||
"mindmap.content.updated",
|
||||
tree_stream_delta_hint("noop", json!({})),
|
||||
)
|
||||
};
|
||||
let command = CommandEnvelope {
|
||||
name: "mindmaps.put".into(),
|
||||
command_id: command_wire.command_id.clone(),
|
||||
@@ -9285,7 +9326,7 @@ fn execute_command(
|
||||
data_json: serde_json::to_string(&payload.data).map_err(|error| {
|
||||
BridgeError::validation(format!("mindmaps.put data 序列化失败: {error}"))
|
||||
})?,
|
||||
create_only: payload.create_only.unwrap_or(false),
|
||||
create_only,
|
||||
},
|
||||
reason: command_wire.reason,
|
||||
refs: command_wire.refs,
|
||||
@@ -9308,20 +9349,12 @@ fn execute_command(
|
||||
"docId": payload.document_id.clone(),
|
||||
"mindmapId": payload.mindmap_id.clone(),
|
||||
"data": payload.data,
|
||||
"createOnly": payload.create_only.unwrap_or(false),
|
||||
"streamDeltaHint": tree_stream_delta_hint("resync_required", json!({
|
||||
"reason": "mindmap.put",
|
||||
"documentId": payload.document_id.clone(),
|
||||
"blockId": payload.mindmap_id.clone(),
|
||||
})),
|
||||
"domainEventHint": tree_domain_event_hint("tree.resource.mindmap.put"),
|
||||
"createOnly": create_only,
|
||||
"streamDeltaHint": stream_delta_hint.clone(),
|
||||
"domainEventHint": tree_domain_event_hint(event_type),
|
||||
"domainEventPlan": tree_domain_event_plan(
|
||||
"tree.resource.mindmap.put",
|
||||
tree_stream_delta_hint("resync_required", json!({
|
||||
"reason": "mindmap.put",
|
||||
"documentId": payload.document_id.clone(),
|
||||
"blockId": payload.mindmap_id.clone(),
|
||||
})),
|
||||
event_type,
|
||||
stream_delta_hint,
|
||||
),
|
||||
}),
|
||||
}))
|
||||
@@ -9364,19 +9397,11 @@ fn execute_command(
|
||||
"commands": payload.commands,
|
||||
"projectionRevision": payload.projection_revision,
|
||||
"canonicalCommand": "mindmap.command.apply",
|
||||
"streamDeltaHint": tree_stream_delta_hint("resync_required", json!({
|
||||
"reason": "mindmap.command.apply",
|
||||
"documentId": payload.document_id.clone(),
|
||||
"blockId": payload.mindmap_id.clone(),
|
||||
})),
|
||||
"domainEventHint": tree_domain_event_hint("tree.resource.mindmap.updated"),
|
||||
"streamDeltaHint": tree_stream_delta_hint("noop", json!({})),
|
||||
"domainEventHint": tree_domain_event_hint("mindmap.content.updated"),
|
||||
"domainEventPlan": tree_domain_event_plan(
|
||||
"tree.resource.mindmap.updated",
|
||||
tree_stream_delta_hint("resync_required", json!({
|
||||
"reason": "mindmap.command.apply",
|
||||
"documentId": payload.document_id.clone(),
|
||||
"blockId": payload.mindmap_id.clone(),
|
||||
})),
|
||||
"mindmap.content.updated",
|
||||
tree_stream_delta_hint("noop", json!({})),
|
||||
),
|
||||
}),
|
||||
}))
|
||||
@@ -11396,7 +11421,7 @@ mod tests {
|
||||
|
||||
assert_eq!(result["schema"], json!("mnote.page_aggregate.v1"));
|
||||
assert_eq!(result["projectionVersion"], json!(1));
|
||||
assert_eq!(result["source"], json!("KernelProjection"));
|
||||
assert_eq!(result["source"], json!("CompatMetaContentJoin"));
|
||||
assert_eq!(result["pageId"], json!("doc_1"));
|
||||
assert_eq!(result["identity"]["documentId"], json!("doc_1"));
|
||||
assert_eq!(result["body"]["revision"], json!(7));
|
||||
@@ -11748,11 +11773,11 @@ mod tests {
|
||||
);
|
||||
assert_eq!(
|
||||
plan.args_json["domainEventPlan"]["eventType"],
|
||||
json!("tree.resource.mindmap.updated")
|
||||
json!("mindmap.content.updated")
|
||||
);
|
||||
assert_eq!(
|
||||
plan.args_json["streamDeltaHint"]["kind"],
|
||||
json!("resync_required")
|
||||
json!("noop")
|
||||
);
|
||||
}
|
||||
RuntimeExecutionPlan::Query(_) | RuntimeExecutionPlan::Tool(_) => {
|
||||
@@ -12096,6 +12121,77 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mindmaps_put_existing_content_update_uses_object_event_without_tree_resync() {
|
||||
let plan = execute_runtime_input(RuntimeInput::Command {
|
||||
context: demo_context(),
|
||||
command: RuntimeCommandEnvelopeWire {
|
||||
name: "mindmaps.put".into(),
|
||||
command_id: "cmd_mindmap_put_update".into(),
|
||||
idempotency_key: None,
|
||||
actor: RuntimeActorWire {
|
||||
actor_type: "user".into(),
|
||||
actor_id: "user_1".into(),
|
||||
session_id: None,
|
||||
},
|
||||
source: RuntimeSourceWire {
|
||||
channel: "rust-web".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: Some("mind_1".into()),
|
||||
}),
|
||||
payload: json!({
|
||||
"documentId": "doc_1",
|
||||
"mindmapId": "mind_1",
|
||||
"data": {
|
||||
"data": {"text": "中心主题已更新"},
|
||||
"children": [],
|
||||
},
|
||||
"createOnly": false,
|
||||
}),
|
||||
preflight_data: None,
|
||||
reason: Some("更新导图内容".into()),
|
||||
refs: vec![],
|
||||
dry_run: false,
|
||||
validate_only: false,
|
||||
},
|
||||
})
|
||||
.expect("mindmap update command plan should build");
|
||||
|
||||
match plan {
|
||||
RuntimeExecutionPlan::Command(plan) => {
|
||||
assert_eq!(
|
||||
plan.args_json["streamDeltaHint"],
|
||||
json!({
|
||||
"family": "tree",
|
||||
"kind": "noop",
|
||||
"args": {}
|
||||
})
|
||||
);
|
||||
assert_eq!(
|
||||
plan.args_json["domainEventPlan"]["eventType"],
|
||||
json!("mindmap.content.updated")
|
||||
);
|
||||
assert_eq!(
|
||||
plan.args_json["domainEventPlan"]["streamDeltaHint"],
|
||||
json!({
|
||||
"family": "tree",
|
||||
"kind": "noop",
|
||||
"args": {}
|
||||
})
|
||||
);
|
||||
}
|
||||
_ => panic!("expected command plan"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn docs_search_tool_plan_uses_transport_and_transform_steps() {
|
||||
let plan = execute_runtime_input(RuntimeInput::Tool {
|
||||
@@ -15539,13 +15635,39 @@ mod tests {
|
||||
RuntimeExecutionPlan::Command(plan) => {
|
||||
assert_eq!(plan.command_name, "documents.options.update");
|
||||
assert_eq!(plan.function_name, "documents:updateOptions");
|
||||
assert_eq!(plan.args_json["id"], json!("doc_1"));
|
||||
assert_eq!(
|
||||
plan.args_json,
|
||||
plan.args_json["options"],
|
||||
json!({
|
||||
"id": "doc_1",
|
||||
"options": {
|
||||
"showToc": true,
|
||||
"layoutDensity": "compact",
|
||||
"showToc": true,
|
||||
"layoutDensity": "compact",
|
||||
})
|
||||
);
|
||||
assert_eq!(
|
||||
plan.args_json["streamDeltaHint"],
|
||||
json!({
|
||||
"family": "tree",
|
||||
"kind": "resync_required",
|
||||
"args": {
|
||||
"reason": "page.layout.updateOptions",
|
||||
"documentId": "doc_1",
|
||||
},
|
||||
})
|
||||
);
|
||||
assert_eq!(
|
||||
plan.args_json["domainEventPlan"],
|
||||
json!({
|
||||
"family": "tree",
|
||||
"schema": "mnote.tree.domain_event",
|
||||
"schemaVersion": 1,
|
||||
"eventType": "page.layout.options_updated",
|
||||
"streamDeltaHint": {
|
||||
"family": "tree",
|
||||
"kind": "resync_required",
|
||||
"args": {
|
||||
"reason": "page.layout.updateOptions",
|
||||
"documentId": "doc_1",
|
||||
},
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
@@ -72,6 +72,10 @@ impl WebError {
|
||||
pub fn message(&self) -> &str {
|
||||
&self.message
|
||||
}
|
||||
|
||||
pub fn status(&self) -> StatusCode {
|
||||
self.status
|
||||
}
|
||||
}
|
||||
|
||||
impl IntoResponse for WebError {
|
||||
|
||||
@@ -102,6 +102,14 @@ fn ok_response(context: &RequestContext, result: Value) -> (StatusCode, HeaderMa
|
||||
)
|
||||
}
|
||||
|
||||
fn execution_artifacts_json(execution: &crate::transport::convex::ConvexCommandExecution) -> Value {
|
||||
execution
|
||||
.artifacts
|
||||
.as_ref()
|
||||
.and_then(|artifacts| serde_json::to_value(artifacts).ok())
|
||||
.unwrap_or(Value::Null)
|
||||
}
|
||||
|
||||
fn stamp_documents_headers(headers: &mut HeaderMap) {
|
||||
if let Ok(name) = HeaderName::from_lowercase(HEADER_MNOTE_WEB_OWNER.as_bytes()) {
|
||||
headers.insert(name, HeaderValue::from_static("mnote-web"));
|
||||
@@ -711,13 +719,16 @@ pub async fn title(
|
||||
dry_run: false,
|
||||
validate_only: false,
|
||||
};
|
||||
let result = execute_runtime_command_via_convex(
|
||||
let execution = execute_runtime_command_via_convex_with_artifacts(
|
||||
state.config(),
|
||||
&context,
|
||||
effective_workspace_id.as_deref(),
|
||||
command,
|
||||
)
|
||||
.await?;
|
||||
let artifacts = execution_artifacts_json(&execution);
|
||||
let artifact_error = execution.artifact_error.clone();
|
||||
let result = execution.result;
|
||||
let mut headers = HeaderMap::new();
|
||||
stamp_documents_headers(&mut headers);
|
||||
Ok((
|
||||
@@ -731,6 +742,8 @@ pub async fn title(
|
||||
"meta": {
|
||||
"commandName": "page.head.updateTitle",
|
||||
"canonicalCommand": "page.head.updateTitle",
|
||||
"artifacts": artifacts,
|
||||
"artifactError": artifact_error,
|
||||
},
|
||||
"result": result,
|
||||
})),
|
||||
@@ -799,13 +812,16 @@ pub async fn options(
|
||||
dry_run: false,
|
||||
validate_only: false,
|
||||
};
|
||||
let result = execute_runtime_command_via_convex(
|
||||
let execution = execute_runtime_command_via_convex_with_artifacts(
|
||||
state.config(),
|
||||
&context,
|
||||
effective_workspace_id.as_deref(),
|
||||
command,
|
||||
)
|
||||
.await?;
|
||||
let artifacts = execution_artifacts_json(&execution);
|
||||
let artifact_error = execution.artifact_error.clone();
|
||||
let result = execution.result;
|
||||
let mut headers = HeaderMap::new();
|
||||
stamp_documents_headers(&mut headers);
|
||||
Ok((
|
||||
@@ -819,6 +835,8 @@ pub async fn options(
|
||||
"meta": {
|
||||
"commandName": "page.layout.updateOptions",
|
||||
"canonicalCommand": "page.layout.updateOptions",
|
||||
"artifacts": artifacts,
|
||||
"artifactError": artifact_error,
|
||||
},
|
||||
"result": result,
|
||||
})),
|
||||
@@ -907,6 +925,14 @@ mod tests {
|
||||
"updated_at": "2026-04-18T09:45:00Z",
|
||||
"revision": 8,
|
||||
"conflict_detection_key": "doc_1:8"
|
||||
},
|
||||
"bridgeLogs:recordCommandLog": {
|
||||
"ok": true,
|
||||
"id": "clog_fixture"
|
||||
},
|
||||
"bridgeLogs:recordDomainEvent": {
|
||||
"ok": true,
|
||||
"id": "evt_fixture"
|
||||
}
|
||||
}"#
|
||||
.into(),
|
||||
@@ -1066,6 +1092,24 @@ mod tests {
|
||||
let payload: Value = serde_json::from_slice(&body).expect("json");
|
||||
assert_eq!(payload["meta"]["commandName"], "page.head.updateTitle");
|
||||
assert_eq!(payload["meta"]["canonicalCommand"], "page.head.updateTitle");
|
||||
assert_eq!(
|
||||
payload["meta"]["artifacts"]["commandLog"]["commandName"],
|
||||
"page.head.updateTitle"
|
||||
);
|
||||
assert_eq!(
|
||||
payload["meta"]["artifacts"]["domainEvent"]["eventType"],
|
||||
"tree.node.renamed"
|
||||
);
|
||||
assert_eq!(
|
||||
payload["meta"]["artifacts"]["domainEvent"]["payload"]["streamDelta"]["op"],
|
||||
"upsert_document"
|
||||
);
|
||||
assert_eq!(
|
||||
payload["meta"]["artifacts"]["domainEvent"]["payload"]["streamDelta"]["document"]
|
||||
["title"],
|
||||
"服务端页面(改名)"
|
||||
);
|
||||
assert_eq!(payload["meta"]["artifactError"], Value::Null);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -1102,6 +1146,19 @@ mod tests {
|
||||
payload["meta"]["canonicalCommand"],
|
||||
"page.layout.updateOptions"
|
||||
);
|
||||
assert_eq!(
|
||||
payload["meta"]["artifacts"]["commandLog"]["commandName"],
|
||||
"page.layout.updateOptions"
|
||||
);
|
||||
assert_eq!(
|
||||
payload["meta"]["artifacts"]["domainEvent"]["eventType"],
|
||||
"page.layout.options_updated"
|
||||
);
|
||||
assert_eq!(
|
||||
payload["meta"]["artifacts"]["domainEvent"]["payload"]["streamDelta"]["op"],
|
||||
"resync_required"
|
||||
);
|
||||
assert_eq!(payload["meta"]["artifactError"], Value::Null);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
@@ -402,4 +402,51 @@ mod tests {
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mindmap_command_apply_returns_object_artifacts_without_tree_resync() {
|
||||
let response = app()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/mindmap/doc_1/mind_1")
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(
|
||||
json!({
|
||||
"commandName": "mindmap.command.apply",
|
||||
"commands": [
|
||||
{
|
||||
"type": "updateText",
|
||||
"mindmapId": "mind_1",
|
||||
"nodeId": "root",
|
||||
"text": "KMIND 已更新"
|
||||
}
|
||||
],
|
||||
"projectionRevision": 1
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.expect("request"),
|
||||
)
|
||||
.await
|
||||
.expect("response");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let body = to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.expect("body");
|
||||
let payload: Value = serde_json::from_slice(&body).expect("json");
|
||||
|
||||
assert_eq!(payload["commandName"], "mindmap.command.apply");
|
||||
assert_eq!(
|
||||
payload["artifacts"]["domainEvent"]["eventType"],
|
||||
"mindmap.content.updated"
|
||||
);
|
||||
assert_eq!(
|
||||
payload["artifacts"]["domainEvent"]["payload"]["streamDelta"],
|
||||
json!({
|
||||
"op": "noop"
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
use crate::app::AppState;
|
||||
use crate::app::AppConfig;
|
||||
use crate::error::WebError;
|
||||
use adapter_onlyoffice::{prepare_proxy_request, sign_config, OnlyOfficeProxyPreparationInput};
|
||||
use axum::body::{Body, Bytes};
|
||||
use axum::extract::{Path, Query, State};
|
||||
use axum::http::{header, HeaderMap, Method, Request, Uri};
|
||||
use axum::http::{header, HeaderMap, HeaderValue, Method, Request, Uri};
|
||||
use axum::response::{Html, IntoResponse, Response};
|
||||
use axum::Json;
|
||||
use serde::Deserialize;
|
||||
@@ -715,6 +716,8 @@ pub async fn proxy(
|
||||
}
|
||||
|
||||
pub async fn callback(
|
||||
State(state): State<AppState>,
|
||||
uri: Uri,
|
||||
Query(query): Query<OnlyOfficeCallbackQuery>,
|
||||
Json(body): Json<Value>,
|
||||
) -> Response {
|
||||
@@ -728,10 +731,41 @@ pub async fn callback(
|
||||
status,
|
||||
"OnlyOffice callback received by mnote-web"
|
||||
);
|
||||
Json(json!({ "error": 0 })).into_response()
|
||||
match proxy_legacy_onlyoffice_json(
|
||||
state.config(),
|
||||
"/api/onlyoffice/callback",
|
||||
uri.query(),
|
||||
Some(body),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(response) => response,
|
||||
Err(error) if error.status() == axum::http::StatusCode::NOT_IMPLEMENTED => (
|
||||
axum::http::StatusCode::NOT_IMPLEMENTED,
|
||||
Json(json!({
|
||||
"error": 1,
|
||||
"degraded": true,
|
||||
"code": "onlyoffice_legacy_writeback_unavailable",
|
||||
"message": error.message(),
|
||||
})),
|
||||
)
|
||||
.into_response(),
|
||||
Err(error) => (
|
||||
axum::http::StatusCode::BAD_GATEWAY,
|
||||
Json(json!({
|
||||
"error": 1,
|
||||
"degraded": true,
|
||||
"code": "onlyoffice_legacy_writeback_failed",
|
||||
"message": error.message(),
|
||||
})),
|
||||
)
|
||||
.into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn forcesave(
|
||||
State(state): State<AppState>,
|
||||
uri: Uri,
|
||||
Query(query): Query<OnlyOfficeForcesaveQuery>,
|
||||
) -> Result<Response, WebError> {
|
||||
let asset_id = query
|
||||
@@ -750,13 +784,85 @@ pub async fn forcesave(
|
||||
.ok_or_else(|| {
|
||||
WebError::bad_request_code("onlyoffice_forcesave_key_missing", "缺少 key")
|
||||
})?;
|
||||
Ok(Json(json!({
|
||||
"ok": true,
|
||||
"via": "mnote-web-rust-noop",
|
||||
"assetId": asset_id,
|
||||
"key": key,
|
||||
}))
|
||||
.into_response())
|
||||
let response = proxy_legacy_onlyoffice_json(
|
||||
state.config(),
|
||||
"/api/onlyoffice/forcesave",
|
||||
uri.query(),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.map_err(|error| {
|
||||
if error.status() == axum::http::StatusCode::NOT_IMPLEMENTED {
|
||||
return WebError::new(
|
||||
axum::http::StatusCode::NOT_IMPLEMENTED,
|
||||
"onlyoffice_legacy_writeback_unavailable",
|
||||
format!("OnlyOffice forcesave 未配置 legacy Next 写回链: assetId={asset_id}, key={key}"),
|
||||
);
|
||||
}
|
||||
error
|
||||
})?;
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
fn legacy_onlyoffice_writeback_base(config: &AppConfig) -> Option<String> {
|
||||
config
|
||||
.legacy_next_base_url
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(|value| value.trim_end_matches('/').to_string())
|
||||
}
|
||||
|
||||
async fn proxy_legacy_onlyoffice_json(
|
||||
config: &AppConfig,
|
||||
path: &str,
|
||||
query: Option<&str>,
|
||||
body: Option<Value>,
|
||||
) -> Result<Response, WebError> {
|
||||
let base = legacy_onlyoffice_writeback_base(config).ok_or_else(|| {
|
||||
WebError::new(
|
||||
axum::http::StatusCode::NOT_IMPLEMENTED,
|
||||
"onlyoffice_legacy_writeback_unavailable",
|
||||
"OnlyOffice Rust route 暂未直接写回,且未配置 legacy Next 写回链",
|
||||
)
|
||||
})?;
|
||||
let target = append_path_and_query(&base, path, query);
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(30))
|
||||
.build()
|
||||
.map_err(|error| {
|
||||
WebError::internal(format!("OnlyOffice legacy proxy HTTP 客户端创建失败: {error}"))
|
||||
})?;
|
||||
let mut request = client
|
||||
.post(target)
|
||||
.header(header::CONTENT_TYPE, "application/json");
|
||||
if let Some(body) = body {
|
||||
request = request.json(&body);
|
||||
} else {
|
||||
request = request.body("{}");
|
||||
}
|
||||
let upstream = request.send().await.map_err(|error| {
|
||||
WebError::bad_gateway_code(
|
||||
"onlyoffice_legacy_writeback_failed",
|
||||
format!("OnlyOffice legacy 写回请求失败: {error}"),
|
||||
)
|
||||
})?;
|
||||
let status = upstream.status();
|
||||
let content_type = upstream
|
||||
.headers()
|
||||
.get(header::CONTENT_TYPE)
|
||||
.cloned()
|
||||
.unwrap_or_else(|| HeaderValue::from_static("application/json"));
|
||||
let bytes = upstream.bytes().await.map_err(|error| {
|
||||
WebError::bad_gateway_code(
|
||||
"onlyoffice_legacy_writeback_failed",
|
||||
format!("OnlyOffice legacy 写回响应读取失败: {error}"),
|
||||
)
|
||||
})?;
|
||||
let mut response = Response::new(Body::from(bytes));
|
||||
*response.status_mut() = status;
|
||||
response.headers_mut().insert(header::CONTENT_TYPE, content_type);
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
fn append_path_and_query(base: &str, path: &str, query: Option<&str>) -> String {
|
||||
@@ -987,6 +1093,13 @@ fn js_hash_abs(input: &str) -> String {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::app::{AppConfig, AppState};
|
||||
use axum::extract::State;
|
||||
use axum::http::StatusCode;
|
||||
use serde_json::json;
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::net::TcpListener;
|
||||
use tokio::sync::oneshot;
|
||||
|
||||
#[test]
|
||||
fn stable_doc_key_uses_onlyoffice_safe_characters() {
|
||||
@@ -1003,4 +1116,166 @@ mod tests {
|
||||
let candidates = onlyoffice_internal_candidates();
|
||||
assert!(candidates.contains(&DEFAULT_ONLYOFFICE_INTERNAL_URL.to_string()));
|
||||
}
|
||||
|
||||
fn test_state(legacy_next_base_url: Option<String>) -> AppState {
|
||||
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,
|
||||
enable_legacy_next_compat: true,
|
||||
enable_debug_shell_routes: false,
|
||||
hermes_base_path: "/api/hermes".into(),
|
||||
compat_next_base_path: "/api/compat/next".into(),
|
||||
convex_url: None,
|
||||
convex_admin_key: None,
|
||||
allow_dev_fixtures: false,
|
||||
query_fixtures_json: None,
|
||||
mutation_fixtures_json: None,
|
||||
dev_user_id: "dev-user".into(),
|
||||
dev_user_name: "开发用户".into(),
|
||||
dev_user_email: "dev@mnote.local".into(),
|
||||
})
|
||||
}
|
||||
|
||||
async fn spawn_legacy_json_server(
|
||||
response_body: &'static str,
|
||||
) -> (String, oneshot::Receiver<String>) {
|
||||
let listener = TcpListener::bind("127.0.0.1:0")
|
||||
.await
|
||||
.expect("bind");
|
||||
let addr = listener.local_addr().expect("addr");
|
||||
let (tx, rx) = oneshot::channel();
|
||||
tokio::spawn(async move {
|
||||
let (mut stream, _) = listener.accept().await.expect("accept");
|
||||
let mut buffer = vec![0_u8; 8192];
|
||||
let read = stream.read(&mut buffer).await.expect("read");
|
||||
let request = String::from_utf8_lossy(&buffer[..read]).to_string();
|
||||
let _ = tx.send(request);
|
||||
let response = format!(
|
||||
"HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}",
|
||||
response_body.len(),
|
||||
response_body
|
||||
);
|
||||
stream.write_all(response.as_bytes()).await.expect("write");
|
||||
});
|
||||
(format!("http://{addr}"), rx)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn onlyoffice_callback_without_legacy_next_fails_explicitly() {
|
||||
let response = callback(
|
||||
State(test_state(None)),
|
||||
"/api/onlyoffice/callback?assetId=asset_1"
|
||||
.parse::<Uri>()
|
||||
.expect("uri"),
|
||||
Query(OnlyOfficeCallbackQuery {
|
||||
asset_id: Some("asset_1".into()),
|
||||
user_id: None,
|
||||
}),
|
||||
Json(json!({
|
||||
"status": 2,
|
||||
"url": "http://127.0.0.1:8082/cache/files/out.docx"
|
||||
})),
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(response.status(), StatusCode::NOT_IMPLEMENTED);
|
||||
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.expect("body");
|
||||
let payload: Value = serde_json::from_slice(&body).expect("json");
|
||||
assert_eq!(payload["error"], 1);
|
||||
assert_eq!(payload["degraded"], true);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn onlyoffice_callback_proxies_to_legacy_next_writeback() {
|
||||
let (base_url, captured) = spawn_legacy_json_server(r#"{"error":0}"#).await;
|
||||
let response = callback(
|
||||
State(test_state(Some(base_url))),
|
||||
"/api/onlyoffice/callback?assetId=asset_1&userId=user_1"
|
||||
.parse::<Uri>()
|
||||
.expect("uri"),
|
||||
Query(OnlyOfficeCallbackQuery {
|
||||
asset_id: Some("asset_1".into()),
|
||||
user_id: Some("user_1".into()),
|
||||
}),
|
||||
Json(json!({
|
||||
"status": 2,
|
||||
"key": "doc_key",
|
||||
"url": "http://127.0.0.1:8082/cache/files/out.docx"
|
||||
})),
|
||||
)
|
||||
.await;
|
||||
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.expect("body");
|
||||
let payload: Value = serde_json::from_slice(&body).expect("json");
|
||||
let request = captured.await.expect("captured");
|
||||
|
||||
assert_eq!(payload["error"], 0);
|
||||
assert!(request.starts_with(
|
||||
"POST /api/onlyoffice/callback?assetId=asset_1&userId=user_1 HTTP/1.1"
|
||||
));
|
||||
assert!(request.contains(r#""status":2"#));
|
||||
assert!(request.contains(r#""key":"doc_key""#));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn onlyoffice_forcesave_without_legacy_next_is_not_noop_success() {
|
||||
let response = forcesave(
|
||||
State(test_state(None)),
|
||||
"/api/onlyoffice/forcesave?assetId=asset_1&key=doc_key"
|
||||
.parse::<Uri>()
|
||||
.expect("uri"),
|
||||
Query(OnlyOfficeForcesaveQuery {
|
||||
asset_id: Some("asset_1".into()),
|
||||
key: Some("doc_key".into()),
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.map(IntoResponse::into_response)
|
||||
.unwrap_or_else(IntoResponse::into_response);
|
||||
|
||||
assert_eq!(response.status(), StatusCode::NOT_IMPLEMENTED);
|
||||
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.expect("body");
|
||||
let payload: Value = serde_json::from_slice(&body).expect("json");
|
||||
assert_eq!(payload["ok"], false);
|
||||
assert_eq!(payload["code"], "onlyoffice_legacy_writeback_unavailable");
|
||||
assert_ne!(payload["via"], "mnote-web-rust-noop");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn onlyoffice_forcesave_proxies_to_legacy_next_writeback() {
|
||||
let (base_url, captured) =
|
||||
spawn_legacy_json_server(r#"{"ok":true,"via":"forcesave","result":{"error":0}}"#)
|
||||
.await;
|
||||
let response = forcesave(
|
||||
State(test_state(Some(base_url))),
|
||||
"/api/onlyoffice/forcesave?assetId=asset_1&key=doc_key"
|
||||
.parse::<Uri>()
|
||||
.expect("uri"),
|
||||
Query(OnlyOfficeForcesaveQuery {
|
||||
asset_id: Some("asset_1".into()),
|
||||
key: Some("doc_key".into()),
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.expect("response");
|
||||
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.expect("body");
|
||||
let payload: Value = serde_json::from_slice(&body).expect("json");
|
||||
let request = captured.await.expect("captured");
|
||||
|
||||
assert_eq!(payload["ok"], true);
|
||||
assert_eq!(payload["via"], "forcesave");
|
||||
assert!(request.starts_with(
|
||||
"POST /api/onlyoffice/forcesave?assetId=asset_1&key=doc_key HTTP/1.1"
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -175,6 +175,8 @@ pub async fn documents(
|
||||
"owner": "mnote-web",
|
||||
"projectionOwner": result.get("projectionOwner").cloned().unwrap_or_else(|| json!("rust-kernel")),
|
||||
"queryName": "search.documents.query",
|
||||
"degraded": result.get("degraded").cloned().unwrap_or_else(|| json!(false)),
|
||||
"degradedReason": result.get("degradedReason").cloned().unwrap_or(Value::Null),
|
||||
"requestId": context.trace.request_id,
|
||||
"traceId": context.trace.trace_id,
|
||||
},
|
||||
@@ -239,12 +241,13 @@ async fn load_search_results_with_filters(
|
||||
.await
|
||||
{
|
||||
Ok(value) => Ok(value),
|
||||
Err(_) => execute_runtime_query_against_data(
|
||||
Err(_) if config.allow_dev_fixtures => execute_runtime_query_against_data(
|
||||
context,
|
||||
Some(workspace_id),
|
||||
runtime_query,
|
||||
fallback_search_dataset(workspace_id),
|
||||
),
|
||||
Err(error) => Ok(degraded_empty_search_projection(error.message())),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -256,6 +259,16 @@ fn empty_search_projection() -> Value {
|
||||
})
|
||||
}
|
||||
|
||||
fn degraded_empty_search_projection(reason: &str) -> Value {
|
||||
json!({
|
||||
"enqueueAssetIds": [],
|
||||
"projectionOwner": "rust-kernel",
|
||||
"results": [],
|
||||
"degraded": true,
|
||||
"degradedReason": reason,
|
||||
})
|
||||
}
|
||||
|
||||
fn fallback_search_dataset(workspace_id: &str) -> Value {
|
||||
json!({
|
||||
"documents": [
|
||||
@@ -413,6 +426,8 @@ mod tests {
|
||||
assert!(html.contains("search.documents.query"));
|
||||
assert!(html.contains("search-result"));
|
||||
assert!(html.contains("Rust Web 搜索结果"));
|
||||
assert!(html.contains("data-mnote-dev-fixture=\"true\""));
|
||||
assert!(html.contains("data-mnote-dev-fixture-kind=\"sidebar-tree\""));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -467,4 +482,53 @@ mod tests {
|
||||
assert_eq!(payload["meta"]["projectionOwner"], "rust-kernel");
|
||||
assert_eq!(payload["projectionOwner"], "rust-kernel");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn search_documents_does_not_return_builtin_fixture_when_convex_unavailable() {
|
||||
let app = 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,
|
||||
hermes_base_path: "/api/hermes".into(),
|
||||
compat_next_base_path: "/api/compat/next".into(),
|
||||
convex_url: None,
|
||||
convex_admin_key: None,
|
||||
allow_dev_fixtures: false,
|
||||
query_fixtures_json: None,
|
||||
mutation_fixtures_json: None,
|
||||
dev_user_id: "dev-user".into(),
|
||||
dev_user_name: "开发用户".into(),
|
||||
dev_user_email: "dev@mnote.local".into(),
|
||||
}));
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/search/documents")
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(
|
||||
json!({
|
||||
"workspaceId": "ws_demo",
|
||||
"query": "Hermes"
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.expect("request"),
|
||||
)
|
||||
.await
|
||||
.expect("response");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let body = to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.expect("body");
|
||||
let payload: Value = serde_json::from_slice(&body).expect("json");
|
||||
assert_eq!(payload["results"], json!([]));
|
||||
assert_eq!(payload["meta"]["degraded"], true);
|
||||
assert!(!payload.to_string().contains("Hermes 技能知识图谱开发"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2814,13 +2814,17 @@ fn build_tree_shell_html(
|
||||
}
|
||||
if (sourceKind === "convex_workspace") {
|
||||
for (const item of deletableItems) {
|
||||
const documentId = getFileTreeRowDocumentId(item);
|
||||
await sendCommand({
|
||||
action: "delete",
|
||||
workspaceId,
|
||||
documentId: getFileTreeRowDocumentId(item),
|
||||
documentId,
|
||||
});
|
||||
applyRemovedDocumentLocally(documentId);
|
||||
}
|
||||
if (!deletableItems.every((item) => !fileTreeRowById.has(item.rowId))) {
|
||||
scheduleRefresh();
|
||||
}
|
||||
scheduleRefresh();
|
||||
return true;
|
||||
}
|
||||
postToHost("tree.filetree.delete", {
|
||||
@@ -2986,6 +2990,113 @@ fn build_tree_shell_html(
|
||||
}, 80);
|
||||
};
|
||||
|
||||
const addTreeItemLocally = (item) => {
|
||||
if (!item?.nodeId || itemById.has(item.nodeId)) return false;
|
||||
normalizedItems.push(item);
|
||||
itemById.set(item.nodeId, item);
|
||||
fileTreeRowById.set(item.rowId, item);
|
||||
const parentId = item.parentNodeId && itemById.has(item.parentNodeId) ? item.parentNodeId : null;
|
||||
if (parentId) {
|
||||
const bucket = childrenByParentId.get(parentId) || [];
|
||||
bucket.push(item);
|
||||
bucket.sort(compareItems);
|
||||
childrenByParentId.set(parentId, bucket);
|
||||
const parentItem = itemById.get(parentId);
|
||||
if (parentItem) parentItem.childCount = Math.max(parentItem.childCount || 0, bucket.length);
|
||||
expanded.add(parentId);
|
||||
} else {
|
||||
roots.push(item);
|
||||
roots.sort(compareItems);
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
const applyCreatedDocumentLocally = (result, parentId, title) => {
|
||||
const documentId =
|
||||
typeof result?.documentId === "string" && result.documentId.trim()
|
||||
? result.documentId.trim()
|
||||
: typeof result?.id === "string" && result.id.trim()
|
||||
? result.id.trim()
|
||||
: "";
|
||||
if (!documentId) return false;
|
||||
const createdAt = normalizeText(result?.updatedAt || result?.execution?.updated_at);
|
||||
const parentNodeId = parentId && itemById.has(parentId) ? parentId : null;
|
||||
const item = {
|
||||
rowId: `doc:${documentId}`,
|
||||
rowKind: "document",
|
||||
nodeId: documentId,
|
||||
parentNodeId,
|
||||
title: normalizeText(result?.title, title || "无标题"),
|
||||
depth: parentNodeId ? normalizeNumber(itemById.get(parentNodeId)?.depth, 0) + 1 : 0,
|
||||
childCount: 0,
|
||||
position: normalizeNumber(result?.sortOrder ?? result?.execution?.sort_order ?? Date.now()),
|
||||
expandedByDefault: true,
|
||||
iconHint: "page",
|
||||
capabilities: ["open", "rename", "delete", "move", "create"],
|
||||
resourceMeta: {
|
||||
resourceKind: "document",
|
||||
documentId,
|
||||
assetId: "",
|
||||
assetKind: "",
|
||||
objectIdentity: { objectKind: "page", documentId, blockId: null, assetId: null },
|
||||
blockAssetRelation: null,
|
||||
},
|
||||
updatedAt: createdAt,
|
||||
};
|
||||
if (!addTreeItemLocally(item)) return false;
|
||||
currentFocusedDocumentId = documentId;
|
||||
focusedNodeId = documentId;
|
||||
currentActiveDocumentId = documentId;
|
||||
renderTree();
|
||||
return true;
|
||||
};
|
||||
|
||||
const removeTreeItemEverywhere = (item) => {
|
||||
if (!item) return;
|
||||
const itemIndex = normalizedItems.indexOf(item);
|
||||
if (itemIndex >= 0) normalizedItems.splice(itemIndex, 1);
|
||||
itemById.delete(item.nodeId);
|
||||
fileTreeRowById.delete(item.rowId);
|
||||
const rootIndex = roots.indexOf(item);
|
||||
if (rootIndex >= 0) roots.splice(rootIndex, 1);
|
||||
const bucket = item.parentNodeId ? childrenByParentId.get(item.parentNodeId) : null;
|
||||
if (bucket) {
|
||||
const bucketIndex = bucket.indexOf(item);
|
||||
if (bucketIndex >= 0) bucket.splice(bucketIndex, 1);
|
||||
if (bucket.length === 0) childrenByParentId.delete(item.parentNodeId);
|
||||
}
|
||||
};
|
||||
|
||||
const applyRemovedDocumentLocally = (documentId) => {
|
||||
const normalizedDocumentId = normalizeText(documentId);
|
||||
if (!normalizedDocumentId) return false;
|
||||
const removedNodeIds = new Set([normalizedDocumentId]);
|
||||
let changed = false;
|
||||
let expandedDuringScan = true;
|
||||
while (expandedDuringScan) {
|
||||
expandedDuringScan = false;
|
||||
normalizedItems.forEach((item) => {
|
||||
if (item.parentNodeId && removedNodeIds.has(item.parentNodeId) && !removedNodeIds.has(item.nodeId)) {
|
||||
removedNodeIds.add(item.nodeId);
|
||||
expandedDuringScan = true;
|
||||
}
|
||||
});
|
||||
}
|
||||
normalizedItems.slice().forEach((item) => {
|
||||
const itemDocumentId = getFileTreeRowDocumentId(item) || item.resourceMeta?.documentId || item.nodeId;
|
||||
if (removedNodeIds.has(item.nodeId) || itemDocumentId === normalizedDocumentId) {
|
||||
removeTreeItemEverywhere(item);
|
||||
changed = true;
|
||||
}
|
||||
});
|
||||
if (!changed) return false;
|
||||
if (currentActiveDocumentId === normalizedDocumentId) currentActiveDocumentId = roots[0]?.nodeId || "";
|
||||
if (currentFocusedDocumentId === normalizedDocumentId) currentFocusedDocumentId = currentActiveDocumentId;
|
||||
focusedNodeId = resolveFocusedNodeIdFromHostState();
|
||||
renderTree();
|
||||
return true;
|
||||
};
|
||||
|
||||
if (sourceKind === "local_folder" && rootUri) {
|
||||
let localWatchRevision = initialLocalWatchRevision;
|
||||
let localWatchRefreshTimer = 0;
|
||||
@@ -4335,6 +4446,14 @@ fn build_tree_shell_html(
|
||||
payload: { documentId },
|
||||
});
|
||||
}
|
||||
if (sourceKind === "convex_workspace" && applyCreatedDocumentLocally(result, parentId, title)) {
|
||||
if (mode === "filetree") {
|
||||
beginInlineRename("filetree", `doc:${documentId}`);
|
||||
} else {
|
||||
beginInlineRename("page", documentId);
|
||||
}
|
||||
return;
|
||||
}
|
||||
scheduleRefresh({
|
||||
renameRowId: localCreatedRowIdFromCommandResult(result, "markdown"),
|
||||
});
|
||||
@@ -6688,6 +6807,10 @@ mod tests {
|
||||
assert!(html.contains("data-testid=\"tree-node-toggle\""));
|
||||
assert!(html.contains("hydrateInitialPageTree"));
|
||||
assert!(html.contains("const usedRustInitialPageRenderer = hydrateInitialPageTree();"));
|
||||
assert!(html.contains("applyCreatedDocumentLocally"));
|
||||
assert!(html.contains("applyRemovedDocumentLocally"));
|
||||
assert!(html.contains("sourceKind === \"convex_workspace\" && applyCreatedDocumentLocally"));
|
||||
assert!(html.contains("if (!deletableItems.every((item) => !fileTreeRowById.has(item.rowId)))"));
|
||||
assert!(html.contains("application/x-mnote-page-tree-node"));
|
||||
assert!(html.contains("页面已拖放到"));
|
||||
assert!(html.contains("setAttribute(\"role\", \"treeitem\")"));
|
||||
|
||||
@@ -2437,10 +2437,9 @@ pub(crate) async fn load_workspace_shell_projection(
|
||||
query: None,
|
||||
max_results: None,
|
||||
};
|
||||
let dataset = load_projection_snapshot(config, context, &spec)
|
||||
.await
|
||||
.map(|snapshot| snapshot.dataset)
|
||||
.unwrap_or_else(|_| {
|
||||
let dataset = match load_projection_snapshot(config, context, &spec).await {
|
||||
Ok(snapshot) => snapshot.dataset,
|
||||
Err(_) if config.allow_dev_fixtures => {
|
||||
let documents = active_document_id
|
||||
.map(|document_id| {
|
||||
json!([{
|
||||
@@ -2457,9 +2456,19 @@ pub(crate) async fn load_workspace_shell_projection(
|
||||
"active_workspace_id": workspace_id,
|
||||
"active_page_id": active_document_id,
|
||||
"workspaces": [{ "id": workspace_id, "name": default_workspace_name }],
|
||||
"documents": documents
|
||||
"documents": documents,
|
||||
"dev_fixture": true
|
||||
})
|
||||
});
|
||||
}
|
||||
Err(_) => json!({
|
||||
"active_workspace_id": workspace_id,
|
||||
"active_page_id": active_document_id,
|
||||
"workspaces": [{ "id": workspace_id, "name": default_workspace_name }],
|
||||
"documents": [],
|
||||
"degraded": true,
|
||||
"degraded_reason": "projection_unavailable"
|
||||
}),
|
||||
};
|
||||
|
||||
build_workspace_shell_projection(
|
||||
&dataset,
|
||||
@@ -2489,7 +2498,7 @@ pub(crate) async fn load_sidebar_tree_html(
|
||||
max_results: None,
|
||||
};
|
||||
let result = match load_projection_snapshot(config, context, &spec).await {
|
||||
Ok(snapshot) => Some(snapshot.projection),
|
||||
Ok(snapshot) => Some((snapshot.projection, false)),
|
||||
Err(_) if config.allow_dev_fixtures => {
|
||||
// Dev 模式降级:如果调用方已经有 active 页面,优先保留这条真实选择链。
|
||||
let documents = active_document_id
|
||||
@@ -2518,17 +2527,20 @@ pub(crate) async fn load_sidebar_tree_html(
|
||||
"mindmap_docs": [],
|
||||
"mindmap_asset_children": {}
|
||||
});
|
||||
execute_kernel_query(context, workspace_id, projection_query(&spec), dev_dataset).ok()
|
||||
execute_kernel_query(context, workspace_id, projection_query(&spec), dev_dataset)
|
||||
.ok()
|
||||
.map(|projection| (projection, true))
|
||||
}
|
||||
Err(_) => None,
|
||||
};
|
||||
result.map(|projection| {
|
||||
result.map(|(projection, dev_fixture)| {
|
||||
let rows = collect_page_tree_render_rows(&projection);
|
||||
render_initial_page_tree_html(&PageTreeInitialRenderInput {
|
||||
let html = render_initial_page_tree_html(&PageTreeInitialRenderInput {
|
||||
rows,
|
||||
active_node_id: active_document_id.map(ToOwned::to_owned),
|
||||
focused_node_id: None,
|
||||
})
|
||||
});
|
||||
mark_dev_fixture_html(html, dev_fixture, "sidebar-tree")
|
||||
})
|
||||
}
|
||||
|
||||
@@ -2550,7 +2562,7 @@ pub(crate) async fn load_file_tree_html(
|
||||
max_results: None,
|
||||
};
|
||||
let result = match load_projection_snapshot(config, context, &spec).await {
|
||||
Ok(snapshot) => Some(snapshot.projection),
|
||||
Ok(snapshot) => Some((snapshot.projection, false)),
|
||||
Err(_) if config.allow_dev_fixtures => {
|
||||
let documents = active_document_id
|
||||
.map(|document_id| {
|
||||
@@ -2577,16 +2589,28 @@ pub(crate) async fn load_file_tree_html(
|
||||
"mindmap_docs": [],
|
||||
"mindmap_asset_children": {}
|
||||
});
|
||||
execute_kernel_query(context, workspace_id, projection_query(&spec), dev_dataset).ok()
|
||||
execute_kernel_query(context, workspace_id, projection_query(&spec), dev_dataset)
|
||||
.ok()
|
||||
.map(|projection| (projection, true))
|
||||
}
|
||||
Err(_) => None,
|
||||
};
|
||||
result.map(|projection| {
|
||||
result.map(|(projection, dev_fixture)| {
|
||||
let rows = collect_filetree_render_rows(&projection, active_document_id);
|
||||
render_initial_filetree_html(&FileTreeInitialRenderInput { rows })
|
||||
let html = render_initial_filetree_html(&FileTreeInitialRenderInput { rows });
|
||||
mark_dev_fixture_html(html, dev_fixture, "file-tree")
|
||||
})
|
||||
}
|
||||
|
||||
fn mark_dev_fixture_html(html: String, dev_fixture: bool, kind: &'static str) -> String {
|
||||
if !dev_fixture {
|
||||
return html;
|
||||
}
|
||||
format!(
|
||||
r#"<span hidden data-mnote-dev-fixture="true" data-mnote-dev-fixture-kind="{kind}"></span>{html}"#
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn render_local_sidebar_tree_html(
|
||||
root_uri: &str,
|
||||
active_document_id: Option<&str>,
|
||||
@@ -2614,8 +2638,9 @@ pub(crate) fn render_local_file_tree_html(
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::app::{build_app, AppConfig, AppState};
|
||||
use crate::context::RequestContext;
|
||||
use axum::body::{to_bytes, Body};
|
||||
use axum::http::{header, Request, StatusCode};
|
||||
use axum::http::{header, HeaderMap, Method, Request, StatusCode, Uri};
|
||||
use serde_json::Value;
|
||||
use tower::util::ServiceExt;
|
||||
|
||||
@@ -2701,6 +2726,9 @@ mod tests {
|
||||
.expect("body");
|
||||
let html = String::from_utf8(body.to_vec()).expect("html");
|
||||
assert!(html.contains("data-testid=\"wolai-sidebar\""));
|
||||
assert!(html.contains("data-mnote-dev-fixture-kind=\"workspace-shell\""));
|
||||
assert!(html.contains("data-mnote-dev-fixture-kind=\"sidebar-tree\""));
|
||||
assert!(html.contains("data-mnote-dev-fixture-kind=\"file-tree\""));
|
||||
assert!(html.contains("data-testid=\"wolai-topbar\""));
|
||||
assert!(html.contains("data-testid=\"wolai-floating-ai\""));
|
||||
assert!(html.contains("星标置顶"));
|
||||
@@ -2755,7 +2783,7 @@ mod tests {
|
||||
.headers()
|
||||
.get("x-mnote-page-aggregate-owner")
|
||||
.and_then(|value| value.to_str().ok()),
|
||||
Some("rust-kernel")
|
||||
Some("compat-join")
|
||||
);
|
||||
assert_eq!(
|
||||
response
|
||||
@@ -2770,7 +2798,7 @@ mod tests {
|
||||
let payload: Value = serde_json::from_slice(&body).expect("json");
|
||||
assert_eq!(payload["owner"], "mnote-web");
|
||||
assert_eq!(payload["schema"], "mnote.page_aggregate.v1");
|
||||
assert_eq!(payload["result"]["source"], "KernelProjection");
|
||||
assert_eq!(payload["result"]["source"], "CompatMetaContentJoin");
|
||||
assert_eq!(payload["result"]["projectionVersion"], 1);
|
||||
assert_eq!(payload["result"]["identity"]["documentId"], "doc_1");
|
||||
assert_eq!(payload["result"]["body"]["revision"], 7);
|
||||
@@ -2893,6 +2921,56 @@ mod tests {
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn sidebar_and_filetree_do_not_return_dev_fixtures_by_default() {
|
||||
let config = 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,
|
||||
hermes_base_path: "/api/hermes".into(),
|
||||
compat_next_base_path: "/api/compat/next".into(),
|
||||
convex_url: None,
|
||||
convex_admin_key: None,
|
||||
allow_dev_fixtures: false,
|
||||
query_fixtures_json: None,
|
||||
mutation_fixtures_json: None,
|
||||
dev_user_id: "dev-user".into(),
|
||||
dev_user_name: "开发用户".into(),
|
||||
dev_user_email: "dev@mnote.local".into(),
|
||||
};
|
||||
let headers = HeaderMap::new();
|
||||
let context = RequestContext::from_http_parts(
|
||||
&Method::GET,
|
||||
&"/documents/doc_1?workspaceId=ws_demo"
|
||||
.parse::<Uri>()
|
||||
.expect("uri"),
|
||||
&headers,
|
||||
);
|
||||
|
||||
let sidebar_html =
|
||||
super::load_sidebar_tree_html(&config, &context, "ws_demo", Some("doc_1")).await;
|
||||
let filetree_html =
|
||||
super::load_file_tree_html(&config, &context, "ws_demo", Some("doc_1")).await;
|
||||
let workspace_projection = super::load_workspace_shell_projection(
|
||||
&config,
|
||||
&context,
|
||||
"ws_demo",
|
||||
Some("doc_1"),
|
||||
"个人空间",
|
||||
)
|
||||
.await;
|
||||
|
||||
assert!(sidebar_html.is_none());
|
||||
assert!(filetree_html.is_none());
|
||||
assert!(workspace_projection.degraded);
|
||||
assert!(workspace_projection.my_page_items.is_empty());
|
||||
assert!(!workspace_projection.dev_fixture);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn document_shell_renders_local_markdown_attachment_name_in_html() {
|
||||
let root = std::env::temp_dir().join(format!(
|
||||
|
||||
@@ -77,6 +77,146 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
return parseJsonScript('__MNOTE_PAGE_AGGREGATE__') || {};
|
||||
}
|
||||
|
||||
function textFromUnknown(value) {
|
||||
if (value == null) return '';
|
||||
if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') return String(value);
|
||||
if (Array.isArray(value)) return value.map(textFromUnknown).filter(Boolean).join(' ');
|
||||
if (typeof value !== 'object') return '';
|
||||
var parts = [];
|
||||
['text', 'title', 'content', 'children', 'blocks', 'body'].forEach(function(key) {
|
||||
if (Object.prototype.hasOwnProperty.call(value, key)) {
|
||||
var text = textFromUnknown(value[key]);
|
||||
if (text) parts.push(text);
|
||||
}
|
||||
});
|
||||
return parts.join(' ');
|
||||
}
|
||||
|
||||
function readLocalEditorBlocks() {
|
||||
var editor = document.querySelector('[data-testid="mnote-leptos-tiptap-island-editor-root"] .editor-surface .ProseMirror');
|
||||
if (!(editor instanceof HTMLElement)) return [];
|
||||
return Array.from(editor.children).filter(function(node) {
|
||||
return node instanceof HTMLElement;
|
||||
}).map(function(node, index) {
|
||||
var tag = String(node.tagName || '').toUpperCase();
|
||||
var headingMatch = tag.match(/^H([1-6])$/);
|
||||
var type = headingMatch ? 'heading' : 'paragraph';
|
||||
var text = searchText(node.textContent || '');
|
||||
var id = searchText(node.getAttribute('data-id') || node.id || 'local-block-' + index);
|
||||
var props = headingMatch ? { level: Number(headingMatch[1]) } : {};
|
||||
return { id: id, type: type, props: props, content: text };
|
||||
}).filter(function(block) {
|
||||
return block.content || block.type === 'heading';
|
||||
});
|
||||
}
|
||||
|
||||
function buildPageAiLocalSubtree(blocks, title) {
|
||||
var documentId = currentDocumentId() || 'current-page';
|
||||
var rootNodeId = 'page:' + documentId;
|
||||
var headingCounters = [0, 0, 0, 0, 0, 0];
|
||||
var headingStack = [];
|
||||
var nodes = [{
|
||||
id: rootNodeId,
|
||||
nodeId: rootNodeId,
|
||||
nodeType: 'page',
|
||||
blockId: null,
|
||||
blockType: 'page',
|
||||
title: title || '',
|
||||
parentNodeId: null,
|
||||
headingLevel: null
|
||||
}];
|
||||
var outline = [];
|
||||
var evidence = [];
|
||||
blocks.forEach(function(block, index) {
|
||||
var level = block.type === 'heading' ? Math.max(1, Math.min(6, Number(block.props && block.props.level || 1))) : null;
|
||||
if (level != null) {
|
||||
while (headingStack.length && headingStack[headingStack.length - 1].level >= level) headingStack.pop();
|
||||
}
|
||||
var parentNodeId = headingStack.length ? headingStack[headingStack.length - 1].nodeId : rootNodeId;
|
||||
var nodeId = 'local-node:' + String(block.id || index);
|
||||
var titleText = block.content || (block.type === 'heading' ? '未命名标题' : '');
|
||||
nodes.push({
|
||||
id: nodeId,
|
||||
nodeId: nodeId,
|
||||
nodeType: 'block',
|
||||
blockId: block.id,
|
||||
blockType: block.type,
|
||||
title: titleText,
|
||||
parentNodeId: parentNodeId,
|
||||
headingLevel: level
|
||||
});
|
||||
if (level != null) {
|
||||
headingCounters[level - 1] += 1;
|
||||
for (var i = level; i < headingCounters.length; i += 1) headingCounters[i] = 0;
|
||||
var numbering = headingCounters.slice(0, level).filter(function(count) { return count > 0; }).join('.');
|
||||
outline.push({
|
||||
id: 'local-outline:' + String(block.id || index),
|
||||
nodeId: nodeId,
|
||||
anchorBlockId: block.id,
|
||||
level: level,
|
||||
title: titleText,
|
||||
numbering: numbering
|
||||
});
|
||||
headingStack.push({ level: level, nodeId: nodeId });
|
||||
}
|
||||
if (titleText) {
|
||||
evidence.push({
|
||||
id: 'local-evidence:' + String(block.id || index),
|
||||
nodeId: nodeId,
|
||||
anchorBlockId: block.id,
|
||||
text: titleText,
|
||||
kind: block.type
|
||||
});
|
||||
}
|
||||
});
|
||||
return {
|
||||
projectionId: 'local-editor-dom:' + documentId,
|
||||
rootNode: {
|
||||
id: rootNodeId,
|
||||
documentId: documentId,
|
||||
title: title || '',
|
||||
nodeType: 'page'
|
||||
},
|
||||
subtree: {
|
||||
rootNodeId: rootNodeId,
|
||||
nodes: nodes
|
||||
},
|
||||
outline: outline,
|
||||
evidence: evidence,
|
||||
stats: {
|
||||
nodeCount: nodes.length,
|
||||
headingCount: outline.length,
|
||||
evidenceCount: evidence.length
|
||||
},
|
||||
source: 'local'
|
||||
};
|
||||
}
|
||||
|
||||
function currentPageAiContextSnapshot() {
|
||||
var aggregate = currentPageAggregate();
|
||||
var body = aggregate.body || {};
|
||||
var serverContent = body.content || null;
|
||||
var serverText = searchText(textFromUnknown(serverContent));
|
||||
var localBlocks = readLocalEditorBlocks();
|
||||
var localText = searchText(localBlocks.map(function(block) { return block.content || ''; }).join(' '));
|
||||
var serverSubtree = aggregate.tree && aggregate.tree.pageSubtree ? aggregate.tree.pageSubtree : null;
|
||||
var title = aggregate.head && aggregate.head.title ? aggregate.head.title : '';
|
||||
if (localBlocks.length && localText && localText !== serverText) {
|
||||
return {
|
||||
aggregate: aggregate,
|
||||
body: Object.assign({}, body, { content: localBlocks }),
|
||||
subtree: buildPageAiLocalSubtree(localBlocks, title),
|
||||
pageSubtreeSource: 'local'
|
||||
};
|
||||
}
|
||||
return {
|
||||
aggregate: aggregate,
|
||||
body: body,
|
||||
subtree: serverSubtree,
|
||||
pageSubtreeSource: serverSubtree ? 'server' : 'none'
|
||||
};
|
||||
}
|
||||
|
||||
function defaultPageOptions() {
|
||||
return {
|
||||
wideLayout: false,
|
||||
@@ -844,6 +984,90 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
});
|
||||
}
|
||||
|
||||
function documentIdFromDelta(data) {
|
||||
return String(data && (data.documentId || data.id || (data.document && data.document.id) || (data.node && data.node.id) || (data.args && data.args.documentId)) || '').trim();
|
||||
}
|
||||
|
||||
function parentIdFromDelta(data) {
|
||||
if (!data || typeof data !== 'object') return null;
|
||||
if (Object.prototype.hasOwnProperty.call(data, 'parentId')) return data.parentId ? String(data.parentId).trim() : null;
|
||||
if (Object.prototype.hasOwnProperty.call(data, 'parent_id')) return data.parent_id ? String(data.parent_id).trim() : null;
|
||||
if (data.args && Object.prototype.hasOwnProperty.call(data.args, 'parentId')) return data.args.parentId ? String(data.args.parentId).trim() : null;
|
||||
return null;
|
||||
}
|
||||
|
||||
function treeRootForMode(mode) {
|
||||
var id = mode === 'filetree' ? 'sidebar-file-tree-root' : 'sidebar-tree-root';
|
||||
return document.querySelector('#' + id + ' .tree-root');
|
||||
}
|
||||
|
||||
function rowSelectorForDocument(mode, documentId) {
|
||||
var escaped = cssEscape(documentId);
|
||||
if (mode === 'filetree') {
|
||||
return '.tree-row[data-shell-mode="filetree"][data-doc-id="' + escaped + '"], .tree-row[data-shell-mode="filetree"][data-document-id="' + escaped + '"]';
|
||||
}
|
||||
return '.tree-row[data-shell-mode="page"][data-node-id="' + escaped + '"]';
|
||||
}
|
||||
|
||||
function ensureTreeChildren(parentRow) {
|
||||
var parentNode = parentRow ? parentRow.closest('.tree-node') : null;
|
||||
if (!parentNode) return null;
|
||||
var children = parentNode.querySelector(':scope > .tree-children');
|
||||
if (!children) {
|
||||
children = document.createElement('ul');
|
||||
children.className = 'tree-children';
|
||||
parentNode.appendChild(children);
|
||||
}
|
||||
children.classList.remove('tree-children--collapsed');
|
||||
parentRow.setAttribute('aria-expanded', 'true');
|
||||
var toggle = parentRow.querySelector('[data-rust-action="toggle"]');
|
||||
if (toggle) toggle.setAttribute('aria-expanded', 'true');
|
||||
return children;
|
||||
}
|
||||
|
||||
function removeDocumentRowForMode(mode, documentId) {
|
||||
var row = document.querySelector(rowSelectorForDocument(mode, documentId));
|
||||
var node = row ? row.closest('.tree-node') : null;
|
||||
if (!node || !node.parentElement) return false;
|
||||
node.parentElement.removeChild(node);
|
||||
return true;
|
||||
}
|
||||
|
||||
function moveDocumentRowForMode(mode, documentId, parentId) {
|
||||
var row = document.querySelector(rowSelectorForDocument(mode, documentId));
|
||||
var node = row ? row.closest('.tree-node') : null;
|
||||
var root = treeRootForMode(mode);
|
||||
if (!row || !node || !root) return false;
|
||||
var targetContainer = root;
|
||||
if (parentId) {
|
||||
var parentRow = document.querySelector(rowSelectorForDocument(mode, parentId));
|
||||
targetContainer = ensureTreeChildren(parentRow);
|
||||
if (!targetContainer) return false;
|
||||
row.setAttribute('data-parent-id', parentId);
|
||||
} else {
|
||||
row.removeAttribute('data-parent-id');
|
||||
}
|
||||
targetContainer.appendChild(node);
|
||||
return true;
|
||||
}
|
||||
|
||||
function applyMoveDocumentDelta(data) {
|
||||
var documentId = documentIdFromDelta(data);
|
||||
if (!documentId) return false;
|
||||
var parentId = parentIdFromDelta(data);
|
||||
var movedPage = moveDocumentRowForMode('page', documentId, parentId);
|
||||
var movedFile = moveDocumentRowForMode('filetree', documentId, parentId);
|
||||
return movedPage || movedFile;
|
||||
}
|
||||
|
||||
function applyRemoveDocumentDelta(data) {
|
||||
var documentId = documentIdFromDelta(data);
|
||||
if (!documentId) return false;
|
||||
var removedPage = removeDocumentRowForMode('page', documentId);
|
||||
var removedFile = removeDocumentRowForMode('filetree', documentId);
|
||||
return removedPage || removedFile;
|
||||
}
|
||||
|
||||
function readProjection(value) {
|
||||
if (!value || typeof value !== 'object') return null;
|
||||
if (value.result && typeof value.result === 'object') return value.result;
|
||||
@@ -879,6 +1103,11 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
return resolved && Array.isArray(resolved.items) ? resolved.items : [];
|
||||
}
|
||||
|
||||
function hasProjectionItems(projection) {
|
||||
var resolved = readProjection(projection);
|
||||
return Boolean(resolved && Array.isArray(resolved.items));
|
||||
}
|
||||
|
||||
function nodeIdOf(item) {
|
||||
return String(item && (item.nodeId || item.id || item.documentId) || '').trim();
|
||||
}
|
||||
@@ -1024,9 +1253,9 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
}
|
||||
|
||||
function renderSidebarSnapshot(payload) {
|
||||
var renderedPage = renderPageProjection(payload);
|
||||
var renderedPage = hasProjectionItems(payload) ? renderPageProjection(payload) : false;
|
||||
var fileProjection = readDatasetProjection(payload, 'kernel_file_tree_projection', 'kernelFileTreeProjection');
|
||||
var renderedFile = fileProjection ? renderFileProjection(fileProjection) : false;
|
||||
var renderedFile = fileProjection && hasProjectionItems(fileProjection) ? renderFileProjection(fileProjection) : false;
|
||||
return renderedPage || renderedFile;
|
||||
}
|
||||
|
||||
@@ -2805,9 +3034,10 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
var prompt = searchText(text);
|
||||
if (!prompt) return;
|
||||
pageAiLoadSessions();
|
||||
var aggregate = currentPageAggregate();
|
||||
var body = aggregate.body || {};
|
||||
var subtree = aggregate.tree && aggregate.tree.pageSubtree ? aggregate.tree.pageSubtree : null;
|
||||
var contextSnapshot = currentPageAiContextSnapshot();
|
||||
var aggregate = contextSnapshot.aggregate || {};
|
||||
var body = contextSnapshot.body || {};
|
||||
var subtree = contextSnapshot.subtree || null;
|
||||
var outline = subtree && subtree.outline ? subtree.outline : null;
|
||||
pageUiState.pageAiBusy = true;
|
||||
pageUiState.pageAiMessages.push({ role: 'user', content: prompt });
|
||||
@@ -2843,6 +3073,7 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
},
|
||||
subtree: subtree,
|
||||
outline: outline,
|
||||
pageSubtreeSource: contextSnapshot.pageSubtreeSource || 'none',
|
||||
evidence: null,
|
||||
pageOptions: currentPageOptions()
|
||||
},
|
||||
@@ -3890,6 +4121,18 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
window.addEventListener('tree:delta', function(event) {
|
||||
var payload = event.detail && event.detail.payload ? event.detail.payload : event.detail;
|
||||
var data = payload && (payload.data || payload.delta || payload);
|
||||
var documentPatch = data && (data.document || data.node);
|
||||
if (data && data.op === 'upsert_document' && documentPatch) {
|
||||
updateTitleEverywhere(documentPatch.id || documentPatch.documentId, documentPatch.title || '无标题');
|
||||
}
|
||||
if (data && data.op === 'move_document' && applyMoveDocumentDelta(data)) {
|
||||
document.documentElement.setAttribute('data-mnote-tree-live-applied', 'delta');
|
||||
return;
|
||||
}
|
||||
if (data && data.op === 'remove_document' && applyRemoveDocumentDelta(data)) {
|
||||
document.documentElement.setAttribute('data-mnote-tree-live-applied', 'delta');
|
||||
return;
|
||||
}
|
||||
var documents = data && (data.upsertDocuments || data.upsert_documents);
|
||||
if (Array.isArray(documents)) {
|
||||
documents.forEach(function(doc) {
|
||||
|
||||
@@ -376,17 +376,27 @@ pub async fn execute_convex_query_by_name(
|
||||
|
||||
fn convex_command_args_for_plan(plan: &RuntimeCommandExecutionPlan) -> Value {
|
||||
let mut args = plan.args_json.clone();
|
||||
if matches!(
|
||||
plan.command_name.as_str(),
|
||||
"tree.node.create"
|
||||
| "tree.node.rename"
|
||||
| "tree.node.archive"
|
||||
| "tree.node.restore"
|
||||
| "tree.subtree.move"
|
||||
| "documents.create"
|
||||
| "documents.title.update"
|
||||
| "documents.delete"
|
||||
| "documents.restore"
|
||||
| "documents.move"
|
||||
) {
|
||||
strip_tree_artifact_fields(&mut args);
|
||||
}
|
||||
if matches!(plan.command_name.as_str(), "mindmaps.put")
|
||||
|| matches!(plan.function_name.as_str(), "mindmaps:put")
|
||||
{
|
||||
if let Value::Object(map) = &mut args {
|
||||
// Rust plan 保留 tree domain event / stream hint 作为正式契约;
|
||||
// Convex mindmaps.put legacy validator 仍只接收真实写入字段。
|
||||
map.remove("streamDeltaHint");
|
||||
map.remove("domainEventHint");
|
||||
map.remove("domainEventPlan");
|
||||
map.remove("domainEventPlans");
|
||||
}
|
||||
// Rust plan 保留 tree domain event / stream hint 作为正式契约;
|
||||
// Convex mindmaps.put legacy validator 仍只接收真实写入字段。
|
||||
strip_tree_artifact_fields(&mut args);
|
||||
}
|
||||
if matches!(
|
||||
plan.command_name.as_str(),
|
||||
@@ -397,11 +407,16 @@ fn convex_command_args_for_plan(plan: &RuntimeCommandExecutionPlan) -> Value {
|
||||
// Rust plan 保留正式事件契约,但发送给 legacy mutation 时只传它实际接受的字段。
|
||||
map.remove("editorDocument");
|
||||
map.remove("tiptapDocument");
|
||||
map.remove("streamDeltaHint");
|
||||
map.remove("domainEventHint");
|
||||
map.remove("domainEventPlan");
|
||||
map.remove("domainEventPlans");
|
||||
}
|
||||
strip_tree_artifact_fields(&mut args);
|
||||
}
|
||||
if matches!(
|
||||
plan.command_name.as_str(),
|
||||
"documents.options.update" | "page.layout.updateOptions"
|
||||
) {
|
||||
// documents:updateOptions 仍只接收页面设置字段;
|
||||
// tree stream hint 留在 Rust artifact plan 中持久化。
|
||||
strip_tree_artifact_fields(&mut args);
|
||||
}
|
||||
if plan.command_name == "mindmap.command.apply" {
|
||||
if let Value::Object(map) = &mut args {
|
||||
@@ -418,6 +433,15 @@ fn convex_command_args_for_plan(plan: &RuntimeCommandExecutionPlan) -> Value {
|
||||
args
|
||||
}
|
||||
|
||||
fn strip_tree_artifact_fields(args: &mut Value) {
|
||||
if let Value::Object(map) = args {
|
||||
map.remove("streamDeltaHint");
|
||||
map.remove("domainEventHint");
|
||||
map.remove("domainEventPlan");
|
||||
map.remove("domainEventPlans");
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn execute_convex_command_plan(
|
||||
config: &AppConfig,
|
||||
context: &RequestContext,
|
||||
@@ -900,6 +924,78 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn convex_command_args_strips_page_options_artifacts_for_legacy_mutation() {
|
||||
let plan = RuntimeCommandExecutionPlan {
|
||||
command_name: "page.layout.updateOptions".into(),
|
||||
command_id: "cmd_options_1".into(),
|
||||
function_name: "documents:updateOptions".into(),
|
||||
workspace_id: Some("ws_1".into()),
|
||||
request_id: "req_1".into(),
|
||||
trace_id: "trace_1".into(),
|
||||
actor_id: "actor_1".into(),
|
||||
idempotency_key: None,
|
||||
source: json!({}),
|
||||
payload_json: "{}".into(),
|
||||
args_json: json!({
|
||||
"id": "doc_1",
|
||||
"options": {
|
||||
"showToc": false,
|
||||
"layoutDensity": "compact",
|
||||
},
|
||||
"streamDeltaHint": {"family": "tree"},
|
||||
"domainEventHint": {"eventType": "page.layout.options_updated"},
|
||||
"domainEventPlan": {"eventType": "page.layout.options_updated"},
|
||||
"domainEventPlans": [{"eventType": "page.layout.options_updated"}],
|
||||
}),
|
||||
};
|
||||
|
||||
let args = convex_command_args_for_plan(&plan);
|
||||
|
||||
assert_eq!(
|
||||
args,
|
||||
json!({
|
||||
"id": "doc_1",
|
||||
"options": {
|
||||
"showToc": false,
|
||||
"layoutDensity": "compact",
|
||||
},
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn convex_command_args_strips_tree_archive_artifacts_for_legacy_mutation() {
|
||||
let plan = RuntimeCommandExecutionPlan {
|
||||
command_name: "tree.node.archive".into(),
|
||||
command_id: "cmd_archive_1".into(),
|
||||
function_name: "documents:softDelete".into(),
|
||||
workspace_id: Some("ws_1".into()),
|
||||
request_id: "req_1".into(),
|
||||
trace_id: "trace_1".into(),
|
||||
actor_id: "actor_1".into(),
|
||||
idempotency_key: None,
|
||||
source: json!({}),
|
||||
payload_json: "{}".into(),
|
||||
args_json: json!({
|
||||
"id": "doc_1",
|
||||
"streamDeltaHint": {"family": "tree", "kind": "remove_document"},
|
||||
"domainEventHint": {"eventType": "tree.node.archived"},
|
||||
"domainEventPlan": {"eventType": "tree.node.archived"},
|
||||
"domainEventPlans": [{"eventType": "tree.node.archived"}],
|
||||
}),
|
||||
};
|
||||
|
||||
let args = convex_command_args_for_plan(&plan);
|
||||
|
||||
assert_eq!(
|
||||
args,
|
||||
json!({
|
||||
"id": "doc_1",
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn convex_command_args_adapts_mindmap_command_apply_for_legacy_mutation() {
|
||||
let plan = RuntimeCommandExecutionPlan {
|
||||
|
||||
@@ -9,6 +9,9 @@ pub struct WorkspaceShellProjection {
|
||||
pub workspace_name: String,
|
||||
pub active_page_id: Option<String>,
|
||||
pub active_page_title: Option<String>,
|
||||
pub degraded: bool,
|
||||
pub degraded_reason: Option<String>,
|
||||
pub dev_fixture: bool,
|
||||
pub starred_items: Vec<WorkspaceShellItem>,
|
||||
pub my_page_items: Vec<WorkspaceShellItem>,
|
||||
pub bottom_entries: Vec<WorkspaceShellEntry>,
|
||||
@@ -88,6 +91,23 @@ pub fn build_workspace_shell_projection(
|
||||
starred_items.sort_by_key(|item| (item.depth, item.title.clone(), item.id.clone()));
|
||||
|
||||
let active_page_title = active_title_from_items(&my_page_items, active_page_id.as_deref());
|
||||
let degraded = dataset
|
||||
.get("degraded")
|
||||
.or_else(|| dataset.get("is_degraded"))
|
||||
.and_then(Value::as_bool)
|
||||
.unwrap_or(false);
|
||||
let degraded_reason = dataset
|
||||
.get("degraded_reason")
|
||||
.or_else(|| dataset.get("degradedReason"))
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToOwned::to_owned);
|
||||
let dev_fixture = dataset
|
||||
.get("dev_fixture")
|
||||
.or_else(|| dataset.get("devFixture"))
|
||||
.and_then(Value::as_bool)
|
||||
.unwrap_or(false);
|
||||
|
||||
WorkspaceShellProjection {
|
||||
schema: "mnote.workspace_shell.v1".into(),
|
||||
@@ -95,6 +115,9 @@ pub fn build_workspace_shell_projection(
|
||||
workspace_name,
|
||||
active_page_id,
|
||||
active_page_title,
|
||||
degraded,
|
||||
degraded_reason,
|
||||
dev_fixture,
|
||||
starred_items,
|
||||
my_page_items,
|
||||
bottom_entries: vec![
|
||||
@@ -278,6 +301,8 @@ mod tests {
|
||||
assert_eq!(projection.workspace_name, "开发用户 的工作区");
|
||||
assert_eq!(projection.active_page_id.as_deref(), Some("page_home"));
|
||||
assert_eq!(projection.active_page_title.as_deref(), Some("个人"));
|
||||
assert!(!projection.degraded);
|
||||
assert!(!projection.dev_fixture);
|
||||
assert_eq!(projection.starred_items.len(), 1);
|
||||
assert_eq!(projection.starred_items[0].title, "个人");
|
||||
assert_eq!(projection.my_page_items.len(), 2);
|
||||
@@ -368,6 +393,42 @@ mod tests {
|
||||
|
||||
assert!(html.contains("data-testid=\"wolai-sidebar-empty-state\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workspace_shell_sidebar_html_marks_degraded_and_dev_fixture_states() {
|
||||
let degraded_dataset = json!({
|
||||
"workspaces": [{ "id": "ws_demo", "name": "开发用户 的工作区" }],
|
||||
"documents": [],
|
||||
"degraded": true,
|
||||
"degraded_reason": "projection_unavailable"
|
||||
});
|
||||
let degraded_projection = build_workspace_shell_projection(
|
||||
°raded_dataset,
|
||||
"ws_demo",
|
||||
None,
|
||||
"开发用户 的工作区",
|
||||
);
|
||||
let degraded_html = render_workspace_shell_sidebar_html(°raded_projection, None, None);
|
||||
|
||||
assert!(degraded_projection.degraded);
|
||||
assert!(degraded_html.contains("data-mnote-workspace-shell-degraded=\"true\""));
|
||||
assert!(degraded_html.contains(
|
||||
"data-mnote-workspace-shell-degraded-reason=\"projection_unavailable\""
|
||||
));
|
||||
|
||||
let dev_dataset = json!({
|
||||
"workspaces": [{ "id": "ws_demo", "name": "开发用户 的工作区" }],
|
||||
"documents": [],
|
||||
"dev_fixture": true
|
||||
});
|
||||
let dev_projection =
|
||||
build_workspace_shell_projection(&dev_dataset, "ws_demo", None, "开发用户 的工作区");
|
||||
let dev_html = render_workspace_shell_sidebar_html(&dev_projection, None, None);
|
||||
|
||||
assert!(dev_projection.dev_fixture);
|
||||
assert!(dev_html.contains("data-mnote-dev-fixture=\"true\""));
|
||||
assert!(dev_html.contains("data-mnote-dev-fixture-kind=\"workspace-shell\""));
|
||||
}
|
||||
}
|
||||
|
||||
pub fn render_workspace_shell_sidebar_html(
|
||||
@@ -434,9 +495,25 @@ pub fn render_workspace_shell_sidebar_html(
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("");
|
||||
let status_markers = format!(
|
||||
r#"{degraded_marker}{dev_fixture_marker}"#,
|
||||
degraded_marker = if projection.degraded {
|
||||
format!(
|
||||
r#"<span hidden data-mnote-workspace-shell-degraded="true" data-mnote-workspace-shell-degraded-reason="{}"></span>"#,
|
||||
escape_html(projection.degraded_reason.as_deref().unwrap_or("projection_unavailable")),
|
||||
)
|
||||
} else {
|
||||
String::new()
|
||||
},
|
||||
dev_fixture_marker = if projection.dev_fixture {
|
||||
r#"<span hidden data-mnote-dev-fixture="true" data-mnote-dev-fixture-kind="workspace-shell"></span>"#.to_string()
|
||||
} else {
|
||||
String::new()
|
||||
},
|
||||
);
|
||||
|
||||
format!(
|
||||
r#"<section class="wolai-sidebar-section wolai-starred-section" aria-label="星标置顶"><div class="wolai-section-title">{}星标置顶<span class="wolai-section-caret">⌄</span></div>{starred_rows}</section><section class="wolai-sidebar-section wolai-my-pages-section" aria-label="页面树" data-testid="wolai-sidebar-page-tree-shell"><div class="wolai-sidebar-tabs" data-testid="wolai-sidebar-tree-tabs" role="tablist" aria-label="页面树和文件树"><button type="button" class="wolai-sidebar-tab wolai-sidebar-tab-active" data-mnote-sidebar-tree-tab="page" aria-selected="true" aria-controls="wolai-sidebar-page-tree-panel">我的页面</button><button type="button" class="wolai-sidebar-tab wolai-sidebar-tab-muted" data-mnote-sidebar-tree-tab="filetree" aria-selected="false" aria-controls="wolai-sidebar-file-tree-panel">{}Explorer</button><span class="wolai-section-caret">⌄</span><button type="button" class="wolai-section-add" data-testid="wolai-sidebar-create-page" data-mnote-action="create-page" data-workspace-id="{}" title="新建页面" aria-label="新建页面">+</button></div><div class="wolai-sidebar-tab-panels"><div id="wolai-sidebar-page-tree-panel" class="wolai-sidebar-tab-panel" data-mnote-sidebar-tree-panel="page">{my_pages}</div><div id="wolai-sidebar-file-tree-panel" class="wolai-sidebar-tab-panel" data-mnote-sidebar-tree-panel="filetree" hidden>{file_tree_content}</div></div></section><div class="wolai-sidebar-footer">{bottom_entries}</div>"#,
|
||||
r#"{status_markers}<section class="wolai-sidebar-section wolai-starred-section" aria-label="星标置顶"><div class="wolai-section-title">{}星标置顶<span class="wolai-section-caret">⌄</span></div>{starred_rows}</section><section class="wolai-sidebar-section wolai-my-pages-section" aria-label="页面树" data-testid="wolai-sidebar-page-tree-shell"><div class="wolai-sidebar-tabs" data-testid="wolai-sidebar-tree-tabs" role="tablist" aria-label="页面树和文件树"><button type="button" class="wolai-sidebar-tab wolai-sidebar-tab-active" data-mnote-sidebar-tree-tab="page" aria-selected="true" aria-controls="wolai-sidebar-page-tree-panel">我的页面</button><button type="button" class="wolai-sidebar-tab wolai-sidebar-tab-muted" data-mnote-sidebar-tree-tab="filetree" aria-selected="false" aria-controls="wolai-sidebar-file-tree-panel">{}Explorer</button><span class="wolai-section-caret">⌄</span><button type="button" class="wolai-section-add" data-testid="wolai-sidebar-create-page" data-mnote-action="create-page" data-workspace-id="{}" title="新建页面" aria-label="新建页面">+</button></div><div class="wolai-sidebar-tab-panels"><div id="wolai-sidebar-page-tree-panel" class="wolai-sidebar-tab-panel" data-mnote-sidebar-tree-panel="page">{my_pages}</div><div id="wolai-sidebar-file-tree-panel" class="wolai-sidebar-tab-panel" data-mnote-sidebar-tree-panel="filetree" hidden>{file_tree_content}</div></div></section><div class="wolai-sidebar-footer">{bottom_entries}</div>"#,
|
||||
render_symbol("star", "wolai-section-icon"),
|
||||
render_symbol("folder_open", "wolai-folder-icon"),
|
||||
escape_html(&projection.workspace_id),
|
||||
|
||||
@@ -98,7 +98,9 @@ pub fn map_query_name_to_convex(query_name: &str) -> &'static str {
|
||||
"bridge.workspace.overview" => "bridgeLogs:listWorkspaceOverview",
|
||||
"documents.meta.get" => "documents:getMeta",
|
||||
"documents.content.get" => "documents:getContent",
|
||||
"page.aggregate.get" => "documents:getPageAggregate",
|
||||
// page aggregate 主读链由 Rust /api/page-aggregate/:id 先取 meta/content 后在 bridge-runtime 组装。
|
||||
// Convex 没有 documents:getPageAggregate,避免旧映射误触发不存在的生产函数。
|
||||
"page.aggregate.get" => "queries:unknown",
|
||||
"mindmaps.get" => "mindmaps:get",
|
||||
"mindmap.projection.get" => "mindmaps:getProjection",
|
||||
"mindmap.editor_scene.get" => "mindmaps:getEditorScene",
|
||||
|
||||
+1
-1
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
|
||||
{"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":{}}
|
||||
{"rustc_fingerprint":9228011546279038255,"outputs":{"7971740275564407648":{"success":true,"status":"","code":0,"stdout":"___\nlib___.rlib\nlib___.so\nlib___.so\nlib___.a\nlib___.so\n/home/lix/.rustup/toolchains/1.89.0-x86_64-unknown-linux-gnu\noff\npacked\nunpacked\n___\ndebug_assertions\npanic=\"unwind\"\nproc_macro\ntarget_abi=\"\"\ntarget_arch=\"x86_64\"\ntarget_endian=\"little\"\ntarget_env=\"gnu\"\ntarget_family=\"unix\"\ntarget_feature=\"fxsr\"\ntarget_feature=\"sse\"\ntarget_feature=\"sse2\"\ntarget_has_atomic=\"16\"\ntarget_has_atomic=\"32\"\ntarget_has_atomic=\"64\"\ntarget_has_atomic=\"8\"\ntarget_has_atomic=\"ptr\"\ntarget_os=\"linux\"\ntarget_pointer_width=\"64\"\ntarget_vendor=\"unknown\"\nunix\n","stderr":""},"11652014622397750202":{"success":true,"status":"","code":0,"stdout":"___.wasm\nlib___.rlib\n___.wasm\nlib___.a\n/home/lix/.rustup/toolchains/1.89.0-x86_64-unknown-linux-gnu\noff\n___\ndebug_assertions\npanic=\"abort\"\nproc_macro\ntarget_abi=\"\"\ntarget_arch=\"wasm32\"\ntarget_endian=\"little\"\ntarget_env=\"\"\ntarget_family=\"wasm\"\ntarget_feature=\"bulk-memory\"\ntarget_feature=\"multivalue\"\ntarget_feature=\"mutable-globals\"\ntarget_feature=\"nontrapping-fptoint\"\ntarget_feature=\"reference-types\"\ntarget_feature=\"sign-ext\"\ntarget_has_atomic=\"16\"\ntarget_has_atomic=\"32\"\ntarget_has_atomic=\"64\"\ntarget_has_atomic=\"8\"\ntarget_has_atomic=\"ptr\"\ntarget_os=\"unknown\"\ntarget_pointer_width=\"32\"\ntarget_vendor=\"unknown\"\n","stderr":"warning: dropping unsupported crate type `dylib` for target `wasm32-unknown-unknown`\n\nwarning: dropping unsupported crate type `proc-macro` for target `wasm32-unknown-unknown`\n\nwarning: 2 warnings emitted\n\n"},"17747080675513052775":{"success":true,"status":"","code":0,"stdout":"rustc 1.89.0 (29483883e 2025-08-04)\nbinary: rustc\ncommit-hash: 29483883eed69d5fb4db01964cdf2af4d86e9cb2\ncommit-date: 2025-08-04\nhost: x86_64-unknown-linux-gnu\nrelease: 1.89.0\nLLVM version: 20.1.7\n","stderr":""}},"successes":{}}
|
||||
@@ -126,7 +126,7 @@ function attachNetworkCapture(page, label, records) {
|
||||
url,
|
||||
status,
|
||||
requestBody: request.postData() || null,
|
||||
responseText: responseText ? responseText.slice(0, 4000) : null,
|
||||
responseText: responseText ? responseText.slice(0, 12000) : null,
|
||||
});
|
||||
});
|
||||
page.on("requestfailed", (request) => {
|
||||
@@ -178,6 +178,28 @@ async function readPageState(page) {
|
||||
const assetRows = Array.from(document.querySelectorAll("#sidebar-file-tree-root [data-asset-id]"))
|
||||
.map((node) => (node instanceof HTMLElement ? node.getAttribute("data-asset-id") || "" : ""))
|
||||
.filter(Boolean);
|
||||
const fileTreeAssetRowElements = Array.from(document.querySelectorAll("#sidebar-file-tree-root [data-asset-id]"))
|
||||
.map((node) => (node instanceof HTMLElement ? node.closest(".tree-row") || node : null))
|
||||
.filter((node, index, rows) => node instanceof HTMLElement && rows.indexOf(node) === index);
|
||||
const mindmapRows = fileTreeAssetRowElements
|
||||
.filter((node) => {
|
||||
if (!(node instanceof HTMLElement)) return false;
|
||||
const objectKind = node.getAttribute("data-object-kind") || "";
|
||||
const objectIdentity = node.getAttribute("data-object-identity") || "";
|
||||
const assetId = node.getAttribute("data-asset-id") || "";
|
||||
return objectKind === "mindmap" || objectIdentity.includes('"objectKind":"mindmap"') || assetId.startsWith("mindmap");
|
||||
})
|
||||
.map((node) => {
|
||||
const element = node;
|
||||
return {
|
||||
rowId: element.getAttribute("data-row-id") || "",
|
||||
documentId: element.getAttribute("data-document-id") || element.getAttribute("data-doc-id") || "",
|
||||
assetId: element.getAttribute("data-asset-id") || "",
|
||||
objectKind: element.getAttribute("data-object-kind") || "",
|
||||
objectIdentity: element.getAttribute("data-object-identity") || "",
|
||||
title: element.textContent || "",
|
||||
};
|
||||
});
|
||||
return {
|
||||
url: window.location.href,
|
||||
bodyText: (document.body?.innerText || "").slice(0, 8000),
|
||||
@@ -212,6 +234,7 @@ async function readPageState(page) {
|
||||
mindmapLoadingEvents: window.__MNOTE_MINDMAP_LOADING_EVENTS__ || [],
|
||||
fileTreeText: (document.getElementById("sidebar-file-tree-root")?.textContent || "").slice(0, 4000),
|
||||
fileTreeAssetIds: assetRows,
|
||||
fileTreeMindmapRows: mindmapRows,
|
||||
treeEvents: window.__MNOTE_SMOKE_TREE_EVENTS__ || [],
|
||||
};
|
||||
});
|
||||
@@ -260,6 +283,24 @@ function stableJson(value) {
|
||||
return JSON.stringify(value ?? null);
|
||||
}
|
||||
|
||||
async function waitForMindmapRuntimeViewSettled(page, mindmapId, label) {
|
||||
let previous = await readMindmapRuntimeStabilityState(page, mindmapId);
|
||||
for (let index = 0; index < 12; index += 1) {
|
||||
await page.waitForTimeout(350);
|
||||
const current = await readMindmapRuntimeStabilityState(page, mindmapId);
|
||||
if (
|
||||
stableJson(current.viewTransform) === stableJson(previous.viewTransform) &&
|
||||
stableJson(current.topicRect) === stableJson(previous.topicRect) &&
|
||||
current.runtimeMountCount === previous.runtimeMountCount &&
|
||||
current.runtimeProjectionApplyCount === previous.runtimeProjectionApplyCount
|
||||
) {
|
||||
return current;
|
||||
}
|
||||
previous = current;
|
||||
}
|
||||
throw new Error(`mindmap_runtime_view_not_settled:${label}`);
|
||||
}
|
||||
|
||||
function rectCenter(rect) {
|
||||
if (!rect) return null;
|
||||
return {
|
||||
@@ -271,7 +312,7 @@ function rectCenter(rect) {
|
||||
function centerDistance(a, b) {
|
||||
const ca = rectCenter(a);
|
||||
const cb = rectCenter(b);
|
||||
if (!ca || !cb) return Number.POSITIVE_INFINITY;
|
||||
if (!ca || !cb) return 0;
|
||||
return Math.hypot(ca.x - cb.x, ca.y - cb.y);
|
||||
}
|
||||
|
||||
@@ -324,6 +365,7 @@ async function dispatchSyntheticMindmapTreeSignal(page, documentId, mindmapId, e
|
||||
}
|
||||
|
||||
async function assertMindmapRuntimeDoesNotRefreshForLiveSignals(page, documentId, mindmapId, failures, label) {
|
||||
await waitForMindmapRuntimeViewSettled(page, mindmapId, label);
|
||||
const before = await readPageState(page);
|
||||
const beforeRuntime = await readMindmapRuntimeStabilityState(page, mindmapId);
|
||||
if (before.hasMindmapError || before.hasMindmapLoading || !before.runtimeReady) {
|
||||
@@ -551,6 +593,56 @@ async function assertFileTreeMindmapOpenUsesObjectShell(page, documentId, mindma
|
||||
return { opened, state: readyState };
|
||||
}
|
||||
|
||||
async function assertSingleMindmapAssetRowStable(page, documentId, mindmapId, expectedObjectIdentity, failures, label) {
|
||||
await openFilesystemView(page);
|
||||
const state = await readPageState(page);
|
||||
const rows = state.fileTreeMindmapRows.filter((row) => row.documentId === documentId);
|
||||
if (rows.length !== 1) {
|
||||
failures.push({
|
||||
code: "filetree_mindmap_asset_row_count_changed",
|
||||
label,
|
||||
expectedCount: 1,
|
||||
actualCount: rows.length,
|
||||
rows,
|
||||
state,
|
||||
});
|
||||
return { ok: false, rows, state, objectIdentity: expectedObjectIdentity };
|
||||
}
|
||||
const row = rows[0];
|
||||
if (row.assetId !== mindmapId) {
|
||||
failures.push({
|
||||
code: "filetree_mindmap_asset_id_changed",
|
||||
label,
|
||||
expectedAssetId: mindmapId,
|
||||
row,
|
||||
state,
|
||||
});
|
||||
}
|
||||
if (!row.objectIdentity.includes(`"objectKind":"mindmap"`) || !row.objectIdentity.includes(`"assetId":"${mindmapId}"`)) {
|
||||
failures.push({
|
||||
code: "filetree_mindmap_asset_identity_invalid",
|
||||
label,
|
||||
row,
|
||||
state,
|
||||
});
|
||||
}
|
||||
if (expectedObjectIdentity && row.objectIdentity !== expectedObjectIdentity) {
|
||||
failures.push({
|
||||
code: "filetree_mindmap_asset_identity_changed",
|
||||
label,
|
||||
expectedObjectIdentity,
|
||||
row,
|
||||
state,
|
||||
});
|
||||
}
|
||||
return {
|
||||
ok: failures.length === 0,
|
||||
rows,
|
||||
state,
|
||||
objectIdentity: row.objectIdentity,
|
||||
};
|
||||
}
|
||||
|
||||
async function assertFileTreeIndexOpenUsesPageAggregate(page, documentId, mindmapId, failures) {
|
||||
await openFilesystemView(page);
|
||||
const opened = await page.evaluate(
|
||||
@@ -942,6 +1034,77 @@ function findBadRecord(records) {
|
||||
});
|
||||
}
|
||||
|
||||
function parseJsonMaybe(value) {
|
||||
if (typeof value !== "string" || !value.trim()) return null;
|
||||
try {
|
||||
return JSON.parse(value);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function assertMindmapCommandApplyArtifactsDoNotTouchTreeResource(records, failures) {
|
||||
const commandApplyResponses = records.filter((record) => {
|
||||
if (record.type !== "response" || record.method !== "POST" || !record.url.includes("/api/mindmap/")) {
|
||||
return false;
|
||||
}
|
||||
const requestBody = parseJsonMaybe(record.requestBody);
|
||||
return requestBody?.commandName === "mindmap.command.apply";
|
||||
});
|
||||
if (commandApplyResponses.length === 0) {
|
||||
failures.push({ code: "mindmap_command_apply_artifact_response_missing" });
|
||||
return { checked: 0, artifacts: [] };
|
||||
}
|
||||
|
||||
const artifacts = [];
|
||||
let parsedCount = 0;
|
||||
let skippedReadFailures = 0;
|
||||
for (const record of commandApplyResponses) {
|
||||
const responseBody = parseJsonMaybe(record.responseText);
|
||||
const eventType = String(responseBody?.artifacts?.domainEvent?.eventType || "");
|
||||
const streamDelta = responseBody?.artifacts?.domainEvent?.payload?.streamDelta || null;
|
||||
const streamOp = String(streamDelta?.op || "");
|
||||
const summary = {
|
||||
url: record.url,
|
||||
commandName: responseBody?.commandName || "",
|
||||
eventType,
|
||||
streamOp,
|
||||
};
|
||||
artifacts.push(summary);
|
||||
if (!responseBody) {
|
||||
if (String(record.responseText || "").startsWith("<<read_response_failed:")) {
|
||||
skippedReadFailures += 1;
|
||||
continue;
|
||||
}
|
||||
failures.push({
|
||||
code: "mindmap_command_apply_artifact_response_unparseable",
|
||||
record,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
parsedCount += 1;
|
||||
if (eventType.startsWith("tree.resource.")) {
|
||||
failures.push({
|
||||
code: "mindmap_command_apply_used_tree_resource_event",
|
||||
summary,
|
||||
});
|
||||
}
|
||||
if (streamOp === "resync_required") {
|
||||
failures.push({
|
||||
code: "mindmap_command_apply_used_tree_resync_delta",
|
||||
summary,
|
||||
});
|
||||
}
|
||||
}
|
||||
if (parsedCount === 0) {
|
||||
failures.push({
|
||||
code: "mindmap_command_apply_artifact_response_all_unparseable",
|
||||
skippedReadFailures,
|
||||
});
|
||||
}
|
||||
return { checked: parsedCount, skippedReadFailures, artifacts };
|
||||
}
|
||||
|
||||
async function main() {
|
||||
await fs.mkdir(OUTPUT_DIR, { recursive: true });
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
@@ -1005,6 +1168,15 @@ async function main() {
|
||||
result.mindmapId,
|
||||
failures,
|
||||
);
|
||||
result.fileTreeMindmapRowsAfterInsert = await assertSingleMindmapAssetRowStable(
|
||||
pageA,
|
||||
doc.documentId,
|
||||
result.mindmapId,
|
||||
null,
|
||||
failures,
|
||||
"after-insert",
|
||||
);
|
||||
const stableMindmapObjectIdentity = result.fileTreeMindmapRowsAfterInsert.objectIdentity || "";
|
||||
await waitForMindmapReady(pageA, "after-filetree-mindmap-open");
|
||||
await pageA.waitForTimeout(1000);
|
||||
screenshots.push(await screenshot(pageA, "01-browser-a-after-insert"));
|
||||
@@ -1034,6 +1206,16 @@ async function main() {
|
||||
"after-topic-return",
|
||||
);
|
||||
result.browserAAfterReturnToMindmapDocument = await readPageState(pageA);
|
||||
result.fileTreeMindmapRowsAfterTopicReturn = await assertSingleMindmapAssetRowStable(
|
||||
pageA,
|
||||
doc.documentId,
|
||||
result.mindmapId,
|
||||
stableMindmapObjectIdentity,
|
||||
failures,
|
||||
"after-topic-return",
|
||||
);
|
||||
await openDocument(pageA, doc.workspaceId, doc.documentId);
|
||||
await waitForMindmapReady(pageA, "return-after-topic-row-check");
|
||||
result.runtimeTextAfterReturnToMindmapDocument = await readMindmapRuntimeTextState(
|
||||
pageA,
|
||||
result.mindmapId,
|
||||
@@ -1078,6 +1260,16 @@ async function main() {
|
||||
"after-draft-return",
|
||||
);
|
||||
result.runtimeTextAfterDraftReturn = await readMindmapRuntimeTextState(pageA, result.mindmapId, draftTopicText);
|
||||
result.fileTreeMindmapRowsAfterDraftReturn = await assertSingleMindmapAssetRowStable(
|
||||
pageA,
|
||||
doc.documentId,
|
||||
result.mindmapId,
|
||||
stableMindmapObjectIdentity,
|
||||
failures,
|
||||
"after-draft-return",
|
||||
);
|
||||
await openDocument(pageA, doc.workspaceId, doc.documentId);
|
||||
await waitForMindmapReady(pageA, "return-after-draft-row-check");
|
||||
if (!result.runtimeTextAfterDraftReturn.includesExpectedText) {
|
||||
failures.push({
|
||||
code: "mindmap_uncommitted_text_edit_lost_after_page_switch",
|
||||
@@ -1093,6 +1285,7 @@ async function main() {
|
||||
failures,
|
||||
"browser-a-after-topic-edit",
|
||||
);
|
||||
result.commandApplyArtifactSemantics = assertMindmapCommandApplyArtifactsDoNotTouchTreeResource(records, failures);
|
||||
result.longLanguageEdit = await assertLongLanguageEditSurvivesLiveRefresh(
|
||||
pageA,
|
||||
contextA.request,
|
||||
@@ -1113,6 +1306,16 @@ async function main() {
|
||||
result.mindmapId,
|
||||
failures,
|
||||
);
|
||||
result.fileTreeMindmapRowsAfterLongLanguageEdit = await assertSingleMindmapAssetRowStable(
|
||||
pageA,
|
||||
doc.documentId,
|
||||
result.mindmapId,
|
||||
stableMindmapObjectIdentity,
|
||||
failures,
|
||||
"after-long-language-edit",
|
||||
);
|
||||
await openDocument(pageA, doc.workspaceId, doc.documentId);
|
||||
await waitForMindmapReady(pageA, "return-after-long-language-row-check");
|
||||
if (result.longLanguageEdit && result.longLanguageEdit.longText) {
|
||||
result.runtimeTextAfterMindmapReopen = await readMindmapRuntimeTextState(
|
||||
pageA,
|
||||
|
||||
@@ -0,0 +1,255 @@
|
||||
#!/usr/bin/env node
|
||||
"use strict";
|
||||
|
||||
const assert = require("node:assert");
|
||||
const { chromium } = require("playwright");
|
||||
const { fetchWithTimeout } = require("./task114-rust-web-gateway-entry-smoke.js");
|
||||
|
||||
const BASE_URL = (process.env.MNOTE_WEB_SMOKE_BASE_URL || process.env.MNOTE_UI_BASE_URL || "http://127.0.0.1:3000").replace(/\/+$/, "");
|
||||
const UI_TIMEOUT_MS = Number(process.env.MNOTE_SMOKE_UI_TIMEOUT_MS || 30_000);
|
||||
|
||||
async function readJsonResponse(response, label) {
|
||||
const text = await response.text();
|
||||
let payload = null;
|
||||
try {
|
||||
payload = text ? JSON.parse(text) : null;
|
||||
} catch {
|
||||
throw new Error(`${label} 返回了非 JSON 内容: ${text.slice(0, 1200)}`);
|
||||
}
|
||||
assert(response.ok, `${label} 请求失败: ${response.status}; body: ${text.slice(0, 1200)}`);
|
||||
return payload;
|
||||
}
|
||||
|
||||
async function postTreeCommand(body, label) {
|
||||
const response = await fetchWithTimeout(`${BASE_URL}/api/tree/commands`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const payload = await readJsonResponse(response, label);
|
||||
const result = payload && typeof payload.result === "object" ? payload.result : null;
|
||||
assert(result, `${label} 缺少 result`);
|
||||
return result;
|
||||
}
|
||||
|
||||
async function createTempPage(title, extra = {}) {
|
||||
const result = await postTreeCommand(
|
||||
{
|
||||
action: "create",
|
||||
title,
|
||||
...extra,
|
||||
},
|
||||
`创建临时页面 ${title}`,
|
||||
);
|
||||
assert(result.documentId, `创建临时页面 ${title} 缺少 documentId`);
|
||||
assert(result.workspaceId, `创建临时页面 ${title} 缺少 workspaceId`);
|
||||
return {
|
||||
documentId: result.documentId,
|
||||
workspaceId: result.workspaceId,
|
||||
title,
|
||||
};
|
||||
}
|
||||
|
||||
async function purgeTempPage(target) {
|
||||
if (!target?.documentId || !target?.workspaceId) return;
|
||||
await postTreeCommand(
|
||||
{
|
||||
action: "purge",
|
||||
workspaceId: target.workspaceId,
|
||||
documentId: target.documentId,
|
||||
},
|
||||
`清理临时页面 ${target.documentId}`,
|
||||
);
|
||||
}
|
||||
|
||||
async function waitForSidebarRow(page, documentId) {
|
||||
const row = page.locator(`[data-testid="wolai-sidebar-row"][data-node-id="${documentId}"]`).first();
|
||||
await row.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
return row;
|
||||
}
|
||||
|
||||
async function armTreeLiveProbe(page) {
|
||||
await page.evaluate(() => {
|
||||
const key = "__task177TreeLiveEvents";
|
||||
window[key] = [];
|
||||
if (window.__task177TreeLiveProbeArmed) return;
|
||||
const push = (kind, detail) => {
|
||||
const payload = detail && typeof detail === "object" && "payload" in detail ? detail.payload : detail;
|
||||
window[key].push({
|
||||
kind,
|
||||
applied: document.documentElement.getAttribute("data-mnote-tree-live-applied") || "",
|
||||
payload,
|
||||
});
|
||||
if (window[key].length > 40) window[key].shift();
|
||||
};
|
||||
window.addEventListener("tree:delta", (event) => push("delta", event.detail));
|
||||
window.addEventListener("tree:resync", (event) => push("resync", event.detail));
|
||||
window.__task177TreeLiveProbeArmed = true;
|
||||
});
|
||||
}
|
||||
|
||||
async function resetTreeLiveProbe(page) {
|
||||
await page.evaluate(() => {
|
||||
document.documentElement.removeAttribute("data-mnote-tree-live-applied");
|
||||
document.documentElement.removeAttribute("data-mnote-tree-live-apply-error");
|
||||
window.__task177TreeLiveEvents = [];
|
||||
});
|
||||
}
|
||||
|
||||
async function waitForTreeLivePayload(page, label, op, documentId) {
|
||||
await page.waitForFunction(
|
||||
({ expectedOp, expectedDocumentId }) => {
|
||||
const events = Array.isArray(window.__task177TreeLiveEvents) ? window.__task177TreeLiveEvents : [];
|
||||
return events.some((event) => {
|
||||
if (!event || (event.kind !== "delta" && event.kind !== "resync")) return false;
|
||||
const applied = event.applied || document.documentElement.getAttribute("data-mnote-tree-live-applied") || "";
|
||||
if (applied !== "delta" && applied !== "resync") return false;
|
||||
try {
|
||||
const raw = JSON.stringify(event.payload || {});
|
||||
return raw.includes(expectedOp) && raw.includes(expectedDocumentId);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
},
|
||||
{ expectedOp: op, expectedDocumentId: documentId },
|
||||
{ timeout: UI_TIMEOUT_MS },
|
||||
);
|
||||
const state = await page.evaluate(() => ({
|
||||
applied: document.documentElement.getAttribute("data-mnote-tree-live-applied") || "",
|
||||
error: document.documentElement.getAttribute("data-mnote-tree-live-apply-error") || "",
|
||||
}));
|
||||
assert(["delta", "resync"].includes(state.applied), `${label} 应应用 delta/resync,实际: ${JSON.stringify(state)}`);
|
||||
assert(!state.error, `${label} 不应留下 live apply error: ${state.error}`);
|
||||
}
|
||||
|
||||
async function assertPageStillStable(page, rootTitle) {
|
||||
await page.waitForFunction(
|
||||
(title) => {
|
||||
const headerText = document.querySelector("header")?.textContent ?? "";
|
||||
const input = document.querySelector('[data-page-title-input="true"][data-pane-role="primary"]');
|
||||
const titleValue =
|
||||
input instanceof HTMLInputElement || input instanceof HTMLTextAreaElement ? input.value : "";
|
||||
return headerText.includes(title) && titleValue.includes(title);
|
||||
},
|
||||
rootTitle,
|
||||
{ timeout: UI_TIMEOUT_MS },
|
||||
);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const suffix = Date.now().toString(36);
|
||||
const rootTitle = `task177-root-${suffix}`;
|
||||
const childTitle = `task177-child-${suffix}`;
|
||||
const targetTitle = `task177-target-${suffix}`;
|
||||
const archiveTitle = `task177-archive-${suffix}`;
|
||||
let rootPage = null;
|
||||
let childPage = null;
|
||||
let targetPage = null;
|
||||
let archivedPage = null;
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const context = await browser.newContext({ viewport: { width: 1440, height: 960 } });
|
||||
const page = await context.newPage();
|
||||
|
||||
try {
|
||||
rootPage = await createTempPage(rootTitle);
|
||||
childPage = await createTempPage(childTitle, {
|
||||
workspaceId: rootPage.workspaceId,
|
||||
parentId: rootPage.documentId,
|
||||
});
|
||||
targetPage = await createTempPage(targetTitle, {
|
||||
workspaceId: rootPage.workspaceId,
|
||||
parentId: rootPage.documentId,
|
||||
});
|
||||
archivedPage = await createTempPage(archiveTitle, {
|
||||
workspaceId: rootPage.workspaceId,
|
||||
parentId: rootPage.documentId,
|
||||
});
|
||||
|
||||
const rootUrl = `${BASE_URL}/documents/${encodeURIComponent(rootPage.documentId)}?workspaceId=${encodeURIComponent(rootPage.workspaceId)}`;
|
||||
const response = await page.goto(rootUrl, { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
|
||||
assert(response, "文档页没有返回响应");
|
||||
assert.equal(response.status(), 200, `文档页状态码异常: ${response.status()}`);
|
||||
assert.equal(response.headers()["x-mnote-web-owner"], "mnote-web", "文档页必须由 mnote-web 拥有");
|
||||
|
||||
await waitForSidebarRow(page, rootPage.documentId);
|
||||
await waitForSidebarRow(page, childPage.documentId);
|
||||
await waitForSidebarRow(page, targetPage.documentId);
|
||||
await waitForSidebarRow(page, archivedPage.documentId);
|
||||
await assertPageStillStable(page, rootTitle);
|
||||
await armTreeLiveProbe(page);
|
||||
|
||||
await resetTreeLiveProbe(page);
|
||||
await postTreeCommand(
|
||||
{
|
||||
action: "move",
|
||||
workspaceId: rootPage.workspaceId,
|
||||
documentId: childPage.documentId,
|
||||
parentId: targetPage.documentId,
|
||||
sortOrder: 0,
|
||||
},
|
||||
"移动临时子页面",
|
||||
);
|
||||
await waitForTreeLivePayload(page, "移动后 tree live", "move_document", childPage.documentId);
|
||||
await page.waitForFunction(
|
||||
({ childId, targetId }) => {
|
||||
const pageRow = document.querySelector(`[data-testid="wolai-sidebar-row"][data-node-id="${childId}"]`);
|
||||
const fileRow = document.querySelector(`.tree-row[data-shell-mode="filetree"][data-doc-id="${childId}"], .tree-row[data-shell-mode="filetree"][data-document-id="${childId}"]`);
|
||||
return pageRow?.getAttribute("data-parent-id") === targetId && fileRow?.getAttribute("data-parent-id") === targetId;
|
||||
},
|
||||
{ childId: childPage.documentId, targetId: targetPage.documentId },
|
||||
{ timeout: UI_TIMEOUT_MS },
|
||||
);
|
||||
await assertPageStillStable(page, rootTitle);
|
||||
|
||||
await resetTreeLiveProbe(page);
|
||||
await postTreeCommand(
|
||||
{
|
||||
action: "archive",
|
||||
workspaceId: rootPage.workspaceId,
|
||||
documentId: archivedPage.documentId,
|
||||
},
|
||||
"归档临时页面",
|
||||
);
|
||||
await waitForTreeLivePayload(page, "归档后 tree live", "remove_document", archivedPage.documentId);
|
||||
await page.waitForFunction(
|
||||
(documentId) =>
|
||||
!document.querySelector(`[data-testid="wolai-sidebar-row"][data-node-id="${documentId}"]`) &&
|
||||
!document.querySelector(`.tree-row[data-shell-mode="filetree"][data-doc-id="${documentId}"], .tree-row[data-shell-mode="filetree"][data-document-id="${documentId}"]`),
|
||||
archivedPage.documentId,
|
||||
{ timeout: UI_TIMEOUT_MS },
|
||||
);
|
||||
await assertPageStillStable(page, rootTitle);
|
||||
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
ok: true,
|
||||
baseUrl: BASE_URL,
|
||||
workspaceId: rootPage.workspaceId,
|
||||
rootDocumentId: rootPage.documentId,
|
||||
movedDocumentId: childPage.documentId,
|
||||
movedParentId: targetPage.documentId,
|
||||
archivedDocumentId: archivedPage.documentId,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
} finally {
|
||||
if (archivedPage) await purgeTempPage(archivedPage).catch(() => undefined);
|
||||
if (childPage) await purgeTempPage(childPage).catch(() => undefined);
|
||||
if (targetPage) await purgeTempPage(targetPage).catch(() => undefined);
|
||||
if (rootPage) await purgeTempPage(rootPage).catch(() => undefined);
|
||||
await page.close().catch(() => undefined);
|
||||
await context.close().catch(() => undefined);
|
||||
await browser.close().catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
main().catch((error) => {
|
||||
console.error(error instanceof Error ? error.stack || error.message : String(error));
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
#!/usr/bin/env node
|
||||
"use strict";
|
||||
|
||||
const assert = require("node:assert");
|
||||
const { chromium } = require("playwright");
|
||||
const { fetchWithTimeout } = require("./task114-rust-web-gateway-entry-smoke.js");
|
||||
|
||||
const BASE_URL = (process.env.MNOTE_WEB_SMOKE_BASE_URL || process.env.MNOTE_UI_BASE_URL || "http://127.0.0.1:3000").replace(/\/+$/, "");
|
||||
const UI_TIMEOUT_MS = Number(process.env.MNOTE_SMOKE_UI_TIMEOUT_MS || 30_000);
|
||||
|
||||
async function readJsonResponse(response, label) {
|
||||
const text = await response.text();
|
||||
let payload = null;
|
||||
try {
|
||||
payload = text ? JSON.parse(text) : null;
|
||||
} catch {
|
||||
throw new Error(`${label} 返回了非 JSON 内容: ${text.slice(0, 1200)}`);
|
||||
}
|
||||
assert(response.ok, `${label} 请求失败: ${response.status}; body: ${text.slice(0, 1200)}`);
|
||||
return payload;
|
||||
}
|
||||
|
||||
async function postTreeCommand(body, label) {
|
||||
const response = await fetchWithTimeout(`${BASE_URL}/api/tree/commands`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const payload = await readJsonResponse(response, label);
|
||||
const result = payload && typeof payload.result === "object" ? payload.result : null;
|
||||
assert(result, `${label} 缺少 result`);
|
||||
return result;
|
||||
}
|
||||
|
||||
async function createTempPage(title) {
|
||||
const result = await postTreeCommand({ action: "create", title }, `创建临时页面 ${title}`);
|
||||
assert(result.documentId, `创建临时页面 ${title} 缺少 documentId`);
|
||||
assert(result.workspaceId, `创建临时页面 ${title} 缺少 workspaceId`);
|
||||
return { documentId: result.documentId, workspaceId: result.workspaceId, title };
|
||||
}
|
||||
|
||||
async function purgeTempPage(target) {
|
||||
if (!target?.documentId || !target?.workspaceId) return;
|
||||
await postTreeCommand(
|
||||
{ action: "purge", workspaceId: target.workspaceId, documentId: target.documentId },
|
||||
`清理临时页面 ${target.documentId}`,
|
||||
);
|
||||
}
|
||||
|
||||
async function waitForRuntimeIsland(page) {
|
||||
await page.waitForFunction(
|
||||
() => {
|
||||
const host = document.querySelector('[data-testid="mnote-leptos-tiptap-island-editor-root"]');
|
||||
const editor = host?.querySelector(".editor-surface .ProseMirror[contenteditable='true']");
|
||||
return (
|
||||
host?.getAttribute("data-editor-host-kind") === "leptos_tiptap_island" &&
|
||||
host?.getAttribute("data-runtime-editor-status") !== "error" &&
|
||||
editor instanceof HTMLElement &&
|
||||
editor.isContentEditable
|
||||
);
|
||||
},
|
||||
null,
|
||||
{ timeout: UI_TIMEOUT_MS },
|
||||
);
|
||||
}
|
||||
|
||||
async function setLocalHeadingFixture(page, headingText) {
|
||||
await page.evaluate((text) => {
|
||||
const editor = document.querySelector(".editor-surface .ProseMirror")?.editor;
|
||||
if (!editor) throw new Error("找不到 Tiptap editor");
|
||||
editor.commands.setContent({
|
||||
type: "doc",
|
||||
content: [
|
||||
{ type: "heading", attrs: { level: 2 }, content: [{ type: "text", text }] },
|
||||
{ type: "paragraph", content: [{ type: "text", text: "本地正文尚未等待服务端 pageSubtree 刷新。" }] },
|
||||
],
|
||||
});
|
||||
}, headingText);
|
||||
await page.waitForFunction(
|
||||
(text) => Array.from(document.querySelectorAll(".editor-surface .ProseMirror h2")).some((node) => (node.textContent || "").includes(text)),
|
||||
headingText,
|
||||
{ timeout: UI_TIMEOUT_MS },
|
||||
);
|
||||
}
|
||||
|
||||
function stringify(value) {
|
||||
try {
|
||||
return JSON.stringify(value);
|
||||
} catch {
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const suffix = Date.now().toString(36);
|
||||
const pageTitle = `task178-page-ai-${suffix}`;
|
||||
const localHeading = `TASK178 本地 Heading ${suffix}`;
|
||||
let target = null;
|
||||
let capturedBody = null;
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const context = await browser.newContext({ viewport: { width: 1440, height: 960 } });
|
||||
const page = await context.newPage();
|
||||
|
||||
try {
|
||||
target = await createTempPage(pageTitle);
|
||||
await page.route("**/api/ai-agent/run", async (route) => {
|
||||
const postData = route.request().postData() || "{}";
|
||||
capturedBody = JSON.parse(postData);
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
headers: { "content-type": "text/event-stream; charset=utf-8" },
|
||||
body: 'event: assistant_message\ndata: {"text":"ok"}\n\n',
|
||||
});
|
||||
});
|
||||
|
||||
const url = `${BASE_URL}/documents/${encodeURIComponent(target.documentId)}?workspaceId=${encodeURIComponent(target.workspaceId)}`;
|
||||
const response = await page.goto(url, { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
|
||||
assert(response, "文档页没有返回响应");
|
||||
assert.equal(response.status(), 200, `文档页状态码异常: ${response.status()}`);
|
||||
assert.equal(response.headers()["x-mnote-web-owner"], "mnote-web", "文档页必须由 mnote-web 拥有");
|
||||
|
||||
await waitForRuntimeIsland(page);
|
||||
await setLocalHeadingFixture(page, localHeading);
|
||||
|
||||
await page.getByTestId("wolai-floating-ai").click({ timeout: UI_TIMEOUT_MS });
|
||||
await page.locator("[data-page-ai-input]").fill(`请基于当前本地结构回答:${localHeading}`, { timeout: UI_TIMEOUT_MS });
|
||||
await page.locator('[data-page-ai-action="send"]').click({ timeout: UI_TIMEOUT_MS });
|
||||
await page.waitForFunction(() => Boolean(window.__task178Noop) || true, null, { timeout: 10 });
|
||||
await page.waitForFunction(
|
||||
() => document.querySelector('[data-testid="wolai-page-ai-drawer"] .wolai-page-ai-message--assistant'),
|
||||
null,
|
||||
{ timeout: UI_TIMEOUT_MS },
|
||||
);
|
||||
|
||||
assert(capturedBody, "未捕获 /api/ai-agent/run 请求");
|
||||
const contextPayload = capturedBody.context || {};
|
||||
const rawContext = stringify(contextPayload);
|
||||
assert.equal(contextPayload.pageSubtreeSource, "local", `AI context 应标记本地 pageSubtree,实际: ${rawContext}`);
|
||||
assert(rawContext.includes(localHeading), `AI context 应包含本地 heading: ${rawContext.slice(0, 1600)}`);
|
||||
assert(Array.isArray(contextPayload.documentBlocks), `AI context 应包含本地 documentBlocks: ${rawContext.slice(0, 1600)}`);
|
||||
assert(contextPayload.outline?.some((item) => stringify(item).includes(localHeading)), `AI context outline 应包含本地 heading: ${rawContext.slice(0, 1600)}`);
|
||||
assert(contextPayload.subtree?.stats?.headingCount >= 1, `AI context subtree stats 应包含 headingCount: ${rawContext.slice(0, 1600)}`);
|
||||
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
ok: true,
|
||||
baseUrl: BASE_URL,
|
||||
documentId: target.documentId,
|
||||
workspaceId: target.workspaceId,
|
||||
localHeading,
|
||||
pageSubtreeSource: contextPayload.pageSubtreeSource,
|
||||
headingCount: contextPayload.subtree?.stats?.headingCount ?? null,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
} finally {
|
||||
if (target) await purgeTempPage(target).catch(() => undefined);
|
||||
await page.close().catch(() => undefined);
|
||||
await context.close().catch(() => undefined);
|
||||
await browser.close().catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
main().catch((error) => {
|
||||
console.error(error instanceof Error ? error.stack || error.message : String(error));
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
#!/usr/bin/env node
|
||||
"use strict";
|
||||
|
||||
const fs = require("node:fs/promises");
|
||||
const path = require("node:path");
|
||||
const { chromium } = require("playwright");
|
||||
const {
|
||||
BASE_URL,
|
||||
UI_TIMEOUT_MS,
|
||||
cleanupDocuments,
|
||||
createTempDocument,
|
||||
ensureAuthenticated,
|
||||
renameDocument,
|
||||
} = require("./tree-shell-smoke-helpers");
|
||||
|
||||
const TASK = "task179-tree-create-delete-no-reload-smoke";
|
||||
const OUTPUT_DIR = path.join(process.cwd(), "tmp", TASK);
|
||||
const RESULT_PATH = path.join(OUTPUT_DIR, "result.json");
|
||||
|
||||
async function writeResult(payload) {
|
||||
await fs.mkdir(OUTPUT_DIR, { recursive: true });
|
||||
await fs.writeFile(RESULT_PATH, `${JSON.stringify({ ...payload, resultPath: RESULT_PATH }, null, 2)}\n`, "utf8");
|
||||
}
|
||||
|
||||
function fileTreeDocumentRowSelector(documentId) {
|
||||
return `[data-testid="filetree-doc-row"][data-document-id="${documentId}"], [data-testid="filetree-doc-row"][data-doc-id="${documentId}"]`;
|
||||
}
|
||||
|
||||
function pageTreeDocumentRowSelector(documentId) {
|
||||
return `.tree-row[data-shell-mode="page"][data-node-id="${documentId}"]`;
|
||||
}
|
||||
|
||||
function documentRowSelector(mode, documentId) {
|
||||
return mode === "page" ? pageTreeDocumentRowSelector(documentId) : fileTreeDocumentRowSelector(documentId);
|
||||
}
|
||||
|
||||
function renameInputSelector(mode) {
|
||||
return mode === "page"
|
||||
? ".tree-rename-input[data-rename-id]"
|
||||
: ".tree-rename-input[data-rename-id^='doc:']";
|
||||
}
|
||||
|
||||
function documentIdFromRenameId(mode, renameId) {
|
||||
return mode === "page" ? String(renameId || "") : String(renameId || "").replace(/^doc:/, "");
|
||||
}
|
||||
|
||||
async function readShellState(page) {
|
||||
return page.evaluate(() => ({
|
||||
url: window.location.href,
|
||||
rows: Array.from(document.querySelectorAll(".tree-row[data-shell-mode='filetree']")).map((row) => ({
|
||||
rowId: row instanceof HTMLElement ? row.dataset.rowId || "" : "",
|
||||
documentId: row instanceof HTMLElement ? row.dataset.documentId || "" : "",
|
||||
title: row instanceof HTMLElement ? row.textContent || "" : "",
|
||||
})),
|
||||
status: document.getElementById("tree-shell-status")?.textContent || "",
|
||||
lastAction: document.getElementById("tree-shell-last-action")?.textContent || "",
|
||||
}));
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const result = {
|
||||
ok: false,
|
||||
task: TASK,
|
||||
baseUrl: BASE_URL,
|
||||
createdIds: [],
|
||||
navigationEvents: [],
|
||||
treeCommandRequests: [],
|
||||
modes: {},
|
||||
};
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const context = await browser.newContext();
|
||||
const page = await context.newPage();
|
||||
|
||||
page.on("request", (request) => {
|
||||
const url = request.url();
|
||||
if (url.includes("/api/tree/commands")) {
|
||||
result.treeCommandRequests.push({
|
||||
method: request.method(),
|
||||
url,
|
||||
body: request.postData() || null,
|
||||
at: Date.now(),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
await ensureAuthenticated(page, context.request);
|
||||
page.on("framenavigated", (frame) => {
|
||||
if (frame === page.mainFrame()) {
|
||||
result.navigationEvents.push({ url: frame.url(), at: Date.now() });
|
||||
}
|
||||
});
|
||||
|
||||
for (const mode of ["filetree", "page"]) {
|
||||
const root = await createTempDocument(context.request, null);
|
||||
result.createdIds.push(root.documentId);
|
||||
await renameDocument(
|
||||
context.request,
|
||||
root.workspaceId,
|
||||
root.documentId,
|
||||
`task179-tree-${mode}-root-${Date.now().toString().slice(-6)}`,
|
||||
);
|
||||
|
||||
const treeUrl = `${BASE_URL}/tree?workspaceId=${encodeURIComponent(root.workspaceId)}&mode=${encodeURIComponent(mode)}&activeDocumentId=${encodeURIComponent(root.documentId)}`;
|
||||
await page.goto(treeUrl, { waitUntil: "commit", timeout: UI_TIMEOUT_MS });
|
||||
await page.locator(documentRowSelector(mode, root.documentId)).waitFor({
|
||||
state: "visible",
|
||||
timeout: UI_TIMEOUT_MS,
|
||||
});
|
||||
const initialUrl = page.url();
|
||||
const navigationStartIndex = result.navigationEvents.length;
|
||||
|
||||
const createStartedAt = Date.now();
|
||||
await page.getByTestId("tree-create-root").click({ timeout: UI_TIMEOUT_MS });
|
||||
const renameInput = page.locator(renameInputSelector(mode)).first();
|
||||
await renameInput.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
const renameId = await renameInput.getAttribute("data-rename-id");
|
||||
const createdDocumentId = documentIdFromRenameId(mode, renameId);
|
||||
if (!createdDocumentId) throw new Error(`${mode}_created_document_id_missing:${renameId || ""}`);
|
||||
result.createdIds.push(createdDocumentId);
|
||||
await page.locator(documentRowSelector(mode, createdDocumentId)).waitFor({
|
||||
state: "visible",
|
||||
timeout: UI_TIMEOUT_MS,
|
||||
});
|
||||
await page.keyboard.press("Escape");
|
||||
const createFinishedAt = Date.now();
|
||||
|
||||
const afterCreateUrl = page.url();
|
||||
if (afterCreateUrl !== initialUrl) {
|
||||
throw new Error(`${mode}_create_changed_url:${initialUrl}->${afterCreateUrl}`);
|
||||
}
|
||||
const navigationAfterCreate = result.navigationEvents.slice(navigationStartIndex);
|
||||
if (navigationAfterCreate.length !== 0) {
|
||||
throw new Error(`${mode}_create_triggered_navigation:${JSON.stringify(navigationAfterCreate)}`);
|
||||
}
|
||||
|
||||
let deleteMs = null;
|
||||
let afterDeleteUrl = page.url();
|
||||
let navigationAfterDelete = result.navigationEvents.slice(navigationStartIndex);
|
||||
if (mode === "filetree") {
|
||||
const deleteStartedAt = Date.now();
|
||||
const createdRow = page.locator(documentRowSelector(mode, createdDocumentId)).first();
|
||||
await createdRow.click({ timeout: UI_TIMEOUT_MS });
|
||||
await page.keyboard.press("Delete");
|
||||
const confirmButton = page.locator('.tree-preflight-actions button[data-role="confirm"]').first();
|
||||
await confirmButton.click({ timeout: UI_TIMEOUT_MS });
|
||||
await page.locator(documentRowSelector(mode, createdDocumentId)).waitFor({
|
||||
state: "detached",
|
||||
timeout: UI_TIMEOUT_MS,
|
||||
});
|
||||
const deleteFinishedAt = Date.now();
|
||||
deleteMs = deleteFinishedAt - deleteStartedAt;
|
||||
|
||||
afterDeleteUrl = page.url();
|
||||
if (afterDeleteUrl !== initialUrl) {
|
||||
throw new Error(`${mode}_delete_changed_url:${initialUrl}->${afterDeleteUrl}`);
|
||||
}
|
||||
navigationAfterDelete = result.navigationEvents.slice(navigationStartIndex);
|
||||
if (navigationAfterDelete.length !== 0) {
|
||||
throw new Error(`${mode}_delete_triggered_navigation:${JSON.stringify(navigationAfterDelete)}`);
|
||||
}
|
||||
}
|
||||
|
||||
result.modes[mode] = {
|
||||
timings: {
|
||||
createMs: createFinishedAt - createStartedAt,
|
||||
deleteMs,
|
||||
},
|
||||
initialUrl,
|
||||
finalUrl: afterDeleteUrl,
|
||||
createdDocumentId,
|
||||
navigationEvents: navigationAfterDelete,
|
||||
finalState: await readShellState(page),
|
||||
};
|
||||
}
|
||||
result.ok = true;
|
||||
await writeResult(result);
|
||||
} catch (error) {
|
||||
result.error = error instanceof Error ? error.stack || error.message : String(error);
|
||||
result.failureState = await readShellState(page).catch(() => null);
|
||||
await writeResult(result);
|
||||
throw error;
|
||||
} finally {
|
||||
await cleanupDocuments(context.request, result.createdIds).catch(() => undefined);
|
||||
await context.close().catch(() => undefined);
|
||||
await browser.close().catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
@@ -0,0 +1,264 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
const fs = require("node:fs/promises");
|
||||
const path = require("node:path");
|
||||
|
||||
const DEFAULT_URL = "http://127.0.0.1:3000/api/sidebar";
|
||||
const DEFAULT_OUTPUT = "tmp/task180-mindmap-ghost-candidate-audit/result.json";
|
||||
|
||||
function stringOrNull(value) {
|
||||
if (typeof value !== "string") return null;
|
||||
const trimmed = value.trim();
|
||||
return trimmed || null;
|
||||
}
|
||||
|
||||
function asArray(value) {
|
||||
return Array.isArray(value) ? value : [];
|
||||
}
|
||||
|
||||
function unwrapSidebarPayload(payload) {
|
||||
let unwrapped = payload && typeof payload === "object" ? payload : {};
|
||||
if (unwrapped.data && typeof unwrapped.data === "object") {
|
||||
unwrapped = unwrapped.data;
|
||||
}
|
||||
const result = unwrapped.result && typeof unwrapped.result === "object" ? unwrapped.result : null;
|
||||
if (Array.isArray(result?.items)) {
|
||||
return {
|
||||
...unwrapped,
|
||||
...result,
|
||||
kernelFileTreeProjection: { items: result.items },
|
||||
};
|
||||
}
|
||||
if (Array.isArray(unwrapped.items)) {
|
||||
return {
|
||||
...unwrapped,
|
||||
kernelFileTreeProjection: { items: unwrapped.items },
|
||||
};
|
||||
}
|
||||
return unwrapped;
|
||||
}
|
||||
|
||||
function readAssetId(asset) {
|
||||
return stringOrNull(asset?.id) ?? stringOrNull(asset?.assetId) ?? stringOrNull(asset?.asset_id);
|
||||
}
|
||||
|
||||
function readDocumentId(value) {
|
||||
return stringOrNull(value?.document_id) ?? stringOrNull(value?.documentId) ?? stringOrNull(value?.docId);
|
||||
}
|
||||
|
||||
function isActiveAsset(asset) {
|
||||
return !stringOrNull(asset?.deleted_at) &&
|
||||
!stringOrNull(asset?.deletedAt) &&
|
||||
!stringOrNull(asset?.purged_at) &&
|
||||
!stringOrNull(asset?.purgedAt);
|
||||
}
|
||||
|
||||
function isMindmapAsset(asset) {
|
||||
const type = stringOrNull(asset?.asset_type) ?? stringOrNull(asset?.assetType);
|
||||
return type === "mindmap" || Boolean(readAssetId(asset)?.startsWith("mindmap"));
|
||||
}
|
||||
|
||||
function normalizeMindmapAsset(asset) {
|
||||
return {
|
||||
assetId: readAssetId(asset),
|
||||
documentId: readDocumentId(asset),
|
||||
deletedAt: stringOrNull(asset?.deleted_at) ?? stringOrNull(asset?.deletedAt),
|
||||
title: stringOrNull(asset?.file_name) ?? stringOrNull(asset?.fileName) ?? stringOrNull(asset?.title),
|
||||
};
|
||||
}
|
||||
|
||||
function readProjectionItems(sidebar) {
|
||||
const projection = sidebar.kernelFileTreeProjection ?? sidebar.kernel_file_tree_projection ?? {};
|
||||
return asArray(projection.items);
|
||||
}
|
||||
|
||||
function readFileTreeMindmapRow(item) {
|
||||
const resourceMeta = item?.resourceMeta ?? item?.resource_meta ?? {};
|
||||
const assetId =
|
||||
stringOrNull(resourceMeta.assetId) ??
|
||||
stringOrNull(resourceMeta.asset_id) ??
|
||||
stringOrNull(item?.assetId) ??
|
||||
stringOrNull(item?.asset_id);
|
||||
const documentId =
|
||||
stringOrNull(resourceMeta.documentId) ??
|
||||
stringOrNull(resourceMeta.document_id) ??
|
||||
readDocumentId(item);
|
||||
const resourceKind = stringOrNull(resourceMeta.resourceKind) ?? stringOrNull(resourceMeta.resource_kind);
|
||||
const rowKind = stringOrNull(item?.rowKind) ?? stringOrNull(item?.row_kind);
|
||||
const objectKind = stringOrNull(item?.objectKind) ?? stringOrNull(resourceMeta.objectKind);
|
||||
const isMindmap =
|
||||
resourceKind === "mindmap" ||
|
||||
objectKind === "mindmap" ||
|
||||
Boolean(assetId?.startsWith("mindmap"));
|
||||
if (!isMindmap) return null;
|
||||
return {
|
||||
rowId: stringOrNull(item?.rowId) ?? stringOrNull(item?.row_id),
|
||||
rowKind,
|
||||
documentId,
|
||||
assetId,
|
||||
title: stringOrNull(item?.title),
|
||||
};
|
||||
}
|
||||
|
||||
function groupBy(items, keyFn) {
|
||||
const map = new Map();
|
||||
for (const item of items) {
|
||||
const key = keyFn(item);
|
||||
if (!key) continue;
|
||||
const bucket = map.get(key) ?? [];
|
||||
bucket.push(item);
|
||||
map.set(key, bucket);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
function auditSidebarMindmapGhostCandidates(payload) {
|
||||
const sidebar = unwrapSidebarPayload(payload);
|
||||
const documents = asArray(sidebar.documents);
|
||||
const documentIds = new Set(documents.map((doc) => stringOrNull(doc?.id)).filter(Boolean));
|
||||
const rawMindmapAssets = [
|
||||
...asArray(sidebar.mindmapAssets),
|
||||
...asArray(sidebar.mindmap_assets),
|
||||
];
|
||||
const activeMindmapAssets = rawMindmapAssets
|
||||
.filter((asset) => isMindmapAsset(asset) && isActiveAsset(asset))
|
||||
.map(normalizeMindmapAsset)
|
||||
.filter((asset) => asset.assetId && asset.documentId);
|
||||
|
||||
const byDocument = groupBy(activeMindmapAssets, (asset) => asset.documentId);
|
||||
const multipleActiveMindmapsByDocument = Array.from(byDocument.entries())
|
||||
.filter(([, assets]) => assets.length > 1)
|
||||
.map(([documentId, assets]) => ({
|
||||
documentId,
|
||||
mindmapIds: assets.map((asset) => asset.assetId),
|
||||
count: assets.length,
|
||||
}));
|
||||
|
||||
const byAssetId = groupBy(activeMindmapAssets, (asset) => asset.assetId);
|
||||
const duplicateActiveAssetIds = Array.from(byAssetId.entries())
|
||||
.filter(([, assets]) => assets.length > 1)
|
||||
.map(([assetId, assets]) => ({
|
||||
assetId,
|
||||
documentIds: Array.from(new Set(assets.map((asset) => asset.documentId).filter(Boolean))),
|
||||
count: assets.length,
|
||||
}));
|
||||
|
||||
const activeAssetsWithMissingDocument = activeMindmapAssets
|
||||
.filter((asset) => asset.documentId && !documentIds.has(asset.documentId))
|
||||
.map((asset) => ({
|
||||
assetId: asset.assetId,
|
||||
documentId: asset.documentId,
|
||||
}));
|
||||
|
||||
const fileTreeMindmapRows = readProjectionItems(sidebar)
|
||||
.map(readFileTreeMindmapRow)
|
||||
.filter(Boolean);
|
||||
const byFileTreeObject = groupBy(fileTreeMindmapRows, (row) => `${row.documentId ?? ""}:${row.assetId ?? ""}`);
|
||||
const duplicateFileTreeMindmapRows = Array.from(byFileTreeObject.entries())
|
||||
.filter(([key, rows]) => !key.startsWith(":") && rows.length > 1)
|
||||
.map(([, rows]) => ({
|
||||
documentId: rows[0].documentId,
|
||||
assetId: rows[0].assetId,
|
||||
rowIds: rows.map((row) => row.rowId).filter(Boolean),
|
||||
count: rows.length,
|
||||
}));
|
||||
|
||||
const candidates = {
|
||||
multipleActiveMindmapsByDocument,
|
||||
duplicateActiveAssetIds,
|
||||
activeAssetsWithMissingDocument,
|
||||
duplicateFileTreeMindmapRows,
|
||||
};
|
||||
const candidateCount = Object.values(candidates).reduce((sum, items) => sum + items.length, 0);
|
||||
return {
|
||||
ok: true,
|
||||
readOnly: true,
|
||||
summary: {
|
||||
documents: documents.length,
|
||||
activeMindmapAssets: activeMindmapAssets.length,
|
||||
fileTreeMindmapRows: fileTreeMindmapRows.length,
|
||||
documentGroupsWithMultipleActiveMindmaps: multipleActiveMindmapsByDocument.length,
|
||||
duplicateActiveAssetIds: duplicateActiveAssetIds.length,
|
||||
activeAssetsWithMissingDocument: activeAssetsWithMissingDocument.length,
|
||||
duplicateFileTreeMindmapRows: duplicateFileTreeMindmapRows.length,
|
||||
candidateCount,
|
||||
},
|
||||
candidates,
|
||||
};
|
||||
}
|
||||
|
||||
function parseArgs(argv) {
|
||||
const args = {
|
||||
input: null,
|
||||
url: DEFAULT_URL,
|
||||
output: DEFAULT_OUTPUT,
|
||||
cookie: process.env.MNOTE_AUTH_COOKIE || "",
|
||||
failOnCandidates: false,
|
||||
help: false,
|
||||
};
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const arg = argv[index];
|
||||
if (arg === "--input") args.input = argv[++index] || null;
|
||||
else if (arg === "--url") args.url = argv[++index] || DEFAULT_URL;
|
||||
else if (arg === "--output") args.output = argv[++index] || DEFAULT_OUTPUT;
|
||||
else if (arg === "--cookie") args.cookie = argv[++index] || "";
|
||||
else if (arg === "--fail-on-candidates") args.failOnCandidates = true;
|
||||
else if (arg === "--help" || arg === "-h") args.help = true;
|
||||
}
|
||||
return args;
|
||||
}
|
||||
|
||||
function printHelp() {
|
||||
console.log([
|
||||
"Usage:",
|
||||
" node scripts/task180-mindmap-ghost-candidate-audit.js --input sidebar.json",
|
||||
" node scripts/task180-mindmap-ghost-candidate-audit.js --url http://127.0.0.1:3000/api/sidebar --cookie '<cookie>'",
|
||||
" node scripts/task180-mindmap-ghost-candidate-audit.js --url 'http://127.0.0.1:3000/api/tree/projections/file?workspaceId=<workspaceId>'",
|
||||
"",
|
||||
"Notes:",
|
||||
" This script is read-only. It only reports cleanup candidates and never deletes data.",
|
||||
" Sidebar payloads can audit duplicate assets, orphan assets, and duplicate File Tree rows.",
|
||||
" File projection payloads only include projection rows, so they can audit duplicate File Tree rows.",
|
||||
].join("\n"));
|
||||
}
|
||||
|
||||
async function loadPayload(args) {
|
||||
if (args.input) {
|
||||
return JSON.parse(await fs.readFile(args.input, "utf8"));
|
||||
}
|
||||
const headers = { accept: "application/json" };
|
||||
if (args.cookie) headers.cookie = args.cookie;
|
||||
const response = await fetch(args.url, { headers });
|
||||
const text = await response.text();
|
||||
if (!response.ok) {
|
||||
throw new Error(`sidebar_audit_fetch_failed:${response.status}:${text.slice(0, 300)}`);
|
||||
}
|
||||
return JSON.parse(text);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
if (args.help) {
|
||||
printHelp();
|
||||
return;
|
||||
}
|
||||
const payload = await loadPayload(args);
|
||||
const result = auditSidebarMindmapGhostCandidates(payload);
|
||||
await fs.mkdir(path.dirname(args.output), { recursive: true });
|
||||
await fs.writeFile(args.output, `${JSON.stringify(result, null, 2)}\n`, "utf8");
|
||||
console.log(JSON.stringify(result.summary, null, 2));
|
||||
if (args.failOnCandidates && result.summary.candidateCount > 0) {
|
||||
process.exitCode = 2;
|
||||
}
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
main().catch((error) => {
|
||||
console.error(error);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
auditSidebarMindmapGhostCandidates,
|
||||
};
|
||||
@@ -0,0 +1,93 @@
|
||||
const assert = require("node:assert/strict");
|
||||
|
||||
const {
|
||||
auditSidebarMindmapGhostCandidates,
|
||||
} = require("./task180-mindmap-ghost-candidate-audit");
|
||||
|
||||
const fixture = {
|
||||
documents: [
|
||||
{ id: "doc_1", title: "带重复导图的页面" },
|
||||
{ id: "doc_2", title: "正常页面" },
|
||||
],
|
||||
mindmapAssets: [
|
||||
{ id: "mind_a", document_id: "doc_1", asset_type: "mindmap", deleted_at: null },
|
||||
{ id: "mind_b", document_id: "doc_1", asset_type: "mindmap", deleted_at: null },
|
||||
{ id: "mind_b", document_id: "doc_1", asset_type: "mindmap", deleted_at: null },
|
||||
{ id: "mind_c", document_id: "doc_2", asset_type: "mindmap", deleted_at: "2026-05-01" },
|
||||
{ id: "mind_orphan", document_id: "doc_missing", asset_type: "mindmap", deleted_at: null },
|
||||
],
|
||||
kernelFileTreeProjection: {
|
||||
items: [
|
||||
{
|
||||
rowId: "asset:mind_a",
|
||||
rowKind: "asset",
|
||||
title: "mind_a",
|
||||
resourceMeta: { resourceKind: "mindmap", documentId: "doc_1", assetId: "mind_a" },
|
||||
},
|
||||
{
|
||||
rowId: "asset:mind_a:dup",
|
||||
rowKind: "asset",
|
||||
title: "mind_a duplicate",
|
||||
resourceMeta: { resourceKind: "mindmap", documentId: "doc_1", assetId: "mind_a" },
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const result = auditSidebarMindmapGhostCandidates(fixture);
|
||||
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(result.summary.activeMindmapAssets, 4);
|
||||
assert.equal(result.summary.documentGroupsWithMultipleActiveMindmaps, 1);
|
||||
assert.equal(result.summary.duplicateActiveAssetIds, 1);
|
||||
assert.equal(result.summary.activeAssetsWithMissingDocument, 1);
|
||||
assert.equal(result.summary.duplicateFileTreeMindmapRows, 1);
|
||||
assert.deepEqual(result.candidates.multipleActiveMindmapsByDocument[0].mindmapIds, [
|
||||
"mind_a",
|
||||
"mind_b",
|
||||
"mind_b",
|
||||
]);
|
||||
assert.deepEqual(result.candidates.duplicateActiveAssetIds[0].assetId, "mind_b");
|
||||
assert.deepEqual(result.candidates.activeAssetsWithMissingDocument[0].assetId, "mind_orphan");
|
||||
assert.deepEqual(result.candidates.duplicateFileTreeMindmapRows[0].assetId, "mind_a");
|
||||
|
||||
const fileProjectionResult = auditSidebarMindmapGhostCandidates({
|
||||
ok: true,
|
||||
result: {
|
||||
items: [
|
||||
{
|
||||
rowId: "resource:mind_projection",
|
||||
title: "导图",
|
||||
resourceMeta: { resourceKind: "mindmap", documentId: "doc_projection", assetId: "mind_projection" },
|
||||
},
|
||||
{
|
||||
rowId: "resource:mind_projection:duplicate",
|
||||
title: "导图副本",
|
||||
resourceMeta: { resourceKind: "mindmap", documentId: "doc_projection", assetId: "mind_projection" },
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(fileProjectionResult.summary.documents, 0);
|
||||
assert.equal(fileProjectionResult.summary.activeMindmapAssets, 0);
|
||||
assert.equal(fileProjectionResult.summary.fileTreeMindmapRows, 2);
|
||||
assert.equal(fileProjectionResult.summary.duplicateFileTreeMindmapRows, 1);
|
||||
assert.deepEqual(fileProjectionResult.candidates.duplicateFileTreeMindmapRows[0].rowIds, [
|
||||
"resource:mind_projection",
|
||||
"resource:mind_projection:duplicate",
|
||||
]);
|
||||
|
||||
const directItemsResult = auditSidebarMindmapGhostCandidates({
|
||||
items: [
|
||||
{
|
||||
row_id: "resource:mind_direct",
|
||||
resource_meta: { resource_kind: "mindmap", document_id: "doc_direct", asset_id: "mind_direct" },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
assert.equal(directItemsResult.summary.fileTreeMindmapRows, 1);
|
||||
assert.equal(directItemsResult.summary.duplicateFileTreeMindmapRows, 0);
|
||||
|
||||
console.log("task180 mindmap ghost candidate audit self-test passed");
|
||||
@@ -38,6 +38,10 @@ function decodeCursor(raw: string | null | undefined) {
|
||||
}
|
||||
}
|
||||
|
||||
function stripDomainEventCursorPrefix(id: string) {
|
||||
return id.startsWith("domain_event:") ? id.slice("domain_event:".length) : id;
|
||||
}
|
||||
|
||||
function encodeCursor(row: { created_at?: string | null; id?: string | null } | null) {
|
||||
if (!row?.created_at || !row?.id) return null;
|
||||
return JSON.stringify({
|
||||
@@ -46,6 +50,25 @@ function encodeCursor(row: { created_at?: string | null; id?: string | null } |
|
||||
});
|
||||
}
|
||||
|
||||
function encodeDomainEventCursor(row: { created_at?: string | null; id?: string | null } | null) {
|
||||
if (!row?.created_at || !row?.id) return null;
|
||||
return JSON.stringify({
|
||||
createdAt: row.created_at,
|
||||
id: `domain_event:${row.id}`,
|
||||
});
|
||||
}
|
||||
|
||||
function encodeOverviewNextCursor(
|
||||
commandLogs: Array<{ created_at?: string | null; id?: string | null }>,
|
||||
domainEvents: Array<{ created_at?: string | null; id?: string | null }>,
|
||||
) {
|
||||
// overview 的分页主轴仍以 command log 为准;没有 command log 时才回退到 domain event cursor。
|
||||
// 实时流的 live tail 每轮查最新窗口,不依赖这个 next_cursor。
|
||||
const oldestCommand = commandLogs[commandLogs.length - 1] ?? null;
|
||||
const oldestEvent = domainEvents[domainEvents.length - 1] ?? null;
|
||||
return encodeCursor(oldestCommand) ?? encodeDomainEventCursor(oldestEvent);
|
||||
}
|
||||
|
||||
function matchesCursor<T extends Record<string, any>>(
|
||||
row: T,
|
||||
cursor: { createdAt: string; id: string } | null,
|
||||
@@ -53,10 +76,11 @@ function matchesCursor<T extends Record<string, any>>(
|
||||
if (!cursor) return true;
|
||||
const createdAt = String(row.created_at ?? row.finished_at ?? "");
|
||||
const id = String(row.id ?? "");
|
||||
const cursorId = stripDomainEventCursorPrefix(cursor.id);
|
||||
if (!createdAt || !id) return false;
|
||||
if (createdAt < cursor.createdAt) return true;
|
||||
if (createdAt > cursor.createdAt) return false;
|
||||
return id < cursor.id;
|
||||
return id < cursorId;
|
||||
}
|
||||
|
||||
function normalizeStatusFilter(raw: string | null | undefined) {
|
||||
@@ -69,6 +93,128 @@ function normalizeObjectFilter(raw: string | null | undefined) {
|
||||
return normalized.length > 0 ? normalized : null;
|
||||
}
|
||||
|
||||
type Cursor = { createdAt: string; id: string } | null;
|
||||
|
||||
const OVERVIEW_SCAN_MULTIPLIER = 4;
|
||||
|
||||
function overviewScanLimit(limit: number) {
|
||||
return Math.min(500, Math.max(limit + 1, limit * OVERVIEW_SCAN_MULTIPLIER));
|
||||
}
|
||||
|
||||
function applyCursorUpperBound(query: any, cursor: Cursor) {
|
||||
return cursor ? query.lte("created_at", cursor.createdAt) : query;
|
||||
}
|
||||
|
||||
async function fetchCommandLogWindow(ctx: any, args: {
|
||||
workspaceId: string;
|
||||
limit: number;
|
||||
cursor: Cursor;
|
||||
commandStatus: string | null;
|
||||
targetPageId: string | null;
|
||||
targetBlockId: string | null;
|
||||
}) {
|
||||
const scanLimit = overviewScanLimit(args.limit);
|
||||
let query;
|
||||
if (args.targetBlockId) {
|
||||
query = ctx.db
|
||||
.query("command_logs")
|
||||
.withIndex("by_workspace_target_block_created_at", (q: any) =>
|
||||
applyCursorUpperBound(
|
||||
q.eq("workspace_id", args.workspaceId).eq("target_block_id", args.targetBlockId),
|
||||
args.cursor,
|
||||
),
|
||||
);
|
||||
} else if (args.targetPageId) {
|
||||
query = ctx.db
|
||||
.query("command_logs")
|
||||
.withIndex("by_workspace_target_page_created_at", (q: any) =>
|
||||
applyCursorUpperBound(
|
||||
q.eq("workspace_id", args.workspaceId).eq("target_page_id", args.targetPageId),
|
||||
args.cursor,
|
||||
),
|
||||
);
|
||||
} else if (args.commandStatus) {
|
||||
query = ctx.db
|
||||
.query("command_logs")
|
||||
.withIndex("by_workspace_status_created_at", (q: any) =>
|
||||
applyCursorUpperBound(
|
||||
q.eq("workspace_id", args.workspaceId).eq("status", args.commandStatus),
|
||||
args.cursor,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
query = ctx.db
|
||||
.query("command_logs")
|
||||
.withIndex("by_workspace_created_at", (q: any) =>
|
||||
applyCursorUpperBound(q.eq("workspace_id", args.workspaceId), args.cursor),
|
||||
);
|
||||
}
|
||||
|
||||
const rows = await query.order("desc").take(scanLimit);
|
||||
const filteredRows = rows.filter((row: any) => {
|
||||
if (args.commandStatus && row.status !== args.commandStatus) return false;
|
||||
if (args.targetPageId && String(row.target_page_id ?? "") !== args.targetPageId) return false;
|
||||
if (args.targetBlockId && String(row.target_block_id ?? "") !== args.targetBlockId) return false;
|
||||
return matchesCursor(row, args.cursor);
|
||||
});
|
||||
return {
|
||||
rows: filteredRows.slice(0, args.limit),
|
||||
hasMore: filteredRows.length > args.limit || rows.length === scanLimit,
|
||||
};
|
||||
}
|
||||
|
||||
async function fetchDomainEventWindow(ctx: any, args: {
|
||||
workspaceId: string;
|
||||
limit: number;
|
||||
cursor: Cursor;
|
||||
eventStatus: string | null;
|
||||
aggregateType: string | null;
|
||||
aggregateId: string | null;
|
||||
}) {
|
||||
const scanLimit = overviewScanLimit(args.limit);
|
||||
let query;
|
||||
if (args.aggregateType && args.aggregateId) {
|
||||
query = ctx.db
|
||||
.query("domain_events")
|
||||
.withIndex("by_workspace_aggregate_created_at", (q: any) =>
|
||||
applyCursorUpperBound(
|
||||
q
|
||||
.eq("workspace_id", args.workspaceId)
|
||||
.eq("aggregate_type", args.aggregateType)
|
||||
.eq("aggregate_id", args.aggregateId),
|
||||
args.cursor,
|
||||
),
|
||||
);
|
||||
} else if (args.eventStatus) {
|
||||
query = ctx.db
|
||||
.query("domain_events")
|
||||
.withIndex("by_workspace_status_created_at", (q: any) =>
|
||||
applyCursorUpperBound(
|
||||
q.eq("workspace_id", args.workspaceId).eq("status", args.eventStatus),
|
||||
args.cursor,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
query = ctx.db
|
||||
.query("domain_events")
|
||||
.withIndex("by_workspace_created_at", (q: any) =>
|
||||
applyCursorUpperBound(q.eq("workspace_id", args.workspaceId), args.cursor),
|
||||
);
|
||||
}
|
||||
|
||||
const rows = await query.order("desc").take(scanLimit);
|
||||
const filteredRows = rows.filter((row: any) => {
|
||||
if (args.eventStatus && row.status !== args.eventStatus) return false;
|
||||
if (args.aggregateType && String(row.aggregate_type ?? "") !== args.aggregateType) return false;
|
||||
if (args.aggregateId && String(row.aggregate_id ?? "") !== args.aggregateId) return false;
|
||||
return matchesCursor(row, args.cursor);
|
||||
});
|
||||
return {
|
||||
rows: filteredRows.slice(0, args.limit),
|
||||
hasMore: filteredRows.length > args.limit || rows.length === scanLimit,
|
||||
};
|
||||
}
|
||||
|
||||
export const recordCommandLog = mutation({
|
||||
args: {
|
||||
workspaceId: v.string(),
|
||||
@@ -280,44 +426,33 @@ export const listWorkspaceOverview = query({
|
||||
const aggregateType = normalizeObjectFilter(args.aggregateType);
|
||||
const aggregateId = normalizeObjectFilter(args.aggregateId);
|
||||
|
||||
const allCommandLogs = await ctx.db
|
||||
.query("command_logs")
|
||||
.withIndex("by_workspace_request", (q) => q.eq("workspace_id", args.workspaceId))
|
||||
.collect();
|
||||
const allDomainEvents = await ctx.db
|
||||
.query("domain_events")
|
||||
.withIndex("by_workspace_request", (q) => q.eq("workspace_id", args.workspaceId))
|
||||
.collect();
|
||||
const commandWindow = await fetchCommandLogWindow(ctx, {
|
||||
workspaceId: args.workspaceId,
|
||||
limit,
|
||||
cursor,
|
||||
commandStatus,
|
||||
targetPageId,
|
||||
targetBlockId,
|
||||
});
|
||||
const domainEventWindow = await fetchDomainEventWindow(ctx, {
|
||||
workspaceId: args.workspaceId,
|
||||
limit,
|
||||
cursor,
|
||||
eventStatus,
|
||||
aggregateType,
|
||||
aggregateId,
|
||||
});
|
||||
|
||||
const filteredCommandLogs = sortByNewest(
|
||||
allCommandLogs.filter((row: any) => {
|
||||
if (commandStatus && row.status !== commandStatus) return false;
|
||||
if (targetPageId && String(row.target_page_id ?? "") !== targetPageId) return false;
|
||||
if (targetBlockId && String(row.target_block_id ?? "") !== targetBlockId) return false;
|
||||
return matchesCursor(row, cursor);
|
||||
}),
|
||||
);
|
||||
|
||||
const pageCommandLogs = filteredCommandLogs.slice(0, limit);
|
||||
const nextCursor = encodeCursor(pageCommandLogs[pageCommandLogs.length - 1] ?? null);
|
||||
const commandIds = new Set(pageCommandLogs.map((row: any) => String(row.command_id)));
|
||||
|
||||
const domainEvents = sortByNewest(
|
||||
allDomainEvents.filter((row: any) => {
|
||||
if (commandIds.size > 0 && !commandIds.has(String(row.command_id ?? ""))) return false;
|
||||
if (eventStatus && row.status !== eventStatus) return false;
|
||||
if (aggregateType && String(row.aggregate_type ?? "") !== aggregateType) return false;
|
||||
if (aggregateId && String(row.aggregate_id ?? "") !== aggregateId) return false;
|
||||
return true;
|
||||
}),
|
||||
);
|
||||
const pageCommandLogs = sortByNewest(commandWindow.rows);
|
||||
const domainEvents = sortByNewest(domainEventWindow.rows);
|
||||
const nextCursor = encodeOverviewNextCursor(pageCommandLogs, domainEvents);
|
||||
|
||||
return {
|
||||
workspace_id: args.workspaceId,
|
||||
command_logs: pageCommandLogs,
|
||||
domain_events: domainEvents,
|
||||
next_cursor: nextCursor,
|
||||
has_more: filteredCommandLogs.length > pageCommandLogs.length,
|
||||
has_more: commandWindow.hasMore || domainEventWindow.hasMore,
|
||||
filters: {
|
||||
command_status: commandStatus,
|
||||
event_status: eventStatus,
|
||||
|
||||
@@ -474,7 +474,11 @@ export default defineSchema({
|
||||
.index("by_command_log_id", ["id"])
|
||||
.index("by_workspace_request", ["workspace_id", "request_id"])
|
||||
.index("by_workspace_trace", ["workspace_id", "trace_id"])
|
||||
.index("by_workspace_command", ["workspace_id", "command_id"]),
|
||||
.index("by_workspace_command", ["workspace_id", "command_id"])
|
||||
.index("by_workspace_created_at", ["workspace_id", "created_at", "id"])
|
||||
.index("by_workspace_status_created_at", ["workspace_id", "status", "created_at", "id"])
|
||||
.index("by_workspace_target_page_created_at", ["workspace_id", "target_page_id", "created_at", "id"])
|
||||
.index("by_workspace_target_block_created_at", ["workspace_id", "target_block_id", "created_at", "id"]),
|
||||
|
||||
domain_events: defineTable({
|
||||
id: v.string(),
|
||||
@@ -495,5 +499,14 @@ export default defineSchema({
|
||||
.index("by_domain_event_id", ["id"])
|
||||
.index("by_workspace_request", ["workspace_id", "request_id"])
|
||||
.index("by_workspace_trace", ["workspace_id", "trace_id"])
|
||||
.index("by_workspace_command", ["workspace_id", "command_id"]),
|
||||
.index("by_workspace_command", ["workspace_id", "command_id"])
|
||||
.index("by_workspace_created_at", ["workspace_id", "created_at", "id"])
|
||||
.index("by_workspace_status_created_at", ["workspace_id", "status", "created_at", "id"])
|
||||
.index("by_workspace_aggregate_created_at", [
|
||||
"workspace_id",
|
||||
"aggregate_type",
|
||||
"aggregate_id",
|
||||
"created_at",
|
||||
"id",
|
||||
]),
|
||||
});
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { POST } from "@/app/api/mindmap-ai/expand-node/route";
|
||||
|
||||
describe("/api/mindmap-ai/expand-node route", () => {
|
||||
it("显式退役旧 Next route,并指向 AI Agent 内置工具", async () => {
|
||||
const response = await POST();
|
||||
const payload = await response.json() as {
|
||||
error: string;
|
||||
code: string;
|
||||
replacement: {
|
||||
kind: string;
|
||||
toolName: string;
|
||||
route: string;
|
||||
};
|
||||
};
|
||||
|
||||
expect(response.status).toBe(410);
|
||||
expect(response.headers.get("x-mnote-compat-boundary")).toBe("mindmap-expand-node-route-retired");
|
||||
expect(payload).toMatchObject({
|
||||
code: "mindmap-expand-node-route-retired",
|
||||
replacement: {
|
||||
kind: "ai-agent-tool",
|
||||
toolName: "mindmap_expand_node",
|
||||
route: "/api/ai-agent/run",
|
||||
},
|
||||
});
|
||||
expect(payload.error).toContain("mindmap_expand_node");
|
||||
});
|
||||
});
|
||||
@@ -1,357 +1,23 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { loadOnlineAiConfig } from "@/lib/ai/onlineAiConfig";
|
||||
import { openAiCompatibleChat, tryExtractJsonObject } from "@/lib/ai/openaiCompatibleChat";
|
||||
import { buildDocumentBridgeContextWithActor } from "@/lib/documents/bridge";
|
||||
import { executeRustBridgeTool } from "@/lib/documents/rust-runtime";
|
||||
import { readMindmapLocal } from "@/lib/mindmap/mindmapLocalStore";
|
||||
|
||||
type RequestPayload = {
|
||||
documentId: string;
|
||||
mindmapId: string;
|
||||
targetUid: string;
|
||||
instruction?: string;
|
||||
sources?: { searxng?: boolean; rag?: boolean };
|
||||
};
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
type SearxResult = { title: string; url: string; snippet?: string; engine?: string };
|
||||
|
||||
const defaultMindmapData = {
|
||||
data: { text: "中心主题" },
|
||||
children: [],
|
||||
};
|
||||
|
||||
const findNodeByUid = (root: any, uid: string): any | null => {
|
||||
const target = String(uid || "");
|
||||
if (!target) return null;
|
||||
const walk = (node: any): any | null => {
|
||||
const nuid = String(node?.data?.uid || node?.uid || "");
|
||||
if (nuid === target) return node;
|
||||
const children = Array.isArray(node?.children) ? node.children : [];
|
||||
for (const c of children) {
|
||||
const hit = walk(c);
|
||||
if (hit) return hit;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
return walk(root);
|
||||
};
|
||||
|
||||
const safeUrlOrNull = (value: unknown) => {
|
||||
const s = typeof value === "string" ? value.trim() : "";
|
||||
if (!s) return null;
|
||||
try {
|
||||
const u = new URL(s);
|
||||
if (u.protocol !== "http:" && u.protocol !== "https:") return null;
|
||||
return u.toString();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const searchSearxng = async (q: string, count = 5): Promise<SearxResult[]> => {
|
||||
const base = (process.env.SEARXNG_BASE_URL ?? "http://127.0.0.1:8889").replace(/\/+$/, "");
|
||||
const token = (process.env.SEARXNG_API_TOKEN ?? "").trim();
|
||||
const url = `${base}/search?q=${encodeURIComponent(q)}&format=json&language=zh-CN&categories=general&safesearch=1`;
|
||||
|
||||
const tryFetch = async (headers: Record<string, string>) => {
|
||||
const res = await fetch(url, { headers, method: "GET" });
|
||||
if (!res.ok) return null;
|
||||
return (await res.json().catch(() => null)) as any;
|
||||
};
|
||||
|
||||
let json: any = null;
|
||||
if (token) {
|
||||
json =
|
||||
(await tryFetch({ Authorization: `Bearer ${token}` })) ??
|
||||
(await tryFetch({ "X-API-Key": token })) ??
|
||||
null;
|
||||
}
|
||||
if (!json) {
|
||||
json = await tryFetch({});
|
||||
}
|
||||
const results = Array.isArray(json?.results) ? json.results : [];
|
||||
const mapped: SearxResult[] = results
|
||||
.map((r: any) => ({
|
||||
title: String(r?.title ?? "").trim(),
|
||||
url: String(r?.url ?? "").trim(),
|
||||
snippet: String(r?.content ?? r?.snippet ?? "").trim(),
|
||||
engine: String(r?.engine ?? "").trim(),
|
||||
}))
|
||||
.filter((r: SearxResult) => r.title && safeUrlOrNull(r.url))
|
||||
.slice(0, Math.max(1, Math.min(10, count)));
|
||||
return mapped;
|
||||
};
|
||||
|
||||
const coerceOpsFromAiJson = (raw: Record<string, unknown>) => {
|
||||
const ops = (raw as any)?.ops;
|
||||
return Array.isArray(ops) ? ops : [];
|
||||
};
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const payload = (await request.json().catch(() => null)) as RequestPayload | null;
|
||||
if (!payload?.documentId || !payload?.mindmapId || !payload?.targetUid) {
|
||||
return NextResponse.json({ error: "缺少 documentId/mindmapId/targetUid" }, { status: 400 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
|
||||
|
||||
/*
|
||||
const supabase = await createSupabaseRouteClient();
|
||||
const {
|
||||
data: { session },
|
||||
} = await supabase.auth.getSession();
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: "未登录" }, { status: 401 });
|
||||
}
|
||||
|
||||
// 校验页面归属
|
||||
const { data: doc, error: docError } = await supabase
|
||||
.from("documents")
|
||||
.select("id,title,mindmap_data")
|
||||
.eq("id", payload.documentId)
|
||||
.eq("user_id", session.user.id)
|
||||
.maybeSingle();
|
||||
if (docError) {
|
||||
return NextResponse.json({ error: docError.message }, { status: 400 });
|
||||
}
|
||||
if (!doc) {
|
||||
return NextResponse.json({ error: "页面不存在" }, { status: 404 });
|
||||
}
|
||||
|
||||
const local = await readMindmapLocal(payload.documentId, payload.mindmapId);
|
||||
const baseData = (local.ok ? local.data : (doc.mindmap_data ?? defaultMindmapData)) as MindmapTreeNode;
|
||||
|
||||
const target = findNodeByUid(baseData, payload.targetUid);
|
||||
if (!target) {
|
||||
return NextResponse.json({ error: "未找到目标节点(uid 不存在)" }, { status: 404 });
|
||||
}
|
||||
|
||||
const targetText = String(target?.data?.text ?? "").trim();
|
||||
const currentChildren = Array.isArray(target?.children)
|
||||
? target.children
|
||||
.map((c: any) => String(c?.data?.text ?? "").trim())
|
||||
.filter(Boolean)
|
||||
.slice(0, 20)
|
||||
: [];
|
||||
|
||||
const instruction = String(payload.instruction ?? "").trim();
|
||||
const query = [targetText, instruction].filter(Boolean).join(" ");
|
||||
|
||||
const useSearx = payload.sources?.searxng !== false;
|
||||
const searxResults = useSearx && query ? await searchSearxng(query, 6).catch(() => []) : [];
|
||||
|
||||
const cfg = await loadOnlineAiConfig().catch(() => null);
|
||||
if (!cfg) {
|
||||
return NextResponse.json({ error: "未找到在线 AI 配置(ai.md 或环境变量)" }, { status: 500 });
|
||||
}
|
||||
|
||||
const system = [
|
||||
"你是一个“思维导图补完器”。",
|
||||
"你必须输出严格 JSON(不要 Markdown/不要代码块/不要解释)。",
|
||||
"你只能输出 {\"ops\": MindmapOp[]} 这一个对象。",
|
||||
"默认策略:为 targetUid 新增 3~6 个子节点(addChild)。",
|
||||
"每个新增节点必须提供 text,并尽量提供 hyperlink 与 refs(引用来自搜索结果 URL)。",
|
||||
"不要删除/改名已有节点;不要输出 addSiblingAfter/updateText/deleteNode。",
|
||||
].join("\n");
|
||||
|
||||
const user = [
|
||||
`documentId=${payload.documentId}`,
|
||||
`mindmapId=${payload.mindmapId}`,
|
||||
`targetUid=${payload.targetUid}`,
|
||||
"",
|
||||
`目标节点:${targetText || "(empty)"}`,
|
||||
currentChildren.length ? `当前子节点(供去重):${currentChildren.join(";")}` : "",
|
||||
instruction ? `用户要求:${instruction}` : "",
|
||||
"",
|
||||
"可用证据(搜索结果):",
|
||||
...(searxResults.length
|
||||
? searxResults.map((r, idx) => {
|
||||
const snip = (r.snippet || "").replace(/\s+/g, " ").slice(0, 180);
|
||||
return `${idx + 1}. ${r.title}\n url: ${r.url}\n snippet: ${snip}`;
|
||||
})
|
||||
: ["(无)"]),
|
||||
"",
|
||||
"MindmapOp JSON Schema(仅供理解):",
|
||||
'{ "ops": [ { "op": "addChild", "parentUid": string, "node": { "text": string, "hyperlink"?: string, "refs"?: NodeRef[] } } ] }',
|
||||
'NodeRef 示例:{ "kind": "url", "fileUrl": "https://example.com", "title": "来源标题", "snippet": "..." }',
|
||||
"",
|
||||
"硬性约束:",
|
||||
"- 仅输出 addChild;parentUid 必须等于 targetUid。",
|
||||
"- 新增节点 text 不要与当前子节点重复。",
|
||||
"- hyperlink 必须是 http(s) URL。",
|
||||
"- 每个新增节点 refs 至少 1 条(kind=url, fileUrl=来源url)。",
|
||||
"- 输出规模控制:最多 6 个节点。",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n");
|
||||
|
||||
let finishReason = "";
|
||||
let ops: MindmapOp[] = [];
|
||||
try {
|
||||
const { text, raw } = await openAiCompatibleChat(
|
||||
[
|
||||
{ role: "system", content: system },
|
||||
{ role: "user", content: user },
|
||||
],
|
||||
{
|
||||
baseUrl: cfg.baseUrl,
|
||||
apiKey: cfg.apiKey,
|
||||
model: cfg.model,
|
||||
timeoutMs: 40_000,
|
||||
maxTokens: 1800,
|
||||
maxCompletionTokens: 1800,
|
||||
responseFormat: "json_object",
|
||||
},
|
||||
);
|
||||
|
||||
finishReason = String((raw as any)?.choices?.[0]?.finish_reason ?? "");
|
||||
|
||||
const json = tryExtractJsonObject(text);
|
||||
if (json) {
|
||||
ops = coerceOpsFromAiJson(json);
|
||||
// 安全收敛:仅允许 addChild 且 parentUid==targetUid
|
||||
ops = ops
|
||||
.filter((op) => op && typeof op === "object" && (op as any).op === "addChild")
|
||||
.filter((op) => String((op as any).parentUid || "") === payload.targetUid)
|
||||
.slice(0, 8);
|
||||
}
|
||||
} catch {
|
||||
// ignore: 后续走兜底策略
|
||||
ops = [];
|
||||
}
|
||||
|
||||
// 再做一次补齐/校验:refs/hyperlink
|
||||
const fallbackRefsFrom = (r: SearxResult): NodeRef[] => [
|
||||
export async function POST() {
|
||||
return NextResponse.json(
|
||||
{
|
||||
kind: "url",
|
||||
fileUrl: r.url,
|
||||
title: r.title,
|
||||
snippet: r.snippet ? r.snippet.slice(0, 300) : undefined,
|
||||
},
|
||||
];
|
||||
|
||||
const existed = new Set(currentChildren);
|
||||
const fixed: MindmapOp[] = [];
|
||||
for (const op of ops) {
|
||||
const node = (op as any).node ?? {};
|
||||
const textVal = String(node.text ?? "").trim();
|
||||
if (!textVal) continue;
|
||||
if (existed.has(textVal)) continue;
|
||||
existed.add(textVal);
|
||||
|
||||
const href = safeUrlOrNull(node.hyperlink) ?? safeUrlOrNull(node.url) ?? null;
|
||||
const refs = Array.isArray(node.refs) ? (node.refs as NodeRef[]) : [];
|
||||
const hasRefUrl = refs.some((x) => x && x.kind === "url" && safeUrlOrNull((x as any).fileUrl));
|
||||
|
||||
let finalRefs = refs;
|
||||
if (!hasRefUrl && searxResults.length) {
|
||||
finalRefs = fallbackRefsFrom(searxResults[0]);
|
||||
}
|
||||
|
||||
fixed.push({
|
||||
op: "addChild",
|
||||
parentUid: payload.targetUid,
|
||||
node: {
|
||||
text: textVal,
|
||||
...(href ? { hyperlink: href } : {}),
|
||||
...(finalRefs.length ? { refs: finalRefs } : {}),
|
||||
error: "Next /api/mindmap-ai/expand-node 已退场,请改用 AI Agent 内置 mindmap_expand_node 工具。",
|
||||
code: "mindmap-expand-node-route-retired",
|
||||
replacement: {
|
||||
kind: "ai-agent-tool",
|
||||
toolName: "mindmap_expand_node",
|
||||
route: "/api/ai-agent/run",
|
||||
},
|
||||
});
|
||||
if (fixed.length >= 6) break;
|
||||
}
|
||||
|
||||
// 兜底:若 AI 没产出有效 ops,则直接用搜索结果生成节点(保证功能可用 + 可追溯)
|
||||
if (!fixed.length && searxResults.length) {
|
||||
for (const r of searxResults.slice(0, 6)) {
|
||||
const title = String(r.title || "").trim();
|
||||
const url = safeUrlOrNull(r.url);
|
||||
if (!title || !url) continue;
|
||||
if (existed.has(title)) continue;
|
||||
existed.add(title);
|
||||
fixed.push({
|
||||
op: "addChild",
|
||||
parentUid: payload.targetUid,
|
||||
node: {
|
||||
text: title,
|
||||
hyperlink: url,
|
||||
refs: fallbackRefsFrom(r),
|
||||
},
|
||||
});
|
||||
if (fixed.length >= 6) break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!fixed.length) {
|
||||
// 最后兜底:至少给出 3 个“待核验”节点(无引用)
|
||||
const base = targetText || "补完";
|
||||
for (let i = 1; i <= 3; i++) {
|
||||
fixed.push({
|
||||
op: "addChild",
|
||||
parentUid: payload.targetUid,
|
||||
node: {
|
||||
text: `${base}(待核验)${i}`,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const context = buildDocumentBridgeContextWithActor({
|
||||
request,
|
||||
actor: {
|
||||
actorType: "service",
|
||||
actorId: "mindmap-expand-node",
|
||||
sessionId: null,
|
||||
},
|
||||
workspaceId: (doc as any).workspace_id ?? null,
|
||||
source: {
|
||||
channel: "mindmap-expand-route",
|
||||
client: "wolai-frontend",
|
||||
{
|
||||
status: 410,
|
||||
headers: {
|
||||
"x-mnote-compat-boundary": "mindmap-expand-node-route-retired",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const result = await executeRustBridgeTool<{
|
||||
ok: boolean;
|
||||
applied?: number;
|
||||
errors?: string[];
|
||||
ops?: unknown[];
|
||||
data?: unknown;
|
||||
meta?: unknown;
|
||||
}>({
|
||||
context,
|
||||
toolName: "mindmap_expand_node",
|
||||
invocationKind: "command",
|
||||
args: {
|
||||
documentId: payload.documentId,
|
||||
mindmapId: payload.mindmapId,
|
||||
targetUid: payload.targetUid,
|
||||
instruction: payload.instruction ?? "",
|
||||
ops: fixed,
|
||||
searchResults: searxResults.map((r) => ({
|
||||
title: r.title,
|
||||
url: r.url,
|
||||
snippet: r.snippet ?? "",
|
||||
})),
|
||||
reason: payload.instruction ?? "",
|
||||
},
|
||||
data: {
|
||||
data: baseData,
|
||||
source: "mindmap-expand-node",
|
||||
},
|
||||
mode: "result",
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
providerUsed: "online",
|
||||
applied: (result.result as any)?.applied ?? 0,
|
||||
errors: (result.result as any)?.errors ?? [],
|
||||
ops: (result.result as any)?.ops ?? fixed,
|
||||
data: (result.result as any)?.data ?? baseData,
|
||||
meta: {
|
||||
finishReason,
|
||||
searched: useSearx,
|
||||
searxCount: searxResults.length,
|
||||
},
|
||||
});
|
||||
*/
|
||||
);
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ export function AppLayoutShell({ initialData, children }: AppLayoutShellProps) {
|
||||
sidebarQueryData: sidebarQuery.data,
|
||||
treeStreamData: treeStream.data,
|
||||
treeStreamStatus: treeStream.status,
|
||||
treeStreamCursor: treeStream.cursor,
|
||||
});
|
||||
|
||||
return (
|
||||
|
||||
@@ -815,6 +815,7 @@ export function DocumentAiAgentPanelRuntime({
|
||||
subtree: contextSubtree,
|
||||
outline: contextOutline,
|
||||
evidence: contextEvidence,
|
||||
pageSubtreeSource: pageAggregateSnapshot.pageSubtreeSource ?? "none",
|
||||
pageOptions: pageAggregateSnapshot.pageOptions,
|
||||
},
|
||||
options: {
|
||||
|
||||
@@ -11,6 +11,7 @@ import { useAiAgentUiStore } from "@/store/ai-agent-ui";
|
||||
export type PageAggregateAiSnapshot = {
|
||||
blocks: Json | null;
|
||||
pageSubtree: PageSubtreeProjection | null;
|
||||
pageSubtreeSource?: "server" | "local" | "none";
|
||||
persistedMeta: {
|
||||
workspaceId: string | null;
|
||||
revision: number | null;
|
||||
|
||||
@@ -45,6 +45,9 @@ type SidebarProps = {
|
||||
activeTab: SidebarPanel | null;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
// 旧 Next route 已退场;补完节点能力保留在 AI Agent 的 mindmap_expand_node 工具中。
|
||||
const MINDMAP_LEGACY_EXPAND_NODE_ENTRY_ENABLED = false;
|
||||
|
||||
const ColorInput = ({
|
||||
value,
|
||||
@@ -1631,41 +1634,43 @@ const AiPanel = ({
|
||||
{docDebug && <div className="text-xs text-gray-500 whitespace-pre-wrap">{docDebug}</div>}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 rounded-md border border-gray-200 p-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs text-gray-500">AI 补完(选中节点)</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Toggle
|
||||
pressed={expandUseSearx}
|
||||
onPressedChange={(v) => setExpandUseSearx(Boolean(v))}
|
||||
size="sm"
|
||||
className="text-xs"
|
||||
>
|
||||
<Network className="h-4 w-4 mr-1" />
|
||||
联网
|
||||
</Toggle>
|
||||
{MINDMAP_LEGACY_EXPAND_NODE_ENTRY_ENABLED ? (
|
||||
<div className="space-y-2 rounded-md border border-gray-200 p-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs text-gray-500">AI 补完(选中节点)</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Toggle
|
||||
pressed={expandUseSearx}
|
||||
onPressedChange={(v) => setExpandUseSearx(Boolean(v))}
|
||||
size="sm"
|
||||
className="text-xs"
|
||||
>
|
||||
<Network className="h-4 w-4 mr-1" />
|
||||
联网
|
||||
</Toggle>
|
||||
</div>
|
||||
</div>
|
||||
<textarea
|
||||
className="w-full rounded-md border border-gray-200 p-2 text-sm"
|
||||
rows={3}
|
||||
value={expandInstruction}
|
||||
onChange={(e) => setExpandInstruction(e.target.value)}
|
||||
placeholder="例如:补充该节点的关键概念、常见误区与参考链接(每条都要可点击来源)"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
disabled={expandLoading}
|
||||
className="w-full rounded-md bg-blue-500 px-3 py-2 text-sm text-white hover:bg-blue-600 disabled:opacity-50"
|
||||
onClick={runExpandSelectedNode}
|
||||
>
|
||||
{expandLoading ? "补完中..." : "补完选中节点(写入并保存)"}
|
||||
</button>
|
||||
{expandDebug && <div className="text-xs text-gray-500 whitespace-pre-wrap">{expandDebug}</div>}
|
||||
<p className="text-xs text-gray-400">
|
||||
说明:服务端会用 SearxNG 检索证据 + 在线 AI 生成 ops,并自动落盘到当前 mindmap 文件中。
|
||||
</p>
|
||||
</div>
|
||||
<textarea
|
||||
className="w-full rounded-md border border-gray-200 p-2 text-sm"
|
||||
rows={3}
|
||||
value={expandInstruction}
|
||||
onChange={(e) => setExpandInstruction(e.target.value)}
|
||||
placeholder="例如:补充该节点的关键概念、常见误区与参考链接(每条都要可点击来源)"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
disabled={expandLoading}
|
||||
className="w-full rounded-md bg-blue-500 px-3 py-2 text-sm text-white hover:bg-blue-600 disabled:opacity-50"
|
||||
onClick={runExpandSelectedNode}
|
||||
>
|
||||
{expandLoading ? "补完中..." : "补完选中节点(写入并保存)"}
|
||||
</button>
|
||||
{expandDebug && <div className="text-xs text-gray-500 whitespace-pre-wrap">{expandDebug}</div>}
|
||||
<p className="text-xs text-gray-400">
|
||||
说明:服务端会用 SearxNG 检索证据 + 在线 AI 生成 ops,并自动落盘到当前 mindmap 文件中。
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<Label className="text-xs text-gray-500">模式</Label>
|
||||
<NativeSelect
|
||||
|
||||
@@ -104,6 +104,7 @@ describe("page-aggregate-client-state", () => {
|
||||
|
||||
const state = createPageAggregateClientState(page);
|
||||
|
||||
expect(state.documentId).toBe("doc_1");
|
||||
expect(state.serverPageTitle).toBe("页面标题");
|
||||
expect(state.persistedPageTitle).toBeNull();
|
||||
expect(state.draftPageTitle).toBeNull();
|
||||
@@ -221,7 +222,7 @@ describe("page-aggregate-client-state", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("本地标题或正文与服务端快照不一致时,不应继续复用旧 pageSubtree", () => {
|
||||
it("本地标题或正文与服务端快照不一致时,应生成临时 pageSubtree 而不是复用旧快照", () => {
|
||||
const page = createPageAggregate();
|
||||
const initialState = createPageAggregateClientState(page);
|
||||
|
||||
@@ -239,7 +240,17 @@ describe("page-aggregate-client-state", () => {
|
||||
selectPageAggregateClientPageSubtree(localContentState, {
|
||||
liveSidebarTitle: "页面标题",
|
||||
}),
|
||||
).toBeNull();
|
||||
).toEqual(
|
||||
expect.objectContaining({
|
||||
projectionId: expect.any(String),
|
||||
projection: "page_tree",
|
||||
rootNode: expect.objectContaining({
|
||||
metadata: expect.objectContaining({
|
||||
title: "页面标题",
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
const draftTitleState = pageAggregateClientStateReducer(initialState, {
|
||||
type: "set_draft_page_title",
|
||||
@@ -249,7 +260,16 @@ describe("page-aggregate-client-state", () => {
|
||||
selectPageAggregateClientPageSubtree(draftTitleState, {
|
||||
liveSidebarTitle: "页面标题",
|
||||
}),
|
||||
).toBeNull();
|
||||
).toEqual(
|
||||
expect.objectContaining({
|
||||
projection: "page_tree",
|
||||
rootNode: expect.objectContaining({
|
||||
metadata: expect.objectContaining({
|
||||
title: "草稿标题",
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
const persistedTitleState = pageAggregateClientStateReducer(initialState, {
|
||||
type: "commit_persisted_page_title",
|
||||
@@ -299,6 +319,7 @@ describe("page-aggregate-client-state", () => {
|
||||
expect(snapshot).toEqual({
|
||||
blocks: page.body.content as Json,
|
||||
pageSubtree: page.tree.pageSubtree,
|
||||
pageSubtreeSource: "server",
|
||||
persistedMeta: {
|
||||
workspaceId: "ws_1",
|
||||
revision: 3,
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import type { PageBodyPersistedMeta } from "@/lib/documents/page-command-client";
|
||||
import type { PageAggregateProjection } from "@/lib/documents/page-aggregate";
|
||||
import type { PageSubtreeProjection } from "@/lib/documents/page-subtree";
|
||||
import { buildPageSubtreeProjection, type PageSubtreeProjection } from "@/lib/documents/page-subtree";
|
||||
import type { PageOptionsState } from "@/types/page-options";
|
||||
import type { Json } from "@/types/supabase";
|
||||
|
||||
export type PageAggregateClientPageSubtreeSource = "server" | "local" | "none";
|
||||
|
||||
export type PageAggregateClientState = {
|
||||
documentId: string;
|
||||
serverPageTitle: string;
|
||||
persistedPageTitle: string | null;
|
||||
draftPageTitle: string | null;
|
||||
@@ -71,6 +74,7 @@ export function createPageAggregateClientState(
|
||||
page: PageAggregateProjection,
|
||||
): PageAggregateClientState {
|
||||
return {
|
||||
documentId: page.identity.documentId,
|
||||
serverPageTitle: normalizePageTitle(page.head.title),
|
||||
persistedPageTitle: null,
|
||||
draftPageTitle: null,
|
||||
@@ -160,13 +164,43 @@ export function selectPageAggregateClientPageSubtree(
|
||||
liveSidebarTitle: string | null;
|
||||
},
|
||||
): PageSubtreeProjection | null {
|
||||
return selectPageAggregateClientPageSubtreeState(state, input).pageSubtree;
|
||||
}
|
||||
|
||||
export function selectPageAggregateClientPageSubtreeState(
|
||||
state: PageAggregateClientState,
|
||||
input: {
|
||||
liveSidebarTitle: string | null;
|
||||
},
|
||||
): {
|
||||
pageSubtree: PageSubtreeProjection | null;
|
||||
source: PageAggregateClientPageSubtreeSource;
|
||||
} {
|
||||
const titleState = selectPageAggregateClientTitleState(state, input);
|
||||
const contentUnchanged = state.content === state.serverContentSnapshot;
|
||||
|
||||
if (state.serverPageSubtreeSnapshot && !titleState.hasDraft && contentUnchanged) {
|
||||
return withResolvedPageSubtreeTitle(state.serverPageSubtreeSnapshot, titleState.committedTitle);
|
||||
return {
|
||||
pageSubtree: withResolvedPageSubtreeTitle(state.serverPageSubtreeSnapshot, titleState.committedTitle),
|
||||
source: "server",
|
||||
};
|
||||
}
|
||||
return null;
|
||||
|
||||
if (state.content != null) {
|
||||
return {
|
||||
pageSubtree: buildPageSubtreeProjection({
|
||||
documentId: state.documentId,
|
||||
title: titleState.displayTitle,
|
||||
content: state.content,
|
||||
}),
|
||||
source: "local",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
pageSubtree: null,
|
||||
source: "none",
|
||||
};
|
||||
}
|
||||
|
||||
export function selectPageAggregateClientAiSnapshot(
|
||||
@@ -178,6 +212,7 @@ export function selectPageAggregateClientAiSnapshot(
|
||||
): {
|
||||
blocks: Json | null;
|
||||
pageSubtree: PageSubtreeProjection | null;
|
||||
pageSubtreeSource: PageAggregateClientPageSubtreeSource;
|
||||
persistedMeta: {
|
||||
workspaceId: string | null;
|
||||
revision: number | null;
|
||||
@@ -186,12 +221,14 @@ export function selectPageAggregateClientAiSnapshot(
|
||||
pageOptions: PageOptionsState;
|
||||
} {
|
||||
const blocks = state.content as Json | null;
|
||||
const pageSubtreeState = selectPageAggregateClientPageSubtreeState(state, {
|
||||
liveSidebarTitle: input.liveSidebarTitle,
|
||||
});
|
||||
|
||||
return {
|
||||
blocks,
|
||||
pageSubtree: selectPageAggregateClientPageSubtree(state, {
|
||||
liveSidebarTitle: input.liveSidebarTitle,
|
||||
}),
|
||||
pageSubtree: pageSubtreeState.pageSubtree,
|
||||
pageSubtreeSource: pageSubtreeState.source,
|
||||
persistedMeta: {
|
||||
workspaceId: input.workspaceId,
|
||||
revision: state.contentRevision,
|
||||
|
||||
@@ -3,6 +3,7 @@ import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
const SIDEBAR_SOURCE = path.join(process.cwd(), "src/components/sidebar/sidebar.tsx");
|
||||
const TREE_SHELL_DOM_HOST_SOURCE = path.join(process.cwd(), "src/components/sidebar/tree-shell-dom-host.tsx");
|
||||
|
||||
describe("sidebar file tree delete preflight source", () => {
|
||||
it("rust_family 删除链应走 Rust delete preflight,而不是本地 delete target helper", () => {
|
||||
@@ -28,4 +29,58 @@ describe("sidebar file tree delete preflight source", () => {
|
||||
expect(source).toContain('if (viewMode === "filesystem") {');
|
||||
expect(source).toContain("await handleDeleteResourceSelection();");
|
||||
});
|
||||
|
||||
it("页面新建/删除成功后应本地更新 Sidebar,不再同步等待整树刷新", () => {
|
||||
const source = fs.readFileSync(SIDEBAR_SOURCE, "utf8");
|
||||
const createStart = source.indexOf("const handleCreate = useCallback(");
|
||||
const createEnd = source.indexOf("const handleRename = useCallback(");
|
||||
const deleteSelectionStart = source.indexOf("const handleDeleteResourceSelection = useCallback(");
|
||||
const deleteSelectionEnd = source.indexOf("const handleFileTreeShellDeleteSelection = useCallback(");
|
||||
const deleteStart = source.indexOf("const handleDelete = useCallback(");
|
||||
const deleteEnd = source.indexOf("const handleDeleteFromContextMenuNode = useCallback(");
|
||||
|
||||
expect(createStart).toBeGreaterThanOrEqual(0);
|
||||
expect(createEnd).toBeGreaterThan(createStart);
|
||||
expect(deleteSelectionStart).toBeGreaterThanOrEqual(0);
|
||||
expect(deleteSelectionEnd).toBeGreaterThan(deleteSelectionStart);
|
||||
expect(deleteStart).toBeGreaterThanOrEqual(0);
|
||||
expect(deleteEnd).toBeGreaterThan(deleteStart);
|
||||
|
||||
const createBody = source.slice(createStart, createEnd);
|
||||
const deleteSelectionBody = source.slice(deleteSelectionStart, deleteSelectionEnd);
|
||||
const deleteBody = source.slice(deleteStart, deleteEnd);
|
||||
|
||||
expect(createBody).not.toContain("await refreshTree();");
|
||||
expect(deleteSelectionBody).toContain("removeDocumentsFromTree(docIds);");
|
||||
expect(deleteSelectionBody).not.toContain("await refreshTree();");
|
||||
expect(deleteBody).toContain("removeDocumentsFromTree([documentId]);");
|
||||
expect(deleteBody).not.toContain("await refreshTree();");
|
||||
});
|
||||
|
||||
it("Rust-family tree shell mutation 成功后应本地 apply,不再默认整树刷新", () => {
|
||||
const source = fs.readFileSync(SIDEBAR_SOURCE, "utf8");
|
||||
const domHostSource = fs.readFileSync(TREE_SHELL_DOM_HOST_SOURCE, "utf8");
|
||||
const mutationStart = source.indexOf("const handleTreeShellMutation = useCallback(");
|
||||
const mutationEnd = source.indexOf("useEffect(() => {", mutationStart);
|
||||
|
||||
expect(mutationStart).toBeGreaterThanOrEqual(0);
|
||||
expect(mutationEnd).toBeGreaterThan(mutationStart);
|
||||
|
||||
const mutationBody = source.slice(mutationStart, mutationEnd);
|
||||
expect(mutationBody).toContain('payload.type === "tree.node.created"');
|
||||
expect(mutationBody).toContain('payload.type === "tree.node.renamed"');
|
||||
expect(mutationBody).toContain('payload.type === "tree.subtree.moved"');
|
||||
expect(mutationBody).toContain("insertNode(prev, parentId, Number.MAX_SAFE_INTEGER, nextNode)");
|
||||
expect(mutationBody).toContain("renameDocumentInTree(prev, documentId, title, payload.updatedAt ?? null)");
|
||||
expect(mutationBody).toContain("moveLocalNode(prev, documentId, parentId, sortOrder)");
|
||||
expect(mutationBody).not.toMatch(
|
||||
/const handleTreeShellMutation = useCallback\(\(\) => \{\s*void refreshTree\(\);\s*\}, \[refreshTree\]\);/,
|
||||
);
|
||||
|
||||
expect(domHostSource).toContain("onTreeMutation?.({");
|
||||
expect(domHostSource).toContain('type: "tree.node.created"');
|
||||
expect(domHostSource).toContain("parentId: commandParentId");
|
||||
expect(domHostSource).toContain("title: commandTitle");
|
||||
expect(domHostSource).toContain("sortOrder: commandSortOrder");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -50,6 +50,7 @@ import {
|
||||
import { usePreferredSidebarSnapshot } from "@/components/sidebar/use-preferred-sidebar-snapshot";
|
||||
import { bindSidebarRefreshEvents } from "@/components/sidebar/sidebar-events";
|
||||
import { SidebarTreeSurface } from "@/components/sidebar/tree-shell-surface";
|
||||
import type { TreeShellMutationPayload } from "@/components/sidebar/tree-shell-host";
|
||||
import { buildSidebarSectionsFromTree } from "@/lib/sidebar-tree";
|
||||
import {
|
||||
buildPageTreeProjectionItems,
|
||||
@@ -203,6 +204,7 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar
|
||||
sidebarQueryData: sidebarQuery.data,
|
||||
treeStreamData: treeStream.data,
|
||||
treeStreamStatus: treeStream.status,
|
||||
treeStreamCursor: treeStream.cursor,
|
||||
});
|
||||
const sidebarData = externalSidebarData ?? preferredSidebarSnapshot.data;
|
||||
|
||||
@@ -1188,9 +1190,91 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar
|
||||
[assetById, handleOpenAssetInMain],
|
||||
);
|
||||
|
||||
const handleTreeShellMutation = useCallback(() => {
|
||||
const moveLocalNode = useCallback((currentTree: SidebarTreeNode[], nodeId: string, parentId: string | null, index: number) => {
|
||||
const cloned = cloneNodes(currentTree);
|
||||
const { removed, tree: withoutTarget } = removeNode(cloned, nodeId);
|
||||
if (!removed) {
|
||||
return currentTree;
|
||||
}
|
||||
const next = insertNode(withoutTarget, parentId, index, removed);
|
||||
return next;
|
||||
}, []);
|
||||
|
||||
const handleTreeShellMutation = useCallback((payload: TreeShellMutationPayload) => {
|
||||
const documentId = payload.documentId?.trim() || "";
|
||||
if (!documentId) {
|
||||
void refreshTree();
|
||||
return;
|
||||
}
|
||||
|
||||
if (payload.type === "tree.node.created") {
|
||||
const parentId = payload.parentId?.trim() || null;
|
||||
const title = payload.title?.trim() || "无标题";
|
||||
const createdAt = payload.execution?.created_at || new Date().toISOString();
|
||||
const updatedAt = payload.execution?.updated_at ?? payload.updatedAt ?? createdAt;
|
||||
const commandSortOrder =
|
||||
typeof payload.sortOrder === "number" && Number.isFinite(payload.sortOrder)
|
||||
? payload.sortOrder
|
||||
: null;
|
||||
const accessScope = payload.execution?.access_scope ?? "private";
|
||||
const nextNode: SidebarTreeNode = {
|
||||
id: documentId,
|
||||
workspace_id: payload.workspaceId?.trim() || sidebarData.activeWorkspaceId || "",
|
||||
title,
|
||||
parent_id: parentId,
|
||||
sort_order: commandSortOrder,
|
||||
access_scope: accessScope,
|
||||
is_starred: false,
|
||||
is_template: payload.execution?.is_template ?? false,
|
||||
created_at: createdAt,
|
||||
updated_at: updatedAt,
|
||||
children: [],
|
||||
kernel: {
|
||||
nodeType: "page",
|
||||
depth: 0,
|
||||
position: commandSortOrder,
|
||||
childCount: 0,
|
||||
expandedByDefault: true,
|
||||
},
|
||||
};
|
||||
setTree((prev) => {
|
||||
if (nodeById.has(documentId)) return prev;
|
||||
return insertNode(prev, parentId, Number.MAX_SAFE_INTEGER, nextNode);
|
||||
});
|
||||
setExpanded((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (parentId) next.add(parentId);
|
||||
next.add(documentId);
|
||||
return next;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (payload.type === "tree.node.renamed") {
|
||||
const title = payload.title?.trim();
|
||||
if (!title) {
|
||||
void refreshTree();
|
||||
return;
|
||||
}
|
||||
setTree((prev) => renameDocumentInTree(prev, documentId, title, payload.updatedAt ?? null));
|
||||
return;
|
||||
}
|
||||
|
||||
if (payload.type === "tree.subtree.moved") {
|
||||
const parentId = payload.parentId?.trim() || null;
|
||||
const sortOrder =
|
||||
typeof payload.sortOrder === "number" && Number.isFinite(payload.sortOrder)
|
||||
? payload.sortOrder
|
||||
: Number.MAX_SAFE_INTEGER;
|
||||
setTree((prev) => moveLocalNode(prev, documentId, parentId, sortOrder));
|
||||
if (parentId) {
|
||||
setExpanded((prev) => new Set(prev).add(parentId));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
void refreshTree();
|
||||
}, [refreshTree]);
|
||||
}, [moveLocalNode, nodeById, refreshTree, sidebarData.activeWorkspaceId]);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = async (event: KeyboardEvent) => {
|
||||
@@ -1619,6 +1703,23 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar
|
||||
[mediaAssets, mindmapAssets, refreshTree, tableAssets],
|
||||
);
|
||||
|
||||
const removeDocumentsFromTree = useCallback((documentIds: string[]) => {
|
||||
const targetIds = Array.from(new Set(documentIds.map((item) => item.trim()).filter(Boolean)));
|
||||
if (targetIds.length === 0) return;
|
||||
setTree((prev) => {
|
||||
let next = prev;
|
||||
let changed = false;
|
||||
targetIds.forEach((documentId) => {
|
||||
const result = removeNode(next, documentId);
|
||||
if (result.removed) {
|
||||
next = result.tree;
|
||||
changed = true;
|
||||
}
|
||||
});
|
||||
return changed ? next : prev;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleDeleteResourceSelection = useCallback(async (selectionOverride?: {
|
||||
selectedRowIds: string[];
|
||||
anchorRowId: string | null;
|
||||
@@ -1734,6 +1835,7 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar
|
||||
return;
|
||||
}
|
||||
|
||||
removeDocumentsFromTree(docIds);
|
||||
docIds.forEach((documentId) => emitDocumentsChanged(documentId));
|
||||
if (activeId && docIds.includes(activeId)) {
|
||||
router.push("/");
|
||||
@@ -1744,7 +1846,6 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar
|
||||
await handleDeleteAssets(assetIds, selectedAssetHints);
|
||||
}
|
||||
|
||||
await refreshTree();
|
||||
setContextMenu(null);
|
||||
if (!isRustFamilyTreeRenderer) {
|
||||
setLegacyResourceSelection((prev) => reduceTreePaneSelection(prev, { type: "clear" }));
|
||||
@@ -1760,7 +1861,7 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar
|
||||
resourceShellRowById,
|
||||
resourceSelection,
|
||||
handleDeleteAssets,
|
||||
refreshTree,
|
||||
removeDocumentsFromTree,
|
||||
router,
|
||||
sidebarData.activeWorkspaceId,
|
||||
]);
|
||||
@@ -1844,7 +1945,6 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar
|
||||
return next;
|
||||
});
|
||||
|
||||
await refreshTree();
|
||||
const query = new URLSearchParams();
|
||||
if (nextNode.workspace_id) {
|
||||
query.set("workspaceId", nextNode.workspace_id);
|
||||
@@ -1861,7 +1961,7 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar
|
||||
creatingDocumentUnderParentRef.current.delete(creatingKey);
|
||||
}
|
||||
},
|
||||
[refreshTree, router],
|
||||
[router],
|
||||
);
|
||||
|
||||
const handleRename = useCallback(
|
||||
@@ -1885,16 +1985,6 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar
|
||||
[refreshTree, sidebarData.activeWorkspaceId],
|
||||
);
|
||||
|
||||
const moveLocalNode = useCallback((currentTree: SidebarTreeNode[], nodeId: string, parentId: string | null, index: number) => {
|
||||
const cloned = cloneNodes(currentTree);
|
||||
const { removed, tree: withoutTarget } = removeNode(cloned, nodeId);
|
||||
if (!removed) {
|
||||
return currentTree;
|
||||
}
|
||||
const next = insertNode(withoutTarget, parentId, index, removed);
|
||||
return next;
|
||||
}, []);
|
||||
|
||||
const handleMove = useCallback(
|
||||
async (documentId: string, parentId: string | null, index: number) => {
|
||||
setTree((prev) => moveLocalNode(prev, documentId, parentId, index));
|
||||
@@ -1948,8 +2038,9 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar
|
||||
return;
|
||||
}
|
||||
|
||||
if (uploadTargetPlan.targetMindmapId) {
|
||||
setExpandedAssetFolders((prev) => new Set(prev).add(uploadTargetPlan.targetMindmapId));
|
||||
const targetMindmapId = uploadTargetPlan.targetMindmapId;
|
||||
if (targetMindmapId) {
|
||||
setExpandedAssetFolders((prev) => new Set(prev).add(targetMindmapId));
|
||||
}
|
||||
|
||||
const errors: string[] = [];
|
||||
@@ -2151,13 +2242,13 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar
|
||||
documentId,
|
||||
workspaceId: sidebarData.activeWorkspaceId ?? null,
|
||||
});
|
||||
await refreshTree();
|
||||
removeDocumentsFromTree([documentId]);
|
||||
emitDocumentsChanged(documentId);
|
||||
if (activeId === documentId) {
|
||||
router.push("/");
|
||||
}
|
||||
},
|
||||
[activeId, refreshTree, router, sidebarData.activeWorkspaceId],
|
||||
[activeId, removeDocumentsFromTree, router, sidebarData.activeWorkspaceId],
|
||||
);
|
||||
|
||||
const handleDeleteFromContextMenuNode = useCallback(
|
||||
@@ -3489,6 +3580,32 @@ const removeNode = (
|
||||
return { removed, tree: nextTree };
|
||||
};
|
||||
|
||||
const renameDocumentInTree = (
|
||||
nodes: SidebarTreeNode[],
|
||||
documentId: string,
|
||||
title: string,
|
||||
updatedAt: string | null,
|
||||
): SidebarTreeNode[] => {
|
||||
let changed = false;
|
||||
const next = nodes.map((node) => {
|
||||
if (node.id === documentId) {
|
||||
changed = true;
|
||||
return {
|
||||
...node,
|
||||
title,
|
||||
updated_at: updatedAt ?? node.updated_at,
|
||||
};
|
||||
}
|
||||
const children = renameDocumentInTree(node.children, documentId, title, updatedAt);
|
||||
if (children !== node.children) {
|
||||
changed = true;
|
||||
return { ...node, children };
|
||||
}
|
||||
return node;
|
||||
});
|
||||
return changed ? next : nodes;
|
||||
};
|
||||
|
||||
const insertNode = (nodes: SidebarTreeNode[], parentId: string | null, index: number, newNode: SidebarTreeNode): SidebarTreeNode[] => {
|
||||
if (!parentId) {
|
||||
const next = [...nodes];
|
||||
|
||||
@@ -15,6 +15,7 @@ import type {
|
||||
FileTreeShellDeleteSelectionPayload,
|
||||
FileTreeShellExternalDropPayload,
|
||||
FileTreeShellInternalDropPayload,
|
||||
TreeShellMutationPayload,
|
||||
TreeShellHostMode,
|
||||
TreeShellPickerCommand,
|
||||
} from "@/components/sidebar/tree-shell-host";
|
||||
@@ -100,7 +101,7 @@ type TreeShellRustDomShellHostProps = {
|
||||
onInternalDrop?: (payload: FileTreeShellInternalDropPayload) => void;
|
||||
onDropFiles?: (payload: FileTreeShellExternalDropPayload) => void;
|
||||
onAssetOpen?: (payload: { assetId: string; documentId: string | null }) => void;
|
||||
onTreeMutation?: (payload: { type: string; documentId: string | null }) => void;
|
||||
onTreeMutation?: (payload: TreeShellMutationPayload) => void;
|
||||
children?: ReactNode;
|
||||
};
|
||||
|
||||
@@ -120,6 +121,19 @@ type TreeShellRuntimeWasmModule = {
|
||||
|
||||
type TreeShellRuntimeReducer = (request: unknown) => Promise<unknown> | unknown;
|
||||
|
||||
type TreeCommandResponsePayload = {
|
||||
id?: string;
|
||||
result?: {
|
||||
documentId?: string;
|
||||
parentId?: string | null;
|
||||
title?: string | null;
|
||||
sortOrder?: number | null;
|
||||
workspaceId?: string | null;
|
||||
updatedAt?: string | null;
|
||||
execution?: TreeShellMutationPayload["execution"];
|
||||
} | null;
|
||||
};
|
||||
|
||||
type TreeShellRuntimeGlobal = typeof globalThis & {
|
||||
__MNOTE_TREE_SHELL_RUNTIME__?: {
|
||||
reduceTreeShellRuntime?: TreeShellRuntimeReducer;
|
||||
@@ -179,6 +193,20 @@ function normalizeShellRowKind(value: unknown, defaultValue = "") {
|
||||
return rowKind;
|
||||
}
|
||||
|
||||
function readTreeCommandResult(payload: unknown): NonNullable<TreeCommandResponsePayload["result"]> {
|
||||
if (!isRecord(payload) || !isRecord(payload.result)) {
|
||||
return {};
|
||||
}
|
||||
return payload.result;
|
||||
}
|
||||
|
||||
function readTreeCommandDocumentId(payload: unknown, result: NonNullable<TreeCommandResponsePayload["result"]>) {
|
||||
if (result.documentId) {
|
||||
return normalizeString(result.documentId);
|
||||
}
|
||||
return isRecord(payload) ? normalizeString(payload.id) : "";
|
||||
}
|
||||
|
||||
function itemHasVisibleChildren(item: TreeShellDomProjectionItem, childrenByParent: Map<string, TreeShellDomProjectionItem[]>) {
|
||||
return item.childCount > 0 && (childrenByParent.get(item.nodeId)?.length ?? 0) > 0;
|
||||
}
|
||||
@@ -796,16 +824,40 @@ export function TreeShellRustDomShellHost({
|
||||
if (event.kind === "createNode") {
|
||||
const parentId = normalizeString(event.parentNodeId) || null;
|
||||
const result = await runTreeCommand({ action: "create", parentId });
|
||||
const documentId = normalizeString((result as { result?: { documentId?: string }; id?: string })?.result?.documentId, normalizeString((result as { id?: string })?.id));
|
||||
onTreeMutation?.({ type: "tree.node.created", documentId: documentId || null });
|
||||
const commandResult = readTreeCommandResult(result);
|
||||
const documentId = readTreeCommandDocumentId(result, commandResult);
|
||||
const commandParentId = normalizeString(commandResult.parentId, parentId ?? "") || parentId;
|
||||
const commandTitle = normalizeString(commandResult.title, "无标题");
|
||||
const commandSortOrder =
|
||||
typeof commandResult.sortOrder === "number" && Number.isFinite(commandResult.sortOrder)
|
||||
? commandResult.sortOrder
|
||||
: null;
|
||||
onTreeMutation?.({
|
||||
type: "tree.node.created",
|
||||
documentId: documentId || null,
|
||||
parentId: commandParentId,
|
||||
title: commandTitle,
|
||||
sortOrder: commandSortOrder,
|
||||
workspaceId: normalizeString(commandResult.workspaceId, workspaceId) || workspaceId,
|
||||
updatedAt: normalizeString(commandResult.updatedAt) || null,
|
||||
execution: commandResult.execution ?? null,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (event.kind === "renameNode") {
|
||||
const documentId = normalizeString(event.nodeId);
|
||||
const title = normalizeString(event.title);
|
||||
if (!documentId || !title) continue;
|
||||
await runTreeCommand({ action: "rename", documentId, title });
|
||||
onTreeMutation?.({ type: "tree.node.renamed", documentId });
|
||||
const result = await runTreeCommand({ action: "rename", documentId, title });
|
||||
const commandResult = readTreeCommandResult(result);
|
||||
onTreeMutation?.({
|
||||
type: "tree.node.renamed",
|
||||
documentId,
|
||||
title: normalizeString(commandResult.title, title),
|
||||
workspaceId: normalizeString(commandResult.workspaceId, workspaceId) || workspaceId,
|
||||
updatedAt: normalizeString(commandResult.updatedAt) || null,
|
||||
execution: commandResult.execution ?? null,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (event.kind === "moveSubtree") {
|
||||
@@ -813,12 +865,26 @@ export function TreeShellRustDomShellHost({
|
||||
if (!documentId) continue;
|
||||
const parentId = normalizeString(event.targetParentId) || null;
|
||||
const sortOrder = typeof event.sortOrder === "number" && Number.isFinite(event.sortOrder) ? event.sortOrder : 0;
|
||||
await runTreeCommand({ action: "move", documentId, parentId, sortOrder });
|
||||
onTreeMutation?.({ type: "tree.subtree.moved", documentId });
|
||||
const result = await runTreeCommand({ action: "move", documentId, parentId, sortOrder });
|
||||
const commandResult = readTreeCommandResult(result);
|
||||
const commandParentId = normalizeString(commandResult.parentId, parentId ?? "") || parentId;
|
||||
const commandSortOrder =
|
||||
typeof commandResult.sortOrder === "number" && Number.isFinite(commandResult.sortOrder)
|
||||
? commandResult.sortOrder
|
||||
: sortOrder;
|
||||
onTreeMutation?.({
|
||||
type: "tree.subtree.moved",
|
||||
documentId,
|
||||
parentId: commandParentId,
|
||||
sortOrder: commandSortOrder,
|
||||
workspaceId: normalizeString(commandResult.workspaceId, workspaceId) || workspaceId,
|
||||
updatedAt: normalizeString(commandResult.updatedAt) || null,
|
||||
execution: commandResult.execution ?? null,
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
[onTreeMutation, runTreeCommand],
|
||||
[onTreeMutation, runTreeCommand, workspaceId],
|
||||
);
|
||||
|
||||
const handlePageOpen = useCallback(
|
||||
@@ -837,7 +903,7 @@ export function TreeShellRustDomShellHost({
|
||||
onPageExpandChange?.({ documentId: nodeId, expanded: state.expandedIds.includes(nodeId) });
|
||||
}
|
||||
},
|
||||
[onPageExpandChange, pageState.expandedIds, reducePageAction],
|
||||
[onPageExpandChange, pageState, reducePageAction],
|
||||
);
|
||||
|
||||
const handlePageKeyDown = useCallback(
|
||||
|
||||
@@ -46,6 +46,22 @@ export type FileTreeShellDeleteSelectionPayload = {
|
||||
focusedRowId: string | null;
|
||||
};
|
||||
|
||||
export type TreeShellMutationPayload = {
|
||||
type: string;
|
||||
documentId: string | null;
|
||||
parentId?: string | null;
|
||||
title?: string | null;
|
||||
sortOrder?: number | null;
|
||||
workspaceId?: string | null;
|
||||
updatedAt?: string | null;
|
||||
execution?: ({
|
||||
access_scope?: "private" | "shared" | "public";
|
||||
is_template?: boolean;
|
||||
created_at?: string | null;
|
||||
updated_at?: string | null;
|
||||
} & Record<string, unknown>) | null;
|
||||
};
|
||||
|
||||
type TreeShellHostProps = {
|
||||
mode: TreeShellHostMode;
|
||||
surfaceTestId: string;
|
||||
@@ -89,7 +105,7 @@ type TreeShellHostProps = {
|
||||
onInternalDrop?: (payload: FileTreeShellInternalDropPayload) => void;
|
||||
onDropFiles?: (payload: FileTreeShellExternalDropPayload) => void;
|
||||
onAssetOpen?: (payload: { assetId: string; documentId: string | null }) => void;
|
||||
onTreeMutation?: (payload: { type: string; documentId: string | null }) => void;
|
||||
onTreeMutation?: (payload: TreeShellMutationPayload) => void;
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
|
||||
@@ -2,9 +2,11 @@
|
||||
|
||||
import { useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
|
||||
import type {
|
||||
FileTreeShellDeleteSelectionPayload,
|
||||
FileTreeShellExternalDropPayload,
|
||||
FileTreeShellInternalDropPayload,
|
||||
TreeShellHostMode,
|
||||
TreeShellMutationPayload,
|
||||
TreeShellPickerCommand,
|
||||
} from "@/components/sidebar/tree-shell-host";
|
||||
import {
|
||||
@@ -33,10 +35,7 @@ type TreeShellBridgePageExpandPayload = {
|
||||
expanded: boolean;
|
||||
};
|
||||
|
||||
type TreeShellBridgeMutationPayload = {
|
||||
type: string;
|
||||
documentId: string | null;
|
||||
};
|
||||
type TreeShellBridgeMutationPayload = TreeShellMutationPayload;
|
||||
|
||||
type TreeShellBridgeSelectionPayload = {
|
||||
selectedRowIds: string[];
|
||||
@@ -137,6 +136,7 @@ export type TreeShellIframeHostProps = {
|
||||
onPageFocusChange?: (payload: { documentId: string | null }) => void;
|
||||
onFileTreeContextMenu?: (payload: TreeShellBridgeContextMenuPayload) => void;
|
||||
onFileTreeSelectionChange?: (payload: TreeShellBridgeSelectionPayload) => void;
|
||||
onFileTreeDeleteSelection?: (payload: FileTreeShellDeleteSelectionPayload) => void;
|
||||
onInternalDrop?: (payload: FileTreeShellInternalDropPayload) => void;
|
||||
onDropFiles?: (payload: FileTreeShellExternalDropPayload) => void;
|
||||
onAssetOpen?: (payload: { assetId: string; documentId: string | null }) => void;
|
||||
@@ -160,6 +160,12 @@ type TreeShellBridgeMessage = {
|
||||
copy: boolean;
|
||||
files: File[];
|
||||
itemKey: string | null;
|
||||
parentId?: string | null;
|
||||
title?: string | null;
|
||||
sortOrder?: number | null;
|
||||
workspaceId?: string | null;
|
||||
updatedAt?: string | null;
|
||||
execution?: TreeShellMutationPayload["execution"];
|
||||
};
|
||||
|
||||
const INLINE_TREE_SHELL_LOADING_HTML = [
|
||||
@@ -1269,15 +1275,21 @@ const LOCAL_TREE_SHELL_TEMPLATE = `<!doctype html>
|
||||
sortOrder = position === "after" ? targetIndex + 1 : targetIndex;
|
||||
}
|
||||
try {
|
||||
await runTreeCommand({
|
||||
const result = await runTreeCommand({
|
||||
action: "move",
|
||||
documentId: sourceNodeId,
|
||||
parentId,
|
||||
sortOrder,
|
||||
});
|
||||
const commandResult = result && result.result && typeof result.result === "object" ? result.result : {};
|
||||
setLastAction(targetItem ? \`页面已拖放到 \${targetItem.title}\` : "页面已完成拖放移动");
|
||||
postToHost("tree.subtree.moved", {
|
||||
documentId: sourceNodeId,
|
||||
parentId: normalizeText(commandResult.parentId, parentId || "") || null,
|
||||
sortOrder: Number.isFinite(commandResult.sortOrder) ? Number(commandResult.sortOrder) : sortOrder,
|
||||
workspaceId: normalizeText(commandResult.workspaceId, workspaceId) || workspaceId || null,
|
||||
updatedAt: normalizeText(commandResult.updatedAt) || null,
|
||||
execution: commandResult.execution && typeof commandResult.execution === "object" ? commandResult.execution : null,
|
||||
target: { documentId: sourceNodeId },
|
||||
});
|
||||
return true;
|
||||
@@ -1301,8 +1313,15 @@ const LOCAL_TREE_SHELL_TEMPLATE = `<!doctype html>
|
||||
normalizeText(result && result.id),
|
||||
);
|
||||
setLastAction(documentId ? \`已创建子页面 \${documentId}\` : "已创建子页面");
|
||||
const commandResult = result && result.result && typeof result.result === "object" ? result.result : {};
|
||||
postToHost("tree.node.created", {
|
||||
documentId: documentId || null,
|
||||
parentId: normalizeText(commandResult.parentId, parentId || "") || null,
|
||||
title: normalizeText(commandResult.title, "无标题"),
|
||||
sortOrder: Number.isFinite(commandResult.sortOrder) ? Number(commandResult.sortOrder) : null,
|
||||
workspaceId: normalizeText(commandResult.workspaceId, workspaceId) || workspaceId || null,
|
||||
updatedAt: normalizeText(commandResult.updatedAt) || null,
|
||||
execution: commandResult.execution && typeof commandResult.execution === "object" ? commandResult.execution : null,
|
||||
target: { documentId: documentId || null },
|
||||
});
|
||||
return true;
|
||||
@@ -1322,16 +1341,21 @@ const LOCAL_TREE_SHELL_TEMPLATE = `<!doctype html>
|
||||
);
|
||||
if (!nodeId || !title) return false;
|
||||
try {
|
||||
await runTreeCommand({
|
||||
const result = await runTreeCommand({
|
||||
action: "rename",
|
||||
documentId: nodeId,
|
||||
title,
|
||||
});
|
||||
const commandResult = result && result.result && typeof result.result === "object" ? result.result : {};
|
||||
const item = itemById.get(nodeId);
|
||||
if (item) item.title = title;
|
||||
setLastAction(\`已重命名为 \${title}\`);
|
||||
postToHost("tree.node.renamed", {
|
||||
documentId: nodeId,
|
||||
title: normalizeText(commandResult.title, title),
|
||||
workspaceId: normalizeText(commandResult.workspaceId, workspaceId) || workspaceId || null,
|
||||
updatedAt: normalizeText(commandResult.updatedAt) || null,
|
||||
execution: commandResult.execution && typeof commandResult.execution === "object" ? commandResult.execution : null,
|
||||
target: { documentId: nodeId },
|
||||
});
|
||||
if (fallbackCommandEvent?.titleElement) {
|
||||
@@ -4424,8 +4448,11 @@ export function TreeShellIframeHost({
|
||||
mode,
|
||||
workspaceId,
|
||||
rootNodeId,
|
||||
activePickerItemKey,
|
||||
focusedDocumentId,
|
||||
inlineBootstrapActiveDocumentId,
|
||||
inlineInitialTreeHtml,
|
||||
inlineProjectionItems,
|
||||
allowRootPick,
|
||||
excludeIds,
|
||||
resolvedChannel,
|
||||
@@ -4590,6 +4617,12 @@ export function TreeShellIframeHost({
|
||||
onTreeMutation?.({
|
||||
type: message.type,
|
||||
documentId: message.documentId,
|
||||
...(message.parentId !== undefined ? { parentId: message.parentId } : {}),
|
||||
...(message.title !== undefined ? { title: message.title } : {}),
|
||||
...(message.sortOrder !== undefined ? { sortOrder: message.sortOrder } : {}),
|
||||
...(message.workspaceId !== undefined ? { workspaceId: message.workspaceId } : {}),
|
||||
...(message.updatedAt !== undefined ? { updatedAt: message.updatedAt } : {}),
|
||||
...(message.execution !== undefined ? { execution: message.execution } : {}),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
type FileTreeShellDeleteSelectionPayload,
|
||||
type FileTreeShellExternalDropPayload,
|
||||
type FileTreeShellInternalDropPayload,
|
||||
type TreeShellMutationPayload,
|
||||
type TreeShellPickerCommand,
|
||||
type TreeRendererFamily,
|
||||
} from "@/components/sidebar/tree-shell-host";
|
||||
@@ -36,7 +37,7 @@ type SidebarPageTreeSurfaceProps = {
|
||||
onPageContextMenu?: (payload: { documentId: string; x: number; y: number }) => void;
|
||||
onPageExpandChange?: (payload: { documentId: string | null; expanded: boolean }) => void;
|
||||
onPageFocusChange?: (payload: { documentId: string | null }) => void;
|
||||
onTreeMutation?: (payload: { type: string; documentId: string | null }) => void;
|
||||
onTreeMutation?: (payload: TreeShellMutationPayload) => void;
|
||||
};
|
||||
|
||||
type SidebarFileTreeSurfaceProps = {
|
||||
@@ -74,7 +75,7 @@ type SidebarFileTreeSurfaceProps = {
|
||||
}) => void;
|
||||
onFileTreeDeleteSelection?: (payload: FileTreeShellDeleteSelectionPayload) => void;
|
||||
onAssetOpen?: (payload: { assetId: string; documentId: string | null }) => void;
|
||||
onTreeMutation?: (payload: { type: string; documentId: string | null }) => void;
|
||||
onTreeMutation?: (payload: TreeShellMutationPayload) => void;
|
||||
};
|
||||
|
||||
export type SidebarTreeSurfaceProps =
|
||||
|
||||
@@ -40,6 +40,7 @@ function Harness(props: {
|
||||
sidebarQueryData: SidebarInitialData | null;
|
||||
treeStreamData: SidebarInitialData | null;
|
||||
treeStreamStatus?: "idle" | "connecting" | "live" | "fallback";
|
||||
treeStreamCursor?: string | null;
|
||||
onState: (state: ReturnType<typeof usePreferredSidebarSnapshot>) => void;
|
||||
}) {
|
||||
const state = usePreferredSidebarSnapshot(props);
|
||||
@@ -145,6 +146,80 @@ describe("usePreferredSidebarSnapshot", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("query 版本比已更新过的 stream 更新时,应优先 query,避免旧 stream 回压", async () => {
|
||||
const initialData = buildSidebarData([buildDocument({ title: "标题 A" })]);
|
||||
const staleTreeStream = buildSidebarData([
|
||||
buildDocument({
|
||||
title: "标题 B",
|
||||
updated_at: "2026-04-21T00:00:01.000Z",
|
||||
}),
|
||||
]);
|
||||
const refreshedQuery = buildSidebarData([
|
||||
buildDocument({
|
||||
title: "标题 C",
|
||||
updated_at: "2026-04-21T00:00:02.000Z",
|
||||
}),
|
||||
]);
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<Harness
|
||||
initialData={initialData}
|
||||
sidebarQueryData={refreshedQuery}
|
||||
treeStreamData={staleTreeStream}
|
||||
treeStreamStatus="live"
|
||||
treeStreamCursor="cursor_stream_1"
|
||||
onState={onState}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
expect(onState.mock.lastCall?.[0]).toMatchObject({
|
||||
source: "query",
|
||||
cursor: null,
|
||||
data: expect.objectContaining({
|
||||
kernelSidebarTree: [expect.objectContaining({ title: "标题 C" })],
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it("stream 与 query 版本相同时,应优先 stream 并暴露 cursor", async () => {
|
||||
const initialData = buildSidebarData([buildDocument({ title: "标题 A" })]);
|
||||
const queryData = buildSidebarData([
|
||||
buildDocument({
|
||||
title: "标题 B",
|
||||
updated_at: "2026-04-21T00:00:01.000Z",
|
||||
}),
|
||||
]);
|
||||
const treeStreamData = buildSidebarData([
|
||||
buildDocument({
|
||||
title: "标题 B",
|
||||
updated_at: "2026-04-21T00:00:01.000Z",
|
||||
}),
|
||||
]);
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<Harness
|
||||
initialData={initialData}
|
||||
sidebarQueryData={queryData}
|
||||
treeStreamData={treeStreamData}
|
||||
treeStreamStatus="live"
|
||||
treeStreamCursor="cursor_stream_2"
|
||||
onState={onState}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
expect(onState.mock.lastCall?.[0]).toMatchObject({
|
||||
source: "tree_stream",
|
||||
cursor: "cursor_stream_2",
|
||||
data: expect.objectContaining({
|
||||
kernelSidebarTree: [expect.objectContaining({ title: "标题 B" })],
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it("tree stream 进入 fallback 后应回退到 query 快照", async () => {
|
||||
const initialData = buildSidebarData([buildDocument({ title: "标题 A" })]);
|
||||
const staleTreeStream = buildSidebarData([buildDocument({ title: "标题 A" })]);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useMemo } from "react";
|
||||
import type { SidebarInitialData } from "@/components/sidebar/types";
|
||||
import { buildSidebarDataSyncKey } from "@/components/sidebar/sidebar-sync";
|
||||
import { buildSidebarDataSyncKey, getSidebarDataFreshness } from "@/components/sidebar/sidebar-sync";
|
||||
|
||||
export type PreferredSidebarSnapshotSource = "initial" | "query" | "tree_stream";
|
||||
|
||||
@@ -9,6 +9,7 @@ export function usePreferredSidebarSnapshot(input: {
|
||||
sidebarQueryData: SidebarInitialData | null;
|
||||
treeStreamData: SidebarInitialData | null;
|
||||
treeStreamStatus?: "idle" | "connecting" | "live" | "fallback";
|
||||
treeStreamCursor?: string | null;
|
||||
}) {
|
||||
const querySyncKey = useMemo(
|
||||
() => (input.sidebarQueryData ? buildSidebarDataSyncKey(input.sidebarQueryData) : null),
|
||||
@@ -19,29 +20,40 @@ export function usePreferredSidebarSnapshot(input: {
|
||||
[input.treeStreamData],
|
||||
);
|
||||
const initialSyncKey = useMemo(() => buildSidebarDataSyncKey(input.initialData), [input.initialData]);
|
||||
const initialVersion = useMemo(() => getSidebarDataFreshness(input.initialData), [input.initialData]);
|
||||
const queryVersion = useMemo(
|
||||
() => (input.sidebarQueryData ? getSidebarDataFreshness(input.sidebarQueryData) : null),
|
||||
[input.sidebarQueryData],
|
||||
);
|
||||
const treeStreamVersion = useMemo(
|
||||
() => (input.treeStreamData ? getSidebarDataFreshness(input.treeStreamData) : null),
|
||||
[input.treeStreamData],
|
||||
);
|
||||
const streamIsPreferred = input.treeStreamStatus !== "fallback";
|
||||
const queryHasFreshSnapshot =
|
||||
input.sidebarQueryData != null &&
|
||||
querySyncKey != null &&
|
||||
querySyncKey !== initialSyncKey &&
|
||||
treeStreamSyncKey === initialSyncKey;
|
||||
|
||||
const preferredVersion = Math.max(
|
||||
initialVersion,
|
||||
queryVersion ?? 0,
|
||||
streamIsPreferred ? treeStreamVersion ?? 0 : 0,
|
||||
);
|
||||
const queryHasPreferredVersion = queryVersion != null && queryVersion >= preferredVersion;
|
||||
const treeStreamHasPreferredVersion =
|
||||
streamIsPreferred &&
|
||||
input.treeStreamData != null &&
|
||||
treeStreamVersion != null &&
|
||||
treeStreamVersion >= preferredVersion;
|
||||
|
||||
const source = useMemo<PreferredSidebarSnapshotSource>(() => {
|
||||
if (queryHasFreshSnapshot) {
|
||||
return "query";
|
||||
}
|
||||
if (input.treeStreamData && streamIsPreferred) {
|
||||
if (treeStreamHasPreferredVersion) {
|
||||
return "tree_stream";
|
||||
}
|
||||
if (input.sidebarQueryData) {
|
||||
if (queryHasPreferredVersion) {
|
||||
return "query";
|
||||
}
|
||||
return "initial";
|
||||
}, [
|
||||
input.sidebarQueryData,
|
||||
input.treeStreamData,
|
||||
queryHasFreshSnapshot,
|
||||
streamIsPreferred,
|
||||
queryHasPreferredVersion,
|
||||
treeStreamHasPreferredVersion,
|
||||
]);
|
||||
|
||||
const data =
|
||||
@@ -56,13 +68,22 @@ export function usePreferredSidebarSnapshot(input: {
|
||||
: source === "query"
|
||||
? querySyncKey ?? initialSyncKey
|
||||
: initialSyncKey;
|
||||
const version =
|
||||
source === "tree_stream"
|
||||
? treeStreamVersion ?? initialVersion
|
||||
: source === "query"
|
||||
? queryVersion ?? initialVersion
|
||||
: initialVersion;
|
||||
const cursor = source === "tree_stream" ? input.treeStreamCursor ?? null : null;
|
||||
|
||||
return useMemo(
|
||||
() => ({
|
||||
data,
|
||||
source,
|
||||
syncKey,
|
||||
version,
|
||||
cursor,
|
||||
}),
|
||||
[data, source, syncKey],
|
||||
[cursor, data, source, syncKey, version],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -301,4 +301,33 @@ describe("tiptap-content-converter", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("mindmap 引用多轮 TipTap/legacy 转换时保持 mindmapId 稳定", () => {
|
||||
const original = {
|
||||
type: "doc" as const,
|
||||
content: [
|
||||
{
|
||||
type: "paragraph",
|
||||
attrs: {
|
||||
blockId: "mind_block_stable",
|
||||
mnoteBlockType: "mindmap",
|
||||
mindmapId: "mindmap_stable_1",
|
||||
rootNodeId: "root",
|
||||
},
|
||||
content: [{ type: "text", text: "中心主题" }],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const firstLegacy = blocksFromTiptapDoc(original);
|
||||
const secondTiptap = tiptapDocFromBlocks(firstLegacy as never);
|
||||
const secondLegacy = blocksFromTiptapDoc(secondTiptap);
|
||||
const thirdTiptap = tiptapDocFromBlocks(secondLegacy as never);
|
||||
|
||||
expect(firstLegacy[0]?.props?.mindmapId).toBe("mindmap_stable_1");
|
||||
expect(secondLegacy[0]?.props?.mindmapId).toBe("mindmap_stable_1");
|
||||
expect(secondTiptap.content?.[0]?.attrs?.mindmapId).toBe("mindmap_stable_1");
|
||||
expect(thirdTiptap.content?.[0]?.attrs?.mindmapId).toBe("mindmap_stable_1");
|
||||
expect(thirdTiptap.content?.[0]?.attrs?.blockId).toBe("mind_block_stable");
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
@@ -55,6 +55,12 @@ describe("mindmap action map", () => {
|
||||
expect(getMindmapActionMapping("showMenu")).toMatchObject({ readonlyAllowed: true });
|
||||
});
|
||||
|
||||
it("导出能力暂缓时不下发未受控 runtime command", () => {
|
||||
expect(getMindmapActionMapping("export")).toMatchObject({ target: "localView", readonlyAllowed: true });
|
||||
expect(getMindmapActionMapping("export")?.runtimeCommand).toBeUndefined();
|
||||
expect(mapMindmapActionToCommand({ actionId: "export", mindmapId: "mind_1" })).toEqual({});
|
||||
});
|
||||
|
||||
it("把主题、结构和扩展字段映射到 kernel/compat", () => {
|
||||
expect(mapMindmapActionToCommand({ actionId: "setTheme", mindmapId: "mind_1", value: "classic4" })).toEqual({
|
||||
command: { type: "setTheme", mindmapId: "mind_1", theme: "classic4" },
|
||||
|
||||
@@ -62,7 +62,8 @@ const mappings: MindmapActionMapping[] = [
|
||||
{ actionId: "formula", target: "compatPatch", requiresActiveNode: true, readonlyAllowed: false, compatPath: nodeDataPath("formula") },
|
||||
{ actionId: "painter", target: "compatPatch", requiresActiveNode: true, readonlyAllowed: false, compatPath: nodeDataPath("style") },
|
||||
{ actionId: "import", target: "compatPatch", requiresActiveNode: false, readonlyAllowed: false, compatPath: () => "import" },
|
||||
{ actionId: "export", target: "runtimeCommand", runtimeCommand: "EXPORT", requiresActiveNode: false, readonlyAllowed: true },
|
||||
// 导出能力暂缓:UI state 会禁用该入口,这里不能继续下发未受控的 runtime EXPORT 命令。
|
||||
{ actionId: "export", target: "localView", requiresActiveNode: false, readonlyAllowed: true },
|
||||
{ actionId: "centerRoot", target: "localView", runtimeMethod: "centerRoot", requiresActiveNode: false, readonlyAllowed: true },
|
||||
{ actionId: "zoomIn", target: "localView", runtimeMethod: "zoomIn", requiresActiveNode: false, readonlyAllowed: true },
|
||||
{ actionId: "zoomOut", target: "localView", runtimeMethod: "zoomOut", requiresActiveNode: false, readonlyAllowed: true },
|
||||
|
||||
@@ -44,6 +44,12 @@ describe("mindmap UI state", () => {
|
||||
expect(state.disabledActions.insertChild).toBe(false);
|
||||
});
|
||||
|
||||
it("导出能力暂缓时在 UI state 中保持禁用", () => {
|
||||
const state = deriveMindmapUiState({ activeNodeId: "node_1", readonly: false });
|
||||
|
||||
expect(state.disabledActions.export).toBe(true);
|
||||
});
|
||||
|
||||
it("派生默认 shell state,给 Leptos shell 提供稳定合同", () => {
|
||||
const state = deriveMindmapUiState({
|
||||
activeNodeId: "node_1",
|
||||
|
||||
Reference in New Issue
Block a user