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:
lix-2026
2026-05-14 05:52:08 +08:00
parent b4a452a8b7
commit 96e03645f7
69 changed files with 4780 additions and 979 deletions
@@ -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 断言。
@@ -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 commandUI 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,因此本轮不需要执行历史数据清理,也不会删除用户数据。
@@ -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` ownerNext 仅保留 legacy compat/debug 边界 > - 本稿定义的 default gate 已在当前主线代码中成立:`mnote-web` 是 `3000` ownerNext 仅保留 legacy compat/debug 边界
> - `MNOTE_WEB_ENABLE_LEGACY_NEXT_COMPAT` 默认关闭与 `task117` guard 已落地,故迁入 `done/` > - `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 会话与编排长期归 Hermesmnote 通过 Hermes skill/plugin 暴露业务工具
## 当前 owner ## 当前 owner
@@ -15,7 +16,7 @@
- `/search`Rust Web `search::shell`Search projection owner `rust-kernel` - `/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` - `/mindmap/{doc_id}/{mindmap_id}`Rust Web `mindmap_shell::mindmap_object_shell`Mindmap projection owner `rust-kernel`
- `/api/tree/events`Rust Web SSEstream owner `rust-web` - `/api/tree/events`Rust Web SSEstream 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,仅用于迁移期兼容与调试。 - `/api/compat/next/*`legacy compat boundary,仅用于迁移期兼容与调试。
## Gate ## Gate
@@ -23,12 +24,12 @@
- `MNOTE_WEB_ENABLE_LEGACY_NEXT_COMPAT` 默认关闭;只有显式设置为 `1/true/yes` 才允许 fallback proxy。 - `MNOTE_WEB_ENABLE_LEGACY_NEXT_COMPAT` 默认关闭;只有显式设置为 `1/true/yes` 才允许 fallback proxy。
- 默认主路径不得返回 `x-mnote-legacy-upstream: next-app-router` - 默认主路径不得返回 `x-mnote-legacy-upstream: next-app-router`
- 导图、搜索、文档页 contract 不得再把 `next-app-router` 声明为主 runtime。 - 导图、搜索、文档页 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/sidebar`Sidebar、workspace shell、file tree smoke 均证明 Rust projection 可覆盖默认入口。
- 删除 `/api/compat/next/ai-agent/run`AI 面板默认请求统一 CLI hostlegacy 调用方清零;Hermes 仅保留为可插拔外置 agent - 删除 `/api/compat/next/ai-agent/run`AI 面板默认请求统一 Hermes client proxylegacy 调用方清零;`mnote-cli` 只可作为 mnote plugin 内部适配器或调试入口
- 删除 fallback proxy`task117` 默认关闭 legacy compat 后覆盖首页、文档页、搜索页、导图页与 tree SSE。 - 删除 fallback proxy`task117` 默认关闭 legacy compat 后覆盖首页、文档页、搜索页、导图页与 tree SSE。
## 验收命令 ## 验收命令
@@ -1,4 +1,4 @@
# 3-15 [process] Runtime Fallback 退场 Checklist v1 # 3-15 [done] Runtime Fallback 退场 Checklist v1
> 更新时间:2026-05-09 > 更新时间: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/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-5-page-aggregate-single-truth-alignment-v1.md`
> - `/mnt/Data1T/mnote/design/05-editor-mainline/process/5-6-page-aggregate-alignment-checklist-v1.md` > - `/mnt/Data1T/mnote/design/05-editor-mainline/process/5-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. 目标 ## 1. 目标
@@ -59,14 +64,14 @@
- [x] 把 `BlockNote` 从“系统默认可退回编辑器”降为 `recycle` 参考实现 - [x] 把 `BlockNote` 从“系统默认可退回编辑器”降为 `recycle` 参考实现
- [x] 活跃设计稿中不再把 `BlockNote` 写成系统 fallback,只允许写成历史对照链 - [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=codex` 直连分支
- [x] 删除 Next `/api/ai-agent/run``provider=hermes` 直连分支 - [x] 删除 Next `/api/ai-agent/run``provider=hermes` 直连分支
- [x] 删除 Rust `compat` 中代理到 legacy Next AI route 的优先分支 - [x] 删除 Rust `compat` 中代理到 legacy Next AI route 的优先分支
- [x] 删除 Rust `compat` 中 direct Hermes 分支 - [x] 删除 Rust `compat` 中 direct Hermes 分支
- [x] 删除 Rust `compat` 中 document AI orchestrator fallback 分支 - [x] 删除 Rust `compat` 中 document AI orchestrator fallback 分支
- [x] 统一只保留 `mnote-cli` host 作为默认主执行入口 - [x] `/api/ai-agent/run` 不再作为页面 AI 主执行入口;历史 `mnote-cli` host 只保留为兼容/内部适配,不再代表长期页面 AI 主线
- [x] 次要 provider 请求改为明确失败,不再静默降级 - [x] 次要 provider 请求改为明确失败,不再静默降级
### 4.3 legacy Next compat / alias / debug 壳 ### 4.3 legacy Next compat / alias / debug 壳
@@ -133,7 +138,7 @@
### 8.2 必须消失 ### 8.2 必须消失
- [x] 默认运行时不再自动触发 `BlockNote` fallback - [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] 默认 gateway 不再把 legacy Next 当作未迁路径的总兜底
- [x] `/api/documents/page` 不再在 runtime 中组装 TS fallback projection - [x] `/api/documents/page` 不再在 runtime 中组装 TS fallback projection
@@ -120,6 +120,8 @@
### 3.2 Hermes / AI tool registry 归属仍需继续收口 ### 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` 当前前端 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` - 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 正式入口 -Hermes 持有 session / run / tool event / usage / model 编排
- Rust `bridge-runtime` 作为内核语义执行层 - Rust `bridge-runtime` 作为 mnote 业务语义执行层,经 Hermes skill/plugin 暴露
- `3000` 只负责页面与同源 API 边界,不再额外绕回 `3104` - `3000` 只负责页面与同源 API 边界,不再额外绕回 `3104`
当前状态: 当前状态:
@@ -47,7 +47,7 @@
- [x] `Phase 2` 文档阅读态分离已经有实质代码 - [x] `Phase 2` 文档阅读态分离已经有实质代码
- [x] `Phase 3` 主 Sidebar 已出现 `kernelSidebarTree` 消费接缝 - [x] `Phase 3` 主 Sidebar 已出现 `kernelSidebarTree` 消费接缝
- [x] `Phase 4` 搜索已做 host/runtime 拆分,并开始返回 `nodeId` / `subtreeRootId` / `evidence` - [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 6` Mindmap 独立页已去掉 `editorStub` 主入口
- [x] `Phase 7` 阅读态已直接消费 `pageSubtree``BlockNote` 也已不是文档页默认唯一入口 - [x] `Phase 7` 阅读态已直接消费 `pageSubtree``BlockNote` 也已不是文档页默认唯一入口
@@ -107,7 +107,7 @@
| Phase 2 | 文档阅读页 server-first 化 | `PARTIAL` 接近 `DONE` | 阅读态/编辑态已明显分离,但仍有旧链回退和大量客户端状态集中在 `DocumentContent` | | Phase 2 | 文档阅读页 server-first 化 | `PARTIAL` 接近 `DONE` | 阅读态/编辑态已明显分离,但仍有旧链回退和大量客户端状态集中在 `DocumentContent` |
| Phase 3 | Sidebar / 树结构 Rust 化 | `PARTIAL` 偏早期 | 主 Sidebar 已以 `kernelSidebarTree` 作为主树来源,但整体仍是超大客户端组件 | | Phase 3 | Sidebar / 树结构 Rust 化 | `PARTIAL` 偏早期 | 主 Sidebar 已以 `kernelSidebarTree` 作为主树来源,但整体仍是超大客户端组件 |
| Phase 4 | 搜索 Rust 化与 island 化 | `PARTIAL` | host/runtime 懒加载拆分已做,结果形状也开始带 `nodeId` / `subtreeRootId` / `evidence`,但仍未完成 server-first 搜索页 | | 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 6 | Mindmap 独立对象化 | `PARTIAL` | 独立页脱离 editor stub 主入口,并补出 `standalone` / `documentBridge` 边界,但仍是客户端重壳 |
| Phase 7 | BlockNote 孤岛化 | `PARTIAL` | 阅读态已直接消费 `pageSubtree`,编辑器按需挂载,但外围 drawer/panel 仍集中在同一内容组件 | | 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,旧链彻底删除仍待后续 | | 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 5AI 面板进一步收口为 `mnote-cli` host / 纯桥接 island ## 10. Phase 5AI 面板收口为 Hermes 页面内客户端 / mnote plugin bridge
**当前状态:`PARTIAL`** **当前状态:`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 已落地事实 ### 10.1 已落地事实
- [x] 文档页 AI 已拆为 host + runtime - [x] 文档页 AI 已拆为 host + runtime
@@ -301,24 +306,24 @@
- [DocumentAiAgentPanel.runtime.tsx](/mnt/Data1T/mnote/wolai-frontend/src/components/editor/DocumentAiAgentPanel.runtime.tsx) - [DocumentAiAgentPanel.runtime.tsx](/mnt/Data1T/mnote/wolai-frontend/src/components/editor/DocumentAiAgentPanel.runtime.tsx)
- [x] Mindmap / OnlyOffice 也有类似 runtime 拆分 - [x] Mindmap / OnlyOffice 也有类似 runtime 拆分
- [x] `GlobalAiAgentHost` 已不在 `(app)/layout.tsx` 主布局中挂载 - [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) - [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) - [route.ts](/mnt/Data1T/mnote/wolai-frontend/src/app/api/ai-agent/run/route.ts)
### 10.2 当前还没完成 ### 10.2 当前还没完成
- [ ] runtime 组件仍然非常重 - [ ] runtime 组件仍然非常重
- [ ] “最小会话协议、统一流式协议、页面上下文协议”还更多体现在组件内部,不是系统级协议层 - [ ] “最小会话协议、统一流式协议、页面上下文协议”还更多体现在组件内部,不是 Hermes client proxy / plugin tool contract
- [ ] 还不能说 AI 面板已经变成“纯桥接壳 - [ ] 还不能说 AI 面板已经变成“只调用 Hermes 的页面内客户端
- [ ] 页面级 AI adapter 仍然很大,只是改成了懒加载 - [ ] 页面级 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 后续任务 ### 10.3 v2 后续任务
- [ ] 继续下沉 runtime 内状态与协议 - [ ] 移除页面 AI 对旧 `/api/ai-agent/run` 主路径的长期依赖,改走正式 Hermes client proxy
- [ ] 把会话、tool event、client action 收口到共享协议层 - [ ] 把会话、message、tool event、usage、model 选择交给 Hermes session 存储
- [ ] 让 runtime 再减重,而不只是拆文件 - [ ] 把 mnote 页面、树、artifact、edge 能力注册为 Hermes skill/plugin tools
- [ ] 把当前上下文注入进一步收口为稳定 kernel node / subtree / edge bridge - [ ] 把当前上下文注入进一步收口为 Hermes run context + 稳定 kernel node / subtree / edge tool bridge
--- ---
@@ -25,7 +25,7 @@
- Rust 内核和 Web 承载层如何分工 - Rust 内核和 Web 承载层如何分工
- 前端哪些部分应该退出当前重 React 壳 - 前端哪些部分应该退出当前重 React 壳
- `axum``Leptos` 这类 Rust Web 方案是否值得采用 - `axum``Leptos` 这类 Rust Web 方案是否值得采用
- `mnote-cli` 应如何成为唯一长期 agent 执行面 - 页面 AI 应如何作为 Hermes 页面内客户端接入,并让 mnote 通过 Hermes skill/plugin 暴露业务能力
- 默认主编辑器已经切到页面内 `leptos-tiptap` island 后,剩余重编辑兼容链应如何继续收口 - 默认主编辑器已经切到页面内 `leptos-tiptap` island 后,剩余重编辑兼容链应如何继续收口
--- ---
@@ -57,7 +57,8 @@
- **业务执行面:现有 Rust workspace 继续作为唯一业务真执行面** - **业务执行面:现有 Rust workspace 继续作为唯一业务真执行面**
- **Web 承载层:`axum`** - **Web 承载层:`axum`**
- **页面渲染模型:`Leptos Islands`** - **页面渲染模型:`Leptos Islands`**
- **AI 执行面:`mnote-cli`** - **AI 会话与编排面:Hermes**
- **mnote AI 能力面:Hermes skill/plugin -> Rust runtime / kernel**
- **最后保留的重编辑兼容孤岛:少量编辑器 runtime(当前默认主编辑器已是页面内 `leptos-tiptap` island`BlockNote` 仅保留为 `recycle/` 历史参考副本)** - **最后保留的重编辑兼容孤岛:少量编辑器 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 外壳承载层 - 页面 SSR 外壳承载层
- 流式更新、通知、事件推送层 - 流式更新、通知、事件推送层
@@ -413,14 +414,15 @@ Leptos Islands 很适合承接下面这类长期目标:
目标: 目标:
- 继续桥接统一 CLI host - 作为 Hermes 页面内客户端运行
- 不再承担前端本地 orchestration - 不再承担前端本地 orchestration 或私有会话存储
- 不再成为常驻大壳的一部分 - 不再成为常驻大壳的一部分
归位: 归位:
- 面板只保留最小 UI 与上下文桥接 - 面板只保留 Hermes chat/session/run/tool event 的页面内子集 UI 与当前页面上下文桥接
- 工具执行全部走 `mnote-cli` + Rust tools - Hermes 持有 session / message / usage / model 真相
- mnote 通过 Hermes skill/plugin 暴露页面、树、artifact、edge 工具,最终执行回到 Rust runtime / kernel
- 面板按页面需要懒挂载成单独 island - 面板按页面需要懒挂载成单独 island
### 9.5 Mindmap ### 9.5 Mindmap
@@ -5,6 +5,7 @@
> 状态说明: > 状态说明:
> - 本稿对应的页面设置 `popover`、页面 AI `drawer`、入口位置与最小接线方案已在当前 `mnote-web` 文档壳中落地,故迁入 `done/` > - 本稿对应的页面设置 `popover`、页面 AI `drawer`、入口位置与最小接线方案已在当前 `mnote-web` 文档壳中落地,故迁入 `done/`
> - 本稿中的 deferred 项继续由后续独立任务推进,不影响这一轮“页面设置 + 页面 AI 交互壳”完成判定 > - 本稿中的 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` > - `/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-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-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/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` > - `/mnt/Data1T/mnote/design/08-wolai-aline-test-flow/process/wolai-aline-test-flow-v1.md`
## 1. 文档目的 ## 1. 文档目的
@@ -28,7 +29,7 @@
1. 页面设置入口与面板形态 1. 页面设置入口与面板形态
2. 页面级 AI 入口与右侧抽屉 2. 页面级 AI 入口与右侧抽屉
3. 两者与现有 `Page Aggregate` / `mnote-cli` 主线的接线方式 3. 两者与现有 `Page Aggregate` / Hermes 面板主线的接线方式
一句话收口: 一句话收口:
@@ -45,7 +46,7 @@
- 页面设置 UI 已存在:`PageOptionsSidebar` - 页面设置 UI 已存在:`PageOptionsSidebar`
- 页面设置写链已存在:`page.layout.updateOptions` - 页面设置写链已存在:`page.layout.updateOptions`
- 页面 AI host 已存在:`DocumentAiAgentPanel` - 页面 AI host 已存在:`DocumentAiAgentPanel`
- AI 执行面已收口到 `mnote-cli` host / client - 历史实现已具备 `mnote-cli` host / client 最小返回链;后续应替换为 Hermes 页面内客户端
- 评论 drawer、历史 drawer、分享 dialog 都已有各自最小实现 - 评论 drawer、历史 drawer、分享 dialog 都已有各自最小实现
但当前现网文档页仍然存在两个明显错位: 但当前现网文档页仍然存在两个明显错位:
@@ -141,14 +142,15 @@
> **把现有设置项放进更接近 Wolai 的容器,同时继续显式区分“已正式接通”和“仅保存字段/待接通”。** > **把现有设置项放进更接近 Wolai 的容器,同时继续显式区分“已正式接通”和“仅保存字段/待接通”。**
### 4.2 页面 AI 继续服从 `CLI-first` ### 4.2 页面 AI 继续服从 Hermes 面板主线
页面 AI 界面可以继续做产品壳,但它不能重新变成独立执行面。 页面 AI 界面可以继续做产品壳,但它不能重新变成独立执行面。
当前长期方向已经明确 当前长期方向已经改为
- `mnote-cli` 是唯一长期 agent 执行面 - Hermes 是 AI session/message/tool event/usage/model 的会话真相
- Web AI 面板只是 host / client - Web AI 面板只是页面内 Hermes client
- mnote 通过 Hermes skill/plugin 暴露页面、树、artifact、edge 等业务工具
因此本轮 AI 界面改造只处理: 因此本轮 AI 界面改造只处理:
@@ -326,7 +328,7 @@ Wolai 里页面设置和 AI 的第一感受,首先是:
本轮不改: 本轮不改:
- `mnote-cli` 执行链 - 历史 `mnote-cli` 执行链
- 工具注册表 - 工具注册表
- 页面 AI 读写命令族 - 页面 AI 读写命令族
- AI 输出协议 - AI 输出协议
@@ -510,11 +512,11 @@ Rust `mnote-web` 当前在文档壳中已有顶栏占位按钮与右下角浮动
### 9.3 页面 AI 重复造轮子的风险 ### 9.3 页面 AI 重复造轮子的风险
如果为了像 Wolai 而新做一层 AI drawer runtime,会直接偏离 `CLI-first` 如果为了像 Wolai 而在 mnote 里新做一层独立 AI drawer runtime,会直接偏离当前 Hermes 面板主线
本轮约束: 本轮约束:
> **页面 AI 只换壳,不换执行面。** > **页面 AI 交互壳可继续沿用本轮成果,执行主线后续切到 Hermes client proxy + mnote plugin。**
### 9.4 范围失控风险 ### 9.4 范围失控风险
@@ -535,11 +537,11 @@ Rust `mnote-web` 当前在文档壳中已有顶栏占位按钮与右下角浮动
- 页面设置入口、容器、关闭语义已和 Wolai 基线一致 - 页面设置入口、容器、关闭语义已和 Wolai 基线一致
- 页面 AI 入口、容器、关闭语义已和 Wolai 基线一致 - 页面 AI 入口、容器、关闭语义已和 Wolai 基线一致
- 页面设置仍继续走 `page.layout.updateOptions` - 页面设置仍继续走 `page.layout.updateOptions`
- 页面 AI 仍继续走 `DocumentAiAgentPanel -> mnote-cli` - 页面 AI 交互壳保留右下角入口 + 右侧抽屉;后续执行主线切到 Hermes client proxy + mnote plugin
- 没有新增第二套页面设置真相 - 没有新增第二套页面设置真相
- 没有新增第二套页面 AI 执行面 - 没有新增第二套页面 AI 执行面
- 评论、协作、成员、演示模式没有被混入首批范围 - 评论、协作、成员、演示模式没有被混入首批范围
本轮完成后的正确口径应是: 本轮完成后的正确口径应是:
> **文档页“页面设置”和“AI 界面”的交互壳开始按 Wolai 收口,但页面域单一真源与 CLI-first AI 的主线保持不变。** > **文档页“页面设置”和“AI 界面”的交互壳开始按 Wolai 收口页面域单一真源保持不变,AI 长期主线改为 Hermes 面板 + mnote plugin。**
@@ -5,6 +5,7 @@
> 状态说明: > 状态说明:
> - 本清单覆盖的页面设置 A/B、页面 AI C、集成护栏 D 均已完成并有 `task160/161/162` 证据,故迁入 `done/` > - 本清单覆盖的页面设置 A/B、页面 AI C、集成护栏 D 均已完成并有 `task160/161/162` 证据,故迁入 `done/`
> - `X1-X6` 属于明确 deferred 项,不构成这轮完成阻塞 > - `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` > - `/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` 主入口 | | C4 | GREEN | 顶栏 `页面AI` 降级 | `task161` 已断言顶栏不再保留 `页面AI` 主入口 |
| C5 | GREEN | 页面 AI 容器类型 | `task161` 已断言页面 AI 以右侧抽屉打开、无遮罩、不跳新页面 | | C5 | GREEN | 页面 AI 容器类型 | `task161` 已断言页面 AI 以右侧抽屉打开、无遮罩、不跳新页面 |
| C6 | GREEN | 页面 AI 关闭语义 | `task161` 已断言 `Esc`、点击空白无效,右上角 `X` 有效 | | C6 | GREEN | 页面 AI 关闭语义 | `task161` 已断言 `Esc`、点击空白无效,右上角 `X` 有效 |
| C7 | GREEN | 页面 AI 页面级绑定 | `task161` 已断言 `/api/ai-agent/run` 请求体携带当前 `documentId``pageOptions` | | 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` 已验证真实页面可返回文本结果 | | 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 可见 | | C9 | GREEN | AI chrome 与 Wolai 接近 | `task161` 已断言标题、输入区、`新会话 / 历史会话 / mnote-cli` chrome 可见;后续文案应改为 Hermes session / model / history |
### 6.1 本阶段 smoke 建议 ### 6.1 本阶段 smoke 建议
@@ -286,8 +287,8 @@ GREEN
- `Esc` 和点击空白不会关闭 - `Esc` 和点击空白不会关闭
- 右上角关闭按钮可关闭 - 右上角关闭按钮可关闭
- 抽屉标题、输入区、会话/模型 chrome 可见 - 抽屉标题、输入区、会话/模型 chrome 可见
- 发送动作继续命中 `/api/ai-agent/run` - 发送动作在历史实现中继续命中 `/api/ai-agent/run`
- 请求体继续携带当前 `documentId``pageOptions` - 历史请求体继续携带当前 `documentId``pageOptions`;后续应转为 Hermes run/session context
新增证据: 新增证据:
@@ -306,6 +307,7 @@ GREEN
- 同时补了认证头显式转发,避免被 Next 侧 307 `/auth` 拦截 - 同时补了认证头显式转发,避免被 Next 侧 307 `/auth` 拦截
- 页面 AI drawer 默认 provider 改为 `hermes` - 页面 AI drawer 默认 provider 改为 `hermes`
- 新增 `task162-wolai-page-ai-real-response-smoke.js`,并在真实后端端口 `41327` 上验证:页面 AI 不再显示 `page_ai_failed_502` / “没有返回文本结果”,而是会回填真实文本 - 新增 `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 真实返回执行记录 ### 6.3 task162 页面 AI 真实返回执行记录
@@ -325,7 +327,7 @@ GREEN
| ID | 状态 | 任务 | 验收要点 | | ID | 状态 | 任务 | 验收要点 |
| --- | --- | --- | --- | | --- | --- | --- | --- |
| D1 | GREEN | 不新造页面设置真相 | 页面设置继续围绕 `currentPageOptions -> /api/documents/options -> page.layout.updateOptions` | | 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 | | D3 | GREEN | 文档页 owner 行为合同固定 | `task160/task161` 已断言 owner 态页面设置和 AI 都不跳新页面、不改 URL |
| D4 | GREEN | mobile / 窄屏退化 | `task160/task161` 已断言移动视口下 popover / drawer 不超出视口 | | D4 | GREEN | mobile / 窄屏退化 | `task160/task161` 已断言移动视口下 popover / drawer 不超出视口 |
| D5 | GREEN | React compat 与 Rust SSR 边界 | 本轮实现仅落在 `mnote-web` Rust SSR 壳、脚本与样式;未再把入口回接 React compat | | D5 | GREEN | React compat 与 Rust SSR 边界 | 本轮实现仅落在 `mnote-web` Rust SSR 壳、脚本与样式;未再把入口回接 React compat |
@@ -355,9 +357,9 @@ GREEN
- 页面设置 `popover` 行为已通过本地 smoke 和 Wolai 基线复核 - 页面设置 `popover` 行为已通过本地 smoke 和 Wolai 基线复核
- 页面 AI 抽屉行为已通过本地 smoke 和 Wolai 基线复核 - 页面 AI 抽屉行为已通过本地 smoke 和 Wolai 基线复核
- 页面设置仍然围绕 `page.layout.updateOptions` - 页面设置仍然围绕 `page.layout.updateOptions`
- 页面 AI 仍然围绕 `/api/ai-agent/run -> mnote-cli` - 页面 AI 历史验证仍然围绕 `/api/ai-agent/run -> mnote-cli`;后续执行主线改为 Hermes client proxy + mnote plugin
- deferred 项没有被误报为已完成 - deferred 项没有被误报为已完成
本轮完成后的正确口径: 本轮完成后的正确口径:
> **页面设置与页面 AI 的交互壳已开始对齐 Wolai,评论、协作、成员、演示模式仍是后续独立任务。** > **页面设置与页面 AI 的交互壳已开始对齐 Wolai;AI 后续执行主线改为 Hermes 面板 + mnote plugin,评论、协作、成员、演示模式仍是后续独立任务。**
@@ -1,6 +1,6 @@
# 5-5-1 [done] Page Aggregate Contract v1 # 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` > - `/mnt/Data1T/mnote/design/05-editor-mainline/process/5-5-page-aggregate-single-truth-alignment-v1.md`
@@ -92,13 +92,13 @@
说明: 说明:
- 这层不是“页面设置面板 UI state”,而是页面设置的持久化真相。 - 这层不是“页面设置面板 UI state”,而是页面设置的持久化真相。
- 其中只有一部分已经进入 `leptos-tiptap` island 运行时 - 以下字段已经进入 `leptos-tiptap` island runtime payload
- `wideLayout` - `wideLayout`
- `smallText` - `smallText`
- `layoutDensity` - `layoutDensity`
- 下面两项当前只完成字段贯通,不可描述成“正式支持”:
- `showHeadingNumbers` - `showHeadingNumbers`
- `embedDefaultBlockId` - `embedDefaultBlockId`
- 其中 `showHeadingNumbers` / `embedDefaultBlockId` 已进入 runtime payload,但深层语义仍是阶段性桥接:前者还不能描述成完整 heading 编号渲染闭环,后者还不能描述成完整嵌入默认落点闭环。
### 2.4 `page_body` ### 2.4 `page_body`
@@ -152,6 +152,24 @@
- 统计不是页面身份,也不是布局或正文真相,但它是 page aggregate 的附属部分。 - 统计不是页面身份,也不是布局或正文真相,但它是 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. 哪些字段是 aggregate truth,哪些只是 UI state
### 3.1 Aggregate Truth ### 3.1 Aggregate Truth
@@ -194,7 +212,7 @@
- 标题输入中的 debounce 缓冲态 - 标题输入中的 debounce 缓冲态
- 正文 host 内的未保存 dirty 态 - 正文 host 内的未保存 dirty 态
- 只影响单次交互的面板/菜单开关 - 只影响单次交互的面板/菜单开关
- `showHeadingNumbers/embedDefaultBlockId` 的“字段已贯通,但深语义未完成”阶段性桥接逻辑 - `showHeadingNumbers/embedDefaultBlockId` 的“runtime payload 已贯通,但深语义未完成”阶段性桥接逻辑
约束: 约束:
@@ -222,11 +240,11 @@
- 树标题与页头标题已经证明消费同一份更新后的 projection - 树标题与页头标题已经证明消费同一份更新后的 projection
- AI 写入口已经完全脱离 editor bridge,正式执行 page body command - AI 写入口已经完全脱离 editor bridge,正式执行 page body command
- `showHeadingNumbers` 真正进入 heading 渲染语义 - `showHeadingNumbers` 完整进入 heading 编号渲染语义
- `embedDefaultBlockId` 真正进入嵌入默认落点逻辑 - `embedDefaultBlockId` 完整进入嵌入默认落点逻辑
## 7. 当前可对外口径 ## 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 - 页面本地 aggregate state reducer
- preferred sidebar snapshot / 页头标题补偿链 - 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 - `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,写入命令面已开始收口”勾选完成。 补充:当前 `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 退出标准 ### 4.3 退出标准
- [x] 后续再讨论标题、页面设置、正文保存时,能够直接定位到它属于 `page_head / page_layout / page_body / page_tree` 的哪一层。 - [x] 后续再讨论标题、页面设置、正文保存时,能够直接定位到它属于 `page_head / page_layout / page_body / page_tree` 的哪一层。
@@ -113,6 +115,8 @@
补充:当前首屏 SSR 与客户端内容重试补拉都已经直接走 `/api/page-aggregate/:id -> PageAggregateProjection`,因此“页面明显由 `meta + content` 两次查询拼起来”的入口级痕迹已经消失;但新增字段仍可能需要继续补 loader / route / island 消费链,所以第二项继续保留未完成。 补充:当前首屏 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` 补充:本轮已新增 `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] 对未支持项给出显式降级说明,而不是仅保存字段。
- [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` 补充:本轮继续把 Inspector 产品态与这份分类对齐:`showHeadingNumbers / embedDefaultBlockId` 已按“正式接通项”更新文案,不再继续显示“已保存字段但未接通”;`showBlockRefCount` 作为纯占位项也不再继续暴露可点击的假开关,而是明确显示 `待接线`。对应最小回归测试为 `page-options-sidebar.test.tsx``page-option-semantics.test.ts`
### 6.4 退出标准 ### 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,因此这里仍然只算“开始收口”,不提前打满。 补充:本轮继续把 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 与主编辑区的关系 ### 8.2 与主编辑区的关系
@@ -240,27 +244,27 @@
补充:进入这一步后,当前最大的真实 blocker 已经明确下来,不再继续靠口头描述模糊处理: 补充:进入这一步后,当前最大的真实 blocker 已经明确下来,不再继续靠口头描述模糊处理:
- `/api/ai-agent/run` 当前默认主路仍是 `mnote-cli host` - 页面 AI 历史实现仍残留 `/api/ai-agent/run` / `mnote-cli host` 路径
- `mnote-cli host` 目前没有真正的模型/tool 执行环,而是 `cargo ... tool run --tool-name doc_get --mode explain-plan` 的说明型入口 - Hermes 页面内客户端、Hermes session 真相和 mnote skill/plugin tool surface 还没有在页面 AI 主链落地
- 因此,当前新增的 `page_options_patch -> page.layout.updateOptions -> structured tool_result` 仍然只是“主路内的最小结构化兼容分支”,不是完整正式 tool surface - 因此,当前新增的 `page_options_patch -> page.layout.updateOptions -> structured tool_result` 仍然只是“历史主路内的最小结构化兼容分支”,不是完整正式 Hermes tool surface
这意味着 `8.2` 后续完成标准必须至少包含: 这意味着 `8.2` 后续完成标准必须至少包含:
1. 页面设置进入真正的 agent/tool 执行环,而不是继续靠 host 内部的自然语言/最小规则分支识别 1. 页面设置进入 Hermes tool call -> mnote plugin -> Rust runtime 的正式执行环,而不是继续靠 host 内部的自然语言/最小规则分支识别
2. 页面设置结构化结果由正式 tool 调用产出,而不是只由 host 自己补一条兼容 `tool_result` 2. 页面设置结构化结果由正式 mnote plugin tool 调用产出,而不是只由 host 自己补一条兼容 `tool_result`
3. 树标题 / 页头 / 页面设置三条 AI 写回链在同一条正式 page aggregate command family 中闭环,并补 smoke 验证 3. 树标题 / 页头 / 页面设置三条 AI 写回链在同一条正式 page aggregate command family 中闭环,并补 smoke 验证
### 8.3 退出标准 ### 8.3 退出标准
- [x] AI 写入口已经可以被明确描述为“操作 page aggregate command family”,而不是“绕过系统写编辑器”。 - [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。 - `doc_insert_blocks / doc_replace_range` 继续按 `page.body.save` 语义落到 `/api/documents/save`,再正式回显主编辑区 island。
- `slash_run(rename current page)` 会把结构化结果回接到当前页 `DocumentContent` 的同一条标题提交链,并继续广播 `emitDocumentsChanged(documentId)`,因此页头标题与树标题不再靠 AI 面板内部本地状态各自漂移。 - `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` - 当前 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` 事件;这意味着“服务端完全没有页面设置结构化写回结果”的状态已经结束。 - 历史 `mnote-cli host` 现已能在命中页面设置 patch 时直接执行 `page.layout.updateOptions`,并向前端回放结构化 `tool_call/tool_result` 事件;这意味着“服务端完全没有页面设置结构化写回结果”的状态已经结束。
-`pageOptions` 仍没有进入 Hermes 正式 tool surface,当前服务端 patch 识别也仍是最小规则分支而不是完整模型工具编排,因此“页面设置类 AI 命令”仍不能算整条线已闭环;`8.2` 的最后一项继续保留未完成,避免误判为整条线已经闭环。 -`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 执行前口径纠偏: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 执行边界:实现与 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 写临时 classRust 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 视觉细节、块引用/页面引用预览与移动端行为另拆后续。 - 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 写临时 classRust 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 编辑能力包不是“补 AI 菜单文案”或“点击后显示 feedback”。最小闭环必须从 `leptos-tiptap` 的 slash / 块菜单入口出发,携带当前 `documentId``workspaceId`、Rust `blockId`、selection 摘要和 Tiptap JSON 快照,进入 Hermes run/session contextHermes 通过 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`。剩余:Wolai AI 菜单视觉基线断流未完成、slash AI/selection toolbar AI、Improve/continue/regenerate/summarize/translate 子命令、SSE 分段 UI 状态、`doc_insert_blocks` 多块插入和标题 `slash_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 pluginWolai 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 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 / EmojiE27 暂停在 online smoke 模型网关层排障状态。当前已确认 `3000 -> 8000` orchestrator 主链可达,本地 `task155/task156` 通过;剩余在线阻塞点在 `20128 /v1/responses` 上游模型/渠道可用性,以及后续真实 actor 收口。恢复 E27 时应从这两点继续,不要回退成“主入口未迁完”。 - E27 当前推进备注:2026-05-03 主线程先切到 E28 Mention / EmojiE27 暂停在 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 与层级收起,不扩新业务命令。 - 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、失败态 | | 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/commandslash `页面目录 /toc` 插入真实节点,NodeView 从当前 headings 派生目录项,支持点击定位、标题显示开关、保存请求、content API 与 reload 恢复;`task153-e25-toc-smoke.js` 覆盖入口、动态 heading 更新、hash 定位和 `props.tiptapTocNode`。剩余 TOC sidebar、active heading 高亮、完整块菜单入口、官方 TableOfContents v3 数据源评估、移动端与像素级视觉 | | E25 | PARTIAL | TOC 能力包 | 已完成 `/toc` 最小真源闭环:`leptos-tiptap` 新增真实 `tocNode` schema/commandslash `页面目录 /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 视觉细节、引用预览和移动端行为。 | | 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 先切 E28E27 暂停在 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 proxyHermes 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 先切 E28E27 暂停在 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 保存链 | | E28 | PAUSED | Mention / Emoji 能力包 | 2026-05-03 暂停;Wolai baseline 已纠偏:正文 `@` 当前是提醒/会议/成员候选入口,不是页面引用搜索,恢复时先重定 mention/emoji 口径,再决定 Tiptap JSON 与 mnote 保存链 |
| E29 | PAUSED | Comment / History 能力包 | 2026-05-03 暂停;评论/历史入口仍需后端边界和 Wolai 基线复核,待 E30 统一菜单状态机后再接入,避免继续复制独立弹层逻辑 | | 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 toolbarWolai 只读基线已确认 selection/type menu 和 table popper 的 Esc/外部点击/URL 不变,slash 与块菜单二级菜单在只读条件下不稳定,后续仍需 editable-test 复核 Enter、外部点击和更多层级收起 | | E30 | PARTIAL | Menu / Floating 状态机能力包 | 2026-05-03 第一刀已完成本地统一状态机 smoke:`task158` 覆盖 slash、块菜单/二级菜单、selection toolbar/color panel、image floating toolbar、table toolbar/options 的互斥打开、Esc 统一关闭、方向键 active,以及打开块菜单时关闭 image toolbarWolai 只读基线已确认 selection/type menu 和 table popper 的 Esc/外部点击/URL 不变,slash 与块菜单二级菜单在只读条件下不稳定,后续仍需 editable-test 复核 Enter、外部点击和更多层级收起 |
@@ -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 > 日期:2026-05-11
> >
@@ -10,7 +10,8 @@
> 状态说明: > 状态说明:
> - 本稿对应 `Phase 7 v2` 的“文档页 AI 最小闭环”已完成,故迁入 `done/` > - 本稿对应 `Phase 7 v2` 的“文档页 AI 最小闭环”已完成,故迁入 `done/`
> - 本稿完成不等于整个 `Phase 7 v2` 已完成;结构化知识写链仍以后续阶段继续推进 > - 本稿完成不等于整个 `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 追加说明: > 2026-05-05 追加说明:
> - 本稿的对象模型、artifact 写链与 kernel 边界仍然有效 > - 本稿的对象模型、artifact 写链与 kernel 边界仍然有效
> - 触发与执行口径被 `/mnt/Data1T/mnote/design/07-ai/process/7-phase7-ai-kernel-projection-plan-v4.md` 覆盖 > - 当时触发与执行口径被 `/mnt/Data1T/mnote/design/old/07-ai/process/7-phase7-ai-kernel-projection-plan-v4.md` 覆盖
> - 当前凡是提到“页面 AI 面板触发”的位置,都应理解为“页面 AI 面板作为 `mnote-cli` host 触发”,而不是独立内置编排主线 > - 该 `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` - `ai_note node`
- `reference edge` - `reference edge`
2. 只允许围绕**当前文档页**创建,不做跨页、跨工作区写链 2. 只允许围绕**当前文档页**创建,不做跨页、跨工作区写链
3. 触发方式不是自然语言,不做自动推断,只允许页面 AI host 暴露的固定动作触发;当前页面 AI 面板只是 `mnote-cli` host 的一个页面内入口 3. 第一版允许页面 AI 面板提供固定快捷入口,但快捷入口本质是向 Hermes 发送意图;Hermes 通过 mnote skill/plugin 调用 artifact 工具,不再由页面 AI host 或 `mnote-cli` host 私有触发
4. 点击按钮后直接创建,不走“先预览再确认”的两阶段流 4. 点击按钮后直接创建,不走“先预览再确认”的两阶段流
5. `summary node` 默认单例覆盖更新;`ai_note node` 每次新建 5. `summary node` 默认单例覆盖更新;`ai_note node` 每次新建
6. 两者都落成**可编辑的页面型节点** 6. 两者都落成**可编辑的页面型节点**
@@ -278,21 +284,25 @@
## 6. 触发方式与产品入口 ## 6. 触发方式与产品入口
### 6.1 允许固定按钮触发 ### 6.1 允许固定按钮触发,但必须经过 Hermes
第一版不走自然语言触发。 第一版允许页面 AI 面板提供两个固定快捷按钮:
不支持:
- “帮我顺手创建一个摘要节点”
- “看起来像摘要就自动生成”
- “只要语义明确就直接落库”
只支持页面 AI host 面板里的两个固定按钮:
- `创建 Summary` - `创建 Summary`
- `创建 AI Note` - `创建 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 点击后直接创建 ### 6.2 点击后直接创建
第一版点击按钮后,直接创建,不走预览确认。 第一版点击按钮后,直接创建,不走预览确认。
@@ -307,6 +317,7 @@
- 正式写链成立 - 正式写链成立
- kernel node / edge 成立 - kernel node / edge 成立
- 文件树 projection 成立 - 文件树 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 会话归 Hermesmnote 能力归 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 CLeptos Hermes 面板最小子集
- 实现 session 创建 / 恢复。
- 实现 run / stream / abort。
- 实现 message / reasoning / tool event 展示。
- 保留 Wolai 右侧抽屉壳。
### Phase Dmnote 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/pluginOnlyOffice 仍是独立页面型编辑器边界,SiYuan 参考稿没有发现被提升为上位架构来源的证据。
主要风险集中在三处:OnlyOffice 在 `mnote-web` 主入口里的 callback/forcesave 仍是 no-op,而 legacy Next route 已有真实写回链;Mindmap 的导出 action 在 schema/action map 中暴露,但 bridge 安全命令集不支持;Mindmap AI 补完 route 当前硬返回 501,和 Rust tool 已登记的能力面不闭合。 主要风险集中在三处: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-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-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-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 proxymnote 能力通过 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` 和主线设计为上位依据。 | | 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 ### AI
- CLI-first 入口基本对齐:`wolai-frontend/src/app/api/ai-agent/run/route.ts:36-57` 将默认执行入口限定到 `mnote-cli host`,拒绝旧 provider。 - CLI-first 入口基本对齐:`wolai-frontend/src/app/api/ai-agent/run/route.ts:36-57` 将默认执行入口限定到 `mnote-cli host`,拒绝旧 provider;但该口径已被 2026-05-13 的 Hermes 面板主线覆盖
- 结构化 artifact 设计仍有未完成项:`design/07-ai/process/7-phase7-ai-kernel-projection-plan-v4.md` 中“后续权限收口”仍未勾选,说明 AI 写链权限模型尚未完全闭环 - 结构化 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;这可作为外置 agent,但需要避免被误认为长期默认主编排。 - 旧后端 agent 仍存在:`wolai-backend/app/routers/ai_agent.py:47-57` 仍提供 streaming run;这只能作为历史/兼容/对照链,不能被误认为长期默认主编排。
### OnlyOffice ### OnlyOffice
@@ -55,8 +55,8 @@
1. P1:补齐或明确代理 OnlyOffice Rust callback/forcesave 写回链,避免主入口下保存成功但内容未持久化。 1. P1:补齐或明确代理 OnlyOffice Rust callback/forcesave 写回链,避免主入口下保存成功但内容未持久化。
2. P2:收口 Mindmap action 可用性,先修 `export` 映射与安全命令不一致,再继续扩展 UI 能力。 2. P2:收口 Mindmap action 可用性,先修 `export` 映射与安全命令不一致,再继续扩展 UI 能力。
3. P2:处理 Mindmap AI route:要么按 Rust bridge/CLI-first 重接 `mindmap_expand_node`,要么显式退役该 Next route。 3. P2:处理 Mindmap AI route:要么按 Hermes plugin / Rust bridge 重接 `mindmap_expand_node`,要么显式退役该 Next route。
4. P2:明确 `wolai-backend` AI agent 的外置/对照定位访问边界,避免与 `mnote-cli` 唯一执行面口径冲突。 4. P2:明确 `wolai-backend` AI agent、旧 `/api/ai-agent/run``mnote-cli host` 的历史/兼容定位访问边界,避免与 Hermes 面板 + mnote plugin 新主线冲突。
5. P3:清理 `design/90-reference` 的问答残留,并在引用规范里再次强调它不是 process/done 设计稿。 5. P3:清理 `design/90-reference` 的问答残留,并在引用规范里再次强调它不是 process/done 设计稿。
## 本次修改文件 ## 本次修改文件
+2
View File
@@ -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 背后 我基本同意你的方向,但要把“文件树为根源”说得更精确:不应让“文件树 UI 组件”成为真源,而应让 Rust kernel 中的 workspace resource tree / file tree projection 背后
的资源层级 成为页面、附件、mindmap、OnlyOffice 等对象的组织根源。页面树则是从这棵资源/对象树派生出的导航投影,类似快捷方式、收藏视图或文档视图。 的资源层级 成为页面、附件、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 artifactRust 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 emptySidebar/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] 增加负向 smokeConvex/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 datasetSidebar/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_streamstream 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 commandUI 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/`
+3
View File
@@ -2,6 +2,8 @@
本目录汇总当前设计与实现的偏差审查结果。四份分域报告分别覆盖 Rust kernel/web、前端编辑器与树体验、Convex/realtime/storage、次级域与设计治理。 本目录汇总当前设计与实现的偏差审查结果。四份分域报告分别覆盖 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) - [前端编辑器 / 树体验实现偏差审查](./02-frontend-editor-tree-review.md)
- [Convex / Realtime / Storage 实现偏差审查](./03-convex-realtime-storage-review.md) - [Convex / Realtime / Storage 实现偏差审查](./03-convex-realtime-storage-review.md)
- [次级域与设计治理实现偏差审查](./04-secondary-domains-and-design-governance-review.md) - [次级域与设计治理实现偏差审查](./04-secondary-domains-and-design-governance-review.md)
- [顺序执行清单与验收标准](./06-execution-checklist-and-acceptance.md)
## 统一判断 ## 统一判断
+1 -3
View File
@@ -79,6 +79,4 @@ yy - 复制,p - 粘贴
:q - 退出 :q - 退出
``` ```
如果需要将文件树组件集成到自己的Rust项目中,建议根据应用类型(终端/GUI)选择对应的组件库,或参考filetree的实现方式进行定制开发。 如果需要将文件树组件集成到 Rust 项目中,建议根据应用类型(终端/GUI)选择对应的组件库,或参考 filetree 的实现方式进行定制开发。
需要我给你一份在 Ratatui 中快速集成 tui-file-explorer 实现 VSCode 风格单面板文件树的最小可运行代码示例吗?
-2
View File
@@ -143,5 +143,3 @@ fn main() -> eframe::Result<()> {
### 七、总结 ### 七、总结
Rust生态中已有丰富的工具可构建Notion风格页面树,从底层数据结构到完整应用全覆盖。若需快速开发,可选择现成组件库或应用;若需高度定制,可基于树结构库+UI框架组合实现,充分发挥Rust的性能与安全优势。 Rust生态中已有丰富的工具可构建Notion风格页面树,从底层数据结构到完整应用全覆盖。若需快速开发,可选择现成组件库或应用;若需高度定制,可基于树结构库+UI框架组合实现,充分发挥Rust的性能与安全优势。
需要我给你一份在 Tauri 中结合 Rust 后端与前端实现可拖拽 Notion 风格页面树的最小可运行示例吗?
+2 -1
View File
@@ -1,6 +1,6 @@
# design 设计稿索引 # design 设计稿索引
> 更新时间:2026-05-11 > 更新时间:2026-05-14
> >
> 状态口径以当前仓库真实代码为准: > 状态口径以当前仓库真实代码为准:
> - `[done]`:对应阶段或收口目标已经在当前主线代码中成立 > - `[done]`:对应阶段或收口目标已经在当前主线代码中成立
@@ -51,6 +51,7 @@
- `90-reference/` - `90-reference/`
- 参考资料,不参与 `[done]/[process]/[recycle]` 状态判断 - 参考资料,不参与 `[done]/[process]/[recycle]` 状态判断
- 引用时只能作为生态资料或背景材料,不能覆盖 `ARCHITECTURE.md``AGENTS.md``01-05` 当前优先级或对应主线 `process/` / `done/` 设计稿
- `old/` - `old/`
- 已废弃或被替代的历史稿件,标题统一标记 `[recycle]` - 已废弃或被替代的历史稿件,标题统一标记 `[recycle]`
- 每个大类继续按 `process/``done/` 分层 - 每个大类继续按 `process/``done/` 分层
@@ -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-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/ARCHITECTURE.md`
> - `/mnt/Data1T/mnote/design/01-05-current-priority-overview.md` > - `/mnt/Data1T/mnote/design/01-05-current-priority-overview.md`
+157 -35
View File
@@ -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> { fn normalize_mindmap_from_value(data: &Value) -> Result<MindmapTreeNode, BridgeError> {
if data.is_null() { if data.is_null() {
return Ok(default_mindmap_tree()); return Ok(default_mindmap_tree());
@@ -8322,11 +8330,12 @@ fn execute_query_result(
} }
"page.aggregate.get" => { "page.aggregate.get" => {
let payload: PageAggregateQueryPayload = parse_payload(query_wire.payload)?; let payload: PageAggregateQueryPayload = parse_payload(query_wire.payload)?;
let source = page_aggregate_source_for_data(&data);
let result = build_page_aggregate_projection_result( let result = build_page_aggregate_projection_result(
&data, &data,
&payload.document_id, &payload.document_id,
payload.workspace_id.as_deref(), payload.workspace_id.as_deref(),
PageAggregateSource::KernelProjection, source,
)?; )?;
serde_json::to_value(result).map_err(|error| { serde_json::to_value(result).map_err(|error| {
BridgeError::transport(format!("page.aggregate.get result 序列化失败: {error}")) BridgeError::transport(format!("page.aggregate.get result 序列化失败: {error}"))
@@ -9032,6 +9041,22 @@ fn execute_command(
args_json: json!({ args_json: json!({
"id": payload.document_id, "id": payload.document_id,
"options": Value::Object(options_json), "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" => { "mindmaps.put" => {
let payload: MindmapPutCommandPayload = parse_payload(command_wire.payload.clone())?; 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 { let command = CommandEnvelope {
name: "mindmaps.put".into(), name: "mindmaps.put".into(),
command_id: command_wire.command_id.clone(), 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| { data_json: serde_json::to_string(&payload.data).map_err(|error| {
BridgeError::validation(format!("mindmaps.put data 序列化失败: {error}")) BridgeError::validation(format!("mindmaps.put data 序列化失败: {error}"))
})?, })?,
create_only: payload.create_only.unwrap_or(false), create_only,
}, },
reason: command_wire.reason, reason: command_wire.reason,
refs: command_wire.refs, refs: command_wire.refs,
@@ -9308,20 +9349,12 @@ fn execute_command(
"docId": payload.document_id.clone(), "docId": payload.document_id.clone(),
"mindmapId": payload.mindmap_id.clone(), "mindmapId": payload.mindmap_id.clone(),
"data": payload.data, "data": payload.data,
"createOnly": payload.create_only.unwrap_or(false), "createOnly": create_only,
"streamDeltaHint": tree_stream_delta_hint("resync_required", json!({ "streamDeltaHint": stream_delta_hint.clone(),
"reason": "mindmap.put", "domainEventHint": tree_domain_event_hint(event_type),
"documentId": payload.document_id.clone(),
"blockId": payload.mindmap_id.clone(),
})),
"domainEventHint": tree_domain_event_hint("tree.resource.mindmap.put"),
"domainEventPlan": tree_domain_event_plan( "domainEventPlan": tree_domain_event_plan(
"tree.resource.mindmap.put", event_type,
tree_stream_delta_hint("resync_required", json!({ stream_delta_hint,
"reason": "mindmap.put",
"documentId": payload.document_id.clone(),
"blockId": payload.mindmap_id.clone(),
})),
), ),
}), }),
})) }))
@@ -9364,19 +9397,11 @@ fn execute_command(
"commands": payload.commands, "commands": payload.commands,
"projectionRevision": payload.projection_revision, "projectionRevision": payload.projection_revision,
"canonicalCommand": "mindmap.command.apply", "canonicalCommand": "mindmap.command.apply",
"streamDeltaHint": tree_stream_delta_hint("resync_required", json!({ "streamDeltaHint": tree_stream_delta_hint("noop", json!({})),
"reason": "mindmap.command.apply", "domainEventHint": tree_domain_event_hint("mindmap.content.updated"),
"documentId": payload.document_id.clone(),
"blockId": payload.mindmap_id.clone(),
})),
"domainEventHint": tree_domain_event_hint("tree.resource.mindmap.updated"),
"domainEventPlan": tree_domain_event_plan( "domainEventPlan": tree_domain_event_plan(
"tree.resource.mindmap.updated", "mindmap.content.updated",
tree_stream_delta_hint("resync_required", json!({ tree_stream_delta_hint("noop", json!({})),
"reason": "mindmap.command.apply",
"documentId": payload.document_id.clone(),
"blockId": payload.mindmap_id.clone(),
})),
), ),
}), }),
})) }))
@@ -11396,7 +11421,7 @@ mod tests {
assert_eq!(result["schema"], json!("mnote.page_aggregate.v1")); assert_eq!(result["schema"], json!("mnote.page_aggregate.v1"));
assert_eq!(result["projectionVersion"], json!(1)); 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["pageId"], json!("doc_1"));
assert_eq!(result["identity"]["documentId"], json!("doc_1")); assert_eq!(result["identity"]["documentId"], json!("doc_1"));
assert_eq!(result["body"]["revision"], json!(7)); assert_eq!(result["body"]["revision"], json!(7));
@@ -11748,11 +11773,11 @@ mod tests {
); );
assert_eq!( assert_eq!(
plan.args_json["domainEventPlan"]["eventType"], plan.args_json["domainEventPlan"]["eventType"],
json!("tree.resource.mindmap.updated") json!("mindmap.content.updated")
); );
assert_eq!( assert_eq!(
plan.args_json["streamDeltaHint"]["kind"], plan.args_json["streamDeltaHint"]["kind"],
json!("resync_required") json!("noop")
); );
} }
RuntimeExecutionPlan::Query(_) | RuntimeExecutionPlan::Tool(_) => { 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] #[test]
fn docs_search_tool_plan_uses_transport_and_transform_steps() { fn docs_search_tool_plan_uses_transport_and_transform_steps() {
let plan = execute_runtime_input(RuntimeInput::Tool { let plan = execute_runtime_input(RuntimeInput::Tool {
@@ -15539,13 +15635,39 @@ mod tests {
RuntimeExecutionPlan::Command(plan) => { RuntimeExecutionPlan::Command(plan) => {
assert_eq!(plan.command_name, "documents.options.update"); assert_eq!(plan.command_name, "documents.options.update");
assert_eq!(plan.function_name, "documents:updateOptions"); assert_eq!(plan.function_name, "documents:updateOptions");
assert_eq!(plan.args_json["id"], json!("doc_1"));
assert_eq!( assert_eq!(
plan.args_json, plan.args_json["options"],
json!({ json!({
"id": "doc_1", "showToc": true,
"options": { "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",
},
}, },
}) })
); );
+4
View File
@@ -72,6 +72,10 @@ impl WebError {
pub fn message(&self) -> &str { pub fn message(&self) -> &str {
&self.message &self.message
} }
pub fn status(&self) -> StatusCode {
self.status
}
} }
impl IntoResponse for WebError { impl IntoResponse for WebError {
+59 -2
View File
@@ -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) { fn stamp_documents_headers(headers: &mut HeaderMap) {
if let Ok(name) = HeaderName::from_lowercase(HEADER_MNOTE_WEB_OWNER.as_bytes()) { if let Ok(name) = HeaderName::from_lowercase(HEADER_MNOTE_WEB_OWNER.as_bytes()) {
headers.insert(name, HeaderValue::from_static("mnote-web")); headers.insert(name, HeaderValue::from_static("mnote-web"));
@@ -711,13 +719,16 @@ pub async fn title(
dry_run: false, dry_run: false,
validate_only: false, validate_only: false,
}; };
let result = execute_runtime_command_via_convex( let execution = execute_runtime_command_via_convex_with_artifacts(
state.config(), state.config(),
&context, &context,
effective_workspace_id.as_deref(), effective_workspace_id.as_deref(),
command, command,
) )
.await?; .await?;
let artifacts = execution_artifacts_json(&execution);
let artifact_error = execution.artifact_error.clone();
let result = execution.result;
let mut headers = HeaderMap::new(); let mut headers = HeaderMap::new();
stamp_documents_headers(&mut headers); stamp_documents_headers(&mut headers);
Ok(( Ok((
@@ -731,6 +742,8 @@ pub async fn title(
"meta": { "meta": {
"commandName": "page.head.updateTitle", "commandName": "page.head.updateTitle",
"canonicalCommand": "page.head.updateTitle", "canonicalCommand": "page.head.updateTitle",
"artifacts": artifacts,
"artifactError": artifact_error,
}, },
"result": result, "result": result,
})), })),
@@ -799,13 +812,16 @@ pub async fn options(
dry_run: false, dry_run: false,
validate_only: false, validate_only: false,
}; };
let result = execute_runtime_command_via_convex( let execution = execute_runtime_command_via_convex_with_artifacts(
state.config(), state.config(),
&context, &context,
effective_workspace_id.as_deref(), effective_workspace_id.as_deref(),
command, command,
) )
.await?; .await?;
let artifacts = execution_artifacts_json(&execution);
let artifact_error = execution.artifact_error.clone();
let result = execution.result;
let mut headers = HeaderMap::new(); let mut headers = HeaderMap::new();
stamp_documents_headers(&mut headers); stamp_documents_headers(&mut headers);
Ok(( Ok((
@@ -819,6 +835,8 @@ pub async fn options(
"meta": { "meta": {
"commandName": "page.layout.updateOptions", "commandName": "page.layout.updateOptions",
"canonicalCommand": "page.layout.updateOptions", "canonicalCommand": "page.layout.updateOptions",
"artifacts": artifacts,
"artifactError": artifact_error,
}, },
"result": result, "result": result,
})), })),
@@ -907,6 +925,14 @@ mod tests {
"updated_at": "2026-04-18T09:45:00Z", "updated_at": "2026-04-18T09:45:00Z",
"revision": 8, "revision": 8,
"conflict_detection_key": "doc_1:8" "conflict_detection_key": "doc_1:8"
},
"bridgeLogs:recordCommandLog": {
"ok": true,
"id": "clog_fixture"
},
"bridgeLogs:recordDomainEvent": {
"ok": true,
"id": "evt_fixture"
} }
}"# }"#
.into(), .into(),
@@ -1066,6 +1092,24 @@ mod tests {
let payload: Value = serde_json::from_slice(&body).expect("json"); let payload: Value = serde_json::from_slice(&body).expect("json");
assert_eq!(payload["meta"]["commandName"], "page.head.updateTitle"); assert_eq!(payload["meta"]["commandName"], "page.head.updateTitle");
assert_eq!(payload["meta"]["canonicalCommand"], "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] #[tokio::test]
@@ -1102,6 +1146,19 @@ mod tests {
payload["meta"]["canonicalCommand"], payload["meta"]["canonicalCommand"],
"page.layout.updateOptions" "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] #[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"
})
);
}
} }
+284 -9
View File
@@ -1,9 +1,10 @@
use crate::app::AppState; use crate::app::AppState;
use crate::app::AppConfig;
use crate::error::WebError; use crate::error::WebError;
use adapter_onlyoffice::{prepare_proxy_request, sign_config, OnlyOfficeProxyPreparationInput}; use adapter_onlyoffice::{prepare_proxy_request, sign_config, OnlyOfficeProxyPreparationInput};
use axum::body::{Body, Bytes}; use axum::body::{Body, Bytes};
use axum::extract::{Path, Query, State}; 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::response::{Html, IntoResponse, Response};
use axum::Json; use axum::Json;
use serde::Deserialize; use serde::Deserialize;
@@ -715,6 +716,8 @@ pub async fn proxy(
} }
pub async fn callback( pub async fn callback(
State(state): State<AppState>,
uri: Uri,
Query(query): Query<OnlyOfficeCallbackQuery>, Query(query): Query<OnlyOfficeCallbackQuery>,
Json(body): Json<Value>, Json(body): Json<Value>,
) -> Response { ) -> Response {
@@ -728,10 +731,41 @@ pub async fn callback(
status, status,
"OnlyOffice callback received by mnote-web" "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( pub async fn forcesave(
State(state): State<AppState>,
uri: Uri,
Query(query): Query<OnlyOfficeForcesaveQuery>, Query(query): Query<OnlyOfficeForcesaveQuery>,
) -> Result<Response, WebError> { ) -> Result<Response, WebError> {
let asset_id = query let asset_id = query
@@ -750,13 +784,85 @@ pub async fn forcesave(
.ok_or_else(|| { .ok_or_else(|| {
WebError::bad_request_code("onlyoffice_forcesave_key_missing", "缺少 key") WebError::bad_request_code("onlyoffice_forcesave_key_missing", "缺少 key")
})?; })?;
Ok(Json(json!({ let response = proxy_legacy_onlyoffice_json(
"ok": true, state.config(),
"via": "mnote-web-rust-noop", "/api/onlyoffice/forcesave",
"assetId": asset_id, uri.query(),
"key": key, None,
})) )
.into_response()) .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 { 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)] #[cfg(test)]
mod tests { mod tests {
use super::*; 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] #[test]
fn stable_doc_key_uses_onlyoffice_safe_characters() { fn stable_doc_key_uses_onlyoffice_safe_characters() {
@@ -1003,4 +1116,166 @@ mod tests {
let candidates = onlyoffice_internal_candidates(); let candidates = onlyoffice_internal_candidates();
assert!(candidates.contains(&DEFAULT_ONLYOFFICE_INTERNAL_URL.to_string())); 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"
));
}
} }
+65 -1
View File
@@ -175,6 +175,8 @@ pub async fn documents(
"owner": "mnote-web", "owner": "mnote-web",
"projectionOwner": result.get("projectionOwner").cloned().unwrap_or_else(|| json!("rust-kernel")), "projectionOwner": result.get("projectionOwner").cloned().unwrap_or_else(|| json!("rust-kernel")),
"queryName": "search.documents.query", "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, "requestId": context.trace.request_id,
"traceId": context.trace.trace_id, "traceId": context.trace.trace_id,
}, },
@@ -239,12 +241,13 @@ async fn load_search_results_with_filters(
.await .await
{ {
Ok(value) => Ok(value), Ok(value) => Ok(value),
Err(_) => execute_runtime_query_against_data( Err(_) if config.allow_dev_fixtures => execute_runtime_query_against_data(
context, context,
Some(workspace_id), Some(workspace_id),
runtime_query, runtime_query,
fallback_search_dataset(workspace_id), 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 { fn fallback_search_dataset(workspace_id: &str) -> Value {
json!({ json!({
"documents": [ "documents": [
@@ -413,6 +426,8 @@ mod tests {
assert!(html.contains("search.documents.query")); assert!(html.contains("search.documents.query"));
assert!(html.contains("search-result")); assert!(html.contains("search-result"));
assert!(html.contains("Rust Web 搜索结果")); 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] #[tokio::test]
@@ -467,4 +482,53 @@ mod tests {
assert_eq!(payload["meta"]["projectionOwner"], "rust-kernel"); assert_eq!(payload["meta"]["projectionOwner"], "rust-kernel");
assert_eq!(payload["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 技能知识图谱开发"));
}
} }
+125 -2
View File
@@ -2814,13 +2814,17 @@ fn build_tree_shell_html(
} }
if (sourceKind === "convex_workspace") { if (sourceKind === "convex_workspace") {
for (const item of deletableItems) { for (const item of deletableItems) {
const documentId = getFileTreeRowDocumentId(item);
await sendCommand({ await sendCommand({
action: "delete", action: "delete",
workspaceId, workspaceId,
documentId: getFileTreeRowDocumentId(item), documentId,
}); });
applyRemovedDocumentLocally(documentId);
}
if (!deletableItems.every((item) => !fileTreeRowById.has(item.rowId))) {
scheduleRefresh();
} }
scheduleRefresh();
return true; return true;
} }
postToHost("tree.filetree.delete", { postToHost("tree.filetree.delete", {
@@ -2986,6 +2990,113 @@ fn build_tree_shell_html(
}, 80); }, 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) { if (sourceKind === "local_folder" && rootUri) {
let localWatchRevision = initialLocalWatchRevision; let localWatchRevision = initialLocalWatchRevision;
let localWatchRefreshTimer = 0; let localWatchRefreshTimer = 0;
@@ -4335,6 +4446,14 @@ fn build_tree_shell_html(
payload: { documentId }, payload: { documentId },
}); });
} }
if (sourceKind === "convex_workspace" && applyCreatedDocumentLocally(result, parentId, title)) {
if (mode === "filetree") {
beginInlineRename("filetree", `doc:${documentId}`);
} else {
beginInlineRename("page", documentId);
}
return;
}
scheduleRefresh({ scheduleRefresh({
renameRowId: localCreatedRowIdFromCommandResult(result, "markdown"), renameRowId: localCreatedRowIdFromCommandResult(result, "markdown"),
}); });
@@ -6688,6 +6807,10 @@ mod tests {
assert!(html.contains("data-testid=\"tree-node-toggle\"")); assert!(html.contains("data-testid=\"tree-node-toggle\""));
assert!(html.contains("hydrateInitialPageTree")); assert!(html.contains("hydrateInitialPageTree"));
assert!(html.contains("const usedRustInitialPageRenderer = 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("application/x-mnote-page-tree-node"));
assert!(html.contains("页面已拖放到")); assert!(html.contains("页面已拖放到"));
assert!(html.contains("setAttribute(\"role\", \"treeitem\")")); assert!(html.contains("setAttribute(\"role\", \"treeitem\")"));
+96 -18
View File
@@ -2437,10 +2437,9 @@ pub(crate) async fn load_workspace_shell_projection(
query: None, query: None,
max_results: None, max_results: None,
}; };
let dataset = load_projection_snapshot(config, context, &spec) let dataset = match load_projection_snapshot(config, context, &spec).await {
.await Ok(snapshot) => snapshot.dataset,
.map(|snapshot| snapshot.dataset) Err(_) if config.allow_dev_fixtures => {
.unwrap_or_else(|_| {
let documents = active_document_id let documents = active_document_id
.map(|document_id| { .map(|document_id| {
json!([{ json!([{
@@ -2457,9 +2456,19 @@ pub(crate) async fn load_workspace_shell_projection(
"active_workspace_id": workspace_id, "active_workspace_id": workspace_id,
"active_page_id": active_document_id, "active_page_id": active_document_id,
"workspaces": [{ "id": workspace_id, "name": default_workspace_name }], "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( build_workspace_shell_projection(
&dataset, &dataset,
@@ -2489,7 +2498,7 @@ pub(crate) async fn load_sidebar_tree_html(
max_results: None, max_results: None,
}; };
let result = match load_projection_snapshot(config, context, &spec).await { 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 => { Err(_) if config.allow_dev_fixtures => {
// Dev 模式降级:如果调用方已经有 active 页面,优先保留这条真实选择链。 // Dev 模式降级:如果调用方已经有 active 页面,优先保留这条真实选择链。
let documents = active_document_id let documents = active_document_id
@@ -2518,17 +2527,20 @@ pub(crate) async fn load_sidebar_tree_html(
"mindmap_docs": [], "mindmap_docs": [],
"mindmap_asset_children": {} "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, Err(_) => None,
}; };
result.map(|projection| { result.map(|(projection, dev_fixture)| {
let rows = collect_page_tree_render_rows(&projection); let rows = collect_page_tree_render_rows(&projection);
render_initial_page_tree_html(&PageTreeInitialRenderInput { let html = render_initial_page_tree_html(&PageTreeInitialRenderInput {
rows, rows,
active_node_id: active_document_id.map(ToOwned::to_owned), active_node_id: active_document_id.map(ToOwned::to_owned),
focused_node_id: None, 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, max_results: None,
}; };
let result = match load_projection_snapshot(config, context, &spec).await { 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 => { Err(_) if config.allow_dev_fixtures => {
let documents = active_document_id let documents = active_document_id
.map(|document_id| { .map(|document_id| {
@@ -2577,16 +2589,28 @@ pub(crate) async fn load_file_tree_html(
"mindmap_docs": [], "mindmap_docs": [],
"mindmap_asset_children": {} "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, Err(_) => None,
}; };
result.map(|projection| { result.map(|(projection, dev_fixture)| {
let rows = collect_filetree_render_rows(&projection, active_document_id); 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( pub(crate) fn render_local_sidebar_tree_html(
root_uri: &str, root_uri: &str,
active_document_id: Option<&str>, active_document_id: Option<&str>,
@@ -2614,8 +2638,9 @@ pub(crate) fn render_local_file_tree_html(
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use crate::app::{build_app, AppConfig, AppState}; use crate::app::{build_app, AppConfig, AppState};
use crate::context::RequestContext;
use axum::body::{to_bytes, Body}; 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 serde_json::Value;
use tower::util::ServiceExt; use tower::util::ServiceExt;
@@ -2701,6 +2726,9 @@ mod tests {
.expect("body"); .expect("body");
let html = String::from_utf8(body.to_vec()).expect("html"); let html = String::from_utf8(body.to_vec()).expect("html");
assert!(html.contains("data-testid=\"wolai-sidebar\"")); 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-topbar\""));
assert!(html.contains("data-testid=\"wolai-floating-ai\"")); assert!(html.contains("data-testid=\"wolai-floating-ai\""));
assert!(html.contains("星标置顶")); assert!(html.contains("星标置顶"));
@@ -2755,7 +2783,7 @@ mod tests {
.headers() .headers()
.get("x-mnote-page-aggregate-owner") .get("x-mnote-page-aggregate-owner")
.and_then(|value| value.to_str().ok()), .and_then(|value| value.to_str().ok()),
Some("rust-kernel") Some("compat-join")
); );
assert_eq!( assert_eq!(
response response
@@ -2770,7 +2798,7 @@ mod tests {
let payload: Value = serde_json::from_slice(&body).expect("json"); let payload: Value = serde_json::from_slice(&body).expect("json");
assert_eq!(payload["owner"], "mnote-web"); assert_eq!(payload["owner"], "mnote-web");
assert_eq!(payload["schema"], "mnote.page_aggregate.v1"); 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"]["projectionVersion"], 1);
assert_eq!(payload["result"]["identity"]["documentId"], "doc_1"); assert_eq!(payload["result"]["identity"]["documentId"], "doc_1");
assert_eq!(payload["result"]["body"]["revision"], 7); 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] #[tokio::test]
async fn document_shell_renders_local_markdown_attachment_name_in_html() { async fn document_shell_renders_local_markdown_attachment_name_in_html() {
let root = std::env::temp_dir().join(format!( let root = std::env::temp_dir().join(format!(
+248 -5
View File
@@ -77,6 +77,146 @@ const SIDEBAR_TREE_JS: &str = r##"
return parseJsonScript('__MNOTE_PAGE_AGGREGATE__') || {}; 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() { function defaultPageOptions() {
return { return {
wideLayout: false, 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) { function readProjection(value) {
if (!value || typeof value !== 'object') return null; if (!value || typeof value !== 'object') return null;
if (value.result && typeof value.result === 'object') return value.result; 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 : []; 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) { function nodeIdOf(item) {
return String(item && (item.nodeId || item.id || item.documentId) || '').trim(); return String(item && (item.nodeId || item.id || item.documentId) || '').trim();
} }
@@ -1024,9 +1253,9 @@ const SIDEBAR_TREE_JS: &str = r##"
} }
function renderSidebarSnapshot(payload) { function renderSidebarSnapshot(payload) {
var renderedPage = renderPageProjection(payload); var renderedPage = hasProjectionItems(payload) ? renderPageProjection(payload) : false;
var fileProjection = readDatasetProjection(payload, 'kernel_file_tree_projection', 'kernelFileTreeProjection'); 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; return renderedPage || renderedFile;
} }
@@ -2805,9 +3034,10 @@ const SIDEBAR_TREE_JS: &str = r##"
var prompt = searchText(text); var prompt = searchText(text);
if (!prompt) return; if (!prompt) return;
pageAiLoadSessions(); pageAiLoadSessions();
var aggregate = currentPageAggregate(); var contextSnapshot = currentPageAiContextSnapshot();
var body = aggregate.body || {}; var aggregate = contextSnapshot.aggregate || {};
var subtree = aggregate.tree && aggregate.tree.pageSubtree ? aggregate.tree.pageSubtree : null; var body = contextSnapshot.body || {};
var subtree = contextSnapshot.subtree || null;
var outline = subtree && subtree.outline ? subtree.outline : null; var outline = subtree && subtree.outline ? subtree.outline : null;
pageUiState.pageAiBusy = true; pageUiState.pageAiBusy = true;
pageUiState.pageAiMessages.push({ role: 'user', content: prompt }); pageUiState.pageAiMessages.push({ role: 'user', content: prompt });
@@ -2843,6 +3073,7 @@ const SIDEBAR_TREE_JS: &str = r##"
}, },
subtree: subtree, subtree: subtree,
outline: outline, outline: outline,
pageSubtreeSource: contextSnapshot.pageSubtreeSource || 'none',
evidence: null, evidence: null,
pageOptions: currentPageOptions() pageOptions: currentPageOptions()
}, },
@@ -3890,6 +4121,18 @@ const SIDEBAR_TREE_JS: &str = r##"
window.addEventListener('tree:delta', function(event) { window.addEventListener('tree:delta', function(event) {
var payload = event.detail && event.detail.payload ? event.detail.payload : event.detail; var payload = event.detail && event.detail.payload ? event.detail.payload : event.detail;
var data = payload && (payload.data || payload.delta || payload); 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); var documents = data && (data.upsertDocuments || data.upsert_documents);
if (Array.isArray(documents)) { if (Array.isArray(documents)) {
documents.forEach(function(doc) { documents.forEach(function(doc) {
+108 -12
View File
@@ -376,17 +376,27 @@ pub async fn execute_convex_query_by_name(
fn convex_command_args_for_plan(plan: &RuntimeCommandExecutionPlan) -> Value { fn convex_command_args_for_plan(plan: &RuntimeCommandExecutionPlan) -> Value {
let mut args = plan.args_json.clone(); 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") if matches!(plan.command_name.as_str(), "mindmaps.put")
|| matches!(plan.function_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 作为正式契约;
// Rust plan 保留 tree domain event / stream hint 作为正式契约; // Convex mindmaps.put legacy validator 仍只接收真实写入字段。
// Convex mindmaps.put legacy validator 仍只接收真实写入字段。 strip_tree_artifact_fields(&mut args);
map.remove("streamDeltaHint");
map.remove("domainEventHint");
map.remove("domainEventPlan");
map.remove("domainEventPlans");
}
} }
if matches!( if matches!(
plan.command_name.as_str(), plan.command_name.as_str(),
@@ -397,11 +407,16 @@ fn convex_command_args_for_plan(plan: &RuntimeCommandExecutionPlan) -> Value {
// Rust plan 保留正式事件契约,但发送给 legacy mutation 时只传它实际接受的字段。 // Rust plan 保留正式事件契约,但发送给 legacy mutation 时只传它实际接受的字段。
map.remove("editorDocument"); map.remove("editorDocument");
map.remove("tiptapDocument"); 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 plan.command_name == "mindmap.command.apply" {
if let Value::Object(map) = &mut args { if let Value::Object(map) = &mut args {
@@ -418,6 +433,15 @@ fn convex_command_args_for_plan(plan: &RuntimeCommandExecutionPlan) -> Value {
args 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( pub async fn execute_convex_command_plan(
config: &AppConfig, config: &AppConfig,
context: &RequestContext, 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] #[test]
fn convex_command_args_adapts_mindmap_command_apply_for_legacy_mutation() { fn convex_command_args_adapts_mindmap_command_apply_for_legacy_mutation() {
let plan = RuntimeCommandExecutionPlan { let plan = RuntimeCommandExecutionPlan {
+78 -1
View File
@@ -9,6 +9,9 @@ pub struct WorkspaceShellProjection {
pub workspace_name: String, pub workspace_name: String,
pub active_page_id: Option<String>, pub active_page_id: Option<String>,
pub active_page_title: 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 starred_items: Vec<WorkspaceShellItem>,
pub my_page_items: Vec<WorkspaceShellItem>, pub my_page_items: Vec<WorkspaceShellItem>,
pub bottom_entries: Vec<WorkspaceShellEntry>, 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())); 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 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 { WorkspaceShellProjection {
schema: "mnote.workspace_shell.v1".into(), schema: "mnote.workspace_shell.v1".into(),
@@ -95,6 +115,9 @@ pub fn build_workspace_shell_projection(
workspace_name, workspace_name,
active_page_id, active_page_id,
active_page_title, active_page_title,
degraded,
degraded_reason,
dev_fixture,
starred_items, starred_items,
my_page_items, my_page_items,
bottom_entries: vec![ bottom_entries: vec![
@@ -278,6 +301,8 @@ mod tests {
assert_eq!(projection.workspace_name, "开发用户 的工作区"); assert_eq!(projection.workspace_name, "开发用户 的工作区");
assert_eq!(projection.active_page_id.as_deref(), Some("page_home")); assert_eq!(projection.active_page_id.as_deref(), Some("page_home"));
assert_eq!(projection.active_page_title.as_deref(), Some("个人")); 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.len(), 1);
assert_eq!(projection.starred_items[0].title, "个人"); assert_eq!(projection.starred_items[0].title, "个人");
assert_eq!(projection.my_page_items.len(), 2); assert_eq!(projection.my_page_items.len(), 2);
@@ -368,6 +393,42 @@ mod tests {
assert!(html.contains("data-testid=\"wolai-sidebar-empty-state\"")); 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(
&degraded_dataset,
"ws_demo",
None,
"开发用户 的工作区",
);
let degraded_html = render_workspace_shell_sidebar_html(&degraded_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( pub fn render_workspace_shell_sidebar_html(
@@ -434,9 +495,25 @@ pub fn render_workspace_shell_sidebar_html(
}) })
.collect::<Vec<_>>() .collect::<Vec<_>>()
.join(""); .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!( 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("star", "wolai-section-icon"),
render_symbol("folder_open", "wolai-folder-icon"), render_symbol("folder_open", "wolai-folder-icon"),
escape_html(&projection.workspace_id), 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", "bridge.workspace.overview" => "bridgeLogs:listWorkspaceOverview",
"documents.meta.get" => "documents:getMeta", "documents.meta.get" => "documents:getMeta",
"documents.content.get" => "documents:getContent", "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", "mindmaps.get" => "mindmaps:get",
"mindmap.projection.get" => "mindmaps:getProjection", "mindmap.projection.get" => "mindmaps:getProjection",
"mindmap.editor_scene.get" => "mindmaps:getEditorScene", "mindmap.editor_scene.get" => "mindmaps:getEditorScene",
+1 -1
View File
@@ -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":{}}
+205 -2
View File
@@ -126,7 +126,7 @@ function attachNetworkCapture(page, label, records) {
url, url,
status, status,
requestBody: request.postData() || null, requestBody: request.postData() || null,
responseText: responseText ? responseText.slice(0, 4000) : null, responseText: responseText ? responseText.slice(0, 12000) : null,
}); });
}); });
page.on("requestfailed", (request) => { 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]")) const assetRows = Array.from(document.querySelectorAll("#sidebar-file-tree-root [data-asset-id]"))
.map((node) => (node instanceof HTMLElement ? node.getAttribute("data-asset-id") || "" : "")) .map((node) => (node instanceof HTMLElement ? node.getAttribute("data-asset-id") || "" : ""))
.filter(Boolean); .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 { return {
url: window.location.href, url: window.location.href,
bodyText: (document.body?.innerText || "").slice(0, 8000), bodyText: (document.body?.innerText || "").slice(0, 8000),
@@ -212,6 +234,7 @@ async function readPageState(page) {
mindmapLoadingEvents: window.__MNOTE_MINDMAP_LOADING_EVENTS__ || [], mindmapLoadingEvents: window.__MNOTE_MINDMAP_LOADING_EVENTS__ || [],
fileTreeText: (document.getElementById("sidebar-file-tree-root")?.textContent || "").slice(0, 4000), fileTreeText: (document.getElementById("sidebar-file-tree-root")?.textContent || "").slice(0, 4000),
fileTreeAssetIds: assetRows, fileTreeAssetIds: assetRows,
fileTreeMindmapRows: mindmapRows,
treeEvents: window.__MNOTE_SMOKE_TREE_EVENTS__ || [], treeEvents: window.__MNOTE_SMOKE_TREE_EVENTS__ || [],
}; };
}); });
@@ -260,6 +283,24 @@ function stableJson(value) {
return JSON.stringify(value ?? null); 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) { function rectCenter(rect) {
if (!rect) return null; if (!rect) return null;
return { return {
@@ -271,7 +312,7 @@ function rectCenter(rect) {
function centerDistance(a, b) { function centerDistance(a, b) {
const ca = rectCenter(a); const ca = rectCenter(a);
const cb = rectCenter(b); 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); 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) { async function assertMindmapRuntimeDoesNotRefreshForLiveSignals(page, documentId, mindmapId, failures, label) {
await waitForMindmapRuntimeViewSettled(page, mindmapId, label);
const before = await readPageState(page); const before = await readPageState(page);
const beforeRuntime = await readMindmapRuntimeStabilityState(page, mindmapId); const beforeRuntime = await readMindmapRuntimeStabilityState(page, mindmapId);
if (before.hasMindmapError || before.hasMindmapLoading || !before.runtimeReady) { if (before.hasMindmapError || before.hasMindmapLoading || !before.runtimeReady) {
@@ -551,6 +593,56 @@ async function assertFileTreeMindmapOpenUsesObjectShell(page, documentId, mindma
return { opened, state: readyState }; 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) { async function assertFileTreeIndexOpenUsesPageAggregate(page, documentId, mindmapId, failures) {
await openFilesystemView(page); await openFilesystemView(page);
const opened = await page.evaluate( 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() { async function main() {
await fs.mkdir(OUTPUT_DIR, { recursive: true }); await fs.mkdir(OUTPUT_DIR, { recursive: true });
const browser = await chromium.launch({ headless: true }); const browser = await chromium.launch({ headless: true });
@@ -1005,6 +1168,15 @@ async function main() {
result.mindmapId, result.mindmapId,
failures, 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 waitForMindmapReady(pageA, "after-filetree-mindmap-open");
await pageA.waitForTimeout(1000); await pageA.waitForTimeout(1000);
screenshots.push(await screenshot(pageA, "01-browser-a-after-insert")); screenshots.push(await screenshot(pageA, "01-browser-a-after-insert"));
@@ -1034,6 +1206,16 @@ async function main() {
"after-topic-return", "after-topic-return",
); );
result.browserAAfterReturnToMindmapDocument = await readPageState(pageA); 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( result.runtimeTextAfterReturnToMindmapDocument = await readMindmapRuntimeTextState(
pageA, pageA,
result.mindmapId, result.mindmapId,
@@ -1078,6 +1260,16 @@ async function main() {
"after-draft-return", "after-draft-return",
); );
result.runtimeTextAfterDraftReturn = await readMindmapRuntimeTextState(pageA, result.mindmapId, draftTopicText); 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) { if (!result.runtimeTextAfterDraftReturn.includesExpectedText) {
failures.push({ failures.push({
code: "mindmap_uncommitted_text_edit_lost_after_page_switch", code: "mindmap_uncommitted_text_edit_lost_after_page_switch",
@@ -1093,6 +1285,7 @@ async function main() {
failures, failures,
"browser-a-after-topic-edit", "browser-a-after-topic-edit",
); );
result.commandApplyArtifactSemantics = assertMindmapCommandApplyArtifactsDoNotTouchTreeResource(records, failures);
result.longLanguageEdit = await assertLongLanguageEditSurvivesLiveRefresh( result.longLanguageEdit = await assertLongLanguageEditSurvivesLiveRefresh(
pageA, pageA,
contextA.request, contextA.request,
@@ -1113,6 +1306,16 @@ async function main() {
result.mindmapId, result.mindmapId,
failures, 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) { if (result.longLanguageEdit && result.longLanguageEdit.longText) {
result.runtimeTextAfterMindmapReopen = await readMindmapRuntimeTextState( result.runtimeTextAfterMindmapReopen = await readMindmapRuntimeTextState(
pageA, 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");
+167 -32
View File
@@ -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) { function encodeCursor(row: { created_at?: string | null; id?: string | null } | null) {
if (!row?.created_at || !row?.id) return null; if (!row?.created_at || !row?.id) return null;
return JSON.stringify({ 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>>( function matchesCursor<T extends Record<string, any>>(
row: T, row: T,
cursor: { createdAt: string; id: string } | null, cursor: { createdAt: string; id: string } | null,
@@ -53,10 +76,11 @@ function matchesCursor<T extends Record<string, any>>(
if (!cursor) return true; if (!cursor) return true;
const createdAt = String(row.created_at ?? row.finished_at ?? ""); const createdAt = String(row.created_at ?? row.finished_at ?? "");
const id = String(row.id ?? ""); const id = String(row.id ?? "");
const cursorId = stripDomainEventCursorPrefix(cursor.id);
if (!createdAt || !id) return false; if (!createdAt || !id) return false;
if (createdAt < cursor.createdAt) return true; if (createdAt < cursor.createdAt) return true;
if (createdAt > cursor.createdAt) return false; if (createdAt > cursor.createdAt) return false;
return id < cursor.id; return id < cursorId;
} }
function normalizeStatusFilter(raw: string | null | undefined) { function normalizeStatusFilter(raw: string | null | undefined) {
@@ -69,6 +93,128 @@ function normalizeObjectFilter(raw: string | null | undefined) {
return normalized.length > 0 ? normalized : null; 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({ export const recordCommandLog = mutation({
args: { args: {
workspaceId: v.string(), workspaceId: v.string(),
@@ -280,44 +426,33 @@ export const listWorkspaceOverview = query({
const aggregateType = normalizeObjectFilter(args.aggregateType); const aggregateType = normalizeObjectFilter(args.aggregateType);
const aggregateId = normalizeObjectFilter(args.aggregateId); const aggregateId = normalizeObjectFilter(args.aggregateId);
const allCommandLogs = await ctx.db const commandWindow = await fetchCommandLogWindow(ctx, {
.query("command_logs") workspaceId: args.workspaceId,
.withIndex("by_workspace_request", (q) => q.eq("workspace_id", args.workspaceId)) limit,
.collect(); cursor,
const allDomainEvents = await ctx.db commandStatus,
.query("domain_events") targetPageId,
.withIndex("by_workspace_request", (q) => q.eq("workspace_id", args.workspaceId)) targetBlockId,
.collect(); });
const domainEventWindow = await fetchDomainEventWindow(ctx, {
workspaceId: args.workspaceId,
limit,
cursor,
eventStatus,
aggregateType,
aggregateId,
});
const filteredCommandLogs = sortByNewest( const pageCommandLogs = sortByNewest(commandWindow.rows);
allCommandLogs.filter((row: any) => { const domainEvents = sortByNewest(domainEventWindow.rows);
if (commandStatus && row.status !== commandStatus) return false; const nextCursor = encodeOverviewNextCursor(pageCommandLogs, domainEvents);
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;
}),
);
return { return {
workspace_id: args.workspaceId, workspace_id: args.workspaceId,
command_logs: pageCommandLogs, command_logs: pageCommandLogs,
domain_events: domainEvents, domain_events: domainEvents,
next_cursor: nextCursor, next_cursor: nextCursor,
has_more: filteredCommandLogs.length > pageCommandLogs.length, has_more: commandWindow.hasMore || domainEventWindow.hasMore,
filters: { filters: {
command_status: commandStatus, command_status: commandStatus,
event_status: eventStatus, event_status: eventStatus,
+15 -2
View File
@@ -474,7 +474,11 @@ export default defineSchema({
.index("by_command_log_id", ["id"]) .index("by_command_log_id", ["id"])
.index("by_workspace_request", ["workspace_id", "request_id"]) .index("by_workspace_request", ["workspace_id", "request_id"])
.index("by_workspace_trace", ["workspace_id", "trace_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({ domain_events: defineTable({
id: v.string(), id: v.string(),
@@ -495,5 +499,14 @@ export default defineSchema({
.index("by_domain_event_id", ["id"]) .index("by_domain_event_id", ["id"])
.index("by_workspace_request", ["workspace_id", "request_id"]) .index("by_workspace_request", ["workspace_id", "request_id"])
.index("by_workspace_trace", ["workspace_id", "trace_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 { 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 = { export const dynamic = "force-dynamic";
documentId: string;
mindmapId: string;
targetUid: string;
instruction?: string;
sources?: { searxng?: boolean; rag?: boolean };
};
type SearxResult = { title: string; url: string; snippet?: string; engine?: string }; export async function POST() {
return NextResponse.json(
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": "..." }',
"",
"硬性约束:",
"- 仅输出 addChildparentUid 必须等于 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[] => [
{ {
kind: "url", error: "Next /api/mindmap-ai/expand-node 已退场,请改用 AI Agent 内置 mindmap_expand_node 工具。",
fileUrl: r.url, code: "mindmap-expand-node-route-retired",
title: r.title, replacement: {
snippet: r.snippet ? r.snippet.slice(0, 300) : undefined, kind: "ai-agent-tool",
}, toolName: "mindmap_expand_node",
]; route: "/api/ai-agent/run",
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 } : {}),
}, },
});
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: { status: 410,
channel: "mindmap-expand-route", headers: {
client: "wolai-frontend", "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, sidebarQueryData: sidebarQuery.data,
treeStreamData: treeStream.data, treeStreamData: treeStream.data,
treeStreamStatus: treeStream.status, treeStreamStatus: treeStream.status,
treeStreamCursor: treeStream.cursor,
}); });
return ( return (
@@ -815,6 +815,7 @@ export function DocumentAiAgentPanelRuntime({
subtree: contextSubtree, subtree: contextSubtree,
outline: contextOutline, outline: contextOutline,
evidence: contextEvidence, evidence: contextEvidence,
pageSubtreeSource: pageAggregateSnapshot.pageSubtreeSource ?? "none",
pageOptions: pageAggregateSnapshot.pageOptions, pageOptions: pageAggregateSnapshot.pageOptions,
}, },
options: { options: {
@@ -11,6 +11,7 @@ import { useAiAgentUiStore } from "@/store/ai-agent-ui";
export type PageAggregateAiSnapshot = { export type PageAggregateAiSnapshot = {
blocks: Json | null; blocks: Json | null;
pageSubtree: PageSubtreeProjection | null; pageSubtree: PageSubtreeProjection | null;
pageSubtreeSource?: "server" | "local" | "none";
persistedMeta: { persistedMeta: {
workspaceId: string | null; workspaceId: string | null;
revision: number | null; revision: number | null;
@@ -45,6 +45,9 @@ type SidebarProps = {
activeTab: SidebarPanel | null; activeTab: SidebarPanel | null;
onClose: () => void; onClose: () => void;
}; };
// 旧 Next route 已退场;补完节点能力保留在 AI Agent 的 mindmap_expand_node 工具中。
const MINDMAP_LEGACY_EXPAND_NODE_ENTRY_ENABLED = false;
const ColorInput = ({ const ColorInput = ({
value, value,
@@ -1631,41 +1634,43 @@ const AiPanel = ({
{docDebug && <div className="text-xs text-gray-500 whitespace-pre-wrap">{docDebug}</div>} {docDebug && <div className="text-xs text-gray-500 whitespace-pre-wrap">{docDebug}</div>}
</div> </div>
<div className="space-y-2 rounded-md border border-gray-200 p-3"> {MINDMAP_LEGACY_EXPAND_NODE_ENTRY_ENABLED ? (
<div className="flex items-center justify-between"> <div className="space-y-2 rounded-md border border-gray-200 p-3">
<Label className="text-xs text-gray-500">AI </Label> <div className="flex items-center justify-between">
<div className="flex items-center gap-2"> <Label className="text-xs text-gray-500">AI </Label>
<Toggle <div className="flex items-center gap-2">
pressed={expandUseSearx} <Toggle
onPressedChange={(v) => setExpandUseSearx(Boolean(v))} pressed={expandUseSearx}
size="sm" onPressedChange={(v) => setExpandUseSearx(Boolean(v))}
className="text-xs" size="sm"
> className="text-xs"
<Network className="h-4 w-4 mr-1" /> >
<Network className="h-4 w-4 mr-1" />
</Toggle>
</Toggle>
</div>
</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> </div>
<textarea ) : null}
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>
<Label className="text-xs text-gray-500"></Label> <Label className="text-xs text-gray-500"></Label>
<NativeSelect <NativeSelect
@@ -104,6 +104,7 @@ describe("page-aggregate-client-state", () => {
const state = createPageAggregateClientState(page); const state = createPageAggregateClientState(page);
expect(state.documentId).toBe("doc_1");
expect(state.serverPageTitle).toBe("页面标题"); expect(state.serverPageTitle).toBe("页面标题");
expect(state.persistedPageTitle).toBeNull(); expect(state.persistedPageTitle).toBeNull();
expect(state.draftPageTitle).toBeNull(); expect(state.draftPageTitle).toBeNull();
@@ -221,7 +222,7 @@ describe("page-aggregate-client-state", () => {
}); });
}); });
it("本地标题或正文与服务端快照不一致时,不应继续复用旧 pageSubtree", () => { it("本地标题或正文与服务端快照不一致时,应生成临时 pageSubtree 而不是复用旧快照", () => {
const page = createPageAggregate(); const page = createPageAggregate();
const initialState = createPageAggregateClientState(page); const initialState = createPageAggregateClientState(page);
@@ -239,7 +240,17 @@ describe("page-aggregate-client-state", () => {
selectPageAggregateClientPageSubtree(localContentState, { selectPageAggregateClientPageSubtree(localContentState, {
liveSidebarTitle: "页面标题", liveSidebarTitle: "页面标题",
}), }),
).toBeNull(); ).toEqual(
expect.objectContaining({
projectionId: expect.any(String),
projection: "page_tree",
rootNode: expect.objectContaining({
metadata: expect.objectContaining({
title: "页面标题",
}),
}),
}),
);
const draftTitleState = pageAggregateClientStateReducer(initialState, { const draftTitleState = pageAggregateClientStateReducer(initialState, {
type: "set_draft_page_title", type: "set_draft_page_title",
@@ -249,7 +260,16 @@ describe("page-aggregate-client-state", () => {
selectPageAggregateClientPageSubtree(draftTitleState, { selectPageAggregateClientPageSubtree(draftTitleState, {
liveSidebarTitle: "页面标题", liveSidebarTitle: "页面标题",
}), }),
).toBeNull(); ).toEqual(
expect.objectContaining({
projection: "page_tree",
rootNode: expect.objectContaining({
metadata: expect.objectContaining({
title: "草稿标题",
}),
}),
}),
);
const persistedTitleState = pageAggregateClientStateReducer(initialState, { const persistedTitleState = pageAggregateClientStateReducer(initialState, {
type: "commit_persisted_page_title", type: "commit_persisted_page_title",
@@ -299,6 +319,7 @@ describe("page-aggregate-client-state", () => {
expect(snapshot).toEqual({ expect(snapshot).toEqual({
blocks: page.body.content as Json, blocks: page.body.content as Json,
pageSubtree: page.tree.pageSubtree, pageSubtree: page.tree.pageSubtree,
pageSubtreeSource: "server",
persistedMeta: { persistedMeta: {
workspaceId: "ws_1", workspaceId: "ws_1",
revision: 3, revision: 3,
@@ -1,10 +1,13 @@
import type { PageBodyPersistedMeta } from "@/lib/documents/page-command-client"; import type { PageBodyPersistedMeta } from "@/lib/documents/page-command-client";
import type { PageAggregateProjection } from "@/lib/documents/page-aggregate"; 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 { PageOptionsState } from "@/types/page-options";
import type { Json } from "@/types/supabase"; import type { Json } from "@/types/supabase";
export type PageAggregateClientPageSubtreeSource = "server" | "local" | "none";
export type PageAggregateClientState = { export type PageAggregateClientState = {
documentId: string;
serverPageTitle: string; serverPageTitle: string;
persistedPageTitle: string | null; persistedPageTitle: string | null;
draftPageTitle: string | null; draftPageTitle: string | null;
@@ -71,6 +74,7 @@ export function createPageAggregateClientState(
page: PageAggregateProjection, page: PageAggregateProjection,
): PageAggregateClientState { ): PageAggregateClientState {
return { return {
documentId: page.identity.documentId,
serverPageTitle: normalizePageTitle(page.head.title), serverPageTitle: normalizePageTitle(page.head.title),
persistedPageTitle: null, persistedPageTitle: null,
draftPageTitle: null, draftPageTitle: null,
@@ -160,13 +164,43 @@ export function selectPageAggregateClientPageSubtree(
liveSidebarTitle: string | null; liveSidebarTitle: string | null;
}, },
): PageSubtreeProjection | 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 titleState = selectPageAggregateClientTitleState(state, input);
const contentUnchanged = state.content === state.serverContentSnapshot; const contentUnchanged = state.content === state.serverContentSnapshot;
if (state.serverPageSubtreeSnapshot && !titleState.hasDraft && contentUnchanged) { 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( export function selectPageAggregateClientAiSnapshot(
@@ -178,6 +212,7 @@ export function selectPageAggregateClientAiSnapshot(
): { ): {
blocks: Json | null; blocks: Json | null;
pageSubtree: PageSubtreeProjection | null; pageSubtree: PageSubtreeProjection | null;
pageSubtreeSource: PageAggregateClientPageSubtreeSource;
persistedMeta: { persistedMeta: {
workspaceId: string | null; workspaceId: string | null;
revision: number | null; revision: number | null;
@@ -186,12 +221,14 @@ export function selectPageAggregateClientAiSnapshot(
pageOptions: PageOptionsState; pageOptions: PageOptionsState;
} { } {
const blocks = state.content as Json | null; const blocks = state.content as Json | null;
const pageSubtreeState = selectPageAggregateClientPageSubtreeState(state, {
liveSidebarTitle: input.liveSidebarTitle,
});
return { return {
blocks, blocks,
pageSubtree: selectPageAggregateClientPageSubtree(state, { pageSubtree: pageSubtreeState.pageSubtree,
liveSidebarTitle: input.liveSidebarTitle, pageSubtreeSource: pageSubtreeState.source,
}),
persistedMeta: { persistedMeta: {
workspaceId: input.workspaceId, workspaceId: input.workspaceId,
revision: state.contentRevision, revision: state.contentRevision,
@@ -3,6 +3,7 @@ import fs from "node:fs";
import path from "node:path"; import path from "node:path";
const SIDEBAR_SOURCE = path.join(process.cwd(), "src/components/sidebar/sidebar.tsx"); 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", () => { describe("sidebar file tree delete preflight source", () => {
it("rust_family 删除链应走 Rust delete preflight,而不是本地 delete target helper", () => { 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('if (viewMode === "filesystem") {');
expect(source).toContain("await handleDeleteResourceSelection();"); 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");
});
}); });
+137 -20
View File
@@ -50,6 +50,7 @@ import {
import { usePreferredSidebarSnapshot } from "@/components/sidebar/use-preferred-sidebar-snapshot"; import { usePreferredSidebarSnapshot } from "@/components/sidebar/use-preferred-sidebar-snapshot";
import { bindSidebarRefreshEvents } from "@/components/sidebar/sidebar-events"; import { bindSidebarRefreshEvents } from "@/components/sidebar/sidebar-events";
import { SidebarTreeSurface } from "@/components/sidebar/tree-shell-surface"; import { SidebarTreeSurface } from "@/components/sidebar/tree-shell-surface";
import type { TreeShellMutationPayload } from "@/components/sidebar/tree-shell-host";
import { buildSidebarSectionsFromTree } from "@/lib/sidebar-tree"; import { buildSidebarSectionsFromTree } from "@/lib/sidebar-tree";
import { import {
buildPageTreeProjectionItems, buildPageTreeProjectionItems,
@@ -203,6 +204,7 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar
sidebarQueryData: sidebarQuery.data, sidebarQueryData: sidebarQuery.data,
treeStreamData: treeStream.data, treeStreamData: treeStream.data,
treeStreamStatus: treeStream.status, treeStreamStatus: treeStream.status,
treeStreamCursor: treeStream.cursor,
}); });
const sidebarData = externalSidebarData ?? preferredSidebarSnapshot.data; const sidebarData = externalSidebarData ?? preferredSidebarSnapshot.data;
@@ -1188,9 +1190,91 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar
[assetById, handleOpenAssetInMain], [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(); void refreshTree();
}, [refreshTree]); }, [moveLocalNode, nodeById, refreshTree, sidebarData.activeWorkspaceId]);
useEffect(() => { useEffect(() => {
const handler = async (event: KeyboardEvent) => { const handler = async (event: KeyboardEvent) => {
@@ -1619,6 +1703,23 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar
[mediaAssets, mindmapAssets, refreshTree, tableAssets], [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?: { const handleDeleteResourceSelection = useCallback(async (selectionOverride?: {
selectedRowIds: string[]; selectedRowIds: string[];
anchorRowId: string | null; anchorRowId: string | null;
@@ -1734,6 +1835,7 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar
return; return;
} }
removeDocumentsFromTree(docIds);
docIds.forEach((documentId) => emitDocumentsChanged(documentId)); docIds.forEach((documentId) => emitDocumentsChanged(documentId));
if (activeId && docIds.includes(activeId)) { if (activeId && docIds.includes(activeId)) {
router.push("/"); router.push("/");
@@ -1744,7 +1846,6 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar
await handleDeleteAssets(assetIds, selectedAssetHints); await handleDeleteAssets(assetIds, selectedAssetHints);
} }
await refreshTree();
setContextMenu(null); setContextMenu(null);
if (!isRustFamilyTreeRenderer) { if (!isRustFamilyTreeRenderer) {
setLegacyResourceSelection((prev) => reduceTreePaneSelection(prev, { type: "clear" })); setLegacyResourceSelection((prev) => reduceTreePaneSelection(prev, { type: "clear" }));
@@ -1760,7 +1861,7 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar
resourceShellRowById, resourceShellRowById,
resourceSelection, resourceSelection,
handleDeleteAssets, handleDeleteAssets,
refreshTree, removeDocumentsFromTree,
router, router,
sidebarData.activeWorkspaceId, sidebarData.activeWorkspaceId,
]); ]);
@@ -1844,7 +1945,6 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar
return next; return next;
}); });
await refreshTree();
const query = new URLSearchParams(); const query = new URLSearchParams();
if (nextNode.workspace_id) { if (nextNode.workspace_id) {
query.set("workspaceId", nextNode.workspace_id); query.set("workspaceId", nextNode.workspace_id);
@@ -1861,7 +1961,7 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar
creatingDocumentUnderParentRef.current.delete(creatingKey); creatingDocumentUnderParentRef.current.delete(creatingKey);
} }
}, },
[refreshTree, router], [router],
); );
const handleRename = useCallback( const handleRename = useCallback(
@@ -1885,16 +1985,6 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar
[refreshTree, sidebarData.activeWorkspaceId], [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( const handleMove = useCallback(
async (documentId: string, parentId: string | null, index: number) => { async (documentId: string, parentId: string | null, index: number) => {
setTree((prev) => moveLocalNode(prev, documentId, parentId, index)); setTree((prev) => moveLocalNode(prev, documentId, parentId, index));
@@ -1948,8 +2038,9 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar
return; return;
} }
if (uploadTargetPlan.targetMindmapId) { const targetMindmapId = uploadTargetPlan.targetMindmapId;
setExpandedAssetFolders((prev) => new Set(prev).add(uploadTargetPlan.targetMindmapId)); if (targetMindmapId) {
setExpandedAssetFolders((prev) => new Set(prev).add(targetMindmapId));
} }
const errors: string[] = []; const errors: string[] = [];
@@ -2151,13 +2242,13 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar
documentId, documentId,
workspaceId: sidebarData.activeWorkspaceId ?? null, workspaceId: sidebarData.activeWorkspaceId ?? null,
}); });
await refreshTree(); removeDocumentsFromTree([documentId]);
emitDocumentsChanged(documentId); emitDocumentsChanged(documentId);
if (activeId === documentId) { if (activeId === documentId) {
router.push("/"); router.push("/");
} }
}, },
[activeId, refreshTree, router, sidebarData.activeWorkspaceId], [activeId, removeDocumentsFromTree, router, sidebarData.activeWorkspaceId],
); );
const handleDeleteFromContextMenuNode = useCallback( const handleDeleteFromContextMenuNode = useCallback(
@@ -3489,6 +3580,32 @@ const removeNode = (
return { removed, tree: nextTree }; 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[] => { const insertNode = (nodes: SidebarTreeNode[], parentId: string | null, index: number, newNode: SidebarTreeNode): SidebarTreeNode[] => {
if (!parentId) { if (!parentId) {
const next = [...nodes]; const next = [...nodes];
@@ -15,6 +15,7 @@ import type {
FileTreeShellDeleteSelectionPayload, FileTreeShellDeleteSelectionPayload,
FileTreeShellExternalDropPayload, FileTreeShellExternalDropPayload,
FileTreeShellInternalDropPayload, FileTreeShellInternalDropPayload,
TreeShellMutationPayload,
TreeShellHostMode, TreeShellHostMode,
TreeShellPickerCommand, TreeShellPickerCommand,
} from "@/components/sidebar/tree-shell-host"; } from "@/components/sidebar/tree-shell-host";
@@ -100,7 +101,7 @@ type TreeShellRustDomShellHostProps = {
onInternalDrop?: (payload: FileTreeShellInternalDropPayload) => void; onInternalDrop?: (payload: FileTreeShellInternalDropPayload) => void;
onDropFiles?: (payload: FileTreeShellExternalDropPayload) => void; onDropFiles?: (payload: FileTreeShellExternalDropPayload) => void;
onAssetOpen?: (payload: { assetId: string; documentId: string | null }) => void; onAssetOpen?: (payload: { assetId: string; documentId: string | null }) => void;
onTreeMutation?: (payload: { type: string; documentId: string | null }) => void; onTreeMutation?: (payload: TreeShellMutationPayload) => void;
children?: ReactNode; children?: ReactNode;
}; };
@@ -120,6 +121,19 @@ type TreeShellRuntimeWasmModule = {
type TreeShellRuntimeReducer = (request: unknown) => Promise<unknown> | unknown; 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 & { type TreeShellRuntimeGlobal = typeof globalThis & {
__MNOTE_TREE_SHELL_RUNTIME__?: { __MNOTE_TREE_SHELL_RUNTIME__?: {
reduceTreeShellRuntime?: TreeShellRuntimeReducer; reduceTreeShellRuntime?: TreeShellRuntimeReducer;
@@ -179,6 +193,20 @@ function normalizeShellRowKind(value: unknown, defaultValue = "") {
return rowKind; 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[]>) { function itemHasVisibleChildren(item: TreeShellDomProjectionItem, childrenByParent: Map<string, TreeShellDomProjectionItem[]>) {
return item.childCount > 0 && (childrenByParent.get(item.nodeId)?.length ?? 0) > 0; return item.childCount > 0 && (childrenByParent.get(item.nodeId)?.length ?? 0) > 0;
} }
@@ -796,16 +824,40 @@ export function TreeShellRustDomShellHost({
if (event.kind === "createNode") { if (event.kind === "createNode") {
const parentId = normalizeString(event.parentNodeId) || null; const parentId = normalizeString(event.parentNodeId) || null;
const result = await runTreeCommand({ action: "create", parentId }); 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)); const commandResult = readTreeCommandResult(result);
onTreeMutation?.({ type: "tree.node.created", documentId: documentId || null }); 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; continue;
} }
if (event.kind === "renameNode") { if (event.kind === "renameNode") {
const documentId = normalizeString(event.nodeId); const documentId = normalizeString(event.nodeId);
const title = normalizeString(event.title); const title = normalizeString(event.title);
if (!documentId || !title) continue; if (!documentId || !title) continue;
await runTreeCommand({ action: "rename", documentId, title }); const result = await runTreeCommand({ action: "rename", documentId, title });
onTreeMutation?.({ type: "tree.node.renamed", documentId }); 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; continue;
} }
if (event.kind === "moveSubtree") { if (event.kind === "moveSubtree") {
@@ -813,12 +865,26 @@ export function TreeShellRustDomShellHost({
if (!documentId) continue; if (!documentId) continue;
const parentId = normalizeString(event.targetParentId) || null; const parentId = normalizeString(event.targetParentId) || null;
const sortOrder = typeof event.sortOrder === "number" && Number.isFinite(event.sortOrder) ? event.sortOrder : 0; const sortOrder = typeof event.sortOrder === "number" && Number.isFinite(event.sortOrder) ? event.sortOrder : 0;
await runTreeCommand({ action: "move", documentId, parentId, sortOrder }); const result = await runTreeCommand({ action: "move", documentId, parentId, sortOrder });
onTreeMutation?.({ type: "tree.subtree.moved", documentId }); 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( const handlePageOpen = useCallback(
@@ -837,7 +903,7 @@ export function TreeShellRustDomShellHost({
onPageExpandChange?.({ documentId: nodeId, expanded: state.expandedIds.includes(nodeId) }); onPageExpandChange?.({ documentId: nodeId, expanded: state.expandedIds.includes(nodeId) });
} }
}, },
[onPageExpandChange, pageState.expandedIds, reducePageAction], [onPageExpandChange, pageState, reducePageAction],
); );
const handlePageKeyDown = useCallback( const handlePageKeyDown = useCallback(
@@ -46,6 +46,22 @@ export type FileTreeShellDeleteSelectionPayload = {
focusedRowId: string | null; 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 = { type TreeShellHostProps = {
mode: TreeShellHostMode; mode: TreeShellHostMode;
surfaceTestId: string; surfaceTestId: string;
@@ -89,7 +105,7 @@ type TreeShellHostProps = {
onInternalDrop?: (payload: FileTreeShellInternalDropPayload) => void; onInternalDrop?: (payload: FileTreeShellInternalDropPayload) => void;
onDropFiles?: (payload: FileTreeShellExternalDropPayload) => void; onDropFiles?: (payload: FileTreeShellExternalDropPayload) => void;
onAssetOpen?: (payload: { assetId: string; documentId: string | null }) => void; onAssetOpen?: (payload: { assetId: string; documentId: string | null }) => void;
onTreeMutation?: (payload: { type: string; documentId: string | null }) => void; onTreeMutation?: (payload: TreeShellMutationPayload) => void;
children: ReactNode; children: ReactNode;
}; };
@@ -2,9 +2,11 @@
import { useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react"; import { useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
import type { import type {
FileTreeShellDeleteSelectionPayload,
FileTreeShellExternalDropPayload, FileTreeShellExternalDropPayload,
FileTreeShellInternalDropPayload, FileTreeShellInternalDropPayload,
TreeShellHostMode, TreeShellHostMode,
TreeShellMutationPayload,
TreeShellPickerCommand, TreeShellPickerCommand,
} from "@/components/sidebar/tree-shell-host"; } from "@/components/sidebar/tree-shell-host";
import { import {
@@ -33,10 +35,7 @@ type TreeShellBridgePageExpandPayload = {
expanded: boolean; expanded: boolean;
}; };
type TreeShellBridgeMutationPayload = { type TreeShellBridgeMutationPayload = TreeShellMutationPayload;
type: string;
documentId: string | null;
};
type TreeShellBridgeSelectionPayload = { type TreeShellBridgeSelectionPayload = {
selectedRowIds: string[]; selectedRowIds: string[];
@@ -137,6 +136,7 @@ export type TreeShellIframeHostProps = {
onPageFocusChange?: (payload: { documentId: string | null }) => void; onPageFocusChange?: (payload: { documentId: string | null }) => void;
onFileTreeContextMenu?: (payload: TreeShellBridgeContextMenuPayload) => void; onFileTreeContextMenu?: (payload: TreeShellBridgeContextMenuPayload) => void;
onFileTreeSelectionChange?: (payload: TreeShellBridgeSelectionPayload) => void; onFileTreeSelectionChange?: (payload: TreeShellBridgeSelectionPayload) => void;
onFileTreeDeleteSelection?: (payload: FileTreeShellDeleteSelectionPayload) => void;
onInternalDrop?: (payload: FileTreeShellInternalDropPayload) => void; onInternalDrop?: (payload: FileTreeShellInternalDropPayload) => void;
onDropFiles?: (payload: FileTreeShellExternalDropPayload) => void; onDropFiles?: (payload: FileTreeShellExternalDropPayload) => void;
onAssetOpen?: (payload: { assetId: string; documentId: string | null }) => void; onAssetOpen?: (payload: { assetId: string; documentId: string | null }) => void;
@@ -160,6 +160,12 @@ type TreeShellBridgeMessage = {
copy: boolean; copy: boolean;
files: File[]; files: File[];
itemKey: string | null; 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 = [ const INLINE_TREE_SHELL_LOADING_HTML = [
@@ -1269,15 +1275,21 @@ const LOCAL_TREE_SHELL_TEMPLATE = `<!doctype html>
sortOrder = position === "after" ? targetIndex + 1 : targetIndex; sortOrder = position === "after" ? targetIndex + 1 : targetIndex;
} }
try { try {
await runTreeCommand({ const result = await runTreeCommand({
action: "move", action: "move",
documentId: sourceNodeId, documentId: sourceNodeId,
parentId, parentId,
sortOrder, sortOrder,
}); });
const commandResult = result && result.result && typeof result.result === "object" ? result.result : {};
setLastAction(targetItem ? \`页面已拖放到 \${targetItem.title}\` : "页面已完成拖放移动"); setLastAction(targetItem ? \`页面已拖放到 \${targetItem.title}\` : "页面已完成拖放移动");
postToHost("tree.subtree.moved", { postToHost("tree.subtree.moved", {
documentId: sourceNodeId, 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 }, target: { documentId: sourceNodeId },
}); });
return true; return true;
@@ -1301,8 +1313,15 @@ const LOCAL_TREE_SHELL_TEMPLATE = `<!doctype html>
normalizeText(result && result.id), normalizeText(result && result.id),
); );
setLastAction(documentId ? \`已创建子页面 \${documentId}\` : "已创建子页面"); setLastAction(documentId ? \`已创建子页面 \${documentId}\` : "已创建子页面");
const commandResult = result && result.result && typeof result.result === "object" ? result.result : {};
postToHost("tree.node.created", { postToHost("tree.node.created", {
documentId: documentId || null, 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 }, target: { documentId: documentId || null },
}); });
return true; return true;
@@ -1322,16 +1341,21 @@ const LOCAL_TREE_SHELL_TEMPLATE = `<!doctype html>
); );
if (!nodeId || !title) return false; if (!nodeId || !title) return false;
try { try {
await runTreeCommand({ const result = await runTreeCommand({
action: "rename", action: "rename",
documentId: nodeId, documentId: nodeId,
title, title,
}); });
const commandResult = result && result.result && typeof result.result === "object" ? result.result : {};
const item = itemById.get(nodeId); const item = itemById.get(nodeId);
if (item) item.title = title; if (item) item.title = title;
setLastAction(\`已重命名为 \${title}\`); setLastAction(\`已重命名为 \${title}\`);
postToHost("tree.node.renamed", { postToHost("tree.node.renamed", {
documentId: nodeId, 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 }, target: { documentId: nodeId },
}); });
if (fallbackCommandEvent?.titleElement) { if (fallbackCommandEvent?.titleElement) {
@@ -4424,8 +4448,11 @@ export function TreeShellIframeHost({
mode, mode,
workspaceId, workspaceId,
rootNodeId, rootNodeId,
activePickerItemKey,
focusedDocumentId,
inlineBootstrapActiveDocumentId, inlineBootstrapActiveDocumentId,
inlineInitialTreeHtml, inlineInitialTreeHtml,
inlineProjectionItems,
allowRootPick, allowRootPick,
excludeIds, excludeIds,
resolvedChannel, resolvedChannel,
@@ -4590,6 +4617,12 @@ export function TreeShellIframeHost({
onTreeMutation?.({ onTreeMutation?.({
type: message.type, type: message.type,
documentId: message.documentId, 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; return;
} }
@@ -6,6 +6,7 @@ import {
type FileTreeShellDeleteSelectionPayload, type FileTreeShellDeleteSelectionPayload,
type FileTreeShellExternalDropPayload, type FileTreeShellExternalDropPayload,
type FileTreeShellInternalDropPayload, type FileTreeShellInternalDropPayload,
type TreeShellMutationPayload,
type TreeShellPickerCommand, type TreeShellPickerCommand,
type TreeRendererFamily, type TreeRendererFamily,
} from "@/components/sidebar/tree-shell-host"; } from "@/components/sidebar/tree-shell-host";
@@ -36,7 +37,7 @@ type SidebarPageTreeSurfaceProps = {
onPageContextMenu?: (payload: { documentId: string; x: number; y: number }) => void; onPageContextMenu?: (payload: { documentId: string; x: number; y: number }) => void;
onPageExpandChange?: (payload: { documentId: string | null; expanded: boolean }) => void; onPageExpandChange?: (payload: { documentId: string | null; expanded: boolean }) => void;
onPageFocusChange?: (payload: { documentId: string | null }) => void; onPageFocusChange?: (payload: { documentId: string | null }) => void;
onTreeMutation?: (payload: { type: string; documentId: string | null }) => void; onTreeMutation?: (payload: TreeShellMutationPayload) => void;
}; };
type SidebarFileTreeSurfaceProps = { type SidebarFileTreeSurfaceProps = {
@@ -74,7 +75,7 @@ type SidebarFileTreeSurfaceProps = {
}) => void; }) => void;
onFileTreeDeleteSelection?: (payload: FileTreeShellDeleteSelectionPayload) => void; onFileTreeDeleteSelection?: (payload: FileTreeShellDeleteSelectionPayload) => void;
onAssetOpen?: (payload: { assetId: string; documentId: string | null }) => void; onAssetOpen?: (payload: { assetId: string; documentId: string | null }) => void;
onTreeMutation?: (payload: { type: string; documentId: string | null }) => void; onTreeMutation?: (payload: TreeShellMutationPayload) => void;
}; };
export type SidebarTreeSurfaceProps = export type SidebarTreeSurfaceProps =
@@ -40,6 +40,7 @@ function Harness(props: {
sidebarQueryData: SidebarInitialData | null; sidebarQueryData: SidebarInitialData | null;
treeStreamData: SidebarInitialData | null; treeStreamData: SidebarInitialData | null;
treeStreamStatus?: "idle" | "connecting" | "live" | "fallback"; treeStreamStatus?: "idle" | "connecting" | "live" | "fallback";
treeStreamCursor?: string | null;
onState: (state: ReturnType<typeof usePreferredSidebarSnapshot>) => void; onState: (state: ReturnType<typeof usePreferredSidebarSnapshot>) => void;
}) { }) {
const state = usePreferredSidebarSnapshot(props); 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 () => { it("tree stream 进入 fallback 后应回退到 query 快照", async () => {
const initialData = buildSidebarData([buildDocument({ title: "标题 A" })]); const initialData = buildSidebarData([buildDocument({ title: "标题 A" })]);
const staleTreeStream = buildSidebarData([buildDocument({ title: "标题 A" })]); const staleTreeStream = buildSidebarData([buildDocument({ title: "标题 A" })]);
@@ -1,6 +1,6 @@
import { useMemo } from "react"; import { useMemo } from "react";
import type { SidebarInitialData } from "@/components/sidebar/types"; 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"; export type PreferredSidebarSnapshotSource = "initial" | "query" | "tree_stream";
@@ -9,6 +9,7 @@ export function usePreferredSidebarSnapshot(input: {
sidebarQueryData: SidebarInitialData | null; sidebarQueryData: SidebarInitialData | null;
treeStreamData: SidebarInitialData | null; treeStreamData: SidebarInitialData | null;
treeStreamStatus?: "idle" | "connecting" | "live" | "fallback"; treeStreamStatus?: "idle" | "connecting" | "live" | "fallback";
treeStreamCursor?: string | null;
}) { }) {
const querySyncKey = useMemo( const querySyncKey = useMemo(
() => (input.sidebarQueryData ? buildSidebarDataSyncKey(input.sidebarQueryData) : null), () => (input.sidebarQueryData ? buildSidebarDataSyncKey(input.sidebarQueryData) : null),
@@ -19,29 +20,40 @@ export function usePreferredSidebarSnapshot(input: {
[input.treeStreamData], [input.treeStreamData],
); );
const initialSyncKey = useMemo(() => buildSidebarDataSyncKey(input.initialData), [input.initialData]); 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 streamIsPreferred = input.treeStreamStatus !== "fallback";
const queryHasFreshSnapshot =
input.sidebarQueryData != null && const preferredVersion = Math.max(
querySyncKey != null && initialVersion,
querySyncKey !== initialSyncKey && queryVersion ?? 0,
treeStreamSyncKey === initialSyncKey; streamIsPreferred ? treeStreamVersion ?? 0 : 0,
);
const queryHasPreferredVersion = queryVersion != null && queryVersion >= preferredVersion;
const treeStreamHasPreferredVersion =
streamIsPreferred &&
input.treeStreamData != null &&
treeStreamVersion != null &&
treeStreamVersion >= preferredVersion;
const source = useMemo<PreferredSidebarSnapshotSource>(() => { const source = useMemo<PreferredSidebarSnapshotSource>(() => {
if (queryHasFreshSnapshot) { if (treeStreamHasPreferredVersion) {
return "query";
}
if (input.treeStreamData && streamIsPreferred) {
return "tree_stream"; return "tree_stream";
} }
if (input.sidebarQueryData) { if (queryHasPreferredVersion) {
return "query"; return "query";
} }
return "initial"; return "initial";
}, [ }, [
input.sidebarQueryData, queryHasPreferredVersion,
input.treeStreamData, treeStreamHasPreferredVersion,
queryHasFreshSnapshot,
streamIsPreferred,
]); ]);
const data = const data =
@@ -56,13 +68,22 @@ export function usePreferredSidebarSnapshot(input: {
: source === "query" : source === "query"
? querySyncKey ?? initialSyncKey ? querySyncKey ?? initialSyncKey
: 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( return useMemo(
() => ({ () => ({
data, data,
source, source,
syncKey, 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 }); 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", () => { it("把主题、结构和扩展字段映射到 kernel/compat", () => {
expect(mapMindmapActionToCommand({ actionId: "setTheme", mindmapId: "mind_1", value: "classic4" })).toEqual({ expect(mapMindmapActionToCommand({ actionId: "setTheme", mindmapId: "mind_1", value: "classic4" })).toEqual({
command: { type: "setTheme", mindmapId: "mind_1", theme: "classic4" }, 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: "formula", target: "compatPatch", requiresActiveNode: true, readonlyAllowed: false, compatPath: nodeDataPath("formula") },
{ actionId: "painter", target: "compatPatch", requiresActiveNode: true, readonlyAllowed: false, compatPath: nodeDataPath("style") }, { actionId: "painter", target: "compatPatch", requiresActiveNode: true, readonlyAllowed: false, compatPath: nodeDataPath("style") },
{ actionId: "import", target: "compatPatch", requiresActiveNode: false, readonlyAllowed: false, compatPath: () => "import" }, { 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: "centerRoot", target: "localView", runtimeMethod: "centerRoot", requiresActiveNode: false, readonlyAllowed: true },
{ actionId: "zoomIn", target: "localView", runtimeMethod: "zoomIn", requiresActiveNode: false, readonlyAllowed: true }, { actionId: "zoomIn", target: "localView", runtimeMethod: "zoomIn", requiresActiveNode: false, readonlyAllowed: true },
{ actionId: "zoomOut", target: "localView", runtimeMethod: "zoomOut", 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); 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 提供稳定合同", () => { it("派生默认 shell state,给 Leptos shell 提供稳定合同", () => {
const state = deriveMindmapUiState({ const state = deriveMindmapUiState({
activeNodeId: "node_1", activeNodeId: "node_1",