diff --git a/bugs/04-tree-domain/done/4-25-tree-command-create-delete-reload-latency-v1.md b/bugs/04-tree-domain/done/4-25-tree-command-create-delete-reload-latency-v1.md new file mode 100644 index 00000000..f9ae1320 --- /dev/null +++ b/bugs/04-tree-domain/done/4-25-tree-command-create-delete-reload-latency-v1.md @@ -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-` 页面。 +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` 防回归。 diff --git a/bugs/04-tree-domain/process/4-25-tree-command-create-delete-reload-latency-v1.md b/bugs/04-tree-domain/process/4-25-tree-command-create-delete-reload-latency-v1.md deleted file mode 100644 index 4f5c59b0..00000000 --- a/bugs/04-tree-domain/process/4-25-tree-command-create-delete-reload-latency-v1.md +++ /dev/null @@ -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-` 页面。 -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 断言。 diff --git a/bugs/05-editor-mainline/done/5-11-mindmap-ghost-assets-and-tree-command-latency-v1.md b/bugs/05-editor-mainline/done/5-11-mindmap-ghost-assets-and-tree-command-latency-v1.md new file mode 100644 index 00000000..84dd0e97 --- /dev/null +++ b/bugs/05-editor-mainline/done/5-11-mindmap-ghost-assets-and-tree-command-latency-v1.md @@ -0,0 +1,293 @@ +# 5-11 [done][bug] Mindmap 幽灵附件增长与树命令卡顿 v1 + +> 更新时间:2026-05-14 +> +> 分类归属: +> - `05-editor-mainline/process` +> - 涉及边界:`04-tree-domain/tree command + file_tree projection`、`06-mindmap/runtime save + resource relation` +> +> 用户证据: +> - `/mnt/Data1T/mnote/tmp/image copy 94.png` +> - `/mnt/Data1T/mnote/tmp/image copy 93.png` + +## 1. 问题定义 + +用户在页面中只是修改了一下 mindmap,文件树/资源树下却陆续出现多个 `mindmap-mindmap...` 附件行。截图显示同一页面 `新页面3` 下有 `index.md`,并且 mindmap 附件从 2 条增长到 4 条。 + +同一轮反馈还指出:删除页面和新建页面都很慢,表现为树操作后明显卡顿。 + +这不是单纯的图标显示问题。当前症状同时暴露两条链路风险: + +1. mindmap 编辑/初始化/保存链路会多次向资产层广播同一个资源存在,且缺少“页面正文 block 与 mindmap asset 关系唯一”的硬约束。 +2. tree shell 的页面新建、删除、重命名、移动仍在命令成功后强制整页刷新,和当前 live projection/SSE 机制重复,导致用户感知卡顿。 + +## 2. 真实现象 + +已观察到的用户现象: + +1. 新页面下初始只有 `index.md` 和少量 mindmap 附件。 +2. 用户只是编辑 mindmap,不是主动新建 mindmap。 +3. 等一会儿或再次修改后,同一页面下又出现新的 `mindmap-mindmap...` 行。 +4. 页面新建和删除动作响应慢,像是页面/树整体重新加载。 + +期望结果: + +1. 一个页面内的一个 mindmap block 只对应一个稳定 `mindmapId` 和一个 file tree asset row。 +2. mindmap 保存只能更新既有资源,不应创建新的 mindmap 资产行。 +3. file tree projection 应按 `{documentId, blockId, assetId}` 或明确 object identity 去重。 +4. 页面新建/删除成功后应优先消费 command result / tree delta 更新局部投影,不应默认整页 reload。 + +## 3. 初步调查证据 + +### 3.1 mindmap 资产广播存在多入口 + +`wolai-frontend/src/components/editor/blocks/MindmapBlock.tsx` 中同一个 mindmap 资源至少有三处会触发资产刷新广播: + +- 保存成功后广播 `emitAssetsChanged(docId, { id: mindmapId, asset_type: "mindmap", ... })`:`MindmapBlock.tsx:1499` 到 `:1534` +- 初始同步 `createOnly: true` 成功后广播:`MindmapBlock.tsx:1591` 到 `:1621` +- mindmap 实例就绪后立即广播:`MindmapBlock.tsx:1636` 到 `:1647` + +另一个 legacy/compat block wrapper 也会在 mount 时执行 `createOnly` 并广播资产:`MindmapBlock.tsx:3573` 到 `:3612`。 + +这些广播本身用 `id = mindmapId`,理论上同 ID 会被 sidebar 本地 state 去重;但只要保存/转换链路让同一视觉 mindmap 换了新的 `mindmapId`,就会生成新的资产行。 + +### 3.2 mindmapId 仍可能由时间戳生成 + +当前 `leptos-tiptap` 插入 mindmap 时使用: + +- `rust/spikes/leptos-tiptap-spike/src/lib.rs:5681` 到 `:5689` + +这里 `next_mindmap_id()` 生成 `mindmap_{Date.now()}`,并写入 paragraph attrs 的 `mindmapId`。如果后续转换、保存、重新挂载中丢失原 attrs,fallback 会用新的 block identity / 新插入节点创建新的 `mindmapId`,资产层就会认为这是另一个 mindmap。 + +相关转换锚点: + +- `rust/crates/mnote-web/src/routes/web_shell.rs:818` 到 `:838`:legacy block 转 TipTap 时写入 `mindmapId` +- `rust/crates/mnote-web/src/routes/web_shell.rs:983` 到 `:1009`:TipTap 节点转 editor block 时若 attrs 缺失则用 blockId fallback +- `wolai-frontend/src/lib/documents/tiptap-content-converter.ts:188` 到 `:213`:`mindmapReferenceProps` 会在缺少 `mindmapId` 时 fallback 到 blockId +- `wolai-frontend/src/lib/documents/tiptap-content-converter.ts:410` 到 `:418`:editor block 转 TipTap 时把 mindmap props 写回 paragraph attrs + +当前缺少一条回归断言:连续编辑同一个 mindmap 后,保存前后 `mindmapId` 必须保持不变,且 file tree 下同一页面 mindmap asset 数量不增长。 + +### 3.3 Convex mindmaps 表允许同页多 mindmap,但缺少 block 关系唯一约束 + +`wolai-frontend/convex/schema.ts:164` 到 `:183` 定义 mindmaps 表,并以 `(document_id, mindmap_id)` 做查询索引。注意这里是普通索引,不是唯一约束。 + +`wolai-frontend/convex/mindmaps.ts` 的 `put` 对同一 `(document_id, mindmap_id)` 是幂等 patch/insert,但它并不知道页面正文中的哪个 block 才是唯一来源。也就是说: + +- 同一个 `mindmapId` 重复保存不会多插入。 +- 如果历史或并发路径已经写出同一 `(document_id, mindmap_id)` 的多行,`put` 当前用 `.first()` 只会 patch 第一行,剩余重复行仍会被后续 list/projection 展示。 +- 但如果前端生成了新的 `mindmapId`,后端会按合法新 mindmap 插入。 +- file tree 会把同一 document 下所有 active mindmaps 映射为资产行。 + +对应映射: + +- `wolai-frontend/convex/mindmaps.ts:206` 到 `:245`:`put` 查询 `by_doc_mindmap` 后 `.first()`,不存在则 insert +- `wolai-frontend/convex/sidebar.ts:79` 到 `:100`:`mindmap_id` 被映射为 `id` 和 `mindmap-{mindmap_id}.json` +- `wolai-frontend/convex/sidebar.ts:193` 到 `:220`:所有 active mindmaps 都进入 `mindmap_assets` +- `rust/crates/bridge-runtime/src/lib.rs:7569` 到 `:7647`:file tree projection 从 `mindmap_assets` 构建资源行,并只按 asset id 去重 + +因此,当前有两个需要实测区分的分支: + +1. 同一视觉对象被保存成多个不同 `mindmapId`,每个都被合法展示为一个资源。 +2. 数据表中已经存在同一 `(document_id, mindmap_id)` 多行,`.first()` 更新掩盖重复行,sidebar list 把重复行全部暴露出来。 + +两者都会在截图中表现为同一页面下多个 `mindmap-mindmap...` 行。 + +### 3.4 Rust / Next API 都会把 mindmap 保存转成 tree resync + +保存路径还会触发树刷新: + +- Next API `POST /api/mindmap/[docId]/[mindmapId]` 构造 `mindmaps.put` 或 `mindmap.command.apply`:`wolai-frontend/src/app/api/mindmap/[docId]/[mindmapId]/route.ts:229` 到 `:254` +- Rust API 普通保存也包装为 `mindmaps.put` 并携带 `createOnly`:`rust/crates/mnote-web/src/routes/mindmap_api.rs:177` 到 `:200` +- Bridge runtime 给 `mindmaps.put` 生成 `resync_required` 树事件:`rust/crates/bridge-runtime/src/lib.rs:9273` 到 `:9326` + +这意味着 mindmap 每次保存都会推动资源树重新读 projection;如果底层 mindmaps 数据已经重复,保存/刷新会把重复行显性化。 + +### 3.5 mindmap 节点编辑 artifact 语义过宽 + +当前 `mindmaps.put` / `mindmap.command.apply` 已经会进入 artifact 链路,但普通导图节点编辑生成的是 tree resource 语义: + +- `tree.resource.mindmap.put` +- `tree.resource.mindmap.updated` +- `streamDelta: resync_required` + +这会把“导图 object 内部内容变化”提升成“资源树需要重新同步”。从 `design/10-review/05-tree.md` 的对象边界看,mindmap 是页面内 block 关联的独立 object / asset editor,不是 `index.md` 正文替身;普通节点文字、结构、样式变化应属于 mindmap object 内容事件,而不是 file tree 资源关系事件。 + +因此,artifact 本身仍然需要,但语义需要分层: + +1. 修改 mindmap 节点、文字、样式、折叠状态:应记录 `mindmap.content.updated` / `mindmap.node.updated` 这类 object 内容事件,不应默认触发 file tree resource resync。 +2. 创建、挂载、解绑、删除、移动、重命名 mindmap 资源:才应记录 `tree.resource.*` / `tree.asset.*` 事件,并影响 file tree projection。 +3. `resync_required` 只能作为无法应用精确 delta 时的保守 fallback,不应成为普通节点编辑的默认输出。 + +当前过宽 artifact 会放大本 bug:只要底层存在 ghost mindmap 行,或同一视觉 mindmap 被重新分配了新的 `mindmapId`,普通编辑触发的 tree resync 就会把重复资源更快暴露到文件树,并带来额外刷新成本。 + +### 3.6 次级入口收口进展 + +2026-05-14 已完成 `design/10-review` 第 9 项的入口一致性收口: + +- Mindmap toolbar `export` 不再映射到未受控的 `EXPORT` runtime command,UI state 继续禁用该入口。 +- 旧 `/api/mindmap-ai/expand-node` Next route 从“有效请求稳定 501”改为显式 410 退役边界,并指向 AI Agent 内置 `mindmap_expand_node` 工具。 +- legacy `MindmapSidebar` 中直接调用旧 route 的补完入口已从渲染层关闭。 +- `mindmap.command.apply` 保存 facade 已通过 Rust 单测确认仍存在,未回退到 Page Aggregate body 保存链。 + +这组收口只解决“能力入口与实现不一致”的次级问题,不代表 ghost mindmap 资产增长、普通节点编辑 artifact 语义过宽、file tree 重复资源行这几个核心缺陷已经完成。 + +### 3.7 2026-05-14 ghost asset 止血进展 + +本轮已确认并修复一条会制造幽灵 mindmap asset 的高风险链路: + +- 根因一:`resolveCurrentDocumentId()` 原先优先读取 `window.location.pathname`。切页或打开文件树后,旧 mindmap NodeView 的保存 / 刷新 URL 可能被当前页面路径覆盖成其他 document,导致同一个视觉 mindmap 在错误页面下创建 asset。 +- 根因二:TipTap NodeView 构造时会立即 `fetchScene("initial")`。刚从其他页面切回原页面时,NodeView root 可能尚未插入带 `data-document-id` 的 DOM;旧逻辑会退回 URL document id,进而向错误页面创建同名 asset。 +- `design/05-editor-mainline/reference-code/leptos-tiptap/tiptap/src/extensions/tiptap_paragraph.ts`:`resolveCurrentDocumentId()` 改为优先使用 NodeView anchor 所属的 `[data-document-id]`;当 anchor 存在但尚未连接到 DOM 时返回 `null`,交给已有 retry 机制等待 DOM 归位,不再退回旧 URL。 +- `design/05-editor-mainline/reference-code/leptos-tiptap/tiptap/src/extensions/tiptap_paragraph.test.ts`:补充 NodeView 所属文档优先、无 anchor 时回退 URL、未连接 anchor 不回退旧 URL三类用例。 +- `wolai-frontend/src/lib/documents/tiptap-content-converter.test.ts`:补充 mindmapId 多轮转换稳定性回归用例。 +- `scripts/task169-mindmap-realtime-smoke.js`:增强 File Tree mindmap row 去重与 object identity 断言,等待 runtime view settle 后再做 live 对比。 + +已通过验证: + +- `node --import tsx --test src/extensions/tiptap_paragraph.test.ts`,4 个测试通过。 +- `npm run typecheck`,目录:`design/05-editor-mainline/reference-code/leptos-tiptap/tiptap`。 +- `npm run build`,目录:`design/05-editor-mainline/reference-code/leptos-tiptap/tiptap`,并同步生成物到 leptos-tiptap reference / spike generated JS。 +- `pnpm test -- src/lib/documents/tiptap-content-converter.test.ts`,Vitest 实际执行 112 个测试文件、458 个测试,通过。 +- `node scripts/task169-mindmap-realtime-smoke.js`,结果 `ok=true`、`failures=[]`;同一页面下最终 File Tree mindmap row 数量为 1,`mindmapId=mindmap_1778695090474`,object identity 仍指向同一 `objectKind:"mindmap"`。 + +### 3.8 2026-05-14 普通 mindmap 编辑 artifact 语义收窄 + +本轮已完成普通 mindmap 内容编辑的 artifact 语义收窄: + +- `rust/crates/bridge-runtime/src/lib.rs`:`mindmap.command.apply` 改为产出 `mindmap.content.updated` domain event 与 `noop` stream delta,不再默认产出 `tree.resource.mindmap.updated + resync_required`。 +- `rust/crates/bridge-runtime/src/lib.rs`:`mindmaps.put` 按 `createOnly` 分流;`createOnly=true` 仍表示资源创建 / 挂载,保留 `tree.resource.mindmap.put + resync_required`;`createOnly=false` 表示既有 mindmap blob 内容更新,改为 `mindmap.content.updated + noop`。 +- `rust/crates/mnote-web/src/routes/mindmap_api.rs`:新增 route 单测覆盖 `mindmap.command.apply` 响应中的 object event 与 noop delta。 +- `scripts/task169-mindmap-realtime-smoke.js`:新增真实浏览器 artifact 断言,拦截普通 `mindmap.command.apply` 响应,禁止 `tree.resource.*` event 与 `resync_required` delta。 + +已通过验证: + +- RED:`cargo test -p bridge-runtime mindmaps_put_existing_content_update_uses_object_event_without_tree_resync -- --nocapture` 修改前失败,旧行为为 `resync_required`。 +- RED:`cargo test -p mnote-web mindmap_command_apply_returns_object_artifacts_without_tree_resync -- --nocapture` 修改前失败,旧行为为 `tree.resource.mindmap.put`。 +- GREEN:`cargo test -p bridge-runtime mindmap -- --nocapture`,17 个 mindmap 相关测试通过。 +- GREEN:`cargo test -p mnote-web mindmap -- --nocapture`,6 个 mindmap 相关测试通过。 +- 已通过:`node --check scripts/task169-mindmap-realtime-smoke.js`。 +- 已通过:`node scripts/task169-mindmap-realtime-smoke.js`,最新结果 `ok=true`、`failures=[]`;6 条可解析普通 `mindmap.command.apply` 响应均为 `eventType=mindmap.content.updated`、`streamOp=noop`,另有 1 条 Playwright response body 读取失败被记录为 `skippedReadFailures=1`;同一页面最终仍只有 1 条 mindmap asset row,`mindmapId=mindmap_1778698542703`。 + +仍未完成: + +- 本轮未执行历史幽灵副本清理;若后续需要清理 Convex 中未被正文引用的旧 mindmap 行,必须先经用户确认并保留清理前后证据。 + +### 3.9 2026-05-14 历史幽灵副本只读审计入口 + +本轮新增只读审计脚本,用于在清理前生成候选证据,但不删除任何用户数据: + +- `scripts/task180-mindmap-ghost-candidate-audit.js`:支持从 `/api/sidebar`、导出的 sidebar JSON、`/api/tree/projections/file?workspaceId=` 或导出的 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_`。 +- 页面正文 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,因此本轮不需要执行历史数据清理,也不会删除用户数据。 diff --git a/bugs/05-editor-mainline/process/5-11-mindmap-ghost-assets-and-tree-command-latency-v1.md b/bugs/05-editor-mainline/process/5-11-mindmap-ghost-assets-and-tree-command-latency-v1.md deleted file mode 100644 index c702d491..00000000 --- a/bugs/05-editor-mainline/process/5-11-mindmap-ghost-assets-and-tree-command-latency-v1.md +++ /dev/null @@ -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_`。 -- 页面正文 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. [ ] 若清理历史幽灵副本,必须经用户确认,并保留清理前后证据。 diff --git a/design/03-rust-web/done/3-11-rust-web-legacy-next-retirement-gates-v1.md b/design/03-rust-web/done/3-11-rust-web-legacy-next-retirement-gates-v1.md index ad2ce6dd..5719d852 100644 --- a/design/03-rust-web/done/3-11-rust-web-legacy-next-retirement-gates-v1.md +++ b/design/03-rust-web/done/3-11-rust-web-legacy-next-retirement-gates-v1.md @@ -3,10 +3,11 @@ > 状态说明: > - 本稿定义的 default gate 已在当前主线代码中成立:`mnote-web` 是 `3000` owner,Next 仅保留 legacy compat/debug 边界 > - `MNOTE_WEB_ENABLE_LEGACY_NEXT_COMPAT` 默认关闭与 `task117` guard 已落地,故迁入 `done/` +> - 2026-05-13 追加说明:本文中“长期 agent 执行面收口到 `mnote-cli`”是历史完成口径;当前 AI 长期方向已由 `design/07-ai/process/7-3-page-ai-hermes-panel-and-mnote-plugin-v1.md` 覆盖为 Hermes 页面内客户端 + mnote Hermes skill/plugin ## 目标 -将 Next App Router 从 3000 默认主链降级为显式 legacy/debug/internal 兼容边界。默认首页、文档页、搜索页、导图页、tree SSE 与 AI bridge host 均由 `mnote-web` 承接;长期 agent 执行面收口到 `mnote-cli`。 +将 Next App Router 从 3000 默认主链降级为显式 legacy/debug/internal 兼容边界。默认首页、文档页、搜索页、导图页、tree SSE 与 AI bridge host 均由 `mnote-web` 承接;AI 会话与编排长期归 Hermes,mnote 通过 Hermes skill/plugin 暴露业务工具。 ## 当前 owner @@ -15,7 +16,7 @@ - `/search`:Rust Web `search::shell`,Search projection owner `rust-kernel`。 - `/mindmap/{doc_id}/{mindmap_id}`:Rust Web `mindmap_shell::mindmap_object_shell`,Mindmap projection owner `rust-kernel`。 - `/api/tree/events`:Rust Web SSE,stream owner `rust-web`。 -- `/api/hermes/bridge`:Rust Web 兼容 AI bridge;仅作为过渡边界,不是长期 agent 执行面。 +- `/api/hermes/bridge`:Rust Web 兼容 AI bridge;仅作为过渡边界,不是新的 Hermes 页面内客户端代理。 - `/api/compat/next/*`:legacy compat boundary,仅用于迁移期兼容与调试。 ## Gate @@ -23,12 +24,12 @@ - `MNOTE_WEB_ENABLE_LEGACY_NEXT_COMPAT` 默认关闭;只有显式设置为 `1/true/yes` 才允许 fallback proxy。 - 默认主路径不得返回 `x-mnote-legacy-upstream: next-app-router`。 - 导图、搜索、文档页 contract 不得再把 `next-app-router` 声明为主 runtime。 -- `/api/ai-agent/run` 当前仍保留兼容 route,但长期应降为 `mnote-cli` host / adapter,结构化写入必须经 Rust runtime,外置 agent 不得拥有第二执行面。 +- `/api/ai-agent/run` 当前仍保留兼容 route,但长期应退出页面 AI 主路径;新的页面 AI 应走 Hermes client proxy,结构化写入必须经 mnote Hermes plugin -> Rust runtime / kernel。 ## 删除条件 - 删除 `/api/compat/next/sidebar`:Sidebar、workspace shell、file tree smoke 均证明 Rust projection 可覆盖默认入口。 -- 删除 `/api/compat/next/ai-agent/run`:AI 面板默认请求统一 CLI host,legacy 调用方清零;Hermes 仅保留为可插拔外置 agent。 +- 删除 `/api/compat/next/ai-agent/run`:AI 面板默认请求统一 Hermes client proxy,legacy 调用方清零;`mnote-cli` 只可作为 mnote plugin 内部适配器或调试入口。 - 删除 fallback proxy:`task117` 默认关闭 legacy compat 后覆盖首页、文档页、搜索页、导图页与 tree SSE。 ## 验收命令 diff --git a/design/03-rust-web/process/3-15-runtime-fallback-retirement-checklist-v1.md b/design/03-rust-web/done/3-15-runtime-fallback-retirement-checklist-v1.md similarity index 86% rename from design/03-rust-web/process/3-15-runtime-fallback-retirement-checklist-v1.md rename to design/03-rust-web/done/3-15-runtime-fallback-retirement-checklist-v1.md index 3124857f..62f4b813 100644 --- a/design/03-rust-web/process/3-15-runtime-fallback-retirement-checklist-v1.md +++ b/design/03-rust-web/done/3-15-runtime-fallback-retirement-checklist-v1.md @@ -1,4 +1,4 @@ -# 3-15 [process] Runtime Fallback 退场 Checklist v1 +# 3-15 [done] Runtime Fallback 退场 Checklist v1 > 更新时间:2026-05-09 > @@ -9,7 +9,12 @@ > - `/mnt/Data1T/mnote/design/03-rust-web/process/3-3-rust-web-tree-realtime-event-stream-v1.md` > - `/mnt/Data1T/mnote/design/05-editor-mainline/process/5-5-page-aggregate-single-truth-alignment-v1.md` > - `/mnt/Data1T/mnote/design/05-editor-mainline/process/5-6-page-aggregate-alignment-checklist-v1.md` -> - `/mnt/Data1T/mnote/design/07-ai/process/7-phase7-ai-kernel-projection-plan-v4.md` +> - `/mnt/Data1T/mnote/design/07-ai/process/7-3-page-ai-hermes-panel-and-mnote-plugin-v1.md` +> +> 2026-05-13 口径修正: +> - 本清单中“删除 `provider=hermes` 直连分支”的结论仍然有效,含义是旧 `/api/ai-agent/run` 不再把 Hermes 当 fallback/provider 分支。 +> - 新长期方向不是恢复旧 provider 分支,而是新增正式 Hermes 页面内客户端与 mnote Hermes skill/plugin。 +> - 因此“退场旧 direct Hermes fallback”和“页面 AI 长期调用 Hermes”不冲突。 ## 1. 目标 @@ -59,14 +64,14 @@ - [x] 把 `BlockNote` 从“系统默认可退回编辑器”降为 `recycle` 参考实现 - [x] 活跃设计稿中不再把 `BlockNote` 写成系统 fallback,只允许写成历史对照链 -### 4.2 AI provider 直连分支 +### 4.2 旧 AI provider 直连分支 - [x] 删除 Next `/api/ai-agent/run` 中 `provider=codex` 直连分支 - [x] 删除 Next `/api/ai-agent/run` 中 `provider=hermes` 直连分支 - [x] 删除 Rust `compat` 中代理到 legacy Next AI route 的优先分支 - [x] 删除 Rust `compat` 中 direct Hermes 分支 - [x] 删除 Rust `compat` 中 document AI orchestrator fallback 分支 -- [x] 统一只保留 `mnote-cli` host 作为默认主执行入口 +- [x] 旧 `/api/ai-agent/run` 不再作为页面 AI 主执行入口;历史 `mnote-cli` host 只保留为兼容/内部适配,不再代表长期页面 AI 主线 - [x] 次要 provider 请求改为明确失败,不再静默降级 ### 4.3 legacy Next compat / alias / debug 壳 @@ -133,7 +138,7 @@ ### 8.2 必须消失 - [x] 默认运行时不再自动触发 `BlockNote` fallback -- [x] 默认 AI 运行时不再直连 `Hermes` / `Codex` / 旧 orchestrator compat +- [x] 默认 AI 运行时不再通过旧 `/api/ai-agent/run` provider 分支直连 `Hermes` / `Codex` / 旧 orchestrator compat;新的 Hermes 页面内客户端应走正式 Hermes client proxy - [x] 默认 gateway 不再把 legacy Next 当作未迁路径的总兜底 - [x] `/api/documents/page` 不再在 runtime 中组装 TS fallback projection diff --git a/design/03-rust-web/done/3-4-mnote-web-3104-boundary-retirement-plan-v1.md b/design/03-rust-web/done/3-4-mnote-web-3104-boundary-retirement-plan-v1.md index 5a4fca16..57b2a8da 100644 --- a/design/03-rust-web/done/3-4-mnote-web-3104-boundary-retirement-plan-v1.md +++ b/design/03-rust-web/done/3-4-mnote-web-3104-boundary-retirement-plan-v1.md @@ -120,6 +120,8 @@ ### 3.2 Hermes / AI tool registry 归属仍需继续收口 +2026-05-13 口径更新:本文记录的是 `3104` 退役阶段的历史边界判断,不再代表当前 AI 长期编排主线。当前 AI 长期方向已改为 Hermes 页面内客户端 + mnote Hermes skill/plugin;旧 `openai-agents-python` 编排层与旧 `/api/hermes/bridge` 只作为历史或兼容路径看待。 + 当前前端 AI route 与后端 `openai-agents-python` 编排层已经不再通过 `MNOTE_WEB_BASE_URL` 回源 `mnote-web /api/hermes/bridge` 获取 runtime tool 结果,而是走后端 orchestration + 本地 `bridge-runtime`。 这意味着: @@ -302,12 +304,12 @@ 目标: - AI runtime tool bridge 不再强依赖 `MNOTE_WEB_BASE_URL` -- `openai-agents-python` 编排层与工具桥接边界收口到正式 AI 服务面 +- 历史 `openai-agents-python` 编排层不再被描述为长期 AI 服务面;新的正式边界是 Hermes client proxy + mnote Hermes plugin 推荐方向: -- 由 `wolai-backend:8000` 持有 AI orchestration 正式入口 -- Rust `bridge-runtime` 作为内核语义执行层 +- 由 Hermes 持有 session / run / tool event / usage / model 编排 +- Rust `bridge-runtime` 作为 mnote 业务语义执行层,经 Hermes skill/plugin 暴露 - `3000` 只负责页面与同源 API 边界,不再额外绕回 `3104` 当前状态: diff --git a/design/03-rust-web/process/3-1-rust-web-long-term-checklist-v2.md b/design/03-rust-web/process/3-1-rust-web-long-term-checklist-v2.md index b270af0a..818488cf 100644 --- a/design/03-rust-web/process/3-1-rust-web-long-term-checklist-v2.md +++ b/design/03-rust-web/process/3-1-rust-web-long-term-checklist-v2.md @@ -47,7 +47,7 @@ - [x] `Phase 2` 文档阅读态分离已经有实质代码 - [x] `Phase 3` 主 Sidebar 已出现 `kernelSidebarTree` 消费接缝 - [x] `Phase 4` 搜索已做 host/runtime 拆分,并开始返回 `nodeId` / `subtreeRootId` / `evidence` -- [x] `Phase 5` AI 面板已做 host/runtime 拆分,并开始向 AI bridge 传递 `node` / `subtree` / `outline` / `evidence`;长期执行面应收口到 `mnote-cli`,Web 只作为 CLI host / client,`openai-agents-python` / Hermes / Codex 只作为可插拔外置 agent +- [x] `Phase 5` AI 面板已做 host/runtime 拆分,并开始向 AI bridge 传递 `node` / `subtree` / `outline` / `evidence`;2026-05-13 起长期方向改为 Hermes 页面内客户端 + mnote Hermes skill/plugin,旧 CLI host 只保留为历史/兼容证据 - [x] `Phase 6` Mindmap 独立页已去掉 `editorStub` 主入口 - [x] `Phase 7` 阅读态已直接消费 `pageSubtree`,`BlockNote` 也已不是文档页默认唯一入口 @@ -107,7 +107,7 @@ | Phase 2 | 文档阅读页 server-first 化 | `PARTIAL` 接近 `DONE` | 阅读态/编辑态已明显分离,但仍有旧链回退和大量客户端状态集中在 `DocumentContent` | | Phase 3 | Sidebar / 树结构 Rust 化 | `PARTIAL` 偏早期 | 主 Sidebar 已以 `kernelSidebarTree` 作为主树来源,但整体仍是超大客户端组件 | | Phase 4 | 搜索 Rust 化与 island 化 | `PARTIAL` | host/runtime 懒加载拆分已做,结果形状也开始带 `nodeId` / `subtreeRootId` / `evidence`,但仍未完成 server-first 搜索页 | -| Phase 5 | AI 面板 bridge island 化 | `PARTIAL` | host/runtime 拆分已做,且已开始把 `node` / `subtree` / `outline` / `evidence` 送入 AI bridge;长期执行统一落在 `mnote-cli`,Web 面板只做 host / adapter,但 runtime 仍重,协议也未统一到真正“最小壳” | +| Phase 5 | AI 面板 Hermes client island 化 | `PARTIAL` | host/runtime 拆分已做,且已开始把 `node` / `subtree` / `outline` / `evidence` 送入 AI bridge;后续应把 Web 面板退成 Hermes 页面内客户端,把 mnote 能力注册为 Hermes skill/plugin,但 runtime 仍重,协议也未统一到 Hermes session/run/tool contract | | Phase 6 | Mindmap 独立对象化 | `PARTIAL` | 独立页脱离 editor stub 主入口,并补出 `standalone` / `documentBridge` 边界,但仍是客户端重壳 | | Phase 7 | BlockNote 孤岛化 | `PARTIAL` | 阅读态已直接消费 `pageSubtree`,编辑器按需挂载,但外围 drawer/panel 仍集中在同一内容组件 | | Phase 8 | 旧前端壳下线 | `PARTIAL` 接近 `DONE` | 3000 gateway / 文档 shell / tree realtime / Search / AI bridge / Mindmap object shell 已由 `mnote-web` 持有;Next App Router 降为 legacy compat / island bundle source,旧链彻底删除仍待后续 | @@ -290,10 +290,15 @@ --- -## 10. Phase 5:AI 面板进一步收口为 `mnote-cli` host / 纯桥接 island +## 10. Phase 5:AI 面板收口为 Hermes 页面内客户端 / mnote plugin bridge **当前状态:`PARTIAL`** +> 2026-05-13 口径更新: +> - 本阶段原先把页面 AI 继续收口为 `mnote-cli host / client`,现在已被 `design/07-ai/process/7-3-page-ai-hermes-panel-and-mnote-plugin-v1.md` 覆盖。 +> - 新长期方向是:页面 AI 面板使用 Leptos 实现 Hermes 页面内客户端;Hermes session/message/tool event/usage/model 是 AI 会话真相;mnote 只通过 Hermes skill/plugin 暴露页面、树、artifact、edge 等业务能力。 +> - `mnote-cli` 只能作为 plugin 内部适配器或调试入口,不能再被写成页面 AI 唯一长期执行面。 + ### 10.1 已落地事实 - [x] 文档页 AI 已拆为 host + runtime @@ -301,24 +306,24 @@ - [DocumentAiAgentPanel.runtime.tsx](/mnt/Data1T/mnote/wolai-frontend/src/components/editor/DocumentAiAgentPanel.runtime.tsx) - [x] Mindmap / OnlyOffice 也有类似 runtime 拆分 - [x] `GlobalAiAgentHost` 已不在 `(app)/layout.tsx` 主布局中挂载 -- [x] 文档页 AI 已开始把 `node` / `subtree` / `outline` / `evidence` 传给 AI bridge;长期执行面应收口到 `mnote-cli`,页面 AI 只保留为 CLI host / client,`openai-agents-python` / Hermes / Codex 只作为可插拔外置 agent +- [x] 文档页 AI 已开始把 `node` / `subtree` / `outline` / `evidence` 传给 AI bridge;后续应改为 Hermes run/session context,由 Hermes 通过 mnote skill/plugin 回读和写入业务事实 - [DocumentAiAgentPanel.runtime.tsx](/mnt/Data1T/mnote/wolai-frontend/src/components/editor/DocumentAiAgentPanel.runtime.tsx) - [route.ts](/mnt/Data1T/mnote/wolai-frontend/src/app/api/ai-agent/run/route.ts) ### 10.2 当前还没完成 - [ ] runtime 组件仍然非常重 -- [ ] “最小会话协议、统一流式协议、页面上下文协议”还更多体现在组件内部,不是系统级协议层 -- [ ] 还不能说 AI 面板已经变成“纯桥接壳” +- [ ] “最小会话协议、统一流式协议、页面上下文协议”还更多体现在组件内部,不是 Hermes client proxy / plugin tool contract +- [ ] 还不能说 AI 面板已经变成“只调用 Hermes 的页面内客户端” - [ ] 页面级 AI adapter 仍然很大,只是改成了懒加载 -- [ ] AI 已能消费 `node` / `subtree` / `outline` / `evidence` 上下文,但还不是统一的 `mnote-cli` kernel-first tool 协议;Web 面板也还没有彻底退成单纯 host / adapter +- [ ] AI 已能消费 `node` / `subtree` / `outline` / `evidence` 上下文,但还没有进入稳定 Hermes session/run context 与 mnote plugin tool 协议;Web 面板也还没有彻底退成 Hermes client ### 10.3 v2 后续任务 -- [ ] 继续下沉 runtime 内状态与协议 -- [ ] 把会话、tool event、client action 收口到共享协议层 -- [ ] 让 runtime 再减重,而不只是拆文件 -- [ ] 把当前上下文注入进一步收口为更稳定的 kernel node / subtree / edge bridge +- [ ] 移除页面 AI 对旧 `/api/ai-agent/run` 主路径的长期依赖,改走正式 Hermes client proxy +- [ ] 把会话、message、tool event、usage、model 选择交给 Hermes session 存储 +- [ ] 把 mnote 页面、树、artifact、edge 能力注册为 Hermes skill/plugin tools +- [ ] 把当前上下文注入进一步收口为 Hermes run context + 稳定 kernel node / subtree / edge tool bridge --- diff --git a/design/03-rust-web/process/3-rust-web-long-term-architecture-v1.md b/design/03-rust-web/process/3-rust-web-long-term-architecture-v1.md index a166952f..50ddc9b2 100644 --- a/design/03-rust-web/process/3-rust-web-long-term-architecture-v1.md +++ b/design/03-rust-web/process/3-rust-web-long-term-architecture-v1.md @@ -25,7 +25,7 @@ - Rust 内核和 Web 承载层如何分工 - 前端哪些部分应该退出当前重 React 壳 - `axum`、`Leptos` 这类 Rust Web 方案是否值得采用 -- `mnote-cli` 应如何成为唯一长期 agent 执行面 +- 页面 AI 应如何作为 Hermes 页面内客户端接入,并让 mnote 通过 Hermes skill/plugin 暴露业务能力 - 默认主编辑器已经切到页面内 `leptos-tiptap` island 后,剩余重编辑兼容链应如何继续收口 --- @@ -57,7 +57,8 @@ - **业务执行面:现有 Rust workspace 继续作为唯一业务真执行面** - **Web 承载层:`axum`** - **页面渲染模型:`Leptos Islands`** -- **AI 执行面:`mnote-cli`** +- **AI 会话与编排面:Hermes** +- **mnote AI 能力面:Hermes skill/plugin -> Rust runtime / kernel** - **最后保留的重编辑兼容孤岛:少量编辑器 runtime(当前默认主编辑器已是页面内 `leptos-tiptap` island,`BlockNote` 仅保留为 `recycle/` 历史参考副本)** 一句话概括: @@ -180,7 +181,7 @@ - 文档查询与聚合层 - 树结构装配层 - 搜索服务层 -- AI bridge / CLI host 层;长期 agent 执行面只收口到 `mnote-cli`,Hermes 仅作为历史兼容 bridge / 外置 adapter +- Hermes client proxy 与 mnote tool bridge 层;页面 AI 会话真相归 Hermes,mnote 业务工具最终回到 Rust runtime / kernel - 页面 SSR 外壳承载层 - 流式更新、通知、事件推送层 @@ -413,14 +414,15 @@ Leptos Islands 很适合承接下面这类长期目标: 目标: -- 继续桥接统一 CLI host -- 不再承担前端本地 orchestration +- 作为 Hermes 页面内客户端运行 +- 不再承担前端本地 orchestration 或私有会话存储 - 不再成为常驻大壳的一部分 归位: -- 面板只保留最小 UI 与上下文桥接 -- 工具执行全部走 `mnote-cli` + Rust tools +- 面板只保留 Hermes chat/session/run/tool event 的页面内子集 UI 与当前页面上下文桥接 +- Hermes 持有 session / message / usage / model 真相 +- mnote 通过 Hermes skill/plugin 暴露页面、树、artifact、edge 工具,最终执行回到 Rust runtime / kernel - 面板按页面需要懒挂载成单独 island ### 9.5 Mindmap diff --git a/design/05-editor-mainline/done/5-10-wolai-page-settings-and-ai-surface-alignment-v1.md b/design/05-editor-mainline/done/5-10-wolai-page-settings-and-ai-surface-alignment-v1.md index 83d436d6..d6e9b9e2 100644 --- a/design/05-editor-mainline/done/5-10-wolai-page-settings-and-ai-surface-alignment-v1.md +++ b/design/05-editor-mainline/done/5-10-wolai-page-settings-and-ai-surface-alignment-v1.md @@ -5,6 +5,7 @@ > 状态说明: > - 本稿对应的页面设置 `popover`、页面 AI `drawer`、入口位置与最小接线方案已在当前 `mnote-web` 文档壳中落地,故迁入 `done/` > - 本稿中的 deferred 项继续由后续独立任务推进,不影响这一轮“页面设置 + 页面 AI 交互壳”完成判定 +> - 2026-05-13 追加说明:本文中 `CLI-first` / `mnote-cli host` 是 2026-05-06 完成时的历史接线口径;当前 AI 长期方向已由 `design/07-ai/process/7-3-page-ai-hermes-panel-and-mnote-plugin-v1.md` 覆盖为 Hermes 页面内客户端 + mnote Hermes skill/plugin > > 关联文档: > - `/mnt/Data1T/mnote/ARCHITECTURE.md` @@ -13,7 +14,7 @@ > - `/mnt/Data1T/mnote/design/05-editor-mainline/process/5-6-page-aggregate-alignment-checklist-v1.md` > - `/mnt/Data1T/mnote/design/05-editor-mainline/process/5-7-wolai-page-tree-main-editor-experience-restoration-v1.md` > - `/mnt/Data1T/mnote/design/05-editor-mainline/process/5-9-wolai-aline-continuous-checklist-v1.md` -> - `/mnt/Data1T/mnote/design/07-ai/process/7-phase7-ai-kernel-projection-plan-v4.md` +> - `/mnt/Data1T/mnote/design/07-ai/process/7-3-page-ai-hermes-panel-and-mnote-plugin-v1.md` > - `/mnt/Data1T/mnote/design/08-wolai-aline-test-flow/process/wolai-aline-test-flow-v1.md` ## 1. 文档目的 @@ -28,7 +29,7 @@ 1. 页面设置入口与面板形态 2. 页面级 AI 入口与右侧抽屉 -3. 两者与现有 `Page Aggregate` / `mnote-cli` 主线的接线方式 +3. 两者与现有 `Page Aggregate` / Hermes 面板主线的接线方式 一句话收口: @@ -45,7 +46,7 @@ - 页面设置 UI 已存在:`PageOptionsSidebar` - 页面设置写链已存在:`page.layout.updateOptions` - 页面 AI host 已存在:`DocumentAiAgentPanel` -- AI 执行面已收口到 `mnote-cli` host / client +- 历史实现已具备 `mnote-cli` host / client 最小返回链;后续应替换为 Hermes 页面内客户端 - 评论 drawer、历史 drawer、分享 dialog 都已有各自最小实现 但当前现网文档页仍然存在两个明显错位: @@ -141,14 +142,15 @@ > **把现有设置项放进更接近 Wolai 的容器,同时继续显式区分“已正式接通”和“仅保存字段/待接通”。** -### 4.2 页面 AI 继续服从 `CLI-first` +### 4.2 页面 AI 继续服从 Hermes 面板主线 页面 AI 界面可以继续做产品壳,但它不能重新变成独立执行面。 -当前长期方向已经明确: +当前长期方向已经改为: -- `mnote-cli` 是唯一长期 agent 执行面 -- Web AI 面板只是 host / client +- Hermes 是 AI session/message/tool event/usage/model 的会话真相 +- Web AI 面板只是页面内 Hermes client +- mnote 通过 Hermes skill/plugin 暴露页面、树、artifact、edge 等业务工具 因此本轮 AI 界面改造只处理: @@ -326,7 +328,7 @@ Wolai 里页面设置和 AI 的第一感受,首先是: 本轮不改: -- `mnote-cli` 执行链 +- 历史 `mnote-cli` 执行链 - 工具注册表 - 页面 AI 读写命令族 - AI 输出协议 @@ -510,11 +512,11 @@ Rust `mnote-web` 当前在文档壳中已有顶栏占位按钮与右下角浮动 ### 9.3 页面 AI 重复造轮子的风险 -如果为了像 Wolai 而新做一层 AI drawer runtime,会直接偏离 `CLI-first`。 +如果为了像 Wolai 而在 mnote 里新做一层独立 AI drawer runtime,会直接偏离当前 Hermes 面板主线。 本轮约束: -> **页面 AI 只换壳,不换执行面。** +> **页面 AI 交互壳可继续沿用本轮成果,执行主线后续切到 Hermes client proxy + mnote plugin。** ### 9.4 范围失控风险 @@ -535,11 +537,11 @@ Rust `mnote-web` 当前在文档壳中已有顶栏占位按钮与右下角浮动 - 页面设置入口、容器、关闭语义已和 Wolai 基线一致 - 页面 AI 入口、容器、关闭语义已和 Wolai 基线一致 - 页面设置仍继续走 `page.layout.updateOptions` -- 页面 AI 仍继续走 `DocumentAiAgentPanel -> mnote-cli` +- 页面 AI 交互壳保留右下角入口 + 右侧抽屉;后续执行主线切到 Hermes client proxy + mnote plugin - 没有新增第二套页面设置真相 - 没有新增第二套页面 AI 执行面 - 评论、协作、成员、演示模式没有被混入首批范围 本轮完成后的正确口径应是: -> **文档页“页面设置”和“AI 界面”的交互壳开始按 Wolai 收口,但页面域单一真源与 CLI-first AI 的主线保持不变。** +> **文档页“页面设置”和“AI 界面”的交互壳开始按 Wolai 收口;页面域单一真源保持不变,AI 长期主线改为 Hermes 面板 + mnote plugin。** diff --git a/design/05-editor-mainline/done/5-11-wolai-page-settings-and-ai-surface-checklist-v1.md b/design/05-editor-mainline/done/5-11-wolai-page-settings-and-ai-surface-checklist-v1.md index 8bec5126..a619de72 100644 --- a/design/05-editor-mainline/done/5-11-wolai-page-settings-and-ai-surface-checklist-v1.md +++ b/design/05-editor-mainline/done/5-11-wolai-page-settings-and-ai-surface-checklist-v1.md @@ -5,6 +5,7 @@ > 状态说明: > - 本清单覆盖的页面设置 A/B、页面 AI C、集成护栏 D 均已完成并有 `task160/161/162` 证据,故迁入 `done/` > - `X1-X6` 属于明确 deferred 项,不构成这轮完成阻塞 +> - 2026-05-13 追加说明:本文保留 `task161/162` 对旧 `/api/ai-agent/run -> mnote-cli` 返回链的历史验证证据;AI 长期方向已由 `design/07-ai/process/7-3-page-ai-hermes-panel-and-mnote-plugin-v1.md` 改为 Hermes 页面内客户端 + mnote Hermes skill/plugin > > 本清单服务于: > - `/mnt/Data1T/mnote/design/05-editor-mainline/done/5-10-wolai-page-settings-and-ai-surface-alignment-v1.md` @@ -231,9 +232,9 @@ GREEN: | C4 | GREEN | 顶栏 `页面AI` 降级 | `task161` 已断言顶栏不再保留 `页面AI` 主入口 | | C5 | GREEN | 页面 AI 容器类型 | `task161` 已断言页面 AI 以右侧抽屉打开、无遮罩、不跳新页面 | | C6 | GREEN | 页面 AI 关闭语义 | `task161` 已断言 `Esc`、点击空白无效,右上角 `X` 有效 | -| C7 | GREEN | 页面 AI 页面级绑定 | `task161` 已断言 `/api/ai-agent/run` 请求体携带当前 `documentId` 与 `pageOptions` | -| C8 | GREEN | 页面 AI 执行面不分裂 | 真实 `/api/ai-agent/run` 已改为 `Next 兼容优先 -> 本地 mnote-cli fallback -> 旧 orchestrator 兜底`;`task162` 已验证真实页面可返回文本结果 | -| C9 | GREEN | AI chrome 与 Wolai 接近 | `task161` 已断言标题、输入区、`新会话 / 历史会话 / mnote-cli` chrome 可见 | +| C7 | GREEN | 页面 AI 页面级绑定 | `task161` 已断言历史 `/api/ai-agent/run` 请求体携带当前 `documentId` 与 `pageOptions`;后续同类上下文应进入 Hermes run/session context | +| C8 | GREEN | 页面 AI 历史返回链不分裂 | 真实 `/api/ai-agent/run` 曾改为 `Next 兼容优先 -> 本地 mnote-cli fallback -> 旧 orchestrator 兜底`;`task162` 已验证真实页面可返回文本结果;长期主线已改为 Hermes client proxy + mnote plugin | +| C9 | GREEN | AI chrome 与 Wolai 接近 | `task161` 已断言标题、输入区、`新会话 / 历史会话 / mnote-cli` chrome 可见;后续文案应改为 Hermes session / model / history | ### 6.1 本阶段 smoke 建议 @@ -286,8 +287,8 @@ GREEN: - `Esc` 和点击空白不会关闭 - 右上角关闭按钮可关闭 - 抽屉标题、输入区、会话/模型 chrome 可见 - - 发送动作继续命中 `/api/ai-agent/run` -- 请求体继续携带当前 `documentId` 与 `pageOptions` + - 发送动作在历史实现中继续命中 `/api/ai-agent/run` +- 历史请求体继续携带当前 `documentId` 与 `pageOptions`;后续应转为 Hermes run/session context 新增证据: @@ -306,6 +307,7 @@ GREEN: - 同时补了认证头显式转发,避免被 Next 侧 307 `/auth` 拦截 - 页面 AI drawer 默认 provider 改为 `hermes` - 新增 `task162-wolai-page-ai-real-response-smoke.js`,并在真实后端端口 `41327` 上验证:页面 AI 不再显示 `page_ai_failed_502` / “没有返回文本结果”,而是会回填真实文本 +- 2026-05-13 口径更新:上述修复只证明历史页面 AI 抽屉可返回文本,不再作为后续 AI 长期执行面依据;新的页面 AI 应直接调用 Hermes client proxy,不再通过 `/api/ai-agent/run` 的 provider / fallback 分支模拟 Hermes ### 6.3 task162 页面 AI 真实返回执行记录 @@ -325,7 +327,7 @@ GREEN: | ID | 状态 | 任务 | 验收要点 | | --- | --- | --- | --- | | D1 | GREEN | 不新造页面设置真相 | 页面设置继续围绕 `currentPageOptions -> /api/documents/options -> page.layout.updateOptions` | -| D2 | GREEN | 不新造 AI 执行面 | 页面 AI 继续围绕 `/api/ai-agent/run -> mnote-cli` | +| D2 | GREEN | 不新造 AI 执行面 | 历史实现围绕 `/api/ai-agent/run -> mnote-cli` 保持单链;后续不在 mnote 内新造 AI 执行面,而是调用 Hermes 并通过 mnote plugin 写回业务事实 | | D3 | GREEN | 文档页 owner 行为合同固定 | `task160/task161` 已断言 owner 态页面设置和 AI 都不跳新页面、不改 URL | | D4 | GREEN | mobile / 窄屏退化 | `task160/task161` 已断言移动视口下 popover / drawer 不超出视口 | | D5 | GREEN | React compat 与 Rust SSR 边界 | 本轮实现仅落在 `mnote-web` Rust SSR 壳、脚本与样式;未再把入口回接 React compat | @@ -355,9 +357,9 @@ GREEN: - 页面设置 `popover` 行为已通过本地 smoke 和 Wolai 基线复核 - 页面 AI 抽屉行为已通过本地 smoke 和 Wolai 基线复核 - 页面设置仍然围绕 `page.layout.updateOptions` -- 页面 AI 仍然围绕 `/api/ai-agent/run -> mnote-cli` +- 页面 AI 历史验证仍然围绕 `/api/ai-agent/run -> mnote-cli`;后续执行主线改为 Hermes client proxy + mnote plugin - deferred 项没有被误报为已完成 本轮完成后的正确口径: -> **页面设置与页面 AI 的交互壳已开始对齐 Wolai,但评论、协作、成员、演示模式仍是后续独立任务。** +> **页面设置与页面 AI 的交互壳已开始对齐 Wolai;AI 后续执行主线改为 Hermes 面板 + mnote plugin,评论、协作、成员、演示模式仍是后续独立任务。** diff --git a/design/05-editor-mainline/done/5-5-1-page-aggregate-contract-v1.md b/design/05-editor-mainline/done/5-5-1-page-aggregate-contract-v1.md index 3139b595..1b7b9b06 100644 --- a/design/05-editor-mainline/done/5-5-1-page-aggregate-contract-v1.md +++ b/design/05-editor-mainline/done/5-5-1-page-aggregate-contract-v1.md @@ -1,6 +1,6 @@ # 5-5-1 [done] Page Aggregate Contract v1 -> 更新时间:2026-04-22 +> 更新时间:2026-05-14 > > 关联文档: > - `/mnt/Data1T/mnote/design/05-editor-mainline/process/5-5-page-aggregate-single-truth-alignment-v1.md` @@ -92,13 +92,13 @@ 说明: - 这层不是“页面设置面板 UI state”,而是页面设置的持久化真相。 -- 其中只有一部分已经进入 `leptos-tiptap` island 运行时: +- 以下字段已经进入 `leptos-tiptap` island runtime payload: - `wideLayout` - `smallText` - `layoutDensity` -- 下面两项当前只完成字段贯通,不可描述成“正式支持”: - `showHeadingNumbers` - `embedDefaultBlockId` +- 其中 `showHeadingNumbers` / `embedDefaultBlockId` 已进入 runtime payload,但深层语义仍是阶段性桥接:前者还不能描述成完整 heading 编号渲染闭环,后者还不能描述成完整嵌入默认落点闭环。 ### 2.4 `page_body` @@ -152,6 +152,24 @@ - 统计不是页面身份,也不是布局或正文真相,但它是 page aggregate 的附属部分。 +### 2.7 `source` / provenance + +作用: + +> **标识这份 `PageAggregateProjection` 的真实构建来源。** + +当前允许值: + +- `KernelProjection`:底层已经由 Rust kernel 原生 page aggregate projection 产出。 +- `CompatMetaContentJoin`:对外已经是 Rust `mnote.page_aggregate.v1` 契约,但底层仍由 Rust runtime adapter 消费 `documents:getMeta + documents:getContent` substrate 后聚合。 +- `Fixture`:仅允许在测试或显式 dev fixture 场景出现。 + +说明: + +- 当前 `/api/page-aggregate/:id` 主读链属于 `CompatMetaContentJoin`,不是完整 `KernelProjection`。 +- `source_label()` / `x-mnote-page-aggregate-owner` 必须与实际来源一致:`KernelProjection -> rust-kernel`,`CompatMetaContentJoin -> compat-join`,`Fixture -> fixture`。 +- 前端 loader 只消费 `mnote.page_aggregate.v1` 契约,不应因为来源是 `CompatMetaContentJoin` 而恢复 TS runtime builder 主链。 + ## 3. 哪些字段是 aggregate truth,哪些只是 UI state ### 3.1 Aggregate Truth @@ -194,7 +212,7 @@ - 标题输入中的 debounce 缓冲态 - 正文 host 内的未保存 dirty 态 - 只影响单次交互的面板/菜单开关 -- `showHeadingNumbers/embedDefaultBlockId` 的“字段已贯通,但深语义未完成”阶段性桥接逻辑 +- `showHeadingNumbers/embedDefaultBlockId` 的“runtime payload 已贯通,但深语义未完成”阶段性桥接逻辑 约束: @@ -222,11 +240,11 @@ - 树标题与页头标题已经证明消费同一份更新后的 projection - AI 写入口已经完全脱离 editor bridge,正式执行 page body command -- `showHeadingNumbers` 真正进入 heading 渲染语义 -- `embedDefaultBlockId` 真正进入嵌入默认落点逻辑 +- `showHeadingNumbers` 完整进入 heading 编号渲染语义 +- `embedDefaultBlockId` 完整进入嵌入默认落点逻辑 ## 7. 当前可对外口径 当前可以准确描述为: -> **文档页已开始消费统一 `Page Aggregate`,标题/页面设置/正文保存也开始按 `page_head / page_layout / page_body` 收口;但树域投影统一与 AI 正式 page body 写入口仍未完全完成。** +> **文档页已开始消费统一 `Page Aggregate`,标题/页面设置/正文保存也开始按 `page_head / page_layout / page_body` 收口;当前 Rust route 主读链已成立,但 `/api/page-aggregate/:id` 底层仍是 `CompatMetaContentJoin`,树域投影统一与 AI 正式 page body 写入口也仍未完全完成。** diff --git a/design/05-editor-mainline/process/5-5-page-aggregate-single-truth-alignment-v1.md b/design/05-editor-mainline/process/5-5-page-aggregate-single-truth-alignment-v1.md index 42f15e63..cf551ade 100644 --- a/design/05-editor-mainline/process/5-5-page-aggregate-single-truth-alignment-v1.md +++ b/design/05-editor-mainline/process/5-5-page-aggregate-single-truth-alignment-v1.md @@ -115,6 +115,8 @@ - 页面本地 aggregate state reducer - preferred sidebar snapshot / 页头标题补偿链 +需要特别区分“Rust route 主读链”和“完整 kernel-native projection”:当前 `/api/page-aggregate/:id` 已经由 Rust route / Rust runtime adapter 对外提供稳定 `mnote.page_aggregate.v1` 契约,前端不再恢复 TS runtime builder 主链;但底层 substrate 仍主要来自 `documents:getMeta + documents:getContent` 的兼容聚合。因此当前 projection provenance 应标记为 `CompatMetaContentJoin`,不能写成已经完全由 Rust kernel 原生投影闭环的 `KernelProjection`。 + 这意味着当前已经不再是: - `page.tsx` 手工拉 `meta + content` 再现场拼 props diff --git a/design/05-editor-mainline/process/5-6-page-aggregate-alignment-checklist-v1.md b/design/05-editor-mainline/process/5-6-page-aggregate-alignment-checklist-v1.md index ac64bedf..3e5e2178 100644 --- a/design/05-editor-mainline/process/5-6-page-aggregate-alignment-checklist-v1.md +++ b/design/05-editor-mainline/process/5-6-page-aggregate-alignment-checklist-v1.md @@ -81,6 +81,8 @@ 补充:当前 `page-aggregate-loader.ts`、文档页 SSR 入口与 `DocumentContent` 的内容重试补拉都已经直接消费 Rust `/api/page-aggregate/:id` 返回的 `PageAggregateProjection`,不再由 `page.tsx` 手工拼 `meta + content`。Next `/api/documents/page` 已降级为显式 `410` 的 compat 边界;`page-aggregate-loader.ts` 现在只校验并消费这条 Rust 正式读链,不再在 runtime 中回退到 TS builder。与此同时,`storage-convex-bridge` 与 `bridge-runtime` 已开始接受 `page.head.updateTitle / page.layout.updateOptions / page.body.save` 这组 page command family 的命名口径,因此这里按“读取契约已进入 Rust-first,写入命令面已开始收口”勾选完成。 +补充:这里的“Rust-first”只表示 `/api/page-aggregate/:id` 的对外 route、契约校验和前端消费主链已经收口到 Rust,不表示底层已经完全 kernel-native。2026-05-13 复核后,当前 route 仍由 Rust runtime adapter 消费 `documents:getMeta + documents:getContent` substrate 构建聚合快照,因此 response `source` / `x-mnote-page-aggregate-owner` 应反映为 `CompatMetaContentJoin` / `compat-join`。只有底层真实改为 kernel 原生 page aggregate projection 后,才应标记为 `KernelProjection` / `rust-kernel`。 + ### 4.3 退出标准 - [x] 后续再讨论标题、页面设置、正文保存时,能够直接定位到它属于 `page_head / page_layout / page_body / page_tree` 的哪一层。 @@ -113,6 +115,8 @@ 补充:当前首屏 SSR 与客户端内容重试补拉都已经直接走 `/api/page-aggregate/:id -> PageAggregateProjection`,因此“页面明显由 `meta + content` 两次查询拼起来”的入口级痕迹已经消失;但新增字段仍可能需要继续补 loader / route / island 消费链,所以第二项继续保留未完成。 +补充:入口级痕迹消失不等于来源语义消失。当前前端 loader 仍只接受 `mnote.page_aggregate.v1`,不会恢复 TS builder 主链;但 Rust response 的 provenance 必须继续暴露真实构建来源,避免把兼容 join 误验收成 kernel-native projection。 + 补充:本轮已新增 `page-aggregate-client-state.ts`,并让 `DocumentContent` 把原先分散维护的 `title / options / content / serverContentSnapshot / serverPageSubtreeSnapshot / contentRevision / conflictDetectionKey` 继续收口为同一份 client aggregate state reducer;标题的 draft / committed / persisted 语义也已并入这份 reducer,不再额外维护独立 `usePageHeadTitle` hook 或 `serverPageSubtreeTitle` 双轨状态。这里代表“页面本地 `head/body/layout/tree` 真相已经开始统一”,不等于页面设置运行时分类、AI 对页面设置面的正式入口都已闭环,因此本阶段不提前宣称聚合完成。对应最小回归测试为 `page-aggregate-client-state.test.ts`、`document-content.test.ts`。 --- @@ -160,7 +164,7 @@ - [x] 对未支持项给出显式降级说明,而不是仅保存字段。 - [x] 让“页面设置有值但编辑器内部没变化”这类状态在产品上消失。 -补充:本轮已把 AI 页面设置结构化结果接入 `DocumentContent` 现有的 `patch_page_options + page.layout.updateOptions` 正式链路,页面设置不再只能靠人类点击 inspector 才能进入同一条页面命令面。同时,AI 正式写回白名单已限制为 `page-option-semantics.ts` 中 `runtimeSupport === "wired"` 的字段,`protectEditing / showStructure / showBlockRefCount` 继续排除在正式写回外,避免把 planned / ui_only 选项误描述为稳定能力。`mnote-cli host` 侧也已新增最小服务端分支:命中页面设置 patch 时,直接执行 `page.layout.updateOptions` 并产出结构化 `tool_result(action=update_page_options)`,不再只把整段结果退化成 assistant 文本。对应最小回归测试为 `DocumentAiAgentPanel.runtime.test.tsx`、`mnote-cli-agent-host.test.ts`。 +补充:本轮已把 AI 页面设置结构化结果接入 `DocumentContent` 现有的 `patch_page_options + page.layout.updateOptions` 正式链路,页面设置不再只能靠人类点击 inspector 才能进入同一条页面命令面。同时,AI 正式写回白名单已限制为 `page-option-semantics.ts` 中 `runtimeSupport === "wired"` 的字段,`protectEditing / showStructure / showBlockRefCount` 继续排除在正式写回外,避免把 planned / ui_only 选项误描述为稳定能力。历史 `mnote-cli host` 侧也已新增最小服务端分支:命中页面设置 patch 时,直接执行 `page.layout.updateOptions` 并产出结构化 `tool_result(action=update_page_options)`,不再只把整段结果退化成 assistant 文本。2026-05-13 起后续主线改为 Hermes tool call -> mnote skill/plugin -> `page.layout.updateOptions`,该 host 分支只保留为过渡证据。对应最小回归测试为 `DocumentAiAgentPanel.runtime.test.tsx`、`mnote-cli-agent-host.test.ts`。 补充:本轮继续把 Inspector 产品态与这份分类对齐:`showHeadingNumbers / embedDefaultBlockId` 已按“正式接通项”更新文案,不再继续显示“已保存字段但未接通”;`showBlockRefCount` 作为纯占位项也不再继续暴露可点击的假开关,而是明确显示 `待接线`。对应最小回归测试为 `page-options-sidebar.test.tsx`、`page-option-semantics.test.ts`。 ### 6.4 退出标准 @@ -230,7 +234,7 @@ 补充:本轮继续把 AI 面板的读取边界开始收口到统一页面本地快照:`DocumentContent` 不再向 `DocumentAiAgentPanel` 透传 `getLatestBlocks / getLatestPageSubtree / getLatestPersistedMeta` 三组分散 getter,而是改为一份 `getLatestPageAggregateSnapshot`,由 `page-aggregate-client-state` 导出 `blocks / pageSubtree / persistedMeta / pageOptions`。这代表 AI 面板已开始消费同一份页面本地 aggregate state,而不是继续拼接独立局部真相;但页面设置写工具本身仍未进入 Hermes 正式 tool surface,因此这里仍然只算“开始收口”,不提前打满。 -补充:本轮还把 `pageOptions` 与 `editorRuntimePageOptions` 一并带入 `/api/ai-agent/run -> buildHermesInstructions`,因此 AI 在服务端至少能看到“当前页面设置是什么”以及“哪些设置已经进入 island runtime payload”,不再只依赖正文块快照和树快照来猜测页面语义。对应回归测试为 `src/app/api/ai-agent/run/route.test.ts`。这推进的是 AI 读侧上下文,不等于页面设置写工具已经具备正式入口。 +补充:本轮还把 `pageOptions` 与 `editorRuntimePageOptions` 一并带入历史 `/api/ai-agent/run -> buildHermesInstructions`,因此 AI 在服务端至少能看到“当前页面设置是什么”以及“哪些设置已经进入 island runtime payload”,不再只依赖正文块快照和树快照来猜测页面语义。对应回归测试为 `src/app/api/ai-agent/run/route.test.ts`。2026-05-13 起同类上下文后续应改为 Hermes run/session context,并由 Hermes 通过 mnote plugin 回读最新 page aggregate;这推进的是 AI 读侧上下文,不等于页面设置写工具已经具备正式入口。 ### 8.2 与主编辑区的关系 @@ -240,27 +244,27 @@ 补充:进入这一步后,当前最大的真实 blocker 已经明确下来,不再继续靠口头描述模糊处理: -- `/api/ai-agent/run` 当前默认主路仍是 `mnote-cli host` -- `mnote-cli host` 目前没有真正的模型/tool 执行环,而是 `cargo ... tool run --tool-name doc_get --mode explain-plan` 的说明型入口 -- 因此,当前新增的 `page_options_patch -> page.layout.updateOptions -> structured tool_result` 仍然只是“主路内的最小结构化兼容分支”,不是完整正式 tool surface +- 页面 AI 历史实现仍残留 `/api/ai-agent/run` / `mnote-cli host` 路径 +- Hermes 页面内客户端、Hermes session 真相和 mnote skill/plugin tool surface 还没有在页面 AI 主链落地 +- 因此,当前新增的 `page_options_patch -> page.layout.updateOptions -> structured tool_result` 仍然只是“历史主路内的最小结构化兼容分支”,不是完整正式 Hermes tool surface 这意味着 `8.2` 后续完成标准必须至少包含: -1. 页面设置进入真正的 agent/tool 执行环,而不是继续靠 host 内部的自然语言/最小规则分支识别 -2. 页面设置结构化结果由正式 tool 调用产出,而不是只由 host 自己补一条兼容 `tool_result` +1. 页面设置进入 Hermes tool call -> mnote plugin -> Rust runtime 的正式执行环,而不是继续靠 host 内部的自然语言/最小规则分支识别 +2. 页面设置结构化结果由正式 mnote plugin tool 调用产出,而不是只由 host 自己补一条兼容 `tool_result` 3. 树标题 / 页头 / 页面设置三条 AI 写回链在同一条正式 page aggregate command family 中闭环,并补 smoke 验证 ### 8.3 退出标准 - [x] AI 写入口已经可以被明确描述为“操作 page aggregate command family”,而不是“绕过系统写编辑器”。 -注:当前 `/api/ai-agent/run` 在 `Hermes tool.completed` 后,会优先尝试把 `slash_run / doc_insert_blocks / doc_replace_range` 恢复成 `mnote-web bridge-runtime` 的结构化 `tool_result`,不再只把 Hermes 事件当作薄日志。其后: +注:历史 `/api/ai-agent/run` 在 `Hermes tool.completed` 后,会优先尝试把 `slash_run / doc_insert_blocks / doc_replace_range` 恢复成 `mnote-web bridge-runtime` 的结构化 `tool_result`,不再只把 Hermes 事件当作薄日志。2026-05-13 起这只保留为过渡证据;新的主线应由 Hermes 直接发起 mnote plugin tool call,再由 Rust runtime / kernel 返回 tool result。其后: - `doc_insert_blocks / doc_replace_range` 继续按 `page.body.save` 语义落到 `/api/documents/save`,再正式回显主编辑区 island。 - `slash_run(rename current page)` 会把结构化结果回接到当前页 `DocumentContent` 的同一条标题提交链,并继续广播 `emitDocumentsChanged(documentId)`,因此页头标题与树标题不再靠 AI 面板内部本地状态各自漂移。 - 当前 AI 面板已经能消费结构化 `update_page_options` 结果,并把 `pageOptionsPatch` 回接到当前页 `DocumentContent` 的同一条 `patch_page_options + page.layout.updateOptions` 提交链;同时只允许 `runtimeSupport === "wired"` 的字段进入正式写回,避免 planned / ui_only 页面设置混入主链。对应最小回归测试为 `DocumentAiAgentPanel.runtime.test.tsx`、`document-content.test.ts`。 -- `mnote-cli host` 现已能在命中页面设置 patch 时直接执行 `page.layout.updateOptions`,并向前端回放结构化 `tool_call/tool_result` 事件;这意味着“服务端完全没有页面设置结构化写回结果”的状态已经结束。 -- 但 `pageOptions` 仍没有进入 Hermes 正式 tool surface,当前服务端 patch 识别也仍是最小规则分支而不是完整模型工具编排,因此“页面设置类 AI 命令”仍不能算整条线已闭环;`8.2` 的最后一项继续保留未完成,避免误判为整条线已经闭环。 +- 历史 `mnote-cli host` 现已能在命中页面设置 patch 时直接执行 `page.layout.updateOptions`,并向前端回放结构化 `tool_call/tool_result` 事件;这意味着“服务端完全没有页面设置结构化写回结果”的状态已经结束。 +- 但 `pageOptions` 仍没有进入 mnote Hermes plugin 的正式 tool surface,当前服务端 patch 识别也仍是最小规则分支而不是完整 Hermes tool 编排,因此“页面设置类 AI 命令”仍不能算整条线已闭环;`8.2` 的最后一项继续保留未完成,避免误判为整条线已经闭环。 --- diff --git a/design/05-editor-mainline/process/5-9-wolai-aline-continuous-checklist-v1.md b/design/05-editor-mainline/process/5-9-wolai-aline-continuous-checklist-v1.md index 4ddaa087..dd0edf00 100644 --- a/design/05-editor-mainline/process/5-9-wolai-aline-continuous-checklist-v1.md +++ b/design/05-editor-mainline/process/5-9-wolai-aline-continuous-checklist-v1.md @@ -391,8 +391,8 @@ GREEN: - E26 执行前口径纠偏:2026-05-02 已按 `5-2`、`5-4`、`5-5`、`5-6` 和 `5-7` 设计确认,E26 不是“前端引入 Tiptap UniqueID 即完成”。正式块身份必须来自 Rust `EditorBlock.block_id`,经 Tiptap bridge 映射为 `data-block-id` 供浏览器 runtime 使用,再通过 `page.body.save` 写回 Convex-backed 持久化底座;复制锚点和 hash 定位只能消费这个真源 id。Tiptap `UniqueID` 可以参考其节点身份策略,但不得生成长期块 id、不得绕过 Rust `EditorBlockDocument`、不得绕过 Page Aggregate / `page.body.save`。 - E26 执行边界:实现与 smoke 必须把 `EditorBlockDocument -> Tiptap attrs.blockId/data-block-id -> DOM 锚点 -> page.body.save -> /api/documents/content -> reload/hash 定位` 串成一条链。允许在浏览器 runtime 内用 `UniqueID` 或等价插件帮助定位节点,但只能读取/补齐已有 Rust block id;若某块缺少正式 id,应通过 Rust/保存适配链生成并持久化,而不是让前端临时 id 成为长期合同。Convex 只验证持久化结果可读可刷新,不作为语义真源。 - E26 最小真源闭环:2026-05-02 已完成第一段 anchor 切片。`leptos-tiptap` paragraph/heading/blockquote/codeBlock/image/table 的 `blockId` 渲染同时输出 `data-block-id` 与 DOM `id`,浏览器 hash 命中走 `id=:blockId` + CSS `:target`,不是手工给 ProseMirror DOM 写临时 class;Rust runtime 复制链接和 hash 滚动继续只消费 `EditorBlock.block_id` 派生的 `attrs.blockId`。`mnote-web` 保存 payload 不再发送空 `content: []`,而是派生 `editorDocument`、legacy `content`、`tiptapDocument` 和 `blockCount` 写回 Convex-backed 持久化底座;reload 侧 legacy block 恢复继续补 `attrs.blockId`。本地 smoke `node scripts/task154-e26-anchor-smoke.js` 已覆盖保存请求、`/api/documents/content`、复制链接、DOM `id/:target` 和 reload/hash 定位,截图目录:`/mnt/Data1T/mnote/tmp/wolai-editor-parity/task154-e26-anchor-local-smoke`。剩余:列表项/分割线等更多块类型的统一 anchor attr 覆盖、Wolai 视觉细节、块引用/页面引用预览与移动端行为另拆后续。 -- E27 执行前口径纠偏:AI 编辑能力包不是“补 AI 菜单文案”或“点击后显示 feedback”。最小闭环必须从 `leptos-tiptap` 的 slash / 块菜单入口出发,携带当前 `documentId`、`workspaceId`、Rust `blockId`、selection 摘要和 Tiptap JSON 快照,调用 mnote 现有 AI 真源边界。2026-05-05 起长期口径进一步修正:`/api/ai-agent/run` 不应长期绑定 `openai-agents-python` sidecar,而应收口为 `mnote-cli` 唯一长期 agent 执行面上的 host / adapter;`openai-agents-python`、`Hermes`、`Codex` 只允许作为可插拔外置 agent。结构化写入仍必须回到 Rust tool / `page.body.save` / Convex-backed content 链。E27 第一刀已完成的 sidecar 路径只作为过渡基线,不宣称是长期主线。 -- E27 AI agent 入口与写入闭环:2026-05-02 已完成主路径纠偏,修正此前把 `/api/hermes/bridge` 当作 E27 主入口的误导口径。块菜单 `AI 助理` 现在从当前 `leptos-tiptap` editor 读取 `documentId`、`workspaceId`、Rust `blockId`、selection state、selected text 与 `tiptapDocument` 快照,发起 `/api/ai-agent/run` 请求,payload 固定 `scope=document`、`stream=true`、`options.ai.provider=online`,并显示 `mnote-leptos-tiptap-ai-status` 的 `idle/pending/ready/error` 状态;请求上下文标记 `source=leptos-tiptap-island`、`action=ask_ai`,Next sidecar adapter 已透传 `workspaceId / selectedBlockId / selection / tiptapDocument` 等 block 级上下文给 `openai-agents-python`。本地 smoke `node scripts/task155-e27-ai-edit-smoke.js` 已先 RED 于 Hermes 主入口,再 GREEN 覆盖请求 URL、payload 真源字段和 ready 状态;`node scripts/task156-e27-ai-writeback-smoke.js` 已先 RED 于只返回不写入,再 GREEN 覆盖 SSE `doc_replace_range` tool_result -> 编辑器改写 -> `/api/documents/save` -> `/api/documents/content` 真源读回 -> reload 后页面读回。截图目录:`/mnt/Data1T/mnote/tmp/wolai-editor-parity/task156-e27-ai-writeback-local-smoke`。剩余:Wolai AI 菜单视觉基线断流未完成、slash AI/selection toolbar AI、Improve/continue/regenerate/summarize/translate 子命令、SSE 分段 UI 状态、`doc_insert_blocks` 多块插入和标题 `slash_run` 应用另拆后续。 +- E27 执行前口径纠偏:AI 编辑能力包不是“补 AI 菜单文案”或“点击后显示 feedback”。最小闭环必须从 `leptos-tiptap` 的 slash / 块菜单入口出发,携带当前 `documentId`、`workspaceId`、Rust `blockId`、selection 摘要和 Tiptap JSON 快照,进入 Hermes run/session context;Hermes 通过 mnote skill/plugin 调用稳定工具,再由 Rust runtime / `page.body.save` / Convex-backed content 链写回。2026-05-13 起长期口径进一步修正:`/api/ai-agent/run`、`openai-agents-python` sidecar 和 `mnote-cli host` 都只作为历史/兼容路径,不再代表页面 AI 长期主入口;`mnote-cli` 只可作为 mnote plugin 内部适配器或调试入口。E27 第一刀已完成的 sidecar / `/api/ai-agent/run` 路径只作为过渡基线,不宣称是长期主线。 +- E27 AI agent 入口与写入闭环:2026-05-02 已完成历史主路径纠偏,修正此前把 `/api/hermes/bridge` 当作 E27 主入口的误导口径。块菜单 `AI 助理` 当时从当前 `leptos-tiptap` editor 读取 `documentId`、`workspaceId`、Rust `blockId`、selection state、selected text 与 `tiptapDocument` 快照,发起 `/api/ai-agent/run` 请求,payload 固定 `scope=document`、`stream=true`、`options.ai.provider=online`,并显示 `mnote-leptos-tiptap-ai-status` 的 `idle/pending/ready/error` 状态;请求上下文标记 `source=leptos-tiptap-island`、`action=ask_ai`,Next sidecar adapter 已透传 `workspaceId / selectedBlockId / selection / tiptapDocument` 等 block 级上下文给 `openai-agents-python`。本地 smoke `node scripts/task155-e27-ai-edit-smoke.js` 已先 RED 于 Hermes 主入口,再 GREEN 覆盖请求 URL、payload 真源字段和 ready 状态;`node scripts/task156-e27-ai-writeback-smoke.js` 已先 RED 于只返回不写入,再 GREEN 覆盖 SSE `doc_replace_range` tool_result -> 编辑器改写 -> `/api/documents/save` -> `/api/documents/content` 真源读回 -> reload 后页面读回。截图目录:`/mnt/Data1T/mnote/tmp/wolai-editor-parity/task156-e27-ai-writeback-local-smoke`。剩余:把入口改为 Hermes client proxy,把上下文作为 Hermes run/session context 传入,把写入工具注册为 mnote Hermes plugin,Wolai AI 菜单视觉基线、slash AI/selection toolbar AI、Improve/continue/regenerate/summarize/translate 子命令、Hermes streaming UI 状态、`doc_insert_blocks` 多块插入和标题写入另拆后续。 - E27 Auth 真源纠偏:2026-05-02 补充确认,AI 编辑和在线 smoke 的用户身份必须以真实 Convex Auth 为主线;不同账号的数据隔离依赖 Convex `getAuthUserId(ctx)` 解析真实 identity subject,并由 workspace/member 权限链校验。`/auth` 复用既有 Convex Auth 页面和统一测试账号 `test@example.com` / `Test123456`;`/api/auth/session`、`/api/auth/whoami`、AI orchestrator 转发和 Convex transport 均应真实 token / forwarded actor 优先。`DEV_USER_ID` / admin acting identity 只允许作为本地底层 fallback 或显式 `MNOTE_DEV_AUTH=1` 联调模式,不能作为 E27 在线验收、多账号隔离或正式数据真源。 - E27 当前推进备注:2026-05-03 主线程先切到 E28 Mention / Emoji,E27 暂停在 online smoke 模型网关层排障状态。当前已确认 `3000 -> 8000` orchestrator 主链可达,本地 `task155/task156` 通过;剩余在线阻塞点在 `20128 /v1/responses` 上游模型/渠道可用性,以及后续真实 actor 收口。恢复 E27 时应从这两点继续,不要回退成“主入口未迁完”。 - E28/E29 暂停与 E30 先行口径:2026-05-03 主线程先暂停 E28 Mention / Emoji 与 E29 Comment / History,转入 E30 Menu / Floating 状态机能力包。E28 已有 Wolai Hermes baseline 显示正文输入 `@` 当前弹出提醒/会议/成员候选,并可插入成员 mention,不是页面引用搜索;证据目录为 `/mnt/Data1T/mnote/tmp/wolai-editor-parity/task157-e28-wolai-mention-baseline/`,恢复 E28 时需先重新定 `@mention` 口径,不要按“页面引用第一刀”继续。E29 在评论/历史后端边界未重新确认前保持暂停。E30 第一刀只收敛已有 slash、块菜单、二级菜单、selection/image/table floating toolbar 的打开互斥、Esc、外部点击、方向键、Enter 与层级收起,不扩新业务命令。 @@ -418,7 +418,7 @@ GREEN: | E24 | PARTIAL | Image / Media 能力包 | 已完成图片 `/tp` 最小真源闭环与图片 toolbar 小闭环:slash `媒体与附件` 分组入口调用 `leptos-tiptap` 官方 `set_image(TiptapImageResource)`,启用 `TiptapExtension::Image`,保存链新增 `EditorBlockType::Image` / `TiptapNode::Image` 并保留 `props.tiptapImage`,`mnote-web` reload 恢复 ``;点击图片后出现 `image-floating-toolbar`,删除入口本切片保持禁用,左/中/右对齐通过官方 Image 扩展 `addAttributes("data-align")` + `updateAttributes("image")` 持久化,同源下载通过隐藏 `` 触发且不改文档;`task152-e24-image-smoke.js` 覆盖入口、实际图片加载、toolbar、同源下载、删除入口禁用态、居中对齐、保存请求、content API 和刷新恢复;剩余上传、最近上传、caption、resize、replace、跨源下载 fallback、删除保存链、移动端 toolbar、失败态 | | E25 | PARTIAL | TOC 能力包 | 已完成 `/toc` 最小真源闭环:`leptos-tiptap` 新增真实 `tocNode` schema/command,slash `页面目录 /toc` 插入真实节点,NodeView 从当前 headings 派生目录项,支持点击定位、标题显示开关、保存请求、content API 与 reload 恢复;`task153-e25-toc-smoke.js` 覆盖入口、动态 heading 更新、hash 定位和 `props.tiptapTocNode`。剩余 TOC sidebar、active heading 高亮、完整块菜单入口、官方 TableOfContents v3 数据源评估、移动端与像素级视觉 | | E26 | PARTIAL | UniqueID / Anchor 能力包 | 已完成最小真源闭环:Rust `EditorBlock.block_id` -> Tiptap `attrs.blockId` -> DOM `data-block-id` + `id` -> 复制链接 hash -> `page.body.save` / `/api/documents/content` -> reload 后 `:target` 定位;`task154-e26-anchor-smoke.js` 覆盖保存、复制、reload/hash 和截图。Tiptap `UniqueID` 仍只作为参考/辅助口径,不作为正式块 id;剩余更多块类型 anchor 覆盖、Wolai 视觉细节、引用预览和移动端行为。 | -| E27 | PARTIAL | AI 编辑能力包 | 已完成块菜单 `AI 助理` 主路径与最小写入闭环:点击后发起 `/api/ai-agent/run`,payload 使用 `scope=document`、`stream=true`、`options.ai.provider=online`,携带 `documentId`、`workspaceId`、Rust `blockId`、selection、selected text、Tiptap 快照和 `action=ask_ai`;历史基线里 `Hermes` 只作为 `/api/ai-agent/run` 内部 fallback。2026-05-05 起长期口径改为:`/api/ai-agent/run` 后续应收口为 `mnote-cli` 唯一长期 agent 执行面上的 host / adapter,`Hermes`、`Codex`、`openai-agents-python` 仅为可插拔外置 agent;`task155-e27-ai-edit-smoke.js` 与 `task156-e27-ai-writeback-smoke.js` 保留为过渡主链行为基线。Auth 验收必须复用 Convex Auth 测试账号,真实 token / actor 优先,`dev identity` 仅为本地 fallback。当前主线程已于 2026-05-03 先切 E28;E27 暂停在 online smoke 的 `20128 /v1/responses` 模型网关排障与真实 actor 收口。剩余 slash AI/selection toolbar AI、Improve/continue/regenerate/summarize/translate、Wolai 视觉基线、SSE 分段 UI、`doc_insert_blocks` 多块插入与标题写入。 | +| E27 | PARTIAL | AI 编辑能力包 | 已完成历史块菜单 `AI 助理` 主路径与最小写入闭环:点击后发起 `/api/ai-agent/run`,payload 使用 `scope=document`、`stream=true`、`options.ai.provider=online`,携带 `documentId`、`workspaceId`、Rust `blockId`、selection、selected text、Tiptap 快照和 `action=ask_ai`;`task155-e27-ai-edit-smoke.js` 与 `task156-e27-ai-writeback-smoke.js` 保留为过渡行为基线。2026-05-13 起长期口径改为:页面 AI 入口应调用 Hermes client proxy,Hermes session/message/tool event/usage/model 是会话真相,mnote 通过 Hermes skill/plugin 执行 `page.body.save`、标题、页面设置、artifact、edge 等业务工具;`mnote-cli` 仅可作为 plugin 内部适配器。Auth 验收必须复用 Convex Auth 测试账号,真实 token / actor 优先,`dev identity` 仅为本地 fallback。当前主线程已于 2026-05-03 先切 E28;E27 暂停在 online smoke 的 `20128 /v1/responses` 模型网关排障与真实 actor 收口。剩余 Hermes client proxy 接入、mnote plugin tool 注册、slash AI/selection toolbar AI、Improve/continue/regenerate/summarize/translate、Wolai 视觉基线、Hermes streaming UI、`doc_insert_blocks` 多块插入与标题写入。 | | E28 | PAUSED | Mention / Emoji 能力包 | 2026-05-03 暂停;Wolai baseline 已纠偏:正文 `@` 当前是提醒/会议/成员候选入口,不是页面引用搜索,恢复时先重定 mention/emoji 口径,再决定 Tiptap JSON 与 mnote 保存链 | | E29 | PAUSED | Comment / History 能力包 | 2026-05-03 暂停;评论/历史入口仍需后端边界和 Wolai 基线复核,待 E30 统一菜单状态机后再接入,避免继续复制独立弹层逻辑 | | E30 | PARTIAL | Menu / Floating 状态机能力包 | 2026-05-03 第一刀已完成本地统一状态机 smoke:`task158` 覆盖 slash、块菜单/二级菜单、selection toolbar/color panel、image floating toolbar、table toolbar/options 的互斥打开、Esc 统一关闭、方向键 active,以及打开块菜单时关闭 image toolbar;Wolai 只读基线已确认 selection/type menu 和 table popper 的 Esc/外部点击/URL 不变,slash 与块菜单二级菜单在只读条件下不稳定,后续仍需 editable-test 复核 Enter、外部点击和更多层级收起 | diff --git a/design/06-mindmap/process/6-mindmap-phase6-kmind-parity-detail-checklist-v1.md b/design/06-mindmap/done/6-mindmap-phase6-kmind-parity-detail-checklist-v1.md similarity index 99% rename from design/06-mindmap/process/6-mindmap-phase6-kmind-parity-detail-checklist-v1.md rename to design/06-mindmap/done/6-mindmap-phase6-kmind-parity-detail-checklist-v1.md index c9de8f7f..4491f747 100644 --- a/design/06-mindmap/process/6-mindmap-phase6-kmind-parity-detail-checklist-v1.md +++ b/design/06-mindmap/done/6-mindmap-phase6-kmind-parity-detail-checklist-v1.md @@ -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 > diff --git a/design/07-ai/done/7-1-phase7-document-ai-minimum-loop-checklist-v1.md b/design/07-ai/done/7-1-phase7-document-ai-minimum-loop-checklist-v1.md index 05635fea..4ee41521 100644 --- a/design/07-ai/done/7-1-phase7-document-ai-minimum-loop-checklist-v1.md +++ b/design/07-ai/done/7-1-phase7-document-ai-minimum-loop-checklist-v1.md @@ -10,7 +10,8 @@ > 状态说明: > - 本稿对应 `Phase 7 v2` 的“文档页 AI 最小闭环”已完成,故迁入 `done/` > - 本稿完成不等于整个 `Phase 7 v2` 已完成;结构化知识写链仍以后续阶段继续推进 -> - 2026-05-05 追加说明:本稿记录的是 `openai-agents-python` sidecar 作为过渡主链的完成状态,不代表当前长期方向;长期口径已由 `/mnt/Data1T/mnote/design/07-ai/process/7-phase7-ai-kernel-projection-plan-v4.md` 收口为 `mnote-cli` 是唯一长期 agent 执行面 +> - 2026-05-05 追加说明:本稿记录的是 `openai-agents-python` sidecar 作为过渡主链的完成状态,不代表当前长期方向;当时长期口径曾由 `/mnt/Data1T/mnote/design/old/07-ai/process/7-phase7-ai-kernel-projection-plan-v4.md` 收口为 `mnote-cli` 是唯一长期 agent 执行面 +> - 2026-05-13 追加说明:`mnote-cli` 唯一长期 agent 执行面口径已被 `/mnt/Data1T/mnote/design/07-ai/process/7-3-page-ai-hermes-panel-and-mnote-plugin-v1.md` 覆盖;新的长期方向是页面 AI 面板仅作为 Hermes 页面内客户端,Hermes 持有 AI 会话真相,mnote 通过 Hermes skill/plugin 暴露业务工具 --- diff --git a/design/07-ai/process/7-2-phase7-structured-artifact-write-chain-v1.md b/design/07-ai/process/7-2-phase7-structured-artifact-write-chain-v1.md index 9f11d441..de765232 100644 --- a/design/07-ai/process/7-2-phase7-structured-artifact-write-chain-v1.md +++ b/design/07-ai/process/7-2-phase7-structured-artifact-write-chain-v1.md @@ -12,8 +12,14 @@ > > 2026-05-05 追加说明: > - 本稿的对象模型、artifact 写链与 kernel 边界仍然有效 -> - 但触发与执行口径已被 `/mnt/Data1T/mnote/design/07-ai/process/7-phase7-ai-kernel-projection-plan-v4.md` 覆盖 -> - 当前凡是提到“页面 AI 面板触发”的位置,都应理解为“页面 AI 面板作为 `mnote-cli` host 触发”,而不是独立内置编排主线 +> - 当时触发与执行口径曾被 `/mnt/Data1T/mnote/design/old/07-ai/process/7-phase7-ai-kernel-projection-plan-v4.md` 覆盖 +> - 该 `mnote-cli` host 口径现已被 2026-05-13 的 Hermes 面板主线覆盖 +> +> 2026-05-13 追加说明: +> - 本稿的对象模型、artifact 写链、projection-only `AI Artifacts` 分组与 kernel 边界继续有效 +> - 触发与执行口径改由 `/mnt/Data1T/mnote/design/07-ai/process/7-3-page-ai-hermes-panel-and-mnote-plugin-v1.md` 覆盖 +> - 当前凡是提到“页面 AI 面板触发”或“页面 AI host 固定动作”的位置,都应理解为: +> 页面 AI 面板作为 Hermes 页面内客户端发起意图,Hermes 通过 mnote skill/plugin 调用正式 artifact 工具,最终写入仍回到 Rust runtime / kernel --- @@ -43,7 +49,7 @@ - `ai_note node` - `reference edge` 2. 只允许围绕**当前文档页**创建,不做跨页、跨工作区写链 -3. 触发方式不是自然语言,不做自动推断,只允许页面 AI host 暴露的固定动作触发;当前页面 AI 面板只是 `mnote-cli` host 的一个页面内入口 +3. 第一版允许页面 AI 面板提供固定快捷入口,但快捷入口本质是向 Hermes 发送意图;Hermes 通过 mnote skill/plugin 调用 artifact 工具,不再由页面 AI host 或 `mnote-cli` host 私有触发 4. 点击按钮后直接创建,不走“先预览再确认”的两阶段流 5. `summary node` 默认单例覆盖更新;`ai_note node` 每次新建 6. 两者都落成**可编辑的页面型节点** @@ -278,21 +284,25 @@ ## 6. 触发方式与产品入口 -### 6.1 只允许固定按钮触发 +### 6.1 允许固定按钮触发,但必须经过 Hermes -第一版不走自然语言触发。 - -不支持: - -- “帮我顺手创建一个摘要节点” -- “看起来像摘要就自动生成” -- “只要语义明确就直接落库” - -只支持页面 AI host 面板里的两个固定按钮: +第一版允许页面 AI 面板提供两个固定快捷按钮: - `创建 Summary` - `创建 AI Note` +但按钮不得绕过 Hermes 或 mnote plugin 直接写入。正确链路是: + +```text +Leptos 页面 AI 面板 + -> Hermes session/run + -> mnote Hermes skill/plugin + -> Rust runtime / kernel + -> artifact node / reference edge +``` + +自然语言触发可以作为后续能力加入,但第一版验收仍以固定按钮或固定 tool intent 为准,避免 artifact 写链质量和权限边界同时失控。 + ### 6.2 点击后直接创建 第一版点击按钮后,直接创建,不走预览确认。 @@ -307,6 +317,7 @@ - 正式写链成立 - kernel node / edge 成立 - 文件树 projection 成立 +- Hermes tool call -> mnote plugin -> Rust kernel 的边界成立 而不是先做复杂的人机审核流。 diff --git a/design/07-ai/process/7-3-page-ai-hermes-panel-and-mnote-plugin-v1.md b/design/07-ai/process/7-3-page-ai-hermes-panel-and-mnote-plugin-v1.md new file mode 100644 index 00000000..3d766334 --- /dev/null +++ b/design/07-ai/process/7-3-page-ai-hermes-panel-and-mnote-plugin-v1.md @@ -0,0 +1,427 @@ +# 7-3 [process] 页面 AI Hermes 面板与 mnote Plugin 主线方案 v1 + +> 更新时间:2026-05-13 +> +> 上位依据: +> - `/mnt/Data1T/mnote/ARCHITECTURE.md` +> - `/mnt/Data1T/mnote/design/01-05-current-priority-overview.md` +> - `/mnt/Data1T/mnote/design/03-rust-web/process/3-rust-web-long-term-architecture-v1.md` +> - `/mnt/Data1T/mnote/design/05-editor-mainline/process/5-5-page-aggregate-single-truth-alignment-v1.md` +> - `/mnt/Data1T/mnote/design/05-editor-mainline/process/5-6-page-aggregate-alignment-checklist-v1.md` +> - `/mnt/Data1T/mnote/design/05-editor-mainline/done/5-10-wolai-page-settings-and-ai-surface-alignment-v1.md` +> - `/mnt/Data1T/mnote/design/05-editor-mainline/reference-code/hermes-web-ui-0.5.18` +> - `/mnt/Data1T/mnote/design/07-ai/process/7-2-phase7-structured-artifact-write-chain-v1.md` +> +> 覆盖关系: +> - 覆盖 `/mnt/Data1T/mnote/design/old/07-ai/process/7-phase7-ai-kernel-projection-plan-v4.md` +> 中“`mnote-cli` 是唯一长期 agent 执行面”的口径。 +> - 保留 `v4` 中“Web 不应拥有第二套工具注册表、结构化写入必须回到 Rust runtime / kernel”的判断。 +> - 保留 `7-2` 中 `summary node / ai_note node / reference edge` 的对象模型和写入边界, +> 但触发方改为 Hermes tool call,而不是页面 AI host 私有按钮或 `mnote-cli` host。 + +--- + +## 1. 文档目的 + +这份稿只回答一个问题: + +> **未来页面 AI 的长期主语到底是谁。** + +当前冻结答案是: + +> **页面 AI 面板只是 Hermes 的页面内客户端;Hermes session/message/tool event/usage/model 才是会话真相;mnote 通过 Hermes skill/plugin 暴露业务能力。** + +这不是把 mnote 的业务真相交给 Hermes。长期边界必须分清: + +- Hermes 负责 AI 编排、会话、模型、tool call 调度和聊天历史。 +- mnote 负责页面、树、正文、artifact、edge、projection 和审计事实。 +- 页面 AI 面板只负责在文档页里打开一个 Hermes 客户端。 + +一句话收口: + +> **AI 会话归 Hermes,mnote 能力归 Rust kernel;页面 AI 面板只连接两者。** + +--- + +## 2. 为什么要从 CLI-first 改为 Hermes-first + +`CLI-first` 解决了一个真实问题:避免 Web 前端继续拥有私有 AI 编排、私有工具注册表和私有写链。 + +但它也带来了新的错位: + +- 页面 AI 面板开始伪装成 `mnote-cli` 图形客户端。 +- Hermes、Codex、`openai-agents-python` 被统一压成“外置 agent”,但实际用户希望页面 AI 就是 Hermes 面板。 +- 真实会话能力、模型选择、tool event、thinking、usage、历史搜索这些已经是 Hermes 的强项,mnote 自己再做一套会重复。 +- 当前运行态已经出现冲突:页面壳默认发送 `provider=hermes`,而 `/api/ai-agent/run` 又按旧退场口径返回 `ai_provider_bridge_unavailable`。 + +因此新的主线不是恢复旧的 Web 私有 AI 编排,而是: + +> **把页面 AI 从 `mnote-cli host` 改成 Hermes client,把 mnote 能力从 Web 私有 tool 改成 Hermes 可发现、可调用的 skill/plugin。** + +--- + +## 3. 长期分层 + +### 3.1 Hermes + +Hermes 负责: + +- session 创建、恢复、重命名、删除、搜索 +- message 存储与 conversation history +- model / provider / profile 选择 +- streaming 事件、thinking / reasoning、usage +- tool call 调度、排队、取消、恢复 +- skill / plugin 的发现和启停 + +Hermes 不负责: + +- 直接写 mnote 的 Convex 表 +- 直接构造第二套 page aggregate +- 直接决定页面树、文件树、artifact、edge 的事实结构 + +### 3.2 mnote Rust kernel / runtime + +mnote Rust 负责: + +- `Page Aggregate` +- `page.*` command +- `tree.*` command +- `kernel.*` query / edge +- `summary node / ai_note node / reference edge` +- projection、audit、idempotency、workspace / actor scope + +Rust 不负责: + +- 存储 Hermes 聊天历史 +- 维护 Hermes session 列表 +- 重做 Hermes 模型、profile、usage 管理 + +### 3.3 页面 AI Leptos 面板 + +页面 AI 面板负责: + +- 右下角入口与右侧抽屉壳层继续服从 Wolai 对齐结果 +- 用 Leptos 实现 Hermes chat 的页面内子集 +- 调用 Hermes session / run / stream API +- 把当前页面上下文作为 Hermes run 的输入或 session workspace context +- 展示 Hermes 返回的 message、reasoning、tool event、error、usage + +页面 AI 面板不负责: + +- 自己保存聊天真相 +- 自己维护工具注册表 +- 自己执行页面写入 +- 自己 fallback 到 `mnote-cli` 或旧 sidecar + +### 3.4 mnote Hermes skill/plugin + +mnote 需要作为 Hermes skill/plugin 暴露能力。 + +第一版建议能力分组: + +- `mnote.page.get` +- `mnote.page.save` +- `mnote.page.update_title` +- `mnote.page.update_options` +- `mnote.tree.create` +- `mnote.tree.move` +- `mnote.search.documents` +- `mnote.artifact.create_summary` +- `mnote.artifact.create_ai_note` +- `mnote.kernel.attach_reference` + +这些工具可以由 plugin 内部调用: + +- Rust Web 同源 tool bridge +- `mnote-cli` JSON adapter +- 或后续更稳定的 Rust plugin bridge + +但对 Hermes 来说,它们必须表现为一组稳定 Hermes tools,而不是页面前端私有函数。 + +--- + +## 4. 会话真相 + +页面 AI 的会话真相固定在 Hermes。 + +mnote 不保存: + +- 聊天消息列表 +- assistant 文本历史 +- thinking / reasoning 历史 +- tool event 完整展开状态 +- session 标题、分组、usage + +mnote 可以保存: + +- 结构化写入产生的 audit +- artifact node +- reference edge +- page/body/title/options 的正式变更 +- 与一次 Hermes tool call 对应的 request / trace / actor / reason + +也就是说,mnote 只保存“对 mnote 事实源造成影响的结果”,不复制 Hermes 的聊天数据库。 + +--- + +## 5. 页面上下文进入 Hermes 的方式 + +页面 AI 面板打开时,mnote 应提供最小上下文包: + +- `workspaceId` +- `documentId` +- 页面标题 +- `pageAggregate` 摘要 +- 当前选区 / blockId / selected text +- 页面设置 +- 当前用户 actor / capability 摘要 + +上下文传入 Hermes 有两种可接受方式: + +1. 作为 run input / instructions 的结构化上下文。 +2. 作为 Hermes session workspace context,由 mnote panel 在创建或恢复 session 时设置。 + +第一版优先采用简单方式: + +> 页面 AI 面板每次发起 run 时附带当前页面上下文摘要;Hermes 如需读取最新正文,再通过 `mnote.page.get` 工具回读。 + +这样可以避免把 page aggregate 大对象长期塞进 Hermes session,也避免 stale context 变成事实源。 + +--- + +## 6. API 与路由边界 + +### 6.1 退役 `/api/ai-agent/run` 主路径 + +`/api/ai-agent/run` 不再作为页面 AI 的长期主入口。 + +允许状态: + +- 暂时保留为 legacy compat,明确返回旧接口退场信息 +- 或只用于旧 smoke / 对照验证 + +禁止状态: + +- 页面 AI 新实现继续向它发送 `provider=hermes` +- `/api/ai-agent/run` 继续作为 Hermes 面板的主代理 +- 它继续持有 mnote 私有工具注册表或执行编排 + +### 6.2 新增 Hermes client proxy + +浏览器不应直接暴露 Hermes API key。 + +建议在 `mnote-web` 中提供同源薄代理: + +- `/api/hermes/client/sessions` +- `/api/hermes/client/runs` +- `/api/hermes/client/events` +- `/api/hermes/client/models` +- `/api/hermes/client/tools` + +这层只做: + +- auth / cookie / token 转发 +- 同源安全边界 +- 页面上下文最小注入 +- 错误码标准化 + +这层不做: + +- session 真相存储 +- message 真相存储 +- tool 执行编排 +- 旧 provider fallback + +### 6.3 mnote tool bridge + +Hermes 调用 mnote 工具时,应进入窄桥: + +```text +Hermes tool call + -> mnote Hermes plugin + -> mnote-web /api/hermes/tools/mnote/* + -> Rust runtime / kernel command/query + -> Hermes tool result +``` + +第一版不要求一次性冻结最终 URL,但要求协议字段稳定: + +- `toolName` +- `arguments` +- `workspaceId` +- `documentId` +- `actor` +- `sessionId` +- `traceId` +- `idempotencyKey` +- `dryRun` +- `capabilityScope` + +--- + +## 7. Leptos 面板参考范围 + +参考 `hermes-web-ui-0.5.18`,但只采用页面内必要子集。 + +第一版采用: + +- Chat session list +- Message list +- Chat input +- streaming delta +- thinking / reasoning 展开 +- tool started / tool completed 展开 +- model selector +- error / retry / abort +- session search 可后置 + +第一版不采用: + +- 平台 Channels 管理 +- Jobs / Cron 管理 +- Profiles 管理全页面 +- Logs 全页面 +- Files 全浏览器 +- Terminal +- Group Chat +- Hermes 全局 Settings + +这些能力属于 Hermes 管理台,不属于 mnote 页面 AI 抽屉。 + +实现要求: + +- 使用 Leptos island 实现,不引入 Vue / Naive UI。 +- 保留 Wolai 对齐的右下角入口和右侧 drawer 容器。 +- 文案和视觉以 mnote 文档页密度为准,不照搬 Hermes Web UI 的整站导航。 + +--- + +## 8. 结构化 Artifact 写链 + +`7-2` 的对象模型继续成立: + +- `summary node` +- `ai_note node` +- `reference edge` +- `AI Artifacts` projection-only 分组 + +但触发方改为 Hermes tool call: + +- 用户可以在 Hermes 面板中自然语言要求总结当前页。 +- Hermes 决定调用 `mnote.artifact.create_summary`。 +- 或页面面板提供快捷按钮,但按钮本质也是向 Hermes 发送意图,不是绕过 Hermes 直接写 mnote。 + +第一版允许两个快捷入口: + +- `创建 Summary` +- `创建 AI Note` + +但它们必须走: + +```text +Leptos panel -> Hermes session/run -> mnote plugin tool call -> Rust kernel +``` + +不得走: + +```text +Leptos panel -> /api/documents/* 直接写 artifact +``` + +--- + +## 9. 权限与安全 + +Hermes 可以调度工具,但 mnote 必须做最终授权。 + +每个 mnote tool call 至少校验: + +- 当前 actor 是否登录 +- actor 是否属于 workspace +- 当前页面是否允许 AI 读取 +- 当前页面是否允许 AI 写入 +- 当前工具是否允许写入页面 / 树 / artifact +- 是否需要 `dryRun` 或确认 + +默认第一版: + +- 读当前页:允许 +- 写当前页:按页面 capability +- 创建 summary / ai_note:按当前页 artifact capability +- 跨页写入:默认禁止 +- 跨 workspace:禁止 + +--- + +## 10. 迁移步骤 + +### Phase A:设计口径统一 + +- 新增本稿作为 07-ai 当前主线。 +- 将 `7-v4 CLI-first` 移入 `design/old/07-ai/process/` 并标记 `[recycle]`。 +- 更新仍引用 `mnote-cli` 唯一长期 agent 执行面的活跃设计稿。 + +### Phase B:薄 Hermes client proxy + +- 在 `mnote-web` 补 Hermes client proxy。 +- 页面 AI 面板不再调用 `/api/ai-agent/run`。 +- 浏览器不直接持有 Hermes API key。 + +### Phase C:Leptos Hermes 面板最小子集 + +- 实现 session 创建 / 恢复。 +- 实现 run / stream / abort。 +- 实现 message / reasoning / tool event 展示。 +- 保留 Wolai 右侧抽屉壳。 + +### Phase D:mnote Hermes plugin + +- 暴露 mnote tool manifest。 +- 先接 `mnote.page.get`、`mnote.page.save`、`mnote.artifact.create_summary`、`mnote.artifact.create_ai_note`。 +- 工具结果回到 Hermes tool event。 + +### Phase E:退役旧面板执行链 + +- `/api/ai-agent/run` 从主路径移除。 +- 旧 React `DocumentAiAgentPanel.runtime` 降为历史参考或 compat。 +- `provider=hermes/codex/claudecode` 502 不再出现在新页面 AI 主链。 + +--- + +## 11. 验收标准 + +第一版完成时必须满足: + +- 页面 AI 抽屉打开后可创建或恢复 Hermes session。 +- Hermes session 存储中能看到页面 AI 的消息历史。 +- mnote 本地不复制聊天消息真相。 +- 发送消息后,事件流来自 Hermes run。 +- tool call 展示使用 Hermes tool event。 +- Hermes 可调用至少一个只读 mnote 工具读取当前页面。 +- Hermes 可调用至少一个写入工具,经 Rust runtime 写回当前页或创建 artifact。 +- 页面刷新后,AI 会话从 Hermes 恢复,而不是从 mnote 本地 state 恢复。 +- 结构化写入产生的 artifact / edge 能在 mnote projection 中验证。 + +--- + +## 12. 禁止项 + +- 不再新增 Web 私有 AI tool registry。 +- 不再把 `mnote-cli` 写成页面 AI 唯一长期执行面。 +- 不把 Hermes 聊天历史复制进 mnote page aggregate。 +- 不让 Hermes plugin 直接写 Convex。 +- 不在 Leptos 面板里重做 Hermes 后台管理台。 +- 不把 `AI Artifacts` 做成真实 kernel node。 +- 不绕过 Rust runtime 创建 artifact / edge。 + +--- + +## 13. 最终冻结口径 + +> **页面 AI 是 Hermes 面板,不是 mnote-cli 面板。** + +> **Hermes 持有 AI 会话真相,mnote 持有业务对象真相。** + +> **mnote 通过 Hermes skill/plugin 暴露工具,工具最终回到 Rust runtime / kernel。** + +> **Leptos 负责页面内 Hermes 客户端体验,不负责 AI 编排。** diff --git a/design/10-review/04-secondary-domains-and-design-governance-review.md b/design/10-review/04-secondary-domains-and-design-governance-review.md index f80cb2a7..ec124c0d 100644 --- a/design/10-review/04-secondary-domains-and-design-governance-review.md +++ b/design/10-review/04-secondary-domains-and-design-governance-review.md @@ -6,7 +6,7 @@ ## 结论 -总体方向与当前主线基本一致:Mindmap 已明确降为 `tree-first graph kernel` 的视图/编辑挂件,AI 主执行面正在向 `mnote-cli` 收口,OnlyOffice 仍是独立页面型编辑器边界,SiYuan 参考稿没有发现被提升为上位架构来源的证据。 +总体方向与当前主线基本一致:Mindmap 已明确降为 `tree-first graph kernel` 的视图/编辑挂件;AI 长期方向已从 `mnote-cli` 收口改为 Hermes 页面内客户端 + mnote Hermes skill/plugin;OnlyOffice 仍是独立页面型编辑器边界,SiYuan 参考稿没有发现被提升为上位架构来源的证据。 主要风险集中在三处:OnlyOffice 在 `mnote-web` 主入口里的 callback/forcesave 仍是 no-op,而 legacy Next route 已有真实写回链;Mindmap 的导出 action 在 schema/action map 中暴露,但 bridge 安全命令集不支持;Mindmap AI 补完 route 当前硬返回 501,和 Rust tool 已登记的能力面不闭合。 @@ -17,7 +17,7 @@ | F-01 | 实现偏差 / 风险 | P1 | OnlyOffice `mnote-web` 主入口已挂载 callback/forcesave,但当前实现不写回,只返回成功或 noop,可能让 3000 主链下的 OnlyOffice 保存丢失。 | `rust/crates/mnote-web/src/routes/mod.rs:78-84` 挂载 `/api/onlyoffice/callback` 与 `/api/onlyoffice/forcesave`;`rust/crates/mnote-web/src/routes/onlyoffice.rs:717-759` callback 只记录日志并返回 `{error:0}`,forcesave 返回 `mnote-web-rust-noop`;而 `wolai-frontend/src/app/api/onlyoffice/callback/route.ts:93-192` 有真实 Convex 写回链。 | 优先把 Rust route 接到 `onlyoffice_prepare_callback` / media asset writeback,或显式把该路由代理到 legacy Next,避免主入口 shadow 掉真实写回。 | | F-02 | 未完成 | P2 | Mindmap `export` 动作暴露给 UI action map,但 simple-mind-map 安全执行器不允许 `EXPORT`,默认路径可能显示能力却执行失败。 | `wolai-frontend/src/lib/mindmap/mindmap-action-map.ts:65` 将 `export` 映射为 runtimeCommand `EXPORT`;`wolai-frontend/src/lib/mindmap/simple-mind-map-bridge.ts:119-129` 的 `SIMPLE_MIND_MAP_SAFE_COMMANDS` 不包含 `EXPORT`。 | 要么把 `EXPORT` 加入安全命令并补 smoke,要么在 UI state 中继续禁用导出并标注为延期。 | | F-03 | 未完成 / 方向变化 | P2 | Mindmap AI 补完 route 当前直接 501,后续大段旧 Supabase/在线 AI 实现被注释;但 Rust tool registry 已登记 `mindmap_expand_node`,形成“工具存在、产品入口不可用”的断层。 | `wolai-frontend/src/app/api/mindmap-ai/expand-node/route.ts:90-97` 校验后直接返回 `501`;同文件后续注释块仍保留旧 Supabase/AI 逻辑;`rust/crates/core-protocol/src/tool.rs:231-240` 与 `rust/crates/bridge-runtime/src/lib.rs:3803-3812` 已有 `mindmap_expand_node`。 | 若该能力仍在 Phase 6/7 范围内,应按 CLI/Rust bridge 路线重接;若延期,应把 route 标成 retired/debug,避免前端或测试误以为可用。 | -| F-04 | 方向变化 / 文档滞后 | P2 | AI 主 Web route 已收口到 `mnote-cli` host,但 `wolai-backend` 仍暴露 `openai_agents_python` 文档 agent route 与旧工具面;是否仍部署为可访问入口需进一步验证。 | `wolai-frontend/src/app/api/ai-agent/run/route.ts:36-57` 明确拒绝 codex/hermes/claudecode 并进入 `startMnoteCliAgentHostRun`;`wolai-backend/app/routers/ai_agent.py:34-57` 仍暴露 `/ai-agent/health`、`/ai-agent/document/run`,health 返回 `bridge: openai_agents_python`;`wolai-backend/app/services/ai_document_agent.py:1174-1470` 仍指令 agent 使用 `doc_insert_blocks`、`doc_replace_range`、`slash_run`。 | 在设计或代码注释中明确该后端只作为可插拔外置 agent/对照链;若不再使用,补退场计划和访问边界,尤其确认 `mnote_ai_orchestrator_api_key` 未配置时的开放行为。 | +| F-04 | 方向变化 / 文档滞后 | P2 | AI 主 Web route 曾收口到 `mnote-cli` host,但新主线已改为 Hermes 页面内客户端;`wolai-backend` 仍暴露 `openai_agents_python` 文档 agent route 与旧工具面,旧 route / sidecar / host 的退场关系需重新明确。 | `wolai-frontend/src/app/api/ai-agent/run/route.ts:36-57` 曾明确拒绝 codex/hermes/claudecode 并进入 `startMnoteCliAgentHostRun`;`wolai-backend/app/routers/ai_agent.py:34-57` 仍暴露 `/ai-agent/health`、`/ai-agent/document/run`,health 返回 `bridge: openai_agents_python`;`wolai-backend/app/services/ai_document_agent.py:1174-1470` 仍指令 agent 使用 `doc_insert_blocks`、`doc_replace_range`、`slash_run`。 | 以 `design/07-ai/process/7-3-page-ai-hermes-panel-and-mnote-plugin-v1.md` 为上位依据,明确页面 AI 改走 Hermes client proxy,mnote 能力通过 Hermes skill/plugin 暴露;`openai-agents-python` 和 `mnote-cli host` 只保留为历史/兼容/内部适配路径并补退场计划。 | | F-05 | 文档治理风险 | P3 | `design/90-reference` 符合“参考资料”目录定位,但内容仍带有问答式残留,容易被后续 worker 误用为正式设计结论。 | `design/README.md` 明确 `90-reference/` 不参与 process/done 状态判断;`design/90-reference/90-1-filetree.md` 末尾保留“需要我给你...”类对话尾巴;`design/90-reference/90-2-yemianshu.md` 同样保留示例请求口吻。 | 低优先级清理为中性参考笔记,并在引用时强制以 `ARCHITECTURE.md` 和主线设计为上位依据。 | ## 证据 @@ -31,9 +31,9 @@ ### AI -- CLI-first 入口基本对齐:`wolai-frontend/src/app/api/ai-agent/run/route.ts:36-57` 将默认执行入口限定到 `mnote-cli host`,拒绝旧 provider。 -- 结构化 artifact 设计仍有未完成项:`design/07-ai/process/7-phase7-ai-kernel-projection-plan-v4.md` 中“后续权限收口”仍未勾选,说明 AI 写链权限模型尚未完全闭环。 -- 旧后端 agent 仍存在:`wolai-backend/app/routers/ai_agent.py:47-57` 仍提供 streaming run;这可作为外置 agent,但需要避免被误认为长期默认主编排。 +- 旧 CLI-first 入口曾基本对齐:`wolai-frontend/src/app/api/ai-agent/run/route.ts:36-57` 将默认执行入口限定到 `mnote-cli host`,拒绝旧 provider;但该口径已被 2026-05-13 的 Hermes 面板主线覆盖。 +- 结构化 artifact 设计应继续保留对象模型,但触发方改为 Hermes tool call -> mnote plugin -> Rust runtime / kernel,见 `design/07-ai/process/7-3-page-ai-hermes-panel-and-mnote-plugin-v1.md` 与 `design/07-ai/process/7-2-phase7-structured-artifact-write-chain-v1.md`。 +- 旧后端 agent 仍存在:`wolai-backend/app/routers/ai_agent.py:47-57` 仍提供 streaming run;这只能作为历史/兼容/对照链,不能被误认为长期默认主编排。 ### OnlyOffice @@ -55,8 +55,8 @@ 1. P1:补齐或明确代理 OnlyOffice Rust callback/forcesave 写回链,避免主入口下保存成功但内容未持久化。 2. P2:收口 Mindmap action 可用性,先修 `export` 映射与安全命令不一致,再继续扩展 UI 能力。 -3. P2:处理 Mindmap AI route:要么按 Rust bridge/CLI-first 重接 `mindmap_expand_node`,要么显式退役该 Next route。 -4. P2:明确 `wolai-backend` AI agent 的外置/对照定位与访问边界,避免与 `mnote-cli` 唯一执行面口径冲突。 +3. P2:处理 Mindmap AI route:要么按 Hermes plugin / Rust bridge 重接 `mindmap_expand_node`,要么显式退役该 Next route。 +4. P2:明确 `wolai-backend` AI agent、旧 `/api/ai-agent/run` 与 `mnote-cli host` 的历史/兼容定位和访问边界,避免与 Hermes 面板 + mnote plugin 新主线冲突。 5. P3:清理 `design/90-reference` 的问答残留,并在引用规范里再次强调它不是 process/done 设计稿。 ## 本次修改文件 diff --git a/design/10-review/05-tree.md b/design/10-review/05-tree.md index 6f45fd33..e5a74bf8 100644 --- a/design/10-review/05-tree.md +++ b/design/10-review/05-tree.md @@ -1,3 +1,5 @@ +> 执行状态:本文是 10-review 阶段的树域判断记录,不再作为待办清单直接执行。Resource Tree / File Tree / Page Tree 真源合同已由 `/mnt/Data1T/mnote/design/04-tree-domain/done/4-24-resource-tree-filetree-pagetree-source-contract-checklist-v1.md` 承接;主编辑区 Object Tab、mindmap object editor、`index.md` 隔离已由 `/mnt/Data1T/mnote/design/05-editor-mainline/done/5-12-main-editor-object-tab-resource-alignment-checklist-v1.md` 承接。后续只需按 `06-execution-checklist-and-acceptance.md` 保留 smoke 防回归。 + 结论 我基本同意你的方向,但要把“文件树为根源”说得更精确:不应让“文件树 UI 组件”成为真源,而应让 Rust kernel 中的 workspace resource tree / file tree projection 背后 的资源层级 成为页面、附件、mindmap、OnlyOffice 等对象的组织根源。页面树则是从这棵资源/对象树派生出的导航投影,类似快捷方式、收藏视图或文档视图。 diff --git a/design/10-review/06-execution-checklist-and-acceptance.md b/design/10-review/06-execution-checklist-and-acceptance.md new file mode 100644 index 00000000..ee841e4d --- /dev/null +++ b/design/10-review/06-execution-checklist-and-acceptance.md @@ -0,0 +1,438 @@ +# 10-review 顺序执行清单与验收标准 + +> 更新时间:2026-05-14 +> +> 上游依据: +> - `/mnt/Data1T/mnote/design/10-review/README.md` +> - `/mnt/Data1T/mnote/design/10-review/01-rust-kernel-web-review.md` +> - `/mnt/Data1T/mnote/design/10-review/02-frontend-editor-tree-review.md` +> - `/mnt/Data1T/mnote/design/10-review/03-convex-realtime-storage-review.md` +> - `/mnt/Data1T/mnote/design/10-review/04-secondary-domains-and-design-governance-review.md` +> - `/mnt/Data1T/mnote/design/10-review/05-tree.md` +> - `/mnt/Data1T/mnote/design/04-tree-domain/done/4-24-resource-tree-filetree-pagetree-source-contract-checklist-v1.md` +> - `/mnt/Data1T/mnote/design/05-editor-mainline/done/5-12-main-editor-object-tab-resource-alignment-checklist-v1.md` + +## 0. 执行前提 + +`05-tree.md` 中关于 Resource Tree / File Tree / Page Tree、ObjectIdentity、mindmap 与 `index.md` 隔离的核心事项已经由 `4-24` 与 `5-12` 承接到 `done/`。后续执行不要重复重做该主线,只需要保留防回归验证。 + +本清单按依赖顺序执行。原则是先修会影响主链一致性和数据可信度的 P0,再处理 realtime / projection / compat 收口,最后做次级域和文档治理。 + +## 1. P0:修正 Rust page write artifact 一致性 + +状态:已完成第 1 项代码侧与 Rust 3000 验证。Rust `/api/documents/title` 与 `/api/documents/options` 已切到 artifact 记录路径;`page.layout.updateOptions` 已补 tree domain event / conservative resync artifact;Rust 3000 下已直接验证标题更新后 `bridge.workspace.overview` 可读到 `page.head.updateTitle` command log 与 `tree.node.renamed` domain event,`/api/tree/events` 也能输出包含新标题的 stream 事件。完整 File Tree 标题同步仍归入第 6 项继续处理。 + +目标:让 3000 主入口下的页面标题、页面设置、正文保存拥有一致 side effect,确保 tree stream 能看到相关变更。 + +建议修改范围: + +- `rust/crates/mnote-web/src/routes/documents.rs` +- `rust/crates/mnote-web/src/routes/command_support.rs` +- `rust/crates/mnote-web/src/transport/convex.rs` +- 必要时补充 `wolai-frontend/convex/bridgeLogs.ts` 查询侧验证 + +执行项: + +- [x] 将 Rust `/api/documents/title` 的命令执行切到 artifact 记录路径,或统一复用正式 page/tree command route。 +- [x] 将 Rust `/api/documents/options` 的命令执行切到 artifact 记录路径,或统一复用正式 page/tree command route。 +- [x] 明确 artifact 写失败策略:主 mutation 成功但 artifact 失败时至少要有可观测错误,并触发保守 resync 或明确降级。 +- [x] 补回归测试:标题更新后,`bridgeLogs:listWorkspaceOverview` 能看到对应 command/domain event。 + +验收标准: + +- [x] `cargo test -p mnote-web documents_title -- --nocapture` 或等价标题 route 测试通过。 +- [x] `cargo test -p mnote-web documents_options -- --nocapture` 或等价页面设置 route 测试通过。 +- [x] 手动或脚本验证:更新标题后 `/api/tree/events` 输出 delta 或 resync,不需要刷新整页才能看到 sidebar/breadcrumb 变化。 +- [x] Rust route 与 Next legacy adapter 对同一 page write 的 canonical command / artifacts 语义一致。 + +证据: + +- `rust/crates/mnote-web/src/routes/documents.rs`:`title` / `options` 使用 `execute_runtime_command_via_convex_with_artifacts`,响应 `meta.artifacts` / `meta.artifactError`。 +- `rust/crates/bridge-runtime/src/lib.rs`:`page.layout.updateOptions` 产生 `page.layout.options_updated` domain event 与 `resync_required` stream delta。 +- `rust/crates/mnote-web/src/transport/convex.rs`:发送给 legacy `documents:updateOptions` 时剥离 artifact-only 字段。 +- 已通过:Rust 3000 目标脚本创建临时页面后调用 `/api/documents/title`,再查 `/api/bridge/workspace?targetPageId=&aggregateType=page&aggregateId=`,确认返回 `command_name=page.head.updateTitle` 与 `event_type=tree.node.renamed`。 +- 已通过:Rust 3000 目标脚本创建临时页面后调用 `/api/documents/title`,再查 `/api/tree/events?workspaceId=&maxPolls=1&pollMs=250`,确认 stream 文本包含新标题且包含 `snapshot` / `delta` / `resync` 事件。 +- 已通过:`node scripts/task123-rust-web-tree-live-stream-consumer-smoke.js`。 +- 部分通过:`node scripts/task110-page-title-single-truth-smoke.js` 在 Rust 3000 下已通过页头、breadcrumb、sidebar、page tree 标题一致性检查;当前失败点为 File Tree 标题行,继续归入第 6 项处理。 +- 已通过:`pnpm test -- src/app/api/documents/route-adapters.test.ts src/lib/documents/page-write-command-adapter.test.ts`(Vitest 实际执行 112 个测试文件、457 个测试),Next `documents/title` route 与 page write adapter 继续生成 `page.head.updateTitle` canonical command 并进入 Rust artifact writer。 +- 已通过:`cargo test -p mnote-web documents_ -- --nocapture`。 +- 已通过:`cargo test -p bridge-runtime page_head_update_title_command_plan_uses_canonical_page_command -- --nocapture`。 +- 已通过:`cargo test -p bridge-runtime page_layout_update_options_command_plan_uses_canonical_page_command -- --nocapture`。 +- 已通过:`cargo test -p bridge-runtime document_options_command_plan_maps_to_documents_update_options -- --nocapture`。 +- 已通过:`cargo test -p mnote-web convex_command_args_strips_page_options_artifacts_for_legacy_mutation -- --nocapture`。 + +## 2. P0:移除或显式隔离假 fallback 数据 + +状态:已完成代码侧收口与负向单测。Search 默认不再返回内置固定业务结果;workspace shell projection 默认进入 degraded empty;Sidebar/File Tree dev fallback 仅在 `allow_dev_fixtures` 开启时可用,并带隐藏 debug/dev 标识。 + +目标:失败、超时、Convex 不可用时,不再返回看起来像真实业务数据的 fixture / fallback。 + +建议修改范围: + +- `rust/crates/mnote-web/src/routes/search.rs` +- `rust/crates/mnote-web/src/routes/web_shell.rs` +- `rust/crates/mnote-web/src/routes/snapshot_support.rs` + +执行项: + +- [x] 优先移除或强门禁 Search 的 `fallback_search_dataset`。 +- [x] 对 workspace shell projection 加载失败的最小合成数据做决策:返回明确 degraded/empty/error,不能伪装成真实 workspace projection。 +- [x] 对 `allow_dev_fixtures` 相关 sidebar/filetree fallback 增加明确 debug/dev 标识,并确认 3000 默认启动链不会误开。 +- [x] 增加负向 smoke:Convex/query 不可用时,Search、Sidebar、File Tree 不返回固定假业务数据。 + +验收标准: + +- [x] 关闭或破坏 Convex query 后,Search 不返回内置固定结果。 +- [x] 关闭或破坏 projection 加载后,workspace/sidebar/filetree 响应带明确错误、空状态或 degraded 标记。 +- [x] 3000 默认入口不会出现 fixture workspace、fixture document、fixture filetree row。 +- [x] 相关负向测试或 smoke 在失败场景下能稳定断言“不返回假数据”。 + +证据: + +- `rust/crates/mnote-web/src/routes/search.rs`:Convex/search query 失败时,默认返回空结果与 `meta.degraded=true`;仅 `allow_dev_fixtures` 开启时使用 `fallback_search_dataset`。 +- `rust/crates/mnote-web/src/routes/web_shell.rs`:workspace shell projection 失败时,默认返回 degraded empty dataset;Sidebar/File Tree dev fallback HTML 增加 `data-mnote-dev-fixture` 标识。 +- `rust/crates/mnote-web/src/workspace_shell.rs`:workspace shell projection/render 支持 `degraded` / `devFixture` 标识。 +- 已通过:`cargo test -p mnote-web search_ -- --nocapture`。 +- 已通过:`cargo test -p mnote-web sidebar_and_filetree_do_not_return_dev_fixtures_by_default -- --nocapture`。 +- 已通过:`cargo test -p mnote-web workspace_shell_sidebar_html_marks_degraded_and_dev_fixture_states -- --nocapture`。 +- 已通过:`cargo test -p mnote-web document_shell_returns_page_aggregate_snapshot -- --nocapture`。 + +## 3. P1:补齐 OnlyOffice Rust callback / forcesave 写回链 + +状态:已完成代码侧与真实 Rust 3000 smoke。Rust `/api/onlyoffice/callback` 与 `/api/onlyoffice/forcesave` 已改为显式代理 legacy Next 写回链;未配置 legacy Next 时返回明确失败/unsupported,不再静默 no-op 成功;`task174` 已验证 OnlyOffice 附件打开链路与 docKey。 + +目标:Rust Web 主入口下 OnlyOffice 保存不能出现“返回成功但未写回”的情况。 + +建议修改范围: + +- `rust/crates/mnote-web/src/routes/onlyoffice.rs` +- `rust/crates/adapter-onlyoffice/` +- `wolai-frontend/src/app/api/onlyoffice/callback/route.ts` 作为 legacy 写回参考 +- `scripts/task174-rust-onlyoffice-attachment-open-smoke.js` + +执行项: + +- [x] 决定 Rust route 直接写回,还是显式代理到 legacy Next 写回链。 +- [x] 如果直接写回,接入 media asset / storage 写回与 callback 状态处理。(本轮选择显式代理 legacy Next 写回链,直接写回不适用。) +- [x] forcesave 不再返回纯 `mnote-web-rust-noop` 成功语义;若仍未支持,必须返回明确 unsupported/degraded。 +- [x] 补真实浏览器 smoke:打开 OnlyOffice 附件、触发保存、重新打开后内容或版本可验证。 + +验收标准: + +- [x] `/api/onlyoffice/callback` 在需要写回的 status 下会产生真实持久化写入或明确失败。 +- [x] `/api/onlyoffice/forcesave` 不再静默 no-op 成功。 +- [x] `node scripts/task174-rust-onlyoffice-attachment-open-smoke.js` 或等价 smoke 通过。 +- [x] 3000 主链不会 shadow 掉 legacy Next 的真实写回能力。 + +证据: + +- `rust/crates/mnote-web/src/routes/onlyoffice.rs`:callback / forcesave 代理到 `legacy_next_base_url` 的 Next `/api/onlyoffice/*` 写回链;缺少 legacy 写回链时返回明确失败。 +- 已通过:`cargo test -p mnote-web onlyoffice_ -- --nocapture`。 +- 已通过:`node scripts/task174-rust-onlyoffice-attachment-open-smoke.js`,输出 `ok=true`,创建临时 docx attachment,打开 `/onlyoffice` 编辑 URL,并返回稳定 `docKey`。 + +## 4. P1:收口 Page Aggregate provenance + +状态:已完成代码侧与文档侧收口。`/api/page-aggregate/:id` 继续保持 Rust `mnote.page_aggregate.v1` 主读链,但当底层由 `documents:getMeta + documents:getContent` substrate 构建时,response `source` 与 owner header 已明确标为 `CompatMetaContentJoin` / `compat-join`,不再误写成完整 `KernelProjection`。 + +目标:Page Aggregate 读链保持 Rust-first,但来源标识必须反映真实构建路径,避免把 `meta/content join` 写成完整 kernel-native projection。 + +建议修改范围: + +- `rust/crates/core-protocol/src/page_aggregate.rs` +- `rust/crates/bridge-runtime/src/lib.rs` +- `rust/crates/mnote-web/src/routes/web_shell.rs` +- `wolai-frontend/src/lib/documents/page-aggregate-loader.ts` +- `design/05-editor-mainline/process/5-5-page-aggregate-single-truth-alignment-v1.md` +- `design/05-editor-mainline/process/5-6-page-aggregate-alignment-checklist-v1.md` + +执行项: + +- [x] 明确 `PageAggregateSource::KernelProjection` 与 `CompatMetaContentJoin` 的边界。 +- [x] 如果 route 仍由 `getMeta + getContent` 构建,返回 source 或 response header 应反映真实来源。 +- [x] 文档更新为“Rust route 主读链已成立,底层仍是 Rust runtime adapter 消费 meta/content substrate”。 +- [x] 保留或删除 `PageAggregateSource::Fixture` 前,先确认测试和本地 fixture 依赖。(本轮保留,仅用于测试或显式 dev fixture 语义。) + +验收标准: + +- [x] `/api/page-aggregate/:id` 返回的 source/provenance 与实际构建路径一致。 +- [x] 文档中不再出现“Page Aggregate 已完全 kernel-native projection 闭环”的过满表述。 +- [x] 前端 loader 仍只消费 `mnote.page_aggregate.v1`,不恢复 TS runtime builder 主链。 +- [x] Page Aggregate provenance 变更有 Rust 单测或 loader 单测覆盖。 + +证据: + +- `rust/crates/bridge-runtime/src/lib.rs`:`page.aggregate.get` 根据 query data 是否包含 `meta + content` 选择 `CompatMetaContentJoin`,否则保留 `KernelProjection`。 +- `rust/crates/mnote-web/src/routes/web_shell.rs`:`/api/page-aggregate/:id` 测试断言 `x-mnote-page-aggregate-owner=compat-join` 且 `result.source=CompatMetaContentJoin`。 +- `design/05-editor-mainline/process/5-5-page-aggregate-single-truth-alignment-v1.md`:补充 Rust route 主读链与完整 kernel-native projection 的边界。 +- `design/05-editor-mainline/process/5-6-page-aggregate-alignment-checklist-v1.md`:补充 `CompatMetaContentJoin` / `KernelProjection` 验收口径。 +- `design/05-editor-mainline/done/5-5-1-page-aggregate-contract-v1.md`:新增 `source` / provenance 契约说明。 +- 已通过:`cargo test -p bridge-runtime page_aggregate_get_query_executes_into_core_projection -- --nocapture`。 +- 已通过:`cargo test -p mnote-web page_aggregate_endpoint_returns_snapshot_contract -- --nocapture`。 + +## 5. P1:优化 tree stream 的 Convex 查询模型 + +状态:已完成代码侧与基础 Rust 3000 smoke。`bridgeLogs:listWorkspaceOverview` 已从全量 workspace collect 后内存分页,改为基于 `workspace_id + created_at + id` 及常用过滤组合索引的有界窗口查询;Rust `/api/tree/events` 的 snapshot/delta/resync 协议未改,polling-backed SSE 仍按“最新窗口 + Rust cursor 比较”工作,当前定位为过渡实现。`task123` 和标题更新目标脚本已证明 Rust 3000 stream 可输出 snapshot / title event;完整浏览器 tree integration smoke 仍因 sidebar row 可见性失败,归入第 6 项继续处理。 + +目标:让 `/api/tree/events` 可以长期承载主链,避免 SSE 轮询叠加全量日志扫描。 + +建议修改范围: + +- `wolai-frontend/convex/bridgeLogs.ts` +- `wolai-frontend/convex/schema.ts` +- `rust/crates/mnote-web/src/routes/sse.rs` +- `rust/crates/mnote-web/src/routes/stream_support.rs` +- `wolai-frontend/src/lib/tree-stream/` + +执行项: + +- [x] 为 `command_logs` 增加按 `workspace_id + created_at/id` 的查询路径。 +- [x] 为 `domain_events` 增加按 `workspace_id + created_at/id` 或 command cursor 的查询路径。 +- [x] `listWorkspaceOverview` 不再每轮 collect 全量 workspace 日志后内存分页。 +- [x] 明确 polling-backed SSE 是过渡实现还是长期实现;若长期使用,补连接数、日志量、延迟边界测试。(本轮明确为过渡实现,live 轮询仍查最新窗口并由 Rust 做 cursor 比较。) + +验收标准: + +- [x] 大量历史日志存在时,overview 查询仍按 cursor / limit 返回,不全量 collect。 +- [x] `/api/tree/events` 的 snapshot/delta/resync 协议不破坏现有前端消费。 +- [x] `pnpm --dir wolai-frontend test -- tree-stream` 或等价 tree-stream 测试通过。 +- [x] 至少一条 smoke 覆盖标题/树变更后 SSE 能正常输出增量或保守 resync。 + +证据: + +- `wolai-frontend/convex/schema.ts`:`command_logs` 增加 `by_workspace_created_at`、`by_workspace_status_created_at`、`by_workspace_target_page_created_at`、`by_workspace_target_block_created_at`;`domain_events` 增加 `by_workspace_created_at`、`by_workspace_status_created_at`、`by_workspace_aggregate_created_at`。 +- `wolai-frontend/convex/bridgeLogs.ts`:`listWorkspaceOverview` 改为 `fetchCommandLogWindow` / `fetchDomainEventWindow`,通过索引 `order("desc").take(scanLimit)` 和 cursor 上界查询,移除对 workspace 全量日志的 `.collect()`。 +- `rust/crates/mnote-web/src/routes/sse.rs`:live poll 语义保持不变,仍清空 bridge pagination cursor 后查最新窗口,再由 Rust 比较当前 stream cursor。 +- 已通过:`pnpm test -- src/lib/tree-stream`(Vitest 实际执行 111 个测试文件、452 个测试)。 +- 已通过:`cargo test -p mnote-web stream_ -- --nocapture`。 +- 已通过:`cargo test -p mnote-web tree_realtime_route_returns_rust_web_owned_snapshot_event -- --nocapture`。 +- 已通过:`pnpm exec eslint convex/bridgeLogs.ts convex/schema.ts` 无错误;保留既有 `any` 风格警告。 +- 已通过:`node scripts/task123-rust-web-tree-live-stream-consumer-smoke.js`。 +- 已通过:Rust 3000 目标脚本验证 `/api/documents/title` 后 `/api/tree/events?maxPolls=1&pollMs=250` 输出包含新标题的 stream 事件。 +- 未通过:`node scripts/task120-rust-web-tree-integration-smoke.js` 在等待 child sidebar row 可见时超时,完整浏览器 tree integration 继续归入第 6 项处理。 +- 受既有问题阻塞:`pnpm exec tsc --noEmit --pretty false` 仍被 `.next` 旧生成文件、Convex documents 类型和 mindmap/OnlyOffice 等无关类型错误阻塞;本轮新增 `bridgeLogs.ts` / `schema.ts` 未出现在错误列表中。 + +## 6. P1:统一 Sidebar live cache + +状态:代码侧完成,真实浏览器 create / rename / move / archive / purge 主链已通过。`usePreferredSidebarSnapshot` 已从“sync key 与 initial 对比”的特殊规则,改为基于 `getSidebarDataFreshness()` 的 version 仲裁,并保留 tree stream cursor 作为 live 元信息;`AppLayoutShell`、Sidebar、Breadcrumb、SearchPalette 继续消费同一份 preferred snapshot。Rust 3000 下已修复 `tree:delta` 的 title-only patch 被误当成空 projection 渲染的问题,`task110` 已通过页头、breadcrumb、sidebar、page tree、File Tree 标题一致性检查;`task120` 已通过 child sidebar row 可见性、rename live delta、reload 与 purge 后消失检查;`task177` 已覆盖 move / archive live DOM reducer,确认页面树与 File Tree 行局部更新后文档页头仍稳定。 + +目标:减少 `initial / query / tree_stream` 三源 freshness 选择,把 Sidebar、Breadcrumb、文档页头收口到统一 live cache / projection version 仲裁。 + +建议修改范围: + +- `wolai-frontend/src/components/app-layout-shell.tsx` +- `wolai-frontend/src/components/sidebar/use-preferred-sidebar-snapshot.ts` +- `wolai-frontend/src/components/sidebar/sidebar.tsx` +- `wolai-frontend/src/lib/tree-stream/` + +执行项: + +- [x] 定义 live snapshot 的 version/cursor 仲裁字段。 +- [x] `usePreferredSidebarSnapshot` 从 sync key 比较推进到 cursor/version 比较。 +- [x] Sidebar、Breadcrumb、文档页头统一消费同一 preferred/live snapshot。 +- [x] 保留 initial snapshot 只作为首屏启动输入,不能长期压过更新后的 stream/query。 + +验收标准: + +- [x] 新建、重命名、移动、归档页面后,Sidebar、Breadcrumb、文档页头无互相打架或回退闪烁。 +- [x] `usePreferredSidebarSnapshot` 有单测覆盖 query、initial、tree_stream 三者的新旧仲裁。 +- [x] 真实浏览器 smoke 覆盖至少一个 tree command 后 Sidebar 与文档页头一致。 + +证据: + +- `wolai-frontend/src/components/sidebar/use-preferred-sidebar-snapshot.ts`:输出 `version` 与 `cursor`,以 `getSidebarDataFreshness()` 比较 initial/query/tree_stream;stream fallback 时不参与仲裁,同版本时优先 live stream。 +- `wolai-frontend/src/components/app-layout-shell.tsx`:向 preferred snapshot 仲裁传入 `treeStream.cursor`,Sidebar、Breadcrumb、SearchPalette 仍消费同一 preferred data。 +- `wolai-frontend/src/components/sidebar/sidebar.tsx`:Sidebar 内部 fallback 路径也传入 `treeStream.cursor`。 +- `wolai-frontend/src/components/sidebar/use-preferred-sidebar-snapshot.test.tsx`:新增 query 比旧 stream 更新时优先 query、stream 与 query 同版本时优先 stream 并暴露 cursor 的回归用例。 +- 已通过:`pnpm test -- src/components/sidebar/use-preferred-sidebar-snapshot.test.tsx src/components/app-layout-shell.test.tsx src/components/sidebar/sidebar-sync.test.tsx`(Vitest 实际执行 111 个测试文件、454 个测试)。 +- 已通过:`pnpm exec eslint src/components/sidebar/use-preferred-sidebar-snapshot.ts src/components/sidebar/use-preferred-sidebar-snapshot.test.tsx src/components/app-layout-shell.tsx src/components/sidebar/sidebar.tsx` 无错误;`sidebar.tsx` 保留既有 warning。 +- 已通过:`cargo test -p mnote-web tree_realtime_route_returns_rust_web_owned_snapshot_event -- --nocapture`。 +- `rust/crates/mnote-web/src/ssr/pages/layout.rs`:`renderSidebarSnapshot` 只在 payload 明确带 projection items 时重渲染 Sidebar;单文档 `upsert_document` title patch 改为只调用 `updateTitleEverywhere()`,避免 rename delta 把 Sidebar 渲染成空树。 +- `rust/crates/mnote-web/src/ssr/pages/layout.rs`:新增 `move_document` / `remove_document` 的小型 DOM reducer,移动时更新页面树与 File Tree 行的父节点,归档时局部移除对应行。 +- `rust/crates/mnote-web/src/transport/convex.rs`:tree create / rename / archive / restore / move 及 compat documents mutation 发送给 legacy Convex mutation 前剥离 artifact-only 字段,避免 validator 因 `streamDeltaHint` / `domainEventPlan` 返回 502。 +- 已通过:`cargo test -p mnote-web sidebar_tree_runtime -- --nocapture`。 +- 已通过:`cargo test -p mnote-web convex_command_args_strips -- --nocapture`。 +- 已通过:`node scripts/task110-page-title-single-truth-smoke.js` 在 Rust 3000 下通过页头、breadcrumb、sidebar、page tree、File Tree 标题一致性检查。 +- 已通过:`node scripts/task120-rust-web-tree-integration-smoke.js` 在 Rust 3000 下通过 root/child row、active 切换、rename live delta、reload 与 purge 检查。 +- 已通过:`node scripts/task112-tree-rust-family-regression-smoke.js`,但当前页面选项壳缺少 move/embed 入口导致 picker 部分 skipped:`move_embed_entry_missing_in_current_page_options_shell`。 +- 已通过:`node scripts/task177-tree-move-archive-live-smoke.js`,覆盖 `move_document` live event 后页面树 / File Tree 行 `data-parent-id` 更新,以及 `remove_document` live event 后两棵树局部移除归档行;文档页头和标题输入保持根页面标题稳定。 +- 已通过:`node scripts/task179-tree-create-delete-no-reload-smoke.js`,覆盖 Rust `/tree` `convex_workspace` 下 File Tree create/delete 与 Page Tree create;最新结果 `ok=true`,`filetree.createMs=120`、`filetree.deleteMs=178`、`page.createMs=120`,对应 mode 内 `navigationEvents.length=0`,URL 前后一致。 + +## 7. P1:明确 `pageSubtree` 与本地正文编辑关系 + +状态:已完成。策略已固定为:server `pageSubtree` 仍是正式 projection 来源;当本地标题草稿或正文快照与 server snapshot 不一致时,前端允许用当前本地 content 生成临时 `pageSubtree`,并通过 `pageSubtreeSource=local` 暴露给 AI context,避免继续复用旧 server subtree 或让 AI 长期失去结构上下文。React `DocumentAiAgentPanel` 侧已有 client state / runtime 单测覆盖;Rust 3000 当前文档页使用全局浮动页面 AI,本轮已补同等 local `pageSubtree` context 构造与真实浏览器 smoke。 + +目标:用户正常编辑正文后,AI 面板、阅读视图、结构视图不能因为本地 content 与 server snapshot 不同而长期失去结构上下文。 + +建议修改范围: + +- `wolai-frontend/src/components/editor/page-aggregate-client-state.ts` +- `wolai-frontend/src/components/editor/document-content.tsx` +- `wolai-frontend/src/lib/documents/page-subtree.ts` +- `rust/crates/core-protocol/src/page_aggregate.rs` + +执行项: + +- [x] 决定 `pageSubtree` 是 server-only projection,还是允许本地临时 projection。 +- [x] 标记本地临时 projection 与 server projection 的来源差异,避免把本地临时结构误认为 kernel projection。 +- [x] AI 面板读取最新 page aggregate snapshot 时,能拿到本地编辑后的结构上下文。 +- [x] 真实编辑器 smoke 验证本地正文编辑后阅读态结构 / AI context 不丢失。 + +验收标准: + +- [x] 本地正文或标题草稿变化后,不再继续复用旧 server `pageSubtree`。 +- [x] 本地正文或标题草稿变化后,可生成临时 `pageSubtree` 供阅读态 / AI context 使用。 +- [x] AI context 明确带 `pageSubtreeSource`,可区分 `server` / `local` / `none`。 +- [x] 浏览器实测:编辑正文标题或 heading 后,结构面板 / AI context 能看到本地最新结构。 + +证据: + +- `wolai-frontend/src/components/editor/page-aggregate-client-state.ts`:新增 `PageAggregateClientPageSubtreeSource`,server 快照未变时返回 `source=server`;本地 content/title 草稿变化时用 `buildPageSubtreeProjection` 生成 `source=local`。 +- `wolai-frontend/src/components/editor/DocumentAiAgentPanel.tsx`:`PageAggregateAiSnapshot` 增加 `pageSubtreeSource`。 +- `wolai-frontend/src/components/editor/DocumentAiAgentPanel.runtime.tsx`:AI request context 增加 `pageSubtreeSource`。 +- `wolai-frontend/src/components/editor/page-aggregate-client-state.test.ts`:覆盖本地正文/标题变化后生成临时 `pageSubtree`,以及 server snapshot 下 `pageSubtreeSource=server`。 +- `rust/crates/mnote-web/src/ssr/pages/layout.rs`:Rust 3000 全局浮动页面 AI 在发送 `/api/ai-agent/run` 前读取当前 Leptos/Tiptap 编辑器 DOM;当本地内容与 server aggregate 正文不同,生成 `source=local` 的临时 subtree / outline / evidence,并在 context 中发送 `pageSubtreeSource=local`。 +- 已通过:`pnpm test -- src/components/editor/page-aggregate-client-state.test.ts src/components/editor/DocumentAiAgentPanel.runtime.test.tsx src/components/editor/document-content.test.ts`(Vitest 实际执行 111 个测试文件、454 个测试)。 +- 已通过:`node scripts/task178-page-ai-local-subtree-context-smoke.js`,Rust 3000 下打开真实文档页,把编辑器内容改成本地 heading 后打开页面 AI,并拦截 `/api/ai-agent/run` 请求确认 `context.pageSubtreeSource=local`、`documentBlocks` / `outline` / `subtree.stats.headingCount` 均包含本地最新 heading。 +- 已通过:`pnpm exec eslint src/components/editor/page-aggregate-client-state.ts src/components/editor/page-aggregate-client-state.test.ts src/components/editor/DocumentAiAgentPanel.tsx src/components/editor/DocumentAiAgentPanel.runtime.tsx` 无错误;`DocumentAiAgentPanel.runtime.tsx` 保留既有 `any` warning。 + +## 8. P2:清理或标注 stale mapping / legacy compat + +状态:已完成。`/api/documents/page` 与 `/api/mnote-web/stream` 已经明确退场为 410 compat 边界;`storage-convex-bridge` 不再把 `page.aggregate.get` 映射到不存在的 Convex `documents:getPageAggregate`,而是显式落到 `queries:unknown` 边界。Rust `/api/page-aggregate/:id` 当前先读取 `documents.meta.get` / `documents.content.get`,再交给 `bridge-runtime` 的 `page.aggregate.get` 组装 core projection,不走缺失 Convex function。 + +目标:保留的 compat 都必须是显式迁移/调试边界;不再让后续实现误把 stale mapping 当主链。 + +建议修改范围: + +- `rust/crates/storage-convex-bridge/src/mapping.rs` +- `rust/crates/mnote-web/src/app.rs` +- `rust/crates/mnote-web/src/routes/gateway.rs` +- `rust/crates/mnote-web/src/routes/documents.rs` +- `wolai-frontend/src/lib/documents/page-aggregate-builder.ts` +- `wolai-frontend/src/app/api/documents/page/route.ts` +- `wolai-frontend/src/app/api/mnote-web/stream/route.ts` + +执行项: + +- [x] 处理 `page.aggregate.get -> documents:getPageAggregate`:实现、移除或标注为 future/stale。 +- [x] legacy Next proxy 保留时,命名与注释统一为 explicit migration/debug boundary。 +- [x] TS `page-aggregate-builder` 保留为测试/adapter/reference,不得重新接回 runtime 主链。 +- [x] 410 compat route 保持退场语义,并补负向测试防止重新启用。 + +验收标准: + +- [x] 全仓没有生产路径调用不存在的 `documents:getPageAggregate`。 +- [x] 默认 3000 主链不走 legacy Next proxy。 +- [x] `/api/documents/page` 与 `/api/mnote-web/stream` 继续保持明确退场响应。 +- [x] 文档和代码注释不会把 compat/fallback 描述成当前主路径。 + +证据: + +- `rust/crates/storage-convex-bridge/src/mapping.rs`:`page.aggregate.get` 显式标注为 stale/future compat placeholder。 +- `wolai-frontend/src/app/api/documents/page/route.ts`:返回 410,提示直接使用 `/api/page-aggregate/:documentId`。 +- `wolai-frontend/src/app/api/mnote-web/stream/route.ts`:返回 410,提示直接使用 `/api/tree/events`。 +- `design/10-review/03-convex-realtime-storage-review.md`:已明确指出 `documents:getPageAggregate` 目前缺少 Convex 实现,属于方向滞后或 future/stale 口径。 +- `rust/crates/storage-convex-bridge/src/mapping.rs`:`page.aggregate.get` 改为 `queries:unknown`,并注释说明 page aggregate 主读链由 Rust route + `bridge-runtime` 组装,避免误触发不存在的 `documents:getPageAggregate`。 +- 已通过:`rg -n "documents:getPageAggregate|getPageAggregate" rust wolai-frontend src -g '!node_modules' -g '!target'`,仅剩 mapping 注释说明,无生产调用。 +- 已通过:`cargo test -p storage-convex-bridge -- --nocapture`。 +- 已通过:`cargo test -p bridge-runtime page_aggregate_get_query_executes_into_core_projection -- --nocapture`。 + +## 9. P2:处理 Mindmap 次级未闭合项 + +状态:已完成。Mindmap `export` 不再映射到未受控的 `EXPORT` runtime command,UI state 继续禁用该入口;旧 `/api/mindmap-ai/expand-node` Next route 已显式退役为 410,并指向 AI Agent 内置 `mindmap_expand_node` 工具;legacy MindmapSidebar 中调用旧 route 的补完入口已从渲染层关闭。mindmap 内容保存主链仍保持 `mindmap.command.apply`,没有回退到 Page Aggregate body 保存链;真实 3000 `task169` 已通过,证明 mindmap 与 `index.md` 隔离未回归。 + +目标:`05-tree.md` 的 mindmap 污染 `index.md` 已完成;剩余处理能力入口与实现不一致的问题。 + +建议修改范围: + +- `wolai-frontend/src/lib/mindmap/mindmap-action-map.ts` +- `wolai-frontend/src/lib/mindmap/simple-mind-map-bridge.ts` +- `wolai-frontend/src/app/api/mindmap-ai/expand-node/route.ts` +- `rust/crates/core-protocol/src/tool.rs` +- `rust/crates/bridge-runtime/src/lib.rs` + +执行项: + +- [x] 处理 Mindmap `EXPORT`:加入安全命令并补 smoke,或在 UI state 中禁用并标注延期。 +- [x] 处理 Mindmap AI expand route:按 Hermes plugin / Rust bridge 重接,或显式退役该 route。 +- [x] 保持 mindmap 内容保存继续走 `mindmap.command.apply`,不得回退到 Page Aggregate body 保存链。 + +验收标准: + +- [x] UI 不展示无法执行的 Mindmap export 能力,或 export 能真实执行并有测试覆盖。 +- [x] `/api/mindmap-ai/expand-node` 不再表现为“产品入口存在但稳定 501”而无说明。 +- [x] `node scripts/task169-mindmap-realtime-smoke.js` 继续通过,证明 mindmap 与 `index.md` 隔离未回归。 + +证据: + +- `wolai-frontend/src/lib/mindmap/mindmap-action-map.ts`:`export` 改为 `localView`,不再下发 `runtimeCommand: "EXPORT"`。 +- `wolai-frontend/src/lib/mindmap/mindmap-ui-state.ts`:`unsupportedActionIds` 继续包含 `export`,UI state 中该入口保持禁用。 +- `wolai-frontend/src/app/api/mindmap-ai/expand-node/route.ts`:旧 Next route 返回 410,`x-mnote-compat-boundary=mindmap-expand-node-route-retired`,并指向 `/api/ai-agent/run` + `mindmap_expand_node`。 +- `wolai-frontend/src/components/editor/blocks/MindmapSidebar.tsx`:legacy 侧栏中调用 `/api/mindmap-ai/expand-node` 的补完入口通过 `MINDMAP_LEGACY_EXPAND_NODE_ENTRY_ENABLED=false` 从渲染层关闭。 +- 已通过:`pnpm test -- src/lib/mindmap/mindmap-action-map.test.ts src/lib/mindmap/mindmap-ui-state.test.ts src/lib/mindmap/simple-mind-map-bridge.test.ts src/app/api/mindmap-ai/expand-node/route.test.ts`(Vitest 实际执行 112 个测试文件、457 个测试)。 +- 已通过:`pnpm exec eslint src/lib/mindmap/mindmap-action-map.ts src/lib/mindmap/mindmap-action-map.test.ts src/lib/mindmap/mindmap-ui-state.test.ts src/app/api/mindmap-ai/expand-node/route.ts src/app/api/mindmap-ai/expand-node/route.test.ts src/components/editor/blocks/MindmapSidebar.tsx` 无错误;保留 `MindmapSidebar.tsx` 既有 `AiPanel` 未使用 warning。 +- 已通过:`cargo test -p bridge-runtime mindmap_command_apply_plan_uses_kernel_command_facade -- --nocapture`,确认 mindmap 内容保存仍走 `mindmap.command.apply` / `mindmaps:applyCommand` facade。 +- 已通过:`node scripts/task169-mindmap-realtime-smoke.js`(Rust 3000 已启动,脚本退出码 0)。最终结果 `ok=true`、`failures=[]`,同一页面最终 File Tree mindmap row 数量为 1,`mindmapId=mindmap_1778695090474`,object identity 仍为同一 `objectKind:"mindmap"`。 +- 已通过:`cargo test -p bridge-runtime mindmap -- --nocapture`,17 个 mindmap 相关测试通过;普通 `mindmap.command.apply` 改为 `mindmap.content.updated + noop`,`mindmaps.put createOnly=false` 也不再默认 tree resync。 +- 已通过:`cargo test -p mnote-web mindmap -- --nocapture`,6 个 mindmap 相关测试通过,覆盖 Rust `/api/mindmap/:docId/:mindmapId` 的 object artifact 响应。 +- 已通过:`node scripts/task169-mindmap-realtime-smoke.js` 最新结果 `ok=true`、`failures=[]`;6 条可解析普通 `mindmap.command.apply` 响应均为 `eventType=mindmap.content.updated`、`streamOp=noop`,另有 1 条 Playwright response body 读取失败被记录为 `skippedReadFailures=1`;同一页面最终仍只有 1 条 mindmap asset row,`mindmapId=mindmap_1778698542703`。 + +## 10. P3:文档治理与回填 + +状态:已完成文档侧收口。`10-review/README.md` 已指向本执行清单;`05-tree.md` 已标明 Resource Tree / ObjectIdentity / mindmap 与 `index.md` 隔离主线由 `4-24` / `5-12` 承接完成;`5-5-1` 已修正 `showHeadingNumbers` / `embedDefaultBlockId` 的 runtime payload 口径;`90-reference` 的对话式尾巴已清理,`design/README.md` 已补充参考目录不能覆盖主线设计的引用边界。 + +目标:让设计目录继续反映真实主线,不把参考资料或过时清单误当成当前架构。 + +建议修改范围: + +- `design/10-review/README.md` +- `design/10-review/05-tree.md` +- `design/05-editor-mainline/done/5-5-1-page-aggregate-contract-v1.md` +- `design/90-reference/` + +执行项: + +- [x] 在 `05-tree.md` 中补充最终采用的 object editor / object tab 方案摘要,并说明核心执行已由 `4-24` / `5-12` 完成。 +- [x] 修正 `5-5-1` 中 `showHeadingNumbers` / `embedDefaultBlockId` 已接入 runtime payload 的滞后描述。 +- [x] 清理 `design/90-reference` 问答式残留,保留为中性参考笔记。 +- [x] 在引用规范中再次强调 `90-reference` 不参与 process/done 状态判断,不是上位架构来源。 + +验收标准: + +- [x] `design/10-review/README.md` 能指向本执行清单。 +- [x] `05-tree.md` 不再让后续 worker 误以为 Resource Tree / ObjectIdentity / mindmap 止血仍未执行。 +- [x] `design/90-reference` 的内容不再保留“需要我给你...”这类对话尾巴。 +- [x] 主线文档对 Page Aggregate、tree realtime、ObjectIdentity 的描述与当前代码事实一致。 + +证据: + +- `design/10-review/README.md`:补充执行入口,指向 `06-execution-checklist-and-acceptance.md`,并说明 `05-tree.md` 核心主线已由 `4-24` / `5-12` 承接。 +- `design/10-review/05-tree.md`:开头增加执行状态,明确本文不再作为待办清单直接执行。 +- `design/05-editor-mainline/done/5-5-1-page-aggregate-contract-v1.md`:`showHeadingNumbers` / `embedDefaultBlockId` 改为“runtime payload 已贯通,但深语义未完成”的口径。 +- `design/90-reference/90-1-filetree.md`、`design/90-reference/90-2-yemianshu.md`:删除“需要我给你...”对话尾巴。 +- `design/README.md`:补充 `90-reference/` 只能作为生态资料或背景材料,不能覆盖主线设计稿。 +- `design/03-rust-web/done/3-15-runtime-fallback-retirement-checklist-v1.md`:从 `process/` 迁入 `done/`,文件自身 checkbox 已全部完成,且与第 2 / 第 8 项 fallback、compat 退场证据一致。 +- `design/06-mindmap/done/6-mindmap-phase6-kmind-parity-detail-checklist-v1.md`:从 `process/` 迁入 `done/`,文件自身 checkbox 已全部完成,并已有 `task167-mindmap-kmind-parity-smoke.js` 等 Phase 6 KMind parity 证据。 +- 已验证:`rg -n "需要我给你|我可以|你要不要|是否需要|请告诉我|如果你愿意|要我|我来" design/90-reference` 无匹配。 +- 已完成只读状态审计:`design/03-rust-web/process/3-3-rust-web-tree-realtime-event-stream-v1.md`、`design/05-editor-mainline/process/5-5-page-aggregate-single-truth-alignment-v1.md`、`design/05-editor-mainline/process/5-6-page-aggregate-alignment-checklist-v1.md`、`design/06-mindmap/process/6-mindmap-kernel-phase6-projection-editor-v1.md`、`design/06-mindmap/process/6-mindmap-phase6-leptos-ui-shell-reuse-checklist-v1.md`、`design/07-ai/process/7-3-page-ai-hermes-panel-and-mnote-plugin-v1.md` 仍有未完成项或长期主线尾项,继续保留在 `process/`;`design/old/**` 不纳入活跃 process/done 迁移判断。 + +## 11. 全局 Done Gate + +全部清单完成前,不应把 10-review 状态描述为“单一真源已完全闭环”。可以描述为: + +> Rust 已经持有主语义主导权,Page Aggregate、tree command、tree realtime、Resource/ObjectIdentity 主线已经切入;剩余工作集中在 artifact 一致性、fallback 退场、realtime 查询模型、provenance 口径和 compat 边界收口。 + +全部完成的最低验收: + +- [x] P0 两项全部完成,且有自动化测试或真实 smoke 证据。 +- [x] `05-tree.md` 已执行事项通过 `task169` 或等价 smoke 防回归。 +- [x] `/api/page-aggregate/:id` provenance 与真实来源一致。 +- [x] `/api/tree/events` 不依赖全量日志扫描作为长期查询模型。 +- [x] 3000 默认主链不返回假业务数据、不走 legacy proxy、不静默 no-op 保存。 +- [x] 设计文档中的 `process/`、`done/` 状态与真实代码和验收证据一致。 + +## 12. 关联缺陷验收状态 + +本执行清单本身已完成;与本轮用户反馈直接相关的两个 bug 文档状态如下: + +- `bugs/04-tree-domain/done/4-25-tree-command-create-delete-reload-latency-v1.md`:Rust `/tree` debug shell 的 create / delete 强刷新子项已关闭,`task179` 已通过无 reload 与耗时验收;React Sidebar 直接 create/delete 已改为本地更新,不再同步等待 `refreshTree()`;Rust-family React shell 的 create / rename / move host mutation 回调也已改为本地 apply,不再默认整树 refetch。文档已迁入 `done/`。 +- `bugs/05-editor-mainline/done/5-11-mindmap-ghost-assets-and-tree-command-latency-v1.md`:ghost mindmap asset 增长的 NodeView document id 误归属链路已止血,`task169` 已通过同一页面单 mindmap row 与 object identity 稳定验收;普通 mindmap 节点编辑 artifact 语义已收窄为 `mindmap.content.updated + noop`;已新增 `task180` 只读候选审计脚本用于清理前取证,并支持 `/api/tree/projections/file` 的只读投影输入以审计 File Tree duplicate row。真实 File Tree projection 审计已确认当前 workspace 下 `fileTreeMindmapRows=10`、`duplicateFileTreeMindmapRows=0`、`candidateCount=0`。历史清理候选为 0,因此本轮不执行数据删除;文档已迁入 `done/`。 diff --git a/design/10-review/README.md b/design/10-review/README.md index da4b3e55..eee39b8f 100644 --- a/design/10-review/README.md +++ b/design/10-review/README.md @@ -2,6 +2,8 @@ 本目录汇总当前设计与实现的偏差审查结果。四份分域报告分别覆盖 Rust kernel/web、前端编辑器与树体验、Convex/realtime/storage、次级域与设计治理。 +执行入口以 [顺序执行清单与验收标准](./06-execution-checklist-and-acceptance.md) 为准;`05-tree.md` 的 Resource Tree / File Tree / Page Tree、ObjectIdentity、mindmap 与 `index.md` 隔离主线已经由 `4-24` 与 `5-12` 承接到 `done/`,后续只保留防回归验证。 + ## 结论 当前主线方向总体没有跑偏,但实现仍停留在“主链已切、收口未完”的状态。最需要继续收口的是: @@ -36,6 +38,7 @@ - [前端编辑器 / 树体验实现偏差审查](./02-frontend-editor-tree-review.md) - [Convex / Realtime / Storage 实现偏差审查](./03-convex-realtime-storage-review.md) - [次级域与设计治理实现偏差审查](./04-secondary-domains-and-design-governance-review.md) +- [顺序执行清单与验收标准](./06-execution-checklist-and-acceptance.md) ## 统一判断 diff --git a/design/90-reference/90-1-filetree.md b/design/90-reference/90-1-filetree.md index 09c49d26..0a7f92dd 100644 --- a/design/90-reference/90-1-filetree.md +++ b/design/90-reference/90-1-filetree.md @@ -79,6 +79,4 @@ yy - 复制,p - 粘贴 :q - 退出 ``` -如果你需要将文件树组件集成到自己的Rust项目中,建议根据应用类型(终端/GUI)选择对应的组件库,或参考filetree的实现方式进行定制开发。 - -需要我给你一份在 Ratatui 中快速集成 tui-file-explorer 实现 VSCode 风格单面板文件树的最小可运行代码示例吗? +如果需要将文件树组件集成到 Rust 项目中,建议根据应用类型(终端/GUI)选择对应的组件库,或参考 filetree 的实现方式进行定制开发。 diff --git a/design/90-reference/90-2-yemianshu.md b/design/90-reference/90-2-yemianshu.md index aa704234..1a528569 100644 --- a/design/90-reference/90-2-yemianshu.md +++ b/design/90-reference/90-2-yemianshu.md @@ -143,5 +143,3 @@ fn main() -> eframe::Result<()> { ### 七、总结 Rust生态中已有丰富的工具可构建Notion风格页面树,从底层数据结构到完整应用全覆盖。若需快速开发,可选择现成组件库或应用;若需高度定制,可基于树结构库+UI框架组合实现,充分发挥Rust的性能与安全优势。 - -需要我给你一份在 Tauri 中结合 Rust 后端与前端实现可拖拽 Notion 风格页面树的最小可运行示例吗? diff --git a/design/README.md b/design/README.md index 55f927e4..dca68189 100644 --- a/design/README.md +++ b/design/README.md @@ -1,6 +1,6 @@ # design 设计稿索引 -> 更新时间:2026-05-11 +> 更新时间:2026-05-14 > > 状态口径以当前仓库真实代码为准: > - `[done]`:对应阶段或收口目标已经在当前主线代码中成立 @@ -51,6 +51,7 @@ - `90-reference/` - 参考资料,不参与 `[done]/[process]/[recycle]` 状态判断 + - 引用时只能作为生态资料或背景材料,不能覆盖 `ARCHITECTURE.md`、`AGENTS.md`、`01-05` 当前优先级或对应主线 `process/` / `done/` 设计稿 - `old/` - 已废弃或被替代的历史稿件,标题统一标记 `[recycle]` - 每个大类继续按 `process/` 与 `done/` 分层 diff --git a/design/07-ai/process/7-phase7-ai-kernel-projection-plan-v4.md b/design/old/07-ai/process/7-phase7-ai-kernel-projection-plan-v4.md similarity index 97% rename from design/07-ai/process/7-phase7-ai-kernel-projection-plan-v4.md rename to design/old/07-ai/process/7-phase7-ai-kernel-projection-plan-v4.md index ca7a612c..bb1116af 100644 --- a/design/07-ai/process/7-phase7-ai-kernel-projection-plan-v4.md +++ b/design/old/07-ai/process/7-phase7-ai-kernel-projection-plan-v4.md @@ -1,7 +1,17 @@ -# 7 [process] mnote Kernel Phase 7 CLI-First 外置 Agent 统一执行面方案 v4 +# 7 [recycle] mnote Kernel Phase 7 CLI-First 外置 Agent 统一执行面方案 v4 > 更新时间:2026-05-09 > +> 回收说明(2026-05-13): +> - 本稿的 `mnote-cli` 唯一长期 agent 执行面口径已被 +> `/mnt/Data1T/mnote/design/07-ai/process/7-3-page-ai-hermes-panel-and-mnote-plugin-v1.md` +> 覆盖。 +> - 后续长期方向改为:页面 AI 面板只是 Hermes 的页面内客户端; +> Hermes session/message/tool event/usage/model 才是会话真相; +> mnote 通过 Hermes skill/plugin 暴露页面、树、artifact 与 edge 能力。 +> - 本稿仅作为历史决策记录保留。`mnote-cli` 仍可作为 mnote plugin 内部适配器, +> 但不再是页面 AI 的唯一长期执行面。 +> > 上位依据: > - `/mnt/Data1T/mnote/ARCHITECTURE.md` > - `/mnt/Data1T/mnote/design/01-05-current-priority-overview.md` diff --git a/rust/crates/bridge-runtime/src/lib.rs b/rust/crates/bridge-runtime/src/lib.rs index 5e3b0f04..cedd65b3 100644 --- a/rust/crates/bridge-runtime/src/lib.rs +++ b/rust/crates/bridge-runtime/src/lib.rs @@ -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 { if data.is_null() { return Ok(default_mindmap_tree()); @@ -8322,11 +8330,12 @@ fn execute_query_result( } "page.aggregate.get" => { let payload: PageAggregateQueryPayload = parse_payload(query_wire.payload)?; + let source = page_aggregate_source_for_data(&data); let result = build_page_aggregate_projection_result( &data, &payload.document_id, payload.workspace_id.as_deref(), - PageAggregateSource::KernelProjection, + source, )?; serde_json::to_value(result).map_err(|error| { BridgeError::transport(format!("page.aggregate.get result 序列化失败: {error}")) @@ -9032,6 +9041,22 @@ fn execute_command( args_json: json!({ "id": payload.document_id, "options": Value::Object(options_json), + "streamDeltaHint": tree_resync_required_hint( + "page.layout.updateOptions", + json!({ + "documentId": payload.document_id, + }), + ), + "domainEventHint": tree_domain_event_hint("page.layout.options_updated"), + "domainEventPlan": tree_domain_event_plan( + "page.layout.options_updated", + tree_resync_required_hint( + "page.layout.updateOptions", + json!({ + "documentId": payload.document_id, + }), + ), + ), }), })) } @@ -9272,6 +9297,22 @@ fn execute_command( } "mindmaps.put" => { let payload: MindmapPutCommandPayload = parse_payload(command_wire.payload.clone())?; + let create_only = payload.create_only.unwrap_or(false); + let (event_type, stream_delta_hint) = if create_only { + ( + "tree.resource.mindmap.put", + tree_stream_delta_hint("resync_required", json!({ + "reason": "mindmap.put", + "documentId": payload.document_id.clone(), + "blockId": payload.mindmap_id.clone(), + })), + ) + } else { + ( + "mindmap.content.updated", + tree_stream_delta_hint("noop", json!({})), + ) + }; let command = CommandEnvelope { name: "mindmaps.put".into(), command_id: command_wire.command_id.clone(), @@ -9285,7 +9326,7 @@ fn execute_command( data_json: serde_json::to_string(&payload.data).map_err(|error| { BridgeError::validation(format!("mindmaps.put data 序列化失败: {error}")) })?, - create_only: payload.create_only.unwrap_or(false), + create_only, }, reason: command_wire.reason, refs: command_wire.refs, @@ -9308,20 +9349,12 @@ fn execute_command( "docId": payload.document_id.clone(), "mindmapId": payload.mindmap_id.clone(), "data": payload.data, - "createOnly": payload.create_only.unwrap_or(false), - "streamDeltaHint": tree_stream_delta_hint("resync_required", json!({ - "reason": "mindmap.put", - "documentId": payload.document_id.clone(), - "blockId": payload.mindmap_id.clone(), - })), - "domainEventHint": tree_domain_event_hint("tree.resource.mindmap.put"), + "createOnly": create_only, + "streamDeltaHint": stream_delta_hint.clone(), + "domainEventHint": tree_domain_event_hint(event_type), "domainEventPlan": tree_domain_event_plan( - "tree.resource.mindmap.put", - tree_stream_delta_hint("resync_required", json!({ - "reason": "mindmap.put", - "documentId": payload.document_id.clone(), - "blockId": payload.mindmap_id.clone(), - })), + event_type, + stream_delta_hint, ), }), })) @@ -9364,19 +9397,11 @@ fn execute_command( "commands": payload.commands, "projectionRevision": payload.projection_revision, "canonicalCommand": "mindmap.command.apply", - "streamDeltaHint": tree_stream_delta_hint("resync_required", json!({ - "reason": "mindmap.command.apply", - "documentId": payload.document_id.clone(), - "blockId": payload.mindmap_id.clone(), - })), - "domainEventHint": tree_domain_event_hint("tree.resource.mindmap.updated"), + "streamDeltaHint": tree_stream_delta_hint("noop", json!({})), + "domainEventHint": tree_domain_event_hint("mindmap.content.updated"), "domainEventPlan": tree_domain_event_plan( - "tree.resource.mindmap.updated", - tree_stream_delta_hint("resync_required", json!({ - "reason": "mindmap.command.apply", - "documentId": payload.document_id.clone(), - "blockId": payload.mindmap_id.clone(), - })), + "mindmap.content.updated", + tree_stream_delta_hint("noop", json!({})), ), }), })) @@ -11396,7 +11421,7 @@ mod tests { assert_eq!(result["schema"], json!("mnote.page_aggregate.v1")); assert_eq!(result["projectionVersion"], json!(1)); - assert_eq!(result["source"], json!("KernelProjection")); + assert_eq!(result["source"], json!("CompatMetaContentJoin")); assert_eq!(result["pageId"], json!("doc_1")); assert_eq!(result["identity"]["documentId"], json!("doc_1")); assert_eq!(result["body"]["revision"], json!(7)); @@ -11748,11 +11773,11 @@ mod tests { ); assert_eq!( plan.args_json["domainEventPlan"]["eventType"], - json!("tree.resource.mindmap.updated") + json!("mindmap.content.updated") ); assert_eq!( plan.args_json["streamDeltaHint"]["kind"], - json!("resync_required") + json!("noop") ); } RuntimeExecutionPlan::Query(_) | RuntimeExecutionPlan::Tool(_) => { @@ -12096,6 +12121,77 @@ mod tests { } } + #[test] + fn mindmaps_put_existing_content_update_uses_object_event_without_tree_resync() { + let plan = execute_runtime_input(RuntimeInput::Command { + context: demo_context(), + command: RuntimeCommandEnvelopeWire { + name: "mindmaps.put".into(), + command_id: "cmd_mindmap_put_update".into(), + idempotency_key: None, + actor: RuntimeActorWire { + actor_type: "user".into(), + actor_id: "user_1".into(), + session_id: None, + }, + source: RuntimeSourceWire { + channel: "rust-web".into(), + client: "mnote-web".into(), + source_kind: None, + root_uri: None, + workspace_id: None, + capabilities: Vec::new(), + }, + target: Some(RuntimeTargetWire { + workspace_id: Some("ws_1".into()), + page_id: Some("doc_1".into()), + block_id: Some("mind_1".into()), + }), + payload: json!({ + "documentId": "doc_1", + "mindmapId": "mind_1", + "data": { + "data": {"text": "中心主题已更新"}, + "children": [], + }, + "createOnly": false, + }), + preflight_data: None, + reason: Some("更新导图内容".into()), + refs: vec![], + dry_run: false, + validate_only: false, + }, + }) + .expect("mindmap update command plan should build"); + + match plan { + RuntimeExecutionPlan::Command(plan) => { + assert_eq!( + plan.args_json["streamDeltaHint"], + json!({ + "family": "tree", + "kind": "noop", + "args": {} + }) + ); + assert_eq!( + plan.args_json["domainEventPlan"]["eventType"], + json!("mindmap.content.updated") + ); + assert_eq!( + plan.args_json["domainEventPlan"]["streamDeltaHint"], + json!({ + "family": "tree", + "kind": "noop", + "args": {} + }) + ); + } + _ => panic!("expected command plan"), + } + } + #[test] fn docs_search_tool_plan_uses_transport_and_transform_steps() { let plan = execute_runtime_input(RuntimeInput::Tool { @@ -15539,13 +15635,39 @@ mod tests { RuntimeExecutionPlan::Command(plan) => { assert_eq!(plan.command_name, "documents.options.update"); assert_eq!(plan.function_name, "documents:updateOptions"); + assert_eq!(plan.args_json["id"], json!("doc_1")); assert_eq!( - plan.args_json, + plan.args_json["options"], json!({ - "id": "doc_1", - "options": { - "showToc": true, - "layoutDensity": "compact", + "showToc": true, + "layoutDensity": "compact", + }) + ); + assert_eq!( + plan.args_json["streamDeltaHint"], + json!({ + "family": "tree", + "kind": "resync_required", + "args": { + "reason": "page.layout.updateOptions", + "documentId": "doc_1", + }, + }) + ); + assert_eq!( + plan.args_json["domainEventPlan"], + json!({ + "family": "tree", + "schema": "mnote.tree.domain_event", + "schemaVersion": 1, + "eventType": "page.layout.options_updated", + "streamDeltaHint": { + "family": "tree", + "kind": "resync_required", + "args": { + "reason": "page.layout.updateOptions", + "documentId": "doc_1", + }, }, }) ); diff --git a/rust/crates/mnote-web/src/error.rs b/rust/crates/mnote-web/src/error.rs index 05b3d09a..ee16859d 100644 --- a/rust/crates/mnote-web/src/error.rs +++ b/rust/crates/mnote-web/src/error.rs @@ -72,6 +72,10 @@ impl WebError { pub fn message(&self) -> &str { &self.message } + + pub fn status(&self) -> StatusCode { + self.status + } } impl IntoResponse for WebError { diff --git a/rust/crates/mnote-web/src/routes/documents.rs b/rust/crates/mnote-web/src/routes/documents.rs index 8b5a7cf2..38bc4e62 100644 --- a/rust/crates/mnote-web/src/routes/documents.rs +++ b/rust/crates/mnote-web/src/routes/documents.rs @@ -102,6 +102,14 @@ fn ok_response(context: &RequestContext, result: Value) -> (StatusCode, HeaderMa ) } +fn execution_artifacts_json(execution: &crate::transport::convex::ConvexCommandExecution) -> Value { + execution + .artifacts + .as_ref() + .and_then(|artifacts| serde_json::to_value(artifacts).ok()) + .unwrap_or(Value::Null) +} + fn stamp_documents_headers(headers: &mut HeaderMap) { if let Ok(name) = HeaderName::from_lowercase(HEADER_MNOTE_WEB_OWNER.as_bytes()) { headers.insert(name, HeaderValue::from_static("mnote-web")); @@ -711,13 +719,16 @@ pub async fn title( dry_run: false, validate_only: false, }; - let result = execute_runtime_command_via_convex( + let execution = execute_runtime_command_via_convex_with_artifacts( state.config(), &context, effective_workspace_id.as_deref(), command, ) .await?; + let artifacts = execution_artifacts_json(&execution); + let artifact_error = execution.artifact_error.clone(); + let result = execution.result; let mut headers = HeaderMap::new(); stamp_documents_headers(&mut headers); Ok(( @@ -731,6 +742,8 @@ pub async fn title( "meta": { "commandName": "page.head.updateTitle", "canonicalCommand": "page.head.updateTitle", + "artifacts": artifacts, + "artifactError": artifact_error, }, "result": result, })), @@ -799,13 +812,16 @@ pub async fn options( dry_run: false, validate_only: false, }; - let result = execute_runtime_command_via_convex( + let execution = execute_runtime_command_via_convex_with_artifacts( state.config(), &context, effective_workspace_id.as_deref(), command, ) .await?; + let artifacts = execution_artifacts_json(&execution); + let artifact_error = execution.artifact_error.clone(); + let result = execution.result; let mut headers = HeaderMap::new(); stamp_documents_headers(&mut headers); Ok(( @@ -819,6 +835,8 @@ pub async fn options( "meta": { "commandName": "page.layout.updateOptions", "canonicalCommand": "page.layout.updateOptions", + "artifacts": artifacts, + "artifactError": artifact_error, }, "result": result, })), @@ -907,6 +925,14 @@ mod tests { "updated_at": "2026-04-18T09:45:00Z", "revision": 8, "conflict_detection_key": "doc_1:8" + }, + "bridgeLogs:recordCommandLog": { + "ok": true, + "id": "clog_fixture" + }, + "bridgeLogs:recordDomainEvent": { + "ok": true, + "id": "evt_fixture" } }"# .into(), @@ -1066,6 +1092,24 @@ mod tests { let payload: Value = serde_json::from_slice(&body).expect("json"); assert_eq!(payload["meta"]["commandName"], "page.head.updateTitle"); assert_eq!(payload["meta"]["canonicalCommand"], "page.head.updateTitle"); + assert_eq!( + payload["meta"]["artifacts"]["commandLog"]["commandName"], + "page.head.updateTitle" + ); + assert_eq!( + payload["meta"]["artifacts"]["domainEvent"]["eventType"], + "tree.node.renamed" + ); + assert_eq!( + payload["meta"]["artifacts"]["domainEvent"]["payload"]["streamDelta"]["op"], + "upsert_document" + ); + assert_eq!( + payload["meta"]["artifacts"]["domainEvent"]["payload"]["streamDelta"]["document"] + ["title"], + "服务端页面(改名)" + ); + assert_eq!(payload["meta"]["artifactError"], Value::Null); } #[tokio::test] @@ -1102,6 +1146,19 @@ mod tests { payload["meta"]["canonicalCommand"], "page.layout.updateOptions" ); + assert_eq!( + payload["meta"]["artifacts"]["commandLog"]["commandName"], + "page.layout.updateOptions" + ); + assert_eq!( + payload["meta"]["artifacts"]["domainEvent"]["eventType"], + "page.layout.options_updated" + ); + assert_eq!( + payload["meta"]["artifacts"]["domainEvent"]["payload"]["streamDelta"]["op"], + "resync_required" + ); + assert_eq!(payload["meta"]["artifactError"], Value::Null); } #[tokio::test] diff --git a/rust/crates/mnote-web/src/routes/mindmap_api.rs b/rust/crates/mnote-web/src/routes/mindmap_api.rs index 7a2d85b3..2a442849 100644 --- a/rust/crates/mnote-web/src/routes/mindmap_api.rs +++ b/rust/crates/mnote-web/src/routes/mindmap_api.rs @@ -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" + }) + ); + } } diff --git a/rust/crates/mnote-web/src/routes/onlyoffice.rs b/rust/crates/mnote-web/src/routes/onlyoffice.rs index d3673efc..9bf03edc 100644 --- a/rust/crates/mnote-web/src/routes/onlyoffice.rs +++ b/rust/crates/mnote-web/src/routes/onlyoffice.rs @@ -1,9 +1,10 @@ use crate::app::AppState; +use crate::app::AppConfig; use crate::error::WebError; use adapter_onlyoffice::{prepare_proxy_request, sign_config, OnlyOfficeProxyPreparationInput}; use axum::body::{Body, Bytes}; use axum::extract::{Path, Query, State}; -use axum::http::{header, HeaderMap, Method, Request, Uri}; +use axum::http::{header, HeaderMap, HeaderValue, Method, Request, Uri}; use axum::response::{Html, IntoResponse, Response}; use axum::Json; use serde::Deserialize; @@ -715,6 +716,8 @@ pub async fn proxy( } pub async fn callback( + State(state): State, + uri: Uri, Query(query): Query, Json(body): Json, ) -> Response { @@ -728,10 +731,41 @@ pub async fn callback( status, "OnlyOffice callback received by mnote-web" ); - Json(json!({ "error": 0 })).into_response() + match proxy_legacy_onlyoffice_json( + state.config(), + "/api/onlyoffice/callback", + uri.query(), + Some(body), + ) + .await + { + Ok(response) => response, + Err(error) if error.status() == axum::http::StatusCode::NOT_IMPLEMENTED => ( + axum::http::StatusCode::NOT_IMPLEMENTED, + Json(json!({ + "error": 1, + "degraded": true, + "code": "onlyoffice_legacy_writeback_unavailable", + "message": error.message(), + })), + ) + .into_response(), + Err(error) => ( + axum::http::StatusCode::BAD_GATEWAY, + Json(json!({ + "error": 1, + "degraded": true, + "code": "onlyoffice_legacy_writeback_failed", + "message": error.message(), + })), + ) + .into_response(), + } } pub async fn forcesave( + State(state): State, + uri: Uri, Query(query): Query, ) -> Result { let asset_id = query @@ -750,13 +784,85 @@ pub async fn forcesave( .ok_or_else(|| { WebError::bad_request_code("onlyoffice_forcesave_key_missing", "缺少 key") })?; - Ok(Json(json!({ - "ok": true, - "via": "mnote-web-rust-noop", - "assetId": asset_id, - "key": key, - })) - .into_response()) + let response = proxy_legacy_onlyoffice_json( + state.config(), + "/api/onlyoffice/forcesave", + uri.query(), + None, + ) + .await + .map_err(|error| { + if error.status() == axum::http::StatusCode::NOT_IMPLEMENTED { + return WebError::new( + axum::http::StatusCode::NOT_IMPLEMENTED, + "onlyoffice_legacy_writeback_unavailable", + format!("OnlyOffice forcesave 未配置 legacy Next 写回链: assetId={asset_id}, key={key}"), + ); + } + error + })?; + Ok(response) +} + +fn legacy_onlyoffice_writeback_base(config: &AppConfig) -> Option { + 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, +) -> Result { + let base = legacy_onlyoffice_writeback_base(config).ok_or_else(|| { + WebError::new( + axum::http::StatusCode::NOT_IMPLEMENTED, + "onlyoffice_legacy_writeback_unavailable", + "OnlyOffice Rust route 暂未直接写回,且未配置 legacy Next 写回链", + ) + })?; + let target = append_path_and_query(&base, path, query); + let client = reqwest::Client::builder() + .timeout(Duration::from_secs(30)) + .build() + .map_err(|error| { + WebError::internal(format!("OnlyOffice legacy proxy HTTP 客户端创建失败: {error}")) + })?; + let mut request = client + .post(target) + .header(header::CONTENT_TYPE, "application/json"); + if let Some(body) = body { + request = request.json(&body); + } else { + request = request.body("{}"); + } + let upstream = request.send().await.map_err(|error| { + WebError::bad_gateway_code( + "onlyoffice_legacy_writeback_failed", + format!("OnlyOffice legacy 写回请求失败: {error}"), + ) + })?; + let status = upstream.status(); + let content_type = upstream + .headers() + .get(header::CONTENT_TYPE) + .cloned() + .unwrap_or_else(|| HeaderValue::from_static("application/json")); + let bytes = upstream.bytes().await.map_err(|error| { + WebError::bad_gateway_code( + "onlyoffice_legacy_writeback_failed", + format!("OnlyOffice legacy 写回响应读取失败: {error}"), + ) + })?; + let mut response = Response::new(Body::from(bytes)); + *response.status_mut() = status; + response.headers_mut().insert(header::CONTENT_TYPE, content_type); + Ok(response) } fn append_path_and_query(base: &str, path: &str, query: Option<&str>) -> String { @@ -987,6 +1093,13 @@ fn js_hash_abs(input: &str) -> String { #[cfg(test)] mod tests { use super::*; + use crate::app::{AppConfig, AppState}; + use axum::extract::State; + use axum::http::StatusCode; + use serde_json::json; + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + use tokio::net::TcpListener; + use tokio::sync::oneshot; #[test] fn stable_doc_key_uses_onlyoffice_safe_characters() { @@ -1003,4 +1116,166 @@ mod tests { let candidates = onlyoffice_internal_candidates(); assert!(candidates.contains(&DEFAULT_ONLYOFFICE_INTERNAL_URL.to_string())); } + + fn test_state(legacy_next_base_url: Option) -> 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) { + 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::() + .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::() + .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::() + .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::() + .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" + )); + } } diff --git a/rust/crates/mnote-web/src/routes/search.rs b/rust/crates/mnote-web/src/routes/search.rs index ab832594..8749c02d 100644 --- a/rust/crates/mnote-web/src/routes/search.rs +++ b/rust/crates/mnote-web/src/routes/search.rs @@ -175,6 +175,8 @@ pub async fn documents( "owner": "mnote-web", "projectionOwner": result.get("projectionOwner").cloned().unwrap_or_else(|| json!("rust-kernel")), "queryName": "search.documents.query", + "degraded": result.get("degraded").cloned().unwrap_or_else(|| json!(false)), + "degradedReason": result.get("degradedReason").cloned().unwrap_or(Value::Null), "requestId": context.trace.request_id, "traceId": context.trace.trace_id, }, @@ -239,12 +241,13 @@ async fn load_search_results_with_filters( .await { Ok(value) => Ok(value), - Err(_) => execute_runtime_query_against_data( + Err(_) if config.allow_dev_fixtures => execute_runtime_query_against_data( context, Some(workspace_id), runtime_query, fallback_search_dataset(workspace_id), ), + Err(error) => Ok(degraded_empty_search_projection(error.message())), } } @@ -256,6 +259,16 @@ fn empty_search_projection() -> Value { }) } +fn degraded_empty_search_projection(reason: &str) -> Value { + json!({ + "enqueueAssetIds": [], + "projectionOwner": "rust-kernel", + "results": [], + "degraded": true, + "degradedReason": reason, + }) +} + fn fallback_search_dataset(workspace_id: &str) -> Value { json!({ "documents": [ @@ -413,6 +426,8 @@ mod tests { assert!(html.contains("search.documents.query")); assert!(html.contains("search-result")); assert!(html.contains("Rust Web 搜索结果")); + assert!(html.contains("data-mnote-dev-fixture=\"true\"")); + assert!(html.contains("data-mnote-dev-fixture-kind=\"sidebar-tree\"")); } #[tokio::test] @@ -467,4 +482,53 @@ mod tests { assert_eq!(payload["meta"]["projectionOwner"], "rust-kernel"); assert_eq!(payload["projectionOwner"], "rust-kernel"); } + + #[tokio::test] + async fn search_documents_does_not_return_builtin_fixture_when_convex_unavailable() { + let app = build_app(AppState::new(AppConfig { + service_name: "mnote-web".into(), + service_version: "0.1.0".into(), + bind_addr: "127.0.0.1:0".into(), + public_bind_addr: "127.0.0.1:3000".into(), + legacy_next_base_url: Some("http://127.0.0.1:3100".into()), + enable_legacy_next_compat: true, + enable_debug_shell_routes: false, + hermes_base_path: "/api/hermes".into(), + compat_next_base_path: "/api/compat/next".into(), + convex_url: None, + convex_admin_key: None, + allow_dev_fixtures: false, + query_fixtures_json: None, + mutation_fixtures_json: None, + dev_user_id: "dev-user".into(), + dev_user_name: "开发用户".into(), + dev_user_email: "dev@mnote.local".into(), + })); + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/api/search/documents") + .header("content-type", "application/json") + .body(Body::from( + json!({ + "workspaceId": "ws_demo", + "query": "Hermes" + }) + .to_string(), + )) + .expect("request"), + ) + .await + .expect("response"); + + assert_eq!(response.status(), StatusCode::OK); + let body = to_bytes(response.into_body(), usize::MAX) + .await + .expect("body"); + let payload: Value = serde_json::from_slice(&body).expect("json"); + assert_eq!(payload["results"], json!([])); + assert_eq!(payload["meta"]["degraded"], true); + assert!(!payload.to_string().contains("Hermes 技能知识图谱开发")); + } } diff --git a/rust/crates/mnote-web/src/routes/tree.rs b/rust/crates/mnote-web/src/routes/tree.rs index 41edd969..09fff473 100644 --- a/rust/crates/mnote-web/src/routes/tree.rs +++ b/rust/crates/mnote-web/src/routes/tree.rs @@ -2814,13 +2814,17 @@ fn build_tree_shell_html( } if (sourceKind === "convex_workspace") { for (const item of deletableItems) { + const documentId = getFileTreeRowDocumentId(item); await sendCommand({ action: "delete", workspaceId, - documentId: getFileTreeRowDocumentId(item), + documentId, }); + applyRemovedDocumentLocally(documentId); + } + if (!deletableItems.every((item) => !fileTreeRowById.has(item.rowId))) { + scheduleRefresh(); } - scheduleRefresh(); return true; } postToHost("tree.filetree.delete", { @@ -2986,6 +2990,113 @@ fn build_tree_shell_html( }, 80); }; + const addTreeItemLocally = (item) => { + if (!item?.nodeId || itemById.has(item.nodeId)) return false; + normalizedItems.push(item); + itemById.set(item.nodeId, item); + fileTreeRowById.set(item.rowId, item); + const parentId = item.parentNodeId && itemById.has(item.parentNodeId) ? item.parentNodeId : null; + if (parentId) { + const bucket = childrenByParentId.get(parentId) || []; + bucket.push(item); + bucket.sort(compareItems); + childrenByParentId.set(parentId, bucket); + const parentItem = itemById.get(parentId); + if (parentItem) parentItem.childCount = Math.max(parentItem.childCount || 0, bucket.length); + expanded.add(parentId); + } else { + roots.push(item); + roots.sort(compareItems); + } + return true; + }; + + const applyCreatedDocumentLocally = (result, parentId, title) => { + const documentId = + typeof result?.documentId === "string" && result.documentId.trim() + ? result.documentId.trim() + : typeof result?.id === "string" && result.id.trim() + ? result.id.trim() + : ""; + if (!documentId) return false; + const createdAt = normalizeText(result?.updatedAt || result?.execution?.updated_at); + const parentNodeId = parentId && itemById.has(parentId) ? parentId : null; + const item = { + rowId: `doc:${documentId}`, + rowKind: "document", + nodeId: documentId, + parentNodeId, + title: normalizeText(result?.title, title || "无标题"), + depth: parentNodeId ? normalizeNumber(itemById.get(parentNodeId)?.depth, 0) + 1 : 0, + childCount: 0, + position: normalizeNumber(result?.sortOrder ?? result?.execution?.sort_order ?? Date.now()), + expandedByDefault: true, + iconHint: "page", + capabilities: ["open", "rename", "delete", "move", "create"], + resourceMeta: { + resourceKind: "document", + documentId, + assetId: "", + assetKind: "", + objectIdentity: { objectKind: "page", documentId, blockId: null, assetId: null }, + blockAssetRelation: null, + }, + updatedAt: createdAt, + }; + if (!addTreeItemLocally(item)) return false; + currentFocusedDocumentId = documentId; + focusedNodeId = documentId; + currentActiveDocumentId = documentId; + renderTree(); + return true; + }; + + const removeTreeItemEverywhere = (item) => { + if (!item) return; + const itemIndex = normalizedItems.indexOf(item); + if (itemIndex >= 0) normalizedItems.splice(itemIndex, 1); + itemById.delete(item.nodeId); + fileTreeRowById.delete(item.rowId); + const rootIndex = roots.indexOf(item); + if (rootIndex >= 0) roots.splice(rootIndex, 1); + const bucket = item.parentNodeId ? childrenByParentId.get(item.parentNodeId) : null; + if (bucket) { + const bucketIndex = bucket.indexOf(item); + if (bucketIndex >= 0) bucket.splice(bucketIndex, 1); + if (bucket.length === 0) childrenByParentId.delete(item.parentNodeId); + } + }; + + const applyRemovedDocumentLocally = (documentId) => { + const normalizedDocumentId = normalizeText(documentId); + if (!normalizedDocumentId) return false; + const removedNodeIds = new Set([normalizedDocumentId]); + let changed = false; + let expandedDuringScan = true; + while (expandedDuringScan) { + expandedDuringScan = false; + normalizedItems.forEach((item) => { + if (item.parentNodeId && removedNodeIds.has(item.parentNodeId) && !removedNodeIds.has(item.nodeId)) { + removedNodeIds.add(item.nodeId); + expandedDuringScan = true; + } + }); + } + normalizedItems.slice().forEach((item) => { + const itemDocumentId = getFileTreeRowDocumentId(item) || item.resourceMeta?.documentId || item.nodeId; + if (removedNodeIds.has(item.nodeId) || itemDocumentId === normalizedDocumentId) { + removeTreeItemEverywhere(item); + changed = true; + } + }); + if (!changed) return false; + if (currentActiveDocumentId === normalizedDocumentId) currentActiveDocumentId = roots[0]?.nodeId || ""; + if (currentFocusedDocumentId === normalizedDocumentId) currentFocusedDocumentId = currentActiveDocumentId; + focusedNodeId = resolveFocusedNodeIdFromHostState(); + renderTree(); + return true; + }; + if (sourceKind === "local_folder" && rootUri) { let localWatchRevision = initialLocalWatchRevision; let localWatchRefreshTimer = 0; @@ -4335,6 +4446,14 @@ fn build_tree_shell_html( payload: { documentId }, }); } + if (sourceKind === "convex_workspace" && applyCreatedDocumentLocally(result, parentId, title)) { + if (mode === "filetree") { + beginInlineRename("filetree", `doc:${documentId}`); + } else { + beginInlineRename("page", documentId); + } + return; + } scheduleRefresh({ renameRowId: localCreatedRowIdFromCommandResult(result, "markdown"), }); @@ -6688,6 +6807,10 @@ mod tests { assert!(html.contains("data-testid=\"tree-node-toggle\"")); assert!(html.contains("hydrateInitialPageTree")); assert!(html.contains("const usedRustInitialPageRenderer = hydrateInitialPageTree();")); + assert!(html.contains("applyCreatedDocumentLocally")); + assert!(html.contains("applyRemovedDocumentLocally")); + assert!(html.contains("sourceKind === \"convex_workspace\" && applyCreatedDocumentLocally")); + assert!(html.contains("if (!deletableItems.every((item) => !fileTreeRowById.has(item.rowId)))")); assert!(html.contains("application/x-mnote-page-tree-node")); assert!(html.contains("页面已拖放到")); assert!(html.contains("setAttribute(\"role\", \"treeitem\")")); diff --git a/rust/crates/mnote-web/src/routes/web_shell.rs b/rust/crates/mnote-web/src/routes/web_shell.rs index 897b54b1..d68314e1 100644 --- a/rust/crates/mnote-web/src/routes/web_shell.rs +++ b/rust/crates/mnote-web/src/routes/web_shell.rs @@ -2437,10 +2437,9 @@ pub(crate) async fn load_workspace_shell_projection( query: None, max_results: None, }; - let dataset = load_projection_snapshot(config, context, &spec) - .await - .map(|snapshot| snapshot.dataset) - .unwrap_or_else(|_| { + let dataset = match load_projection_snapshot(config, context, &spec).await { + Ok(snapshot) => snapshot.dataset, + Err(_) if config.allow_dev_fixtures => { let documents = active_document_id .map(|document_id| { json!([{ @@ -2457,9 +2456,19 @@ pub(crate) async fn load_workspace_shell_projection( "active_workspace_id": workspace_id, "active_page_id": active_document_id, "workspaces": [{ "id": workspace_id, "name": default_workspace_name }], - "documents": documents + "documents": documents, + "dev_fixture": true }) - }); + } + Err(_) => json!({ + "active_workspace_id": workspace_id, + "active_page_id": active_document_id, + "workspaces": [{ "id": workspace_id, "name": default_workspace_name }], + "documents": [], + "degraded": true, + "degraded_reason": "projection_unavailable" + }), + }; build_workspace_shell_projection( &dataset, @@ -2489,7 +2498,7 @@ pub(crate) async fn load_sidebar_tree_html( max_results: None, }; let result = match load_projection_snapshot(config, context, &spec).await { - Ok(snapshot) => Some(snapshot.projection), + Ok(snapshot) => Some((snapshot.projection, false)), Err(_) if config.allow_dev_fixtures => { // Dev 模式降级:如果调用方已经有 active 页面,优先保留这条真实选择链。 let documents = active_document_id @@ -2518,17 +2527,20 @@ pub(crate) async fn load_sidebar_tree_html( "mindmap_docs": [], "mindmap_asset_children": {} }); - execute_kernel_query(context, workspace_id, projection_query(&spec), dev_dataset).ok() + execute_kernel_query(context, workspace_id, projection_query(&spec), dev_dataset) + .ok() + .map(|projection| (projection, true)) } Err(_) => None, }; - result.map(|projection| { + result.map(|(projection, dev_fixture)| { let rows = collect_page_tree_render_rows(&projection); - render_initial_page_tree_html(&PageTreeInitialRenderInput { + let html = render_initial_page_tree_html(&PageTreeInitialRenderInput { rows, active_node_id: active_document_id.map(ToOwned::to_owned), focused_node_id: None, - }) + }); + mark_dev_fixture_html(html, dev_fixture, "sidebar-tree") }) } @@ -2550,7 +2562,7 @@ pub(crate) async fn load_file_tree_html( max_results: None, }; let result = match load_projection_snapshot(config, context, &spec).await { - Ok(snapshot) => Some(snapshot.projection), + Ok(snapshot) => Some((snapshot.projection, false)), Err(_) if config.allow_dev_fixtures => { let documents = active_document_id .map(|document_id| { @@ -2577,16 +2589,28 @@ pub(crate) async fn load_file_tree_html( "mindmap_docs": [], "mindmap_asset_children": {} }); - execute_kernel_query(context, workspace_id, projection_query(&spec), dev_dataset).ok() + execute_kernel_query(context, workspace_id, projection_query(&spec), dev_dataset) + .ok() + .map(|projection| (projection, true)) } Err(_) => None, }; - result.map(|projection| { + result.map(|(projection, dev_fixture)| { let rows = collect_filetree_render_rows(&projection, active_document_id); - render_initial_filetree_html(&FileTreeInitialRenderInput { rows }) + let html = render_initial_filetree_html(&FileTreeInitialRenderInput { rows }); + mark_dev_fixture_html(html, dev_fixture, "file-tree") }) } +fn mark_dev_fixture_html(html: String, dev_fixture: bool, kind: &'static str) -> String { + if !dev_fixture { + return html; + } + format!( + r#"{html}"# + ) +} + pub(crate) fn render_local_sidebar_tree_html( root_uri: &str, active_document_id: Option<&str>, @@ -2614,8 +2638,9 @@ pub(crate) fn render_local_file_tree_html( #[cfg(test)] mod tests { use crate::app::{build_app, AppConfig, AppState}; + use crate::context::RequestContext; use axum::body::{to_bytes, Body}; - use axum::http::{header, Request, StatusCode}; + use axum::http::{header, HeaderMap, Method, Request, StatusCode, Uri}; use serde_json::Value; use tower::util::ServiceExt; @@ -2701,6 +2726,9 @@ mod tests { .expect("body"); let html = String::from_utf8(body.to_vec()).expect("html"); assert!(html.contains("data-testid=\"wolai-sidebar\"")); + assert!(html.contains("data-mnote-dev-fixture-kind=\"workspace-shell\"")); + assert!(html.contains("data-mnote-dev-fixture-kind=\"sidebar-tree\"")); + assert!(html.contains("data-mnote-dev-fixture-kind=\"file-tree\"")); assert!(html.contains("data-testid=\"wolai-topbar\"")); assert!(html.contains("data-testid=\"wolai-floating-ai\"")); assert!(html.contains("星标置顶")); @@ -2755,7 +2783,7 @@ mod tests { .headers() .get("x-mnote-page-aggregate-owner") .and_then(|value| value.to_str().ok()), - Some("rust-kernel") + Some("compat-join") ); assert_eq!( response @@ -2770,7 +2798,7 @@ mod tests { let payload: Value = serde_json::from_slice(&body).expect("json"); assert_eq!(payload["owner"], "mnote-web"); assert_eq!(payload["schema"], "mnote.page_aggregate.v1"); - assert_eq!(payload["result"]["source"], "KernelProjection"); + assert_eq!(payload["result"]["source"], "CompatMetaContentJoin"); assert_eq!(payload["result"]["projectionVersion"], 1); assert_eq!(payload["result"]["identity"]["documentId"], "doc_1"); assert_eq!(payload["result"]["body"]["revision"], 7); @@ -2893,6 +2921,56 @@ mod tests { )); } + #[tokio::test] + async fn sidebar_and_filetree_do_not_return_dev_fixtures_by_default() { + let config = AppConfig { + service_name: "mnote-web".into(), + service_version: "0.1.0".into(), + bind_addr: "127.0.0.1:0".into(), + public_bind_addr: "127.0.0.1:3000".into(), + legacy_next_base_url: Some("http://127.0.0.1:3100".into()), + enable_legacy_next_compat: true, + enable_debug_shell_routes: false, + hermes_base_path: "/api/hermes".into(), + compat_next_base_path: "/api/compat/next".into(), + convex_url: None, + convex_admin_key: None, + allow_dev_fixtures: false, + query_fixtures_json: None, + mutation_fixtures_json: None, + dev_user_id: "dev-user".into(), + dev_user_name: "开发用户".into(), + dev_user_email: "dev@mnote.local".into(), + }; + let headers = HeaderMap::new(); + let context = RequestContext::from_http_parts( + &Method::GET, + &"/documents/doc_1?workspaceId=ws_demo" + .parse::() + .expect("uri"), + &headers, + ); + + let sidebar_html = + super::load_sidebar_tree_html(&config, &context, "ws_demo", Some("doc_1")).await; + let filetree_html = + super::load_file_tree_html(&config, &context, "ws_demo", Some("doc_1")).await; + let workspace_projection = super::load_workspace_shell_projection( + &config, + &context, + "ws_demo", + Some("doc_1"), + "个人空间", + ) + .await; + + assert!(sidebar_html.is_none()); + assert!(filetree_html.is_none()); + assert!(workspace_projection.degraded); + assert!(workspace_projection.my_page_items.is_empty()); + assert!(!workspace_projection.dev_fixture); + } + #[tokio::test] async fn document_shell_renders_local_markdown_attachment_name_in_html() { let root = std::env::temp_dir().join(format!( diff --git a/rust/crates/mnote-web/src/ssr/pages/layout.rs b/rust/crates/mnote-web/src/ssr/pages/layout.rs index a7ad2df9..b236b8e6 100644 --- a/rust/crates/mnote-web/src/ssr/pages/layout.rs +++ b/rust/crates/mnote-web/src/ssr/pages/layout.rs @@ -77,6 +77,146 @@ const SIDEBAR_TREE_JS: &str = r##" return parseJsonScript('__MNOTE_PAGE_AGGREGATE__') || {}; } + function textFromUnknown(value) { + if (value == null) return ''; + if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') return String(value); + if (Array.isArray(value)) return value.map(textFromUnknown).filter(Boolean).join(' '); + if (typeof value !== 'object') return ''; + var parts = []; + ['text', 'title', 'content', 'children', 'blocks', 'body'].forEach(function(key) { + if (Object.prototype.hasOwnProperty.call(value, key)) { + var text = textFromUnknown(value[key]); + if (text) parts.push(text); + } + }); + return parts.join(' '); + } + + function readLocalEditorBlocks() { + var editor = document.querySelector('[data-testid="mnote-leptos-tiptap-island-editor-root"] .editor-surface .ProseMirror'); + if (!(editor instanceof HTMLElement)) return []; + return Array.from(editor.children).filter(function(node) { + return node instanceof HTMLElement; + }).map(function(node, index) { + var tag = String(node.tagName || '').toUpperCase(); + var headingMatch = tag.match(/^H([1-6])$/); + var type = headingMatch ? 'heading' : 'paragraph'; + var text = searchText(node.textContent || ''); + var id = searchText(node.getAttribute('data-id') || node.id || 'local-block-' + index); + var props = headingMatch ? { level: Number(headingMatch[1]) } : {}; + return { id: id, type: type, props: props, content: text }; + }).filter(function(block) { + return block.content || block.type === 'heading'; + }); + } + + function buildPageAiLocalSubtree(blocks, title) { + var documentId = currentDocumentId() || 'current-page'; + var rootNodeId = 'page:' + documentId; + var headingCounters = [0, 0, 0, 0, 0, 0]; + var headingStack = []; + var nodes = [{ + id: rootNodeId, + nodeId: rootNodeId, + nodeType: 'page', + blockId: null, + blockType: 'page', + title: title || '', + parentNodeId: null, + headingLevel: null + }]; + var outline = []; + var evidence = []; + blocks.forEach(function(block, index) { + var level = block.type === 'heading' ? Math.max(1, Math.min(6, Number(block.props && block.props.level || 1))) : null; + if (level != null) { + while (headingStack.length && headingStack[headingStack.length - 1].level >= level) headingStack.pop(); + } + var parentNodeId = headingStack.length ? headingStack[headingStack.length - 1].nodeId : rootNodeId; + var nodeId = 'local-node:' + String(block.id || index); + var titleText = block.content || (block.type === 'heading' ? '未命名标题' : ''); + nodes.push({ + id: nodeId, + nodeId: nodeId, + nodeType: 'block', + blockId: block.id, + blockType: block.type, + title: titleText, + parentNodeId: parentNodeId, + headingLevel: level + }); + if (level != null) { + headingCounters[level - 1] += 1; + for (var i = level; i < headingCounters.length; i += 1) headingCounters[i] = 0; + var numbering = headingCounters.slice(0, level).filter(function(count) { return count > 0; }).join('.'); + outline.push({ + id: 'local-outline:' + String(block.id || index), + nodeId: nodeId, + anchorBlockId: block.id, + level: level, + title: titleText, + numbering: numbering + }); + headingStack.push({ level: level, nodeId: nodeId }); + } + if (titleText) { + evidence.push({ + id: 'local-evidence:' + String(block.id || index), + nodeId: nodeId, + anchorBlockId: block.id, + text: titleText, + kind: block.type + }); + } + }); + return { + projectionId: 'local-editor-dom:' + documentId, + rootNode: { + id: rootNodeId, + documentId: documentId, + title: title || '', + nodeType: 'page' + }, + subtree: { + rootNodeId: rootNodeId, + nodes: nodes + }, + outline: outline, + evidence: evidence, + stats: { + nodeCount: nodes.length, + headingCount: outline.length, + evidenceCount: evidence.length + }, + source: 'local' + }; + } + + function currentPageAiContextSnapshot() { + var aggregate = currentPageAggregate(); + var body = aggregate.body || {}; + var serverContent = body.content || null; + var serverText = searchText(textFromUnknown(serverContent)); + var localBlocks = readLocalEditorBlocks(); + var localText = searchText(localBlocks.map(function(block) { return block.content || ''; }).join(' ')); + var serverSubtree = aggregate.tree && aggregate.tree.pageSubtree ? aggregate.tree.pageSubtree : null; + var title = aggregate.head && aggregate.head.title ? aggregate.head.title : ''; + if (localBlocks.length && localText && localText !== serverText) { + return { + aggregate: aggregate, + body: Object.assign({}, body, { content: localBlocks }), + subtree: buildPageAiLocalSubtree(localBlocks, title), + pageSubtreeSource: 'local' + }; + } + return { + aggregate: aggregate, + body: body, + subtree: serverSubtree, + pageSubtreeSource: serverSubtree ? 'server' : 'none' + }; + } + function defaultPageOptions() { return { wideLayout: false, @@ -844,6 +984,90 @@ const SIDEBAR_TREE_JS: &str = r##" }); } + function documentIdFromDelta(data) { + return String(data && (data.documentId || data.id || (data.document && data.document.id) || (data.node && data.node.id) || (data.args && data.args.documentId)) || '').trim(); + } + + function parentIdFromDelta(data) { + if (!data || typeof data !== 'object') return null; + if (Object.prototype.hasOwnProperty.call(data, 'parentId')) return data.parentId ? String(data.parentId).trim() : null; + if (Object.prototype.hasOwnProperty.call(data, 'parent_id')) return data.parent_id ? String(data.parent_id).trim() : null; + if (data.args && Object.prototype.hasOwnProperty.call(data.args, 'parentId')) return data.args.parentId ? String(data.args.parentId).trim() : null; + return null; + } + + function treeRootForMode(mode) { + var id = mode === 'filetree' ? 'sidebar-file-tree-root' : 'sidebar-tree-root'; + return document.querySelector('#' + id + ' .tree-root'); + } + + function rowSelectorForDocument(mode, documentId) { + var escaped = cssEscape(documentId); + if (mode === 'filetree') { + return '.tree-row[data-shell-mode="filetree"][data-doc-id="' + escaped + '"], .tree-row[data-shell-mode="filetree"][data-document-id="' + escaped + '"]'; + } + return '.tree-row[data-shell-mode="page"][data-node-id="' + escaped + '"]'; + } + + function ensureTreeChildren(parentRow) { + var parentNode = parentRow ? parentRow.closest('.tree-node') : null; + if (!parentNode) return null; + var children = parentNode.querySelector(':scope > .tree-children'); + if (!children) { + children = document.createElement('ul'); + children.className = 'tree-children'; + parentNode.appendChild(children); + } + children.classList.remove('tree-children--collapsed'); + parentRow.setAttribute('aria-expanded', 'true'); + var toggle = parentRow.querySelector('[data-rust-action="toggle"]'); + if (toggle) toggle.setAttribute('aria-expanded', 'true'); + return children; + } + + function removeDocumentRowForMode(mode, documentId) { + var row = document.querySelector(rowSelectorForDocument(mode, documentId)); + var node = row ? row.closest('.tree-node') : null; + if (!node || !node.parentElement) return false; + node.parentElement.removeChild(node); + return true; + } + + function moveDocumentRowForMode(mode, documentId, parentId) { + var row = document.querySelector(rowSelectorForDocument(mode, documentId)); + var node = row ? row.closest('.tree-node') : null; + var root = treeRootForMode(mode); + if (!row || !node || !root) return false; + var targetContainer = root; + if (parentId) { + var parentRow = document.querySelector(rowSelectorForDocument(mode, parentId)); + targetContainer = ensureTreeChildren(parentRow); + if (!targetContainer) return false; + row.setAttribute('data-parent-id', parentId); + } else { + row.removeAttribute('data-parent-id'); + } + targetContainer.appendChild(node); + return true; + } + + function applyMoveDocumentDelta(data) { + var documentId = documentIdFromDelta(data); + if (!documentId) return false; + var parentId = parentIdFromDelta(data); + var movedPage = moveDocumentRowForMode('page', documentId, parentId); + var movedFile = moveDocumentRowForMode('filetree', documentId, parentId); + return movedPage || movedFile; + } + + function applyRemoveDocumentDelta(data) { + var documentId = documentIdFromDelta(data); + if (!documentId) return false; + var removedPage = removeDocumentRowForMode('page', documentId); + var removedFile = removeDocumentRowForMode('filetree', documentId); + return removedPage || removedFile; + } + function readProjection(value) { if (!value || typeof value !== 'object') return null; if (value.result && typeof value.result === 'object') return value.result; @@ -879,6 +1103,11 @@ const SIDEBAR_TREE_JS: &str = r##" return resolved && Array.isArray(resolved.items) ? resolved.items : []; } + function hasProjectionItems(projection) { + var resolved = readProjection(projection); + return Boolean(resolved && Array.isArray(resolved.items)); + } + function nodeIdOf(item) { return String(item && (item.nodeId || item.id || item.documentId) || '').trim(); } @@ -1024,9 +1253,9 @@ const SIDEBAR_TREE_JS: &str = r##" } function renderSidebarSnapshot(payload) { - var renderedPage = renderPageProjection(payload); + var renderedPage = hasProjectionItems(payload) ? renderPageProjection(payload) : false; var fileProjection = readDatasetProjection(payload, 'kernel_file_tree_projection', 'kernelFileTreeProjection'); - var renderedFile = fileProjection ? renderFileProjection(fileProjection) : false; + var renderedFile = fileProjection && hasProjectionItems(fileProjection) ? renderFileProjection(fileProjection) : false; return renderedPage || renderedFile; } @@ -2805,9 +3034,10 @@ const SIDEBAR_TREE_JS: &str = r##" var prompt = searchText(text); if (!prompt) return; pageAiLoadSessions(); - var aggregate = currentPageAggregate(); - var body = aggregate.body || {}; - var subtree = aggregate.tree && aggregate.tree.pageSubtree ? aggregate.tree.pageSubtree : null; + var contextSnapshot = currentPageAiContextSnapshot(); + var aggregate = contextSnapshot.aggregate || {}; + var body = contextSnapshot.body || {}; + var subtree = contextSnapshot.subtree || null; var outline = subtree && subtree.outline ? subtree.outline : null; pageUiState.pageAiBusy = true; pageUiState.pageAiMessages.push({ role: 'user', content: prompt }); @@ -2843,6 +3073,7 @@ const SIDEBAR_TREE_JS: &str = r##" }, subtree: subtree, outline: outline, + pageSubtreeSource: contextSnapshot.pageSubtreeSource || 'none', evidence: null, pageOptions: currentPageOptions() }, @@ -3890,6 +4121,18 @@ const SIDEBAR_TREE_JS: &str = r##" window.addEventListener('tree:delta', function(event) { var payload = event.detail && event.detail.payload ? event.detail.payload : event.detail; var data = payload && (payload.data || payload.delta || payload); + var documentPatch = data && (data.document || data.node); + if (data && data.op === 'upsert_document' && documentPatch) { + updateTitleEverywhere(documentPatch.id || documentPatch.documentId, documentPatch.title || '无标题'); + } + if (data && data.op === 'move_document' && applyMoveDocumentDelta(data)) { + document.documentElement.setAttribute('data-mnote-tree-live-applied', 'delta'); + return; + } + if (data && data.op === 'remove_document' && applyRemoveDocumentDelta(data)) { + document.documentElement.setAttribute('data-mnote-tree-live-applied', 'delta'); + return; + } var documents = data && (data.upsertDocuments || data.upsert_documents); if (Array.isArray(documents)) { documents.forEach(function(doc) { diff --git a/rust/crates/mnote-web/src/transport/convex.rs b/rust/crates/mnote-web/src/transport/convex.rs index 53bbbda4..4c64a125 100644 --- a/rust/crates/mnote-web/src/transport/convex.rs +++ b/rust/crates/mnote-web/src/transport/convex.rs @@ -376,17 +376,27 @@ pub async fn execute_convex_query_by_name( fn convex_command_args_for_plan(plan: &RuntimeCommandExecutionPlan) -> Value { let mut args = plan.args_json.clone(); + if matches!( + plan.command_name.as_str(), + "tree.node.create" + | "tree.node.rename" + | "tree.node.archive" + | "tree.node.restore" + | "tree.subtree.move" + | "documents.create" + | "documents.title.update" + | "documents.delete" + | "documents.restore" + | "documents.move" + ) { + strip_tree_artifact_fields(&mut args); + } if matches!(plan.command_name.as_str(), "mindmaps.put") || matches!(plan.function_name.as_str(), "mindmaps:put") { - if let Value::Object(map) = &mut args { - // Rust plan 保留 tree domain event / stream hint 作为正式契约; - // Convex mindmaps.put legacy validator 仍只接收真实写入字段。 - map.remove("streamDeltaHint"); - map.remove("domainEventHint"); - map.remove("domainEventPlan"); - map.remove("domainEventPlans"); - } + // Rust plan 保留 tree domain event / stream hint 作为正式契约; + // Convex mindmaps.put legacy validator 仍只接收真实写入字段。 + strip_tree_artifact_fields(&mut args); } if matches!( plan.command_name.as_str(), @@ -397,11 +407,16 @@ fn convex_command_args_for_plan(plan: &RuntimeCommandExecutionPlan) -> Value { // Rust plan 保留正式事件契约,但发送给 legacy mutation 时只传它实际接受的字段。 map.remove("editorDocument"); map.remove("tiptapDocument"); - map.remove("streamDeltaHint"); - map.remove("domainEventHint"); - map.remove("domainEventPlan"); - map.remove("domainEventPlans"); } + strip_tree_artifact_fields(&mut args); + } + if matches!( + plan.command_name.as_str(), + "documents.options.update" | "page.layout.updateOptions" + ) { + // documents:updateOptions 仍只接收页面设置字段; + // tree stream hint 留在 Rust artifact plan 中持久化。 + strip_tree_artifact_fields(&mut args); } if plan.command_name == "mindmap.command.apply" { if let Value::Object(map) = &mut args { @@ -418,6 +433,15 @@ fn convex_command_args_for_plan(plan: &RuntimeCommandExecutionPlan) -> Value { args } +fn strip_tree_artifact_fields(args: &mut Value) { + if let Value::Object(map) = args { + map.remove("streamDeltaHint"); + map.remove("domainEventHint"); + map.remove("domainEventPlan"); + map.remove("domainEventPlans"); + } +} + pub async fn execute_convex_command_plan( config: &AppConfig, context: &RequestContext, @@ -900,6 +924,78 @@ mod tests { ); } + #[test] + fn convex_command_args_strips_page_options_artifacts_for_legacy_mutation() { + let plan = RuntimeCommandExecutionPlan { + command_name: "page.layout.updateOptions".into(), + command_id: "cmd_options_1".into(), + function_name: "documents:updateOptions".into(), + workspace_id: Some("ws_1".into()), + request_id: "req_1".into(), + trace_id: "trace_1".into(), + actor_id: "actor_1".into(), + idempotency_key: None, + source: json!({}), + payload_json: "{}".into(), + args_json: json!({ + "id": "doc_1", + "options": { + "showToc": false, + "layoutDensity": "compact", + }, + "streamDeltaHint": {"family": "tree"}, + "domainEventHint": {"eventType": "page.layout.options_updated"}, + "domainEventPlan": {"eventType": "page.layout.options_updated"}, + "domainEventPlans": [{"eventType": "page.layout.options_updated"}], + }), + }; + + let args = convex_command_args_for_plan(&plan); + + assert_eq!( + args, + json!({ + "id": "doc_1", + "options": { + "showToc": false, + "layoutDensity": "compact", + }, + }) + ); + } + + #[test] + fn convex_command_args_strips_tree_archive_artifacts_for_legacy_mutation() { + let plan = RuntimeCommandExecutionPlan { + command_name: "tree.node.archive".into(), + command_id: "cmd_archive_1".into(), + function_name: "documents:softDelete".into(), + workspace_id: Some("ws_1".into()), + request_id: "req_1".into(), + trace_id: "trace_1".into(), + actor_id: "actor_1".into(), + idempotency_key: None, + source: json!({}), + payload_json: "{}".into(), + args_json: json!({ + "id": "doc_1", + "streamDeltaHint": {"family": "tree", "kind": "remove_document"}, + "domainEventHint": {"eventType": "tree.node.archived"}, + "domainEventPlan": {"eventType": "tree.node.archived"}, + "domainEventPlans": [{"eventType": "tree.node.archived"}], + }), + }; + + let args = convex_command_args_for_plan(&plan); + + assert_eq!( + args, + json!({ + "id": "doc_1", + }) + ); + } + #[test] fn convex_command_args_adapts_mindmap_command_apply_for_legacy_mutation() { let plan = RuntimeCommandExecutionPlan { diff --git a/rust/crates/mnote-web/src/workspace_shell.rs b/rust/crates/mnote-web/src/workspace_shell.rs index 6bd0051c..25b379a3 100644 --- a/rust/crates/mnote-web/src/workspace_shell.rs +++ b/rust/crates/mnote-web/src/workspace_shell.rs @@ -9,6 +9,9 @@ pub struct WorkspaceShellProjection { pub workspace_name: String, pub active_page_id: Option, pub active_page_title: Option, + pub degraded: bool, + pub degraded_reason: Option, + pub dev_fixture: bool, pub starred_items: Vec, pub my_page_items: Vec, pub bottom_entries: Vec, @@ -88,6 +91,23 @@ pub fn build_workspace_shell_projection( starred_items.sort_by_key(|item| (item.depth, item.title.clone(), item.id.clone())); let active_page_title = active_title_from_items(&my_page_items, active_page_id.as_deref()); + let degraded = dataset + .get("degraded") + .or_else(|| dataset.get("is_degraded")) + .and_then(Value::as_bool) + .unwrap_or(false); + let degraded_reason = dataset + .get("degraded_reason") + .or_else(|| dataset.get("degradedReason")) + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned); + let dev_fixture = dataset + .get("dev_fixture") + .or_else(|| dataset.get("devFixture")) + .and_then(Value::as_bool) + .unwrap_or(false); WorkspaceShellProjection { schema: "mnote.workspace_shell.v1".into(), @@ -95,6 +115,9 @@ pub fn build_workspace_shell_projection( workspace_name, active_page_id, active_page_title, + degraded, + degraded_reason, + dev_fixture, starred_items, my_page_items, bottom_entries: vec![ @@ -278,6 +301,8 @@ mod tests { assert_eq!(projection.workspace_name, "开发用户 的工作区"); assert_eq!(projection.active_page_id.as_deref(), Some("page_home")); assert_eq!(projection.active_page_title.as_deref(), Some("个人")); + assert!(!projection.degraded); + assert!(!projection.dev_fixture); assert_eq!(projection.starred_items.len(), 1); assert_eq!(projection.starred_items[0].title, "个人"); assert_eq!(projection.my_page_items.len(), 2); @@ -368,6 +393,42 @@ mod tests { assert!(html.contains("data-testid=\"wolai-sidebar-empty-state\"")); } + + #[test] + fn workspace_shell_sidebar_html_marks_degraded_and_dev_fixture_states() { + let degraded_dataset = json!({ + "workspaces": [{ "id": "ws_demo", "name": "开发用户 的工作区" }], + "documents": [], + "degraded": true, + "degraded_reason": "projection_unavailable" + }); + let degraded_projection = build_workspace_shell_projection( + °raded_dataset, + "ws_demo", + None, + "开发用户 的工作区", + ); + let degraded_html = render_workspace_shell_sidebar_html(°raded_projection, None, None); + + assert!(degraded_projection.degraded); + assert!(degraded_html.contains("data-mnote-workspace-shell-degraded=\"true\"")); + assert!(degraded_html.contains( + "data-mnote-workspace-shell-degraded-reason=\"projection_unavailable\"" + )); + + let dev_dataset = json!({ + "workspaces": [{ "id": "ws_demo", "name": "开发用户 的工作区" }], + "documents": [], + "dev_fixture": true + }); + let dev_projection = + build_workspace_shell_projection(&dev_dataset, "ws_demo", None, "开发用户 的工作区"); + let dev_html = render_workspace_shell_sidebar_html(&dev_projection, None, None); + + assert!(dev_projection.dev_fixture); + assert!(dev_html.contains("data-mnote-dev-fixture=\"true\"")); + assert!(dev_html.contains("data-mnote-dev-fixture-kind=\"workspace-shell\"")); + } } pub fn render_workspace_shell_sidebar_html( @@ -434,9 +495,25 @@ pub fn render_workspace_shell_sidebar_html( }) .collect::>() .join(""); + let status_markers = format!( + r#"{degraded_marker}{dev_fixture_marker}"#, + degraded_marker = if projection.degraded { + format!( + r#""#, + escape_html(projection.degraded_reason.as_deref().unwrap_or("projection_unavailable")), + ) + } else { + String::new() + }, + dev_fixture_marker = if projection.dev_fixture { + r#""#.to_string() + } else { + String::new() + }, + ); format!( - r#"
{}星标置顶
{starred_rows}
{my_pages}
"#, + r#"{status_markers}
{}星标置顶
{starred_rows}
{my_pages}
"#, render_symbol("star", "wolai-section-icon"), render_symbol("folder_open", "wolai-folder-icon"), escape_html(&projection.workspace_id), diff --git a/rust/crates/storage-convex-bridge/src/mapping.rs b/rust/crates/storage-convex-bridge/src/mapping.rs index 0cc1c439..c248a091 100644 --- a/rust/crates/storage-convex-bridge/src/mapping.rs +++ b/rust/crates/storage-convex-bridge/src/mapping.rs @@ -98,7 +98,9 @@ pub fn map_query_name_to_convex(query_name: &str) -> &'static str { "bridge.workspace.overview" => "bridgeLogs:listWorkspaceOverview", "documents.meta.get" => "documents:getMeta", "documents.content.get" => "documents:getContent", - "page.aggregate.get" => "documents:getPageAggregate", + // page aggregate 主读链由 Rust /api/page-aggregate/:id 先取 meta/content 后在 bridge-runtime 组装。 + // Convex 没有 documents:getPageAggregate,避免旧映射误触发不存在的生产函数。 + "page.aggregate.get" => "queries:unknown", "mindmaps.get" => "mindmaps:get", "mindmap.projection.get" => "mindmaps:getProjection", "mindmap.editor_scene.get" => "mindmaps:getEditorScene", diff --git a/rust/spikes/leptos-tiptap-spike/generated/island/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_paragraph.js b/rust/spikes/leptos-tiptap-spike/generated/island/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_paragraph.js index bbd5fc3a..fd222c47 100644 --- a/rust/spikes/leptos-tiptap-spike/generated/island/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_paragraph.js +++ b/rust/spikes/leptos-tiptap-spike/generated/island/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_paragraph.js @@ -1492,7 +1492,7 @@ body { box-sizing: border-box; } `,minExportImgCanvasScale:2,addContentToHeader:null,addContentToFooter:null,handleBeingExportSvg:null,maxCanvasSize:16384,defaultAssociativeLineText:"\u5173\u8054",associativeLineIsAlwaysAboveNode:!0,associativeLineInitPointsPosition:{from:"",to:""},enableAdjustAssociativeLinePoints:!0,beforeAssociativeLineConnection:null,disableTouchZoom:!1,minTouchZoomScale:20,maxTouchZoomScale:-1,isLimitMindMapInCanvasWhenHasScrollbar:!0,isOnlySearchCurrentRenderNodes:!1,beforeCooperateUpdate:null,rainbowLinesConfig:{open:!1,colorsList:[]},demonstrateConfig:null,enableEditFormulaInRichTextEdit:!0,katexFontPath:"https://unpkg.com/katex@0.16.11/dist/",getKatexOutputType:null,transformRichTextOnEnterEdit:null,beforeHideRichTextEdit:null,outerFramePaddingX:10,outerFramePaddingY:10,defaultOuterFrameText:"\u5916\u6846",onlyPainterNodeCustomStyles:!1,beforeDeleteNodeImg:null,imgResizeBtnSize:25,minImgResizeWidth:50,minImgResizeHeight:50,maxImgResizeWidthInheritTheme:!1,maxImgResizeWidth:1/0,maxImgResizeHeight:1/0,customDeleteBtnInnerHTML:"",customResizeBtnInnerHTML:""}});var ib={};tt(ib,{default:()=>HP});var Cp,_t,_p,HP,rb=T(()=>{Tv();kv();Gv();Cp=pt(O0());Vv();Y0();Yv();Qv();eb();$e();yt();pe();Ro();tb();_t=class i{constructor(e={}){if(i.instanceCount++,this.opt=this.handleOpt((0,Cp.default)(kp,e)),this.opt.data=this.handleData(this.opt.data),this.el=this.opt.el,!this.el)throw new Error("\u7F3A\u5C11\u5BB9\u5668\u5143\u7D20el");this.getElRectInfo(),this.initWidth=this.width,this.initHeight=this.height,this.cssEl=null,this.cssTextMap={},this.nodeInnerPrefixList=[],this.nodeInnerPostfixList=[],this.editNodeClassList=[],this.extendShapeList=[],this.initContainer(),this.initTheme(),this.initCache(),i.pluginList.filter(t=>t.preload).forEach(t=>{this.initPlugin(t)}),this.event=new Av({mindMap:this}),this.keyCommand=new h0({mindMap:this}),this.command=new Zv({mindMap:this}),this.renderer=new jv({mindMap:this}),this.view=new Mv({mindMap:this}),this.batchExecution=new Jv,i.pluginList.filter(t=>!t.preload).forEach(t=>{this.initPlugin(t)}),this.addCss(),this.render(this.opt.fit?()=>this.view.fit():()=>{}),this.opt.addHistoryOnInit&&this.opt.data&&this.command.addHistory()}handleOpt(e){return $c.includes(e.layout)||(e.layout=k.LAYOUT.LOGICAL_STRUCTURE),e.theme=e.theme&&jn[e.theme]?e.theme:"default",e}handleData(e){return Yt(e)||Object.keys(e).length<=0?null:(e=Kt(e||{}),e.data&&!e.data.expand&&(e.data.expand=!0),cs([e],!1,null,!0),e)}initContainer(){let{associativeLineIsAlwaysAboveNode:e}=this.opt;this.el.classList.add("smm-mind-map-container");let t=()=>{this.associativeLineDraw=this.draw.group(),this.associativeLineDraw.addClass("smm-associative-line-container")};this.svg=Ae().addTo(this.el).size(this.width,this.height),this.draw=this.svg.group(),this.draw.addClass("smm-container"),this.lineDraw=this.draw.group(),this.lineDraw.addClass("smm-line-container"),e||t(),this.nodeDraw=this.draw.group(),this.nodeDraw.addClass("smm-node-container"),e&&t(),this.otherDraw=this.draw.group(),this.otherDraw.addClass("smm-other-container")}clearDraw(){this.lineDraw.clear(),this.associativeLineDraw.clear(),this.nodeDraw.clear(),this.otherDraw.clear()}appendCss(e,t){this.cssTextMap[e]=t,this.removeCss(),this.addCss()}removeAppendCss(e){this.cssTextMap[e]&&(delete this.cssTextMap[e],this.removeCss(),this.addCss())}joinCss(){return C3+Object.keys(this.cssTextMap).map(e=>this.cssTextMap[e]).join(` -`)}addCss(){this.cssEl=document.createElement("style"),this.cssEl.type="text/css",this.cssEl.innerHTML=this.joinCss(),document.head.appendChild(this.cssEl)}removeCss(){this.cssEl&&document.head.removeChild(this.cssEl)}checkEditNodeClassIndex(e){return this.editNodeClassList.findIndex(t=>t===e)}addEditNodeClass(e){this.checkEditNodeClassIndex(e)===-1&&this.editNodeClassList.push(e)}deleteEditNodeClass(e){let t=this.checkEditNodeClassIndex(e);t!==-1&&this.editNodeClassList.splice(t,1)}render(e,t=""){this.initTheme(),this.renderer.render(e,t)}reRender(e,t=""){this.renderer.reRender=!0,this.renderer.clearCache(),this.clearDraw(),this.render(e,t)}getElRectInfo(){if(this.elRect=this.el.getBoundingClientRect(),this.width=this.elRect.width,this.height=this.elRect.height,this.width<=0||this.height<=0)throw new Error("\u5BB9\u5668\u5143\u7D20el\u7684\u5BBD\u9AD8\u4E0D\u80FD\u4E3A0")}resize(){let e=this.width,t=this.height;this.getElRectInfo(),this.svg.size(this.width,this.height),(e!==this.width||t!==this.height)&&(this.demonstrate?this.demonstrate.isInDemonstrate||this.render():this.render()),this.emit("resize")}on(e,t){this.event.on(e,t)}emit(e,...t){this.event.emit(e,...t)}off(e,t){this.event.off(e,t)}initCache(){this.commonCaches={measureCustomNodeContentSizeEl:null,measureRichtextNodeTextSizeEl:null}}initTheme(){this.themeConfig=wu(jn[this.opt.theme]||jn.default,this.opt.themeConfig),qo.setBackgroundStyle(this.el,this.themeConfig)}setTheme(e,t=!1){this.execCommand("CLEAR_ACTIVE_NODE"),this.opt.theme=e,t||this.render(null,k.CHANGE_THEME),this.emit("view_theme_change",e)}getTheme(){return this.opt.theme}setThemeConfig(e,t=!1){let r=N2(this.themeConfig,e);if(this.opt.themeConfig=e,!t){let n=u2(r);this.render(null,n?"":k.CHANGE_THEME)}}getCustomThemeConfig(){return this.opt.themeConfig}getThemeConfig(e){return e===void 0?this.themeConfig:this.themeConfig[e]}getConfig(e){return e===void 0?this.opt:this.opt[e]}updateConfig(e={}){this.emit("before_update_config",this.opt);let t={...this.opt};this.opt=this.handleOpt(Cp.default.all([kp,this.opt,e])),this.emit("after_update_config",this.opt,t)}getLayout(){return this.opt.layout}setLayout(e,t=!1){$c.includes(e)||(e=k.LAYOUT.LOGICAL_STRUCTURE),this.opt.layout=e,this.view.reset(),this.renderer.setLayout(),t||this.render(null,k.CHANGE_LAYOUT),this.emit("layout_change",e)}execCommand(...e){this.command.exec(...e)}updateData(e){e=this.handleData(e),this.emit("before_update_data",e),this.renderer.setData(e),this.render(),this.command.addHistory(),this.emit("update_data",e)}setData(e){e=this.handleData(e),this.emit("before_set_data",e),this.opt.data=e,this.execCommand("CLEAR_ACTIVE_NODE"),this.command.clearHistory(),this.command.addHistory(),this.renderer.setData(e),this.reRender(),this.emit("set_data",e)}setFullData(e){e.root&&this.setData(e.root),e.layout&&this.setLayout(e.layout),e.theme&&(e.theme.template&&this.setTheme(e.theme.template),e.theme.config&&this.setThemeConfig(e.theme.config)),e.view&&this.view.setTransformData(e.view)}getData(e){let t=this.command.getCopyData(),r={};return e?r={layout:this.getLayout(),root:t,theme:{template:this.getTheme(),config:this.getCustomThemeConfig()},view:this.view.getTransformData()}:r=t,Kt(r)}async export(...e){try{if(!this.doExport)throw new Error("\u8BF7\u6CE8\u518CExport\u63D2\u4EF6\uFF01");return await this.doExport.export(...e)}catch(t){this.opt.errorHandler(Mi.EXPORT_ERROR,t)}}toPos(e,t){return{x:e-this.elRect.left,y:t-this.elRect.top}}setMode(e){if(![k.MODE.READONLY,k.MODE.EDIT].includes(e))return;let t=e===k.MODE.READONLY;t!==this.opt.readonly&&(t&&(this.renderer.textEdit.isShowTextEdit()&&(this.renderer.textEdit.hideEditTextBox(),this.command.originAddHistory()),this.execCommand("CLEAR_ACTIVE_NODE")),this.opt.readonly=t,!t&&this.command.history.length<=0&&this.command.originAddHistory(),this.emit("mode_change",e))}getSvgData({paddingX:e=0,paddingY:t=0,ignoreWatermark:r=!1,addContentToHeader:n,addContentToFooter:s,node:a}={}){let{watermarkConfig:o,openPerformance:l}=this.opt;l&&this.renderer.forceLoadNode(a);let{cssTextList:h,header:d,headerHeight:c,footer:f,footerHeight:m}=R2({addContentToHeader:n,addContentToFooter:s}),g=this.svg,x=this.draw,y=g.width(),b=g.height(),S=x.transform(),A=this.elRect;x.scale(1/S.scaleX,1/S.scaleY);let C=x.rbox(),I=null;a&&(I=bu(a,C.x,C.y,e,t));let O=0;C.width+=e*2,C.height+=t*2+O+c+m,x.translate(e,t),g.size(C.width,C.height),x.translate(-C.x+A.left,-C.y+A.top);let q=g.clone(),P=this.watermark&&this.watermark.hasWatermark();if(!r&&P){this.watermark.isInExport=!0;let{onlyExport:re}=o;C.width>y||C.height>b?(this.width=C.width,this.height=C.height,this.watermark.onResize(),q=g.clone(),this.width=y,this.height=b,this.watermark.onResize()):re&&(this.watermark.onResize(),q=g.clone()),re&&this.watermark.clear(),this.watermark.isInExport=!1}[this.joinCss(),...h].forEach(re=>{q.add(Ae(``))}),d&&c>0&&(q.findOne(".smm-container").translate(0,c),d.width(C.width),d.y(t),q.add(d,0)),f&&m>0&&(f.width(C.width),f.y(C.height-t-m),q.add(f));let W=g.find("defs"),ne=q.find("defs");return W.forEach((re,oe)=>{let ke=ne[oe];if(!ke)return;let Q=re.children(),Y=ke.children();for(let le=0;ler.name===e.name)||this.extendShapeList.push(e)}removeShape(e){let t=this.extendShapeList.findIndex(r=>r.name===e);t!==-1&&this.extendShapeList.splice(t,1)}getSvgObjects(){return{SVG:Ae,G:_e,Rect:Ve}}addPlugin(e,t){i.hasPlugin(e)===-1&&i.usePlugin(e,t),this.initPlugin(e)}removePlugin(e){let t=i.hasPlugin(e);t!==-1&&(i.pluginList.splice(t,1),this[e.instanceName]&&(this[e.instanceName].beforePluginRemove&&this[e.instanceName].beforePluginRemove(),delete this[e.instanceName]))}initPlugin(e){this[e.instanceName]||(this[e.instanceName]=new e({mindMap:this,pluginOpt:e.pluginOpt}))}destroy(){this.emit("beforeDestroy"),this.renderer.textEdit.hideEditTextBox(),this.renderer.textEdit.removeTextEditEl(),[...i.pluginList].forEach(e=>{this[e.instanceName]&&this[e.instanceName].beforePluginDestroy&&this[e.instanceName].beforePluginDestroy(),this[e.instanceName]=null}),this.event.unbind(),this.svg.remove(),qo.removeBackgroundStyle(this.el),this.el.classList.remove("smm-mind-map-container"),this.el.innerHTML="",this.el=null,this.removeCss(),i.instanceCount--}},_p=[];_t.extendNodeDataNoStylePropList=(i=[])=>{_p.push(...i),$s.push(...i)};_t.resetNodeDataNoStylePropList=()=>{_p.forEach(i=>{let e=$s.findIndex(t=>t===i);e!==-1&&$s.splice(e,1)}),_p=[]};_t.pluginList=[];_t.usePlugin=(i,e={})=>(_t.hasPlugin(i)!==-1||(i.pluginOpt=e,_t.pluginList.push(i)),_t);_t.hasPlugin=i=>_t.pluginList.findIndex(e=>e===i);_t.instanceCount=0;_t.defineTheme=(i,e={})=>{if(jn[i])return new Error("\u8BE5\u4E3B\u9898\u540D\u79F0\u5DF2\u5B58\u5728");jn[i]=wu(B0,e)};_t.removeTheme=i=>{jn[i]&&(jn[i]=null)};HP=_t});var Ic=globalThis.__LEPTOS_TIPTAP_BRIDGE__;if(Ic==null||Ic.modules==null)throw new Error("leptos-tiptap bridge bindings are unavailable");var j=Ic.modules["@tiptap/core"];if(j==null)throw new Error('leptos-tiptap bridge module "@tiptap/core" is unavailable');var tq=j.CommandManager,iq=j.Editor,rq=j.Extension,nq=j.InputRule,sq=j.Mark,f3=j.Node,aq=j.NodePos,oq=j.NodeView,lq=j.PasteRule,hq=j.Tracker,dq=j.callOrReturn,cq=j.canInsertNode,uq=j.combineTransactionSteps,fq=j.createChainableState,mq=j.createDocument,pq=j.createNodeFromContent,gq=j.createStyleTag,xq=j.defaultBlockAt,yq=j.deleteProps,vq=j.elementFromString,bq=j.escapeForRegEx,wq=j.extensions,Mq=j.findChildren,Tq=j.findChildrenInRange,Nq=j.findDuplicates,Eq=j.findParentNode,Sq=j.findParentNodeClosestToPos,Aq=j.fromString,kq=j.generateHTML,Cq=j.generateJSON,_q=j.generateText,Lq=j.getAttributes,Iq=j.getAttributesFromExtensions,zq=j.getChangedRanges,Rq=j.getDebugJSON,Dq=j.getExtensionField,Oq=j.getHTMLFromFragment,Bq=j.getMarkAttributes,Pq=j.getMarkRange,Fq=j.getMarkType,qq=j.getMarksBetween,Hq=j.getNodeAtPosition,Uq=j.getNodeAttributes,$q=j.getNodeType,jq=j.getRenderedAttributes,Gq=j.getSchema,Vq=j.getSchemaByResolvedExtensions,Wq=j.getSchemaTypeByName,Yq=j.getSchemaTypeNameByName,Xq=j.getSplittedAttributes,Kq=j.getText,Zq=j.getTextBetween,Qq=j.getTextContentFromNodes,Jq=j.getTextSerializersFromSchema,eH=j.injectExtensionAttributesToParseRule,tH=j.inputRulesPlugin,iH=j.isActive,rH=j.isAtEndOfNode,nH=j.isAtStartOfNode,sH=j.isEmptyObject,aH=j.isExtensionRulesEnabled,oH=j.isFunction,lH=j.isList,hH=j.isMacOS,dH=j.isMarkActive,cH=j.isNodeActive,uH=j.isNodeEmpty,fH=j.isNodeSelection,mH=j.isNumber,pH=j.isPlainObject,gH=j.isRegExp,xH=j.isSafari,yH=j.isString,vH=j.isTextSelection,bH=j.isiOS,wH=j.markInputRule,MH=j.markPasteRule,m3=j.mergeAttributes,TH=j.mergeDeep,NH=j.minMax,EH=j.nodeInputRule,SH=j.nodePasteRule,AH=j.objectIncludes,kH=j.pasteRulesPlugin,CH=j.posToDOMRect,_H=j.removeDuplicates,LH=j.resolveFocusPosition,IH=j.rewriteUnknownContent,zH=j.selectionToInsertionEnd,RH=j.splitExtensions,DH=j.textInputRule,OH=j.textPasteRule,BH=j.textblockTypeInputRule,PH=j.wrappingInputRule;var p3=f3.create({name:"paragraph",priority:1e3,addOptions(){return{HTMLAttributes:{}}},group:"block",content:"inline*",parseHTML(){return[{tag:"p"}]},renderHTML({HTMLAttributes:i}){return["p",m3(this.options.HTMLAttributes,i),0]},addCommands(){return{setParagraph:()=>({commands:i})=>i.setNode(this.name)}},addKeyboardShortcuts(){return{"Mod-Alt-0":()=>this.editor.commands.setParagraph()}}});var g3="__LEPTOS_TIPTAP_BRIDGE__";function nw(){let i=globalThis,e=i[g3];if(e!=null)return e;let t={modules:{},registerExtension:()=>{throw new Error("leptos-tiptap bridge runtime is not initialized")}};return i[g3]=t,t}function x3(){return nw()}function y3(i){x3().registerExtension(i)}var Dc={};tt(Dc,{applyMetadataOnlyMindmapCommands:()=>M3,applyMindmapCommandsLocally:()=>hw,isLocallyApplicableMindmapCommandSet:()=>lw,isMetadataOnlyMindmapCommandSet:()=>ow});var sw={data:{text:"\u4E2D\u5FC3\u4E3B\u9898"},children:[]},gt=i=>typeof i=="object"&&i!==null&&!Array.isArray(i),nr=i=>JSON.parse(JSON.stringify(i)),b3=i=>{let e=gt(i)?nr(i):nr(sw);return gt(e.data)||(e.data={text:"\u4E2D\u5FC3\u4E3B\u9898"}),Array.isArray(e.children)||(e.children=[]),e},w3=(i,e)=>{if(!gt(i)||!gt(e))return nr(e);let t={...i};return Object.entries(e).forEach(([r,n])=>{if(n===null){delete t[r];return}t[r]=gt(t[r])&>(n)?w3(t[r],n):nr(n)}),t},zc=(i,e,t)=>{let r=e.split(".").filter(Boolean);if(r.length===0)return!1;let n=i;for(let s of r.slice(0,-1))gt(n[s])||(n[s]={}),n=n[s];return n[r[r.length-1]]=nr(t),!0},N0=(i,e)=>{if(!gt(i))return null;let t=gt(i.data)?i.data:null;if(typeof t?.uid=="string"&&t.uid===e)return i;let r=Array.isArray(i.children)?i.children:[];for(let n of r){let s=N0(n,e);if(s)return s}return null},Rc=(i,e)=>{for(let t=0;t({data:{uid:i.uid??`node_${Date.now().toString(36)}`,text:i.text,...typeof i.hyperlink=="string"?{hyperlink:i.hyperlink}:{},...typeof i.note=="string"?{note:i.note}:{},...Array.isArray(i.refs)?{refs:nr(i.refs)}:{}},children:[]}),aw=(i,e)=>{let t=String(e.path||"").trim();if(!t)return!1;if(t.startsWith("root.data.")){let n=t.slice(10),s=gt(i.data)?i.data:i.data={};return zc(s,n,e.value)}if(t.startsWith("nodes.")){let[,n,s,...a]=t.split(".");if(!n||s!=="data"||a.length===0)return!1;let o=N0(i,n);if(!o)return!1;let l=gt(o.data)?o.data:o.data={};return zc(l,a.join("."),e.value)}let r=gt(i.compatPayload)?i.compatPayload:i.compatPayload={};return zc(r,t,e.value)},ow=i=>Array.isArray(i)&&i.every(e=>!gt(e)||typeof e.type!="string"?!1:["setLayout","setTheme","patchView","compatPayloadPatch"].includes(e.type)),lw=i=>Array.isArray(i)&&i.every(e=>!gt(e)||typeof e.type!="string"?!1:["updateText","insertChild","insertSiblingAfter","deleteNode","setLayout","setTheme","patchView","compatPayloadPatch"].includes(e.type)),M3=(i,e)=>{let t=b3(i),r=[],n=0;return e.forEach(s=>{if(s.type==="setLayout"){t.layout=nr(s.layout),n+=1;return}if(s.type==="setTheme"){t.theme=nr(s.theme),s.themeConfig!==void 0&&s.themeConfig!==null&&(t.themeConfig=nr(s.themeConfig)),n+=1;return}if(s.type==="patchView"){t.view=w3(t.view??{},s.patch),n+=1;return}s.type==="compatPayloadPatch"&&(aw(t,s)?n+=1:r.push(`\u65E0\u6CD5\u5E94\u7528 compatPayloadPatch:${s.path}`))}),{applied:n,data:t,errors:r}},hw=(i,e)=>{let t=b3(i),r=[],n=0;return e.forEach(s=>{if(s.type==="setLayout"||s.type==="setTheme"||s.type==="patchView"||s.type==="compatPayloadPatch"){let a=M3(t,[s]);Object.assign(t,a.data),n+=a.applied,r.push(...a.errors);return}if(s.type==="updateText"){let a=N0(t,s.nodeId);if(!a){r.push(`\u672A\u627E\u5230\u8282\u70B9:${s.nodeId}`);return}let o=gt(a.data)?a.data:a.data={};o.text=s.text,n+=1;return}if(s.type==="insertChild"){let a=N0(t,s.parentNodeId);if(!a){r.push(`\u672A\u627E\u5230\u7236\u8282\u70B9:${s.parentNodeId}`);return}(Array.isArray(a.children)?a.children:a.children=[]).push(v3(s.node)),n+=1;return}if(s.type==="insertSiblingAfter"){let a=Rc([t],s.targetNodeId);if(!a){r.push(`\u672A\u627E\u5230\u540C\u7EA7\u8282\u70B9:${s.targetNodeId}`);return}if(a.parentChildren[a.index]===t){r.push("\u6839\u8282\u70B9\u4E0D\u652F\u6301\u63D2\u5165\u540C\u7EA7\u8282\u70B9");return}a.parentChildren.splice(a.index+1,0,v3(s.node)),n+=1;return}if(s.type==="deleteNode"){let a=Rc([t],s.nodeId);if(!a){r.push(`\u672A\u627E\u5230\u5220\u9664\u8282\u70B9:${s.nodeId}`);return}if(a.parentChildren[a.index]===t){r.push("\u6839\u8282\u70B9\u4E0D\u652F\u6301\u5220\u9664");return}a.parentChildren.splice(a.index,1),n+=1}}),{applied:n,data:t,errors:r}};var Dp={};tt(Dp,{MindmapCommandBridgeError:()=>lo,createLeptosMindmapAdapter:()=>rF,createMindmapAdapterProjectionEndpoint:()=>lb,createMindmapCommandApplyEndpoint:()=>zp,executeMindmapCommandApply:()=>hb,executeMindmapCommandApplyAndRefreshProjection:()=>tF,executeMindmapDataPutAndRefreshProjection:()=>iF,isMindmapAdapterProjection:()=>Ip,requestMindmapAdapterProjection:()=>Rp});var Pc={};tt(Pc,{DEFAULT_MINDMAP_LAYOUT:()=>E0,DEFAULT_MINDMAP_THEME:()=>S0,buildMindmapEditorScene:()=>cw,buildMindmapProjection:()=>dw,buildMindmapSimpleMindMapScene:()=>uw,canonicalizeMindmapData:()=>ci,createDefaultMindmapThemeConfig:()=>A0,defaultMindmapData:()=>Us,extractMindmapTitle:()=>Bc,normalizeMindmapData:()=>Oc,summarizeMindmapProjectionNodes:()=>T3});var Us={data:{text:"\u4E2D\u5FC3\u4E3B\u9898"},children:[]},E0="logicalStructure",S0="default",A0=()=>({lineColor:"#7aa2ff",lineStyle:"curve",rootLineKeepSameInCurve:!0,rootLineStartPositionKeepSameInCurve:!0,generalizationLineColor:"#ef6a5b",backgroundColor:"#f6f8fc",root:{fillColor:"#e25563",color:"#ffffff",fontWeight:"bold",borderColor:"transparent",borderWidth:0,borderRadius:8},second:{fillColor:"#4f7df3",color:"#ffffff",borderColor:"transparent",borderWidth:0,borderRadius:8},node:{fillColor:"transparent",color:"#315aa9",borderColor:"transparent",borderWidth:0},generalization:{fillColor:"#ffffff",color:"#ef6a5b",borderColor:"#ef6a5b",borderWidth:1,borderRadius:8}}),Hs=i=>typeof i=="object"&&i!==null&&!Array.isArray(i),Oc=i=>{if(!i||typeof i!="object")return Us;let e=i.root??i,t=r=>{if(!Hs(r))return;let n=Hs(r.data)?r.data:{};r.data=n;let s=n.text;n.text=typeof s=="string"?s:String(s??"");let a=n.generalization,o=l=>{if(!Hs(l))return;let h=l.text;l.text=typeof h=="string"?h:String(h??"")};Array.isArray(a)?a.forEach(o):o(a),Array.isArray(r.children)&&r.children.forEach(t)};return t(e),i},ci=i=>{let e=Oc(i)??Us,t=e&&typeof e=="object"&&"root"in e?e.root:e;return Oc(t)??Us},Bc=i=>{let t=ci(i)?.data?.text;return typeof t=="string"&&t.trim()?t.trim():"\u672A\u547D\u540D\u5BFC\u56FE"},T3=i=>{let t=[{node:ci(i),depth:0}],r=[];for(;t.length>0;){let n=t.shift();if(!n)break;let s=n.node?.data?.uid,a=n.node?.data?.text,o=Array.isArray(n.node?.children)?n.node.children:[];r.push({uid:typeof s=="string"&&s.trim()?s:`depth:${n.depth}:index:${r.length}`,text:typeof a=="string"&&a.trim()?a.trim():"\u672A\u547D\u540D\u8282\u70B9",depth:n.depth,childCount:o.length}),o.forEach(l=>{t.push({node:ci(l),depth:n.depth+1})})}return r},dw=i=>{let e=ci(i.data),t=T3(e),r=Hs(i.meta)?i.meta:null,n=t[0]?.uid??null;return{schema:"mnote.mindmap_projection.v1",projectionId:`mindmap_projection:${i.documentId}:${i.mindmapId}`,projection:"mindmap_subtree",source:i.source??"rust-kernel",owner:i.owner??"rust-kernel",documentId:i.documentId,mindmapId:i.mindmapId,rootNodeId:n,title:Bc(e),nodeCount:t.length,nodes:t,data:e,meta:r}},cw=i=>{let e=ci(i.data),t=Hs(i.meta)?i.meta:null,r=[],n=[],s=(l,h,d)=>{let c=l?.data?.uid,f=l?.data?.text,m=typeof c=="string"&&c.trim()?c.trim():`depth:${h}:index:${r.length}`,g=Array.isArray(l?.children)?l.children:[];r.push({id:m,uid:m,text:typeof f=="string"&&f.trim()?f.trim():"\u672A\u547D\u540D\u8282\u70B9",depth:h,childCount:g.length,parentId:d}),d&&n.push({id:`${d}->${m}`,source:d,target:m}),g.forEach(x=>{s(ci(x),h+1,m)})};s(e,0,null);let a=r[0]?.id??null,o=i.rootNodeId&&i.rootNodeId.trim()?i.rootNodeId.trim():a;return{schema:"mnote.mindmap_editor_scene.v1",source:i.source??"rust-kernel",owner:i.owner??"rust-kernel",documentId:i.documentId,mindmapId:i.mindmapId,rootNodeId:o,title:Bc(e),nodes:r,edges:n,capabilities:{canEditText:!0,canAddChild:!0,canAddSiblingAfter:!0,canDeleteNode:!0},data:e,meta:t}},uw=i=>{let e=ci(i.data),t=Hs(i.meta)?i.meta:null,r=e?.data?.uid,n=typeof r=="string"&&r.trim()?r.trim():"root";return e.data.uid||(e.data.uid=n),{schema:"mnote.mindmap.simple_mind_map_scene.v1",runtime:"simple-mind-map",documentId:i.documentId,mindmapId:i.mindmapId,rootNodeId:n,root:e,layout:E0,theme:S0,themeConfig:A0(),view:{x:0,y:0,scale:1},config:{},compatPayload:{},kernelRevision:1,source:i.source??"rust-kernel",owner:i.owner??"rust-kernel",meta:t}};var UP=["Drag","KeyboardNavigation","Export","Select","AssociativeLine","Search","OuterFrame"],$P=["Scrollbar","MiniMap","Painter","Formula"],jP=["data_change","view_data_change","node_active","back_forward","scale","translate"],GP=new Set(["BACK","FORWARD","INSERT_NODE","INSERT_CHILD_NODE","REMOVE_NODE","DELETE_NODE","ADD_GENERALIZATION","ADD_OUTER_FRAME","SET_NOTATION"]),ao=i=>typeof i=="object"&&i!==null&&!Array.isArray(i),nb=(i,e)=>{if(typeof i=="string"&&i.trim())return i.trim();if(ao(i)){let t=i.template;if(typeof t=="string"&&t.trim())return t.trim()}return e},sb=i=>ao(i)?i:{},ab=i=>!ao(i)||Object.keys(i).length===0?A0():i,VP=i=>!ao(i)||!ao(i.state)?null:{...i,state:i.state,transform:ao(i.transform)?i.transform:{}},WP=i=>{let e=ci(i.projection.root??Us),t=sb(i.projection.config),r=sb(i.runtimeOptions);return{...t,...r,el:i.el,data:e,fit:typeof r.fit=="boolean"?r.fit:typeof t.fit=="boolean"?t.fit:!0,layout:nb(i.projection.layout,E0),theme:nb(i.projection.theme,S0),themeConfig:ab(i.projection.themeConfig),viewData:VP(i.projection.view),initRootNodePosition:["center","center"]}},YP={Drag:()=>Promise.resolve().then(()=>(J2(),Q2)),KeyboardNavigation:()=>Promise.resolve().then(()=>(t6(),e6)),Export:()=>Promise.resolve().then(()=>(d6(),h6)),Select:()=>Promise.resolve().then(()=>(u6(),c6)),AssociativeLine:()=>Promise.resolve().then(()=>(b6(),v6)),Search:()=>Promise.resolve().then(()=>(M6(),w6)),OuterFrame:()=>Promise.resolve().then(()=>(k6(),A6)),Scrollbar:()=>Promise.resolve().then(()=>(_6(),C6)),MiniMap:()=>Promise.resolve().then(()=>(I6(),L6)),Painter:()=>Promise.resolve().then(()=>(R6(),z6)),Formula:()=>Promise.resolve().then(()=>(wv(),bv))},XP=()=>[...UP,...$P],KP=async(i=XP())=>{if(typeof window>"u"||typeof document>"u")throw new Error("simple_mind_map_browser_required");let[{default:e},t]=await Promise.all([Promise.resolve().then(()=>(rb(),ib)),Promise.all(i.map(async s=>[s,(await YP[s]()).default]))]),r=e,n=[];return t.forEach(([s,a])=>{if(!a||typeof r.usePlugin!="function")return;typeof r.hasPlugin=="function"&&r.hasPlugin(a)!==-1||r.usePlugin(a),n.push(s)}),{MindMap:r,registeredPlugins:n}},ZP=i=>(e,...t)=>{if(!GP.has(e))return{ok:!1,command:e,error:"unsupported_command"};if(typeof i.execCommand!="function")return{ok:!1,command:e,error:"runtime_unavailable"};try{return{ok:!0,command:e,result:i.execCommand(e,...t)}}catch{return{ok:!1,command:e,error:"command_failed"}}},QP=i=>{let e=[];return jP.forEach(t=>{let r=(...n)=>{let s=t==="data_change"?i.instance.getData?.():t==="view_data_change"?i.instance.getData?.(!0)??i.instance.getData?.():void 0;i.onEvent?.({type:t,args:n,snapshot:s,kernelRevision:i.projection.kernelRevision})};i.instance.on?.(t,r),e.push(()=>i.instance.off?.(t,r))}),()=>e.splice(0).forEach(t=>t())},ob=async i=>{let e=await KP(i.pluginNames),t=ab(i.projection.themeConfig),r=WP({el:i.el,projection:i.projection,runtimeOptions:i.runtimeOptions}),n=new e.MindMap(r);n.setThemeConfig?.(t),i.mode&&n.setMode?.(i.mode);let s=QP({instance:n,projection:i.projection,onEvent:i.onEvent}),a=ZP(n);return{instance:n,execCommand:a,getSnapshot:()=>n.getData?.(!0)??n.getData?.(),destroy:()=>{n.renderer?.textEdit?.hideEditTextBox?.(),s(),n.destroy?.()}}};var lo=class extends Error{constructor(e,t=null){super(e),this.name="MindmapCommandBridgeError",this.code="command_failed",this.status=t}},oo=i=>typeof i=="object"&&i!==null&&!Array.isArray(i),Lp=i=>{if(!oo(i))return"";let e=[i.error,i.message,i.details].map(t=>typeof t=="string"?t.trim():Array.isArray(t)&&t.length>0?t.join("|"):"").find(Boolean);return e?`:${e}`:""},JP=async i=>{if(typeof i.text!="function"){let t=await i.json().catch(()=>null);return Lp(t)}let e=await i.text().catch(()=>"");if(!e.trim())return"";try{return Lp(JSON.parse(e))||`:${e.trim()}`}catch{return`:${e.trim()}`}},Ip=i=>oo(i)?i.schema==="mnote.mindmap.simple_mind_map_scene.v1"&&i.runtime==="simple-mind-map"&&"root"in i&&typeof i.kernelRevision=="number":!1,lb=(i,e)=>{let t=encodeURIComponent(i),r=encodeURIComponent(e);return`/api/mindmap/${t}/${r}?view=simple_mind_map_scene&queryName=mindmap.simple_mind_map_scene.get`},zp=(i,e)=>{let t=encodeURIComponent(i),r=encodeURIComponent(e);return`/api/mindmap/${t}/${r}`},Rp=async i=>{let e=i.fetcher??fetch,t=i.endpoint??lb(i.documentId,i.mindmapId),r=await e(t,{headers:{Accept:"application/json"}});if(!r.ok)throw new Error(`projection_load_failed:${r.status}`);let n=await r.json(),s=oo(n)&&"result"in n?n.result:n;if(!Ip(s))throw new Error("projection_load_failed:invalid_adapter_projection");return s},eF=i=>{if(!oo(i))return null;let e=[i.kernelRevision,i.projectionRevision,oo(i.result)?i.result.kernelRevision:null,oo(i.result)?i.result.projectionRevision:null];for(let t of e)if(typeof t=="number"&&Number.isFinite(t))return t;return null},hb=async i=>{if(i.commands.length===0)throw new lo("command_failed:empty_commands");let e=i.fetcher??fetch,t=i.endpoint??zp(i.documentId,i.mindmapId),r=JSON.stringify({commandName:"mindmap.command.apply",documentId:i.documentId,mindmapId:i.mindmapId,commands:i.commands,projectionRevision:i.projectionRevision??null}),n=await e(t,{method:"POST",keepalive:r.length<=6e4,headers:{Accept:"application/json","Content-Type":"application/json"},body:r}),s=await n.json().catch(()=>null);if(!n.ok){let a=Lp(s);throw new lo(`command_failed:${n.status}${a}`,n.status)}return{ok:!0,kernelRevision:eF(s),raw:s}},tF=async i=>(await hb(i),Rp({documentId:i.documentId,mindmapId:i.mindmapId,endpoint:i.projectionEndpoint,fetcher:i.fetcher})),iF=async i=>{let e=i.fetcher??fetch,t=i.endpoint??zp(i.documentId,i.mindmapId),r=await e(t,{method:"POST",headers:{Accept:"application/json","Content-Type":"application/json"},body:JSON.stringify({data:i.data,createOnly:!1})});if(!r.ok){let n=await JP(r);throw new lo(`command_failed:${r.status}${n}`,r.status)}return await r.json().catch(()=>null),Rp({documentId:i.documentId,mindmapId:i.mindmapId,endpoint:i.projectionEndpoint,fetcher:e})},rF=async i=>{if(!Ip(i.projection))throw new Error("adapter_init_failed:invalid_projection");return ob(i)};var Fp={};tt(Fp,{createCompatPayloadPatch:()=>Tc,createLayoutCommand:()=>Pp,createThemeCommand:()=>Bp,createToolbarKernelCommand:()=>Op,createViewPatchCommand:()=>nF,diffMindmapRuntimeDataToKernelCommands:()=>hF});var db=i=>({text:i?.text?.trim()||"\u65B0\u8282\u70B9",...i?.uid?{uid:i.uid}:{},...i?.hyperlink?{hyperlink:i.hyperlink}:{},...i?.note?{note:i.note}:{},...i?.refs?{refs:i.refs}:{}}),Op=i=>{let e=i.activeNodeId?.trim();switch(i.runtimeCommand){case"INSERT_CHILD_NODE":return e?{type:"insertChild",mindmapId:i.mindmapId,parentNodeId:e,node:db(i.newNode)}:null;case"INSERT_NODE":return e?{type:"insertSiblingAfter",mindmapId:i.mindmapId,targetNodeId:e,node:db(i.newNode)}:null;case"REMOVE_NODE":case"DELETE_NODE":return e?{type:"deleteNode",mindmapId:i.mindmapId,nodeId:e}:null;case"ADD_GENERALIZATION":return{type:"compatPayloadPatch",mindmapId:i.mindmapId,path:"root.data.generalization",value:{text:"\u6982\u8981"},source:"toolbar"};case"ADD_OUTER_FRAME":return{type:"compatPayloadPatch",mindmapId:i.mindmapId,path:"outerFrame",value:{enabled:!0,activeNodeId:e},source:"toolbar"};default:return null}},nF=(i,e)=>({type:"patchView",mindmapId:i,patch:e}),Bp=(i,e,t)=>({type:"setTheme",mindmapId:i,theme:e,themeConfig:t}),Pp=(i,e)=>({type:"setLayout",mindmapId:i,layout:e}),Tc=i=>({type:"compatPayloadPatch",mindmapId:i.mindmapId,path:i.path,value:i.value,source:i.source,...i.actionId?{actionId:i.actionId}:{},...typeof i.runtimeRevision=="number"?{runtimeRevision:i.runtimeRevision}:{},...typeof i.kernelRevision=="number"?{kernelRevision:i.kernelRevision}:{}}),sF=i=>typeof i=="object"&&i!==null&&!Array.isArray(i),aF=(i,e)=>{let t=i.data?.uid;return typeof t=="string"&&t.trim()?t.trim():e},oF=i=>{let e=i.data?.text;return typeof e=="string"?e:String(e??"")},cb=i=>{let e=ci(i),t=new Map,r=(n,s,a,o)=>{let l=aF(n,o),h=sF(n.data)?n.data:{};t.set(l,{uid:l,parentUid:s,order:a,text:oF(n),data:h,node:n}),(Array.isArray(n.children)?n.children:[]).forEach((c,f)=>{r(ci(c),l,f,`${o}.${f}`)})};return r(e,null,0,"root"),t},ub=i=>JSON.stringify(i??null),fb=i=>({uid:i.uid,text:i.text||"\u65B0\u8282\u70B9",...typeof i.data.hyperlink=="string"?{hyperlink:i.data.hyperlink}:{},...typeof i.data.note=="string"?{note:i.data.note}:{},...Array.isArray(i.data.refs)?{refs:i.data.refs}:{}}),lF=(i,e,t,r)=>{let n=new Set(["uid","text","refs"]);new Set([...Object.keys(e.data),...Object.keys(t.data)]).forEach(a=>{n.has(a)||ub(e.data[a])!==ub(t.data[a])&&r.push(Tc({mindmapId:i,path:`nodes.${t.uid}.data.${a}`,value:t.data[a],source:"adapter-diff"}))})},hF=i=>{let e=cb(i.previous),t=cb(i.next),r=[],n=[];return t.forEach((s,a)=>{let o=e.get(a);if(!o){let l=Array.from(t.values()).find(h=>h.parentUid===s.parentUid&&h.order===s.order-1&&e.has(h.uid));l?r.push({type:"insertSiblingAfter",mindmapId:i.mindmapId,targetNodeId:l.uid,node:fb(s)}):s.parentUid&&r.push({type:"insertChild",mindmapId:i.mindmapId,parentNodeId:s.parentUid,node:fb(s)});return}o.text!==s.text&&r.push({type:"updateText",mindmapId:i.mindmapId,nodeId:a,text:s.text}),(o.parentUid!==s.parentUid||o.order!==s.order)&&r.push({type:"moveNode",mindmapId:i.mindmapId,nodeId:a,newParentNodeId:s.parentUid??"",order:s.order}),lF(i.mindmapId,o,s,n)}),e.forEach((s,a)=>{!t.has(a)&&s.parentUid&&r.push({type:"deleteNode",mindmapId:i.mindmapId,nodeId:a})}),{commands:r,compatPatches:n}};var Hp={};tt(Hp,{getMindmapActionMapping:()=>Nc,listMindmapActionMappings:()=>qp,mapMindmapActionToCommand:()=>cF});var zs=i=>e=>e?`nodes.${e}.data.${i}`:null,mb=[{actionId:"undo",target:"runtimeCommand",runtimeCommand:"BACK",requiresActiveNode:!1,readonlyAllowed:!1},{actionId:"redo",target:"runtimeCommand",runtimeCommand:"FORWARD",requiresActiveNode:!1,readonlyAllowed:!1},{actionId:"editNode",target:"localView",requiresActiveNode:!0,readonlyAllowed:!1},{actionId:"insertSiblingAfter",target:"runtimeCommand",runtimeCommand:"INSERT_NODE",requiresActiveNode:!0,readonlyAllowed:!1},{actionId:"insertChild",target:"runtimeCommand",runtimeCommand:"INSERT_CHILD_NODE",requiresActiveNode:!0,readonlyAllowed:!1},{actionId:"deleteNode",target:"runtimeCommand",runtimeCommand:"REMOVE_NODE",requiresActiveNode:!0,readonlyAllowed:!1},{actionId:"summary",target:"runtimeCommand",runtimeCommand:"ADD_GENERALIZATION",requiresActiveNode:!0,readonlyAllowed:!1},{actionId:"associativeLine",target:"compatPatch",runtimeCommand:"ADD_ASSOCIATIVE_LINE",requiresActiveNode:!0,readonlyAllowed:!1},{actionId:"setTheme",target:"kernelCommand",requiresActiveNode:!1,readonlyAllowed:!1},{actionId:"setLayout",target:"kernelCommand",requiresActiveNode:!1,readonlyAllowed:!1},{actionId:"tag",target:"compatPatch",requiresActiveNode:!0,readonlyAllowed:!1,compatPath:zs("tag")},{actionId:"hyperlink",target:"compatPatch",requiresActiveNode:!0,readonlyAllowed:!1,compatPath:zs("hyperlink")},{actionId:"note",target:"compatPatch",requiresActiveNode:!0,readonlyAllowed:!1,compatPath:zs("note")},{actionId:"image",target:"compatPatch",requiresActiveNode:!0,readonlyAllowed:!1,compatPath:zs("image")},{actionId:"icon",target:"compatPatch",requiresActiveNode:!0,readonlyAllowed:!1,compatPath:zs("icon")},{actionId:"formula",target:"compatPatch",requiresActiveNode:!0,readonlyAllowed:!1,compatPath:zs("formula")},{actionId:"painter",target:"compatPatch",requiresActiveNode:!0,readonlyAllowed:!1,compatPath:zs("style")},{actionId:"import",target:"compatPatch",requiresActiveNode:!1,readonlyAllowed:!1,compatPath:()=>"import"},{actionId:"export",target:"runtimeCommand",runtimeCommand:"EXPORT",requiresActiveNode:!1,readonlyAllowed:!0},{actionId:"centerRoot",target:"localView",runtimeMethod:"centerRoot",requiresActiveNode:!1,readonlyAllowed:!0},{actionId:"zoomIn",target:"localView",runtimeMethod:"zoomIn",requiresActiveNode:!1,readonlyAllowed:!0},{actionId:"zoomOut",target:"localView",runtimeMethod:"zoomOut",requiresActiveNode:!1,readonlyAllowed:!0},{actionId:"fitView",target:"localView",runtimeMethod:"fitView",requiresActiveNode:!1,readonlyAllowed:!0},{actionId:"fullscreenCanvas",target:"localView",requiresActiveNode:!1,readonlyAllowed:!0},{actionId:"fullscreenPage",target:"localView",requiresActiveNode:!1,readonlyAllowed:!0},{actionId:"exitFullscreen",target:"localView",requiresActiveNode:!1,readonlyAllowed:!0},{actionId:"search",target:"localView",requiresActiveNode:!1,readonlyAllowed:!0},{actionId:"showMenu",target:"localView",requiresActiveNode:!1,readonlyAllowed:!0},{actionId:"expandCollapse",target:"localView",requiresActiveNode:!0,readonlyAllowed:!1},{actionId:"copyNodeText",target:"localView",requiresActiveNode:!0,readonlyAllowed:!0},{actionId:"readonly",target:"localView",requiresActiveNode:!1,readonlyAllowed:!0}],qp=()=>mb,Nc=i=>mb.find(e=>e.actionId===i)??null,dF=i=>{let e=i?.trim();return e||null},cF=i=>{let e=Nc(i.actionId);if(!e)return null;let t=dF(i.activeNodeId);if(e.requiresActiveNode&&!t)return null;if(i.actionId==="setTheme")return{command:Bp(i.mindmapId,i.value,i.themeConfig)};if(i.actionId==="setLayout")return{command:Pp(i.mindmapId,i.value)};if(i.actionId==="editNode")return{command:{type:"updateText",mindmapId:i.mindmapId,nodeId:t??"root",text:typeof i.value=="string"?i.value:""}};if(e.runtimeCommand&&["INSERT_CHILD_NODE","INSERT_NODE","DELETE_NODE","REMOVE_NODE"].includes(e.runtimeCommand)){let r=Op({mindmapId:i.mindmapId,runtimeCommand:e.runtimeCommand,activeNodeId:t,newNode:i.node});return r?{runtimeCommand:e.runtimeCommand,command:r}:{runtimeCommand:e.runtimeCommand}}if(e.compatPath){let r=e.compatPath(t);return r?{command:Tc({mindmapId:i.mindmapId,path:r,value:i.value??!0,source:i.source??"toolbar",actionId:i.actionId,runtimeRevision:i.runtimeRevision,kernelRevision:i.kernelRevision})}:null}return e.runtimeCommand?{runtimeCommand:e.runtimeCommand}:{}};var $p={};tt($p,{createDefaultMindmapShellInteractionState:()=>gb,deriveMindmapUiState:()=>wF,reduceMindmapChromeVisibility:()=>bF});var Up={};tt(Up,{MINDMAP_DEBUG_CHROME_QUERY_PARAM:()=>pb,MINDMAP_TOOLBAR_MORE_ACTION_ID:()=>uF,isMindmapDebugChromeEnabled:()=>pF,mindmapDefaultUiSchema:()=>ho,mindmapToolbarFileActionOrder:()=>mF,mindmapToolbarPrimaryActionOrder:()=>fF});var pb="mnoteMindmapDebugChrome",uF="more",fF=["undo","redo","editNode","insertSiblingAfter","deleteNode","insertChild","tag","hyperlink","note","image","icon","summary","associativeLine","formula"],mF=["import","export"],Ee=(i,e,t,r,n,s,a)=>({id:i,iconKey:e,shortLabel:t,longLabel:r,priority:n,overflowGroup:s,cluster:a}),ho={toolbarGroups:[{id:"history",label:"\u5386\u53F2",actions:["undo","redo"],collapsePriority:4},{id:"node",label:"\u8282\u70B9",actions:["editNode","insertSiblingAfter","deleteNode","insertChild"],collapsePriority:1},{id:"insert",label:"\u63D2\u5165",actions:["tag","hyperlink","note","image","icon","summary","associativeLine","formula"],collapsePriority:2},{id:"file",label:"\u6587\u4EF6",actions:["import","export"],collapsePriority:5},{id:"view",label:"\u89C6\u56FE",actions:["painter","centerRoot","zoomOut","zoomIn","search","readonly"],collapsePriority:3}],toolbarActionMeta:{undo:Ee("undo","undo","\u64A4\u9500","\u64A4\u9500",10,"history","main"),redo:Ee("redo","redo","\u91CD\u505A","\u91CD\u505A",20,"history","main"),editNode:Ee("editNode","type","\u7F16\u8F91","\u7F16\u8F91\u8282\u70B9",30,"node","main"),insertSiblingAfter:Ee("insertSiblingAfter","sibling","\u540C\u7EA7","\u63D2\u5165\u540C\u7EA7\u8282\u70B9",40,"node","main"),deleteNode:Ee("deleteNode","trash","\u5220\u9664","\u5220\u9664\u8282\u70B9",50,"node","main"),insertChild:Ee("insertChild","child","\u5B50\u7EA7","\u63D2\u5165\u5B50\u8282\u70B9",60,"node","main"),tag:Ee("tag","tag","\u6807\u7B7E","\u6807\u7B7E",70,"insert","main"),hyperlink:Ee("hyperlink","link","\u94FE\u63A5","\u8D85\u94FE\u63A5",80,"insert","main"),note:Ee("note","note","\u5907\u6CE8","\u5907\u6CE8",90,"insert","main"),image:Ee("image","image","\u56FE\u7247","\u56FE\u7247",100,"insert","main"),icon:Ee("icon","smile","\u56FE\u6807","\u56FE\u6807",110,"insert","main"),summary:Ee("summary","summary","\u6982\u8981","\u6982\u8981",120,"insert","main"),associativeLine:Ee("associativeLine","route","\u5173\u8054","\u5173\u8054\u7EBF",130,"insert","main"),formula:Ee("formula","formula","\u516C\u5F0F","\u516C\u5F0F",140,"insert","main"),painter:Ee("painter","paintbrush","\u683C\u5F0F","\u683C\u5F0F\u5237",210,"view","view"),import:Ee("import","import","\u5BFC\u5165","\u5BFC\u5165",310,"file","file"),export:Ee("export","export","\u5BFC\u51FA","\u5BFC\u51FA",320,"file","file"),setTheme:Ee("setTheme","palette","\u4E3B\u9898","\u4E3B\u9898",410,"view","view"),setLayout:Ee("setLayout","layout","\u7ED3\u6784","\u7ED3\u6784",420,"view","view"),zoomIn:Ee("zoomIn","zoom-in","\u653E\u5927","\u653E\u5927",510,"view","view"),zoomOut:Ee("zoomOut","zoom-out","\u7F29\u5C0F","\u7F29\u5C0F",500,"view","view"),fitView:Ee("fitView","fit","\u9002\u5E94","\u9002\u5E94\u753B\u5E03",520,"view","view"),centerRoot:Ee("centerRoot","target","\u56DE\u6839","\u56DE\u5230\u6839\u8282\u70B9",490,"view","view"),fullscreenCanvas:Ee("fullscreenCanvas","fullscreen","\u5168\u5C4F","\u5168\u5C4F\u67E5\u770B",550,"view","view"),fullscreenPage:Ee("fullscreenPage","fullscreen-page","\u5168\u9875","\u5168\u5C4F\u7F16\u8F91",560,"view","view"),exitFullscreen:Ee("exitFullscreen","exit-fullscreen","\u9000\u51FA","\u9000\u51FA\u5168\u5C4F",570,"view","view"),search:Ee("search","search","\u641C\u7D22","\u641C\u7D22",530,"view","view"),showMenu:Ee("showMenu","menu","\u83DC\u5355","\u663E\u793A\u83DC\u5355",580,"view","view"),expandCollapse:Ee("expandCollapse","expand","\u5C55\u5F00","\u5C55\u5F00/\u6536\u8D77",610,"view","view"),copyNodeText:Ee("copyNodeText","copy","\u590D\u5236","\u590D\u5236\u6587\u672C",620,"view","view"),readonly:Ee("readonly","lock","\u53EA\u8BFB","\u53EA\u8BFB",540,"view","view")},sidebarPanels:[{id:"nodeStyle",kind:"nodeStyle",label:"\u8282\u70B9\u6837\u5F0F",icon:"palette",runtimeCapability:"node-style",phase:1,options:[{id:"node-fill-blue",label:"\u6D77\u84DD",actionId:"painter",value:"#dbeafe",controlType:"swatch",preview:"#dbeafe",compatPath:"nodes.$active.data.fillColor"},{id:"node-fill-green",label:"\u8584\u8377",actionId:"painter",value:"#dcfce7",controlType:"swatch",preview:"#dcfce7",compatPath:"nodes.$active.data.fillColor"},{id:"node-fill-amber",label:"\u6696\u9EC4",actionId:"painter",value:"#fef3c7",controlType:"swatch",preview:"#fef3c7",compatPath:"nodes.$active.data.fillColor"},{id:"node-text-dark",label:"\u6DF1\u8272\u6587\u5B57",actionId:"painter",value:"#0f172a",controlType:"swatch",preview:"#0f172a",compatPath:"nodes.$active.data.color"},{id:"node-text-blue",label:"\u84DD\u8272\u6587\u5B57",actionId:"painter",value:"#1d4ed8",controlType:"swatch",preview:"#1d4ed8",compatPath:"nodes.$active.data.color"},{id:"node-font-14",label:"14 px",actionId:"painter",value:14,controlType:"segmented",compatPath:"nodes.$active.data.fontSize"},{id:"node-font-18",label:"18 px",actionId:"painter",value:18,controlType:"segmented",compatPath:"nodes.$active.data.fontSize"},{id:"node-font-bold",label:"\u52A0\u7C97",actionId:"painter",value:!0,controlType:"toggle",compatPath:"nodes.$active.data.fontWeight"},{id:"node-font-italic",label:"\u659C\u4F53",actionId:"painter",value:!0,controlType:"toggle",compatPath:"nodes.$active.data.fontStyle"},{id:"node-shape-round",label:"\u5706\u89D2\u77E9\u5F62",actionId:"painter",value:"roundedRectangle",controlType:"button",compatPath:"nodes.$active.data.shape"},{id:"node-shape-rect",label:"\u77E9\u5F62",actionId:"painter",value:"rectangle",controlType:"button",compatPath:"nodes.$active.data.shape"},{id:"node-border-blue",label:"\u84DD\u8272\u8FB9\u6846",actionId:"painter",value:"#60a5fa",controlType:"swatch",preview:"#60a5fa",compatPath:"nodes.$active.data.borderColor"},{id:"node-line-teal",label:"\u9752\u8272\u5206\u652F\u7EBF",actionId:"painter",value:"#14b8a6",controlType:"swatch",preview:"#14b8a6",compatPath:"nodes.$active.data.lineColor"},{id:"node-line-width-2",label:"\u8FB9\u7EBF 2",actionId:"painter",value:2,controlType:"segmented",compatPath:"nodes.$active.data.lineWidth"}]},{id:"baseStyle",kind:"baseStyle",label:"\u5BFC\u56FE\u6837\u5F0F",icon:"sliders",runtimeCapability:"base-style",phase:1,options:[{id:"base-curve-line",label:"\u66F2\u7EBF",actionId:"painter",value:"curve",controlType:"segmented",compatPath:"style.map.lineStyle"},{id:"base-direct-line",label:"\u76F4\u7EBF",actionId:"painter",value:"straight",controlType:"segmented",compatPath:"style.map.lineStyle"},{id:"base-rainbow-lines",label:"\u5F69\u8679\u7EBF\u6761",actionId:"painter",value:{enabled:!0},controlType:"toggle",compatPath:"style.map.rainbowLines"},{id:"base-line-width-2",label:"\u7EBF\u5BBD 2",actionId:"painter",value:2,controlType:"segmented",compatPath:"style.map.lineWidth"},{id:"base-line-width-4",label:"\u7EBF\u5BBD 4",actionId:"painter",value:4,controlType:"segmented",compatPath:"style.map.lineWidth"},{id:"base-background-light",label:"\u6D45\u8272\u80CC\u666F",actionId:"painter",value:"#f8fafc",controlType:"swatch",preview:"#f8fafc",compatPath:"style.map.backgroundColor"},{id:"base-node-spacing-36",label:"\u8282\u70B9\u95F4\u8DDD 36",actionId:"painter",value:36,controlType:"numberInput",compatPath:"style.map.nodeSpacing"},{id:"base-summary-bracket",label:"\u62EC\u53F7\u6982\u8981",actionId:"painter",value:"bracket",controlType:"select",compatPath:"style.map.summaryStyle"}]},{id:"theme",kind:"theme",label:"\u4E3B\u9898",icon:"swatch",runtimeCapability:"theme",phase:1,options:[{id:"theme-classic",label:"Classic",actionId:"setTheme",value:"classic",controlType:"swatch",preview:"#60a5fa",description:"\u9ED8\u8BA4\u4E3B\u9898"},{id:"theme-classic4",label:"KMind",actionId:"setTheme",value:"classic4",controlType:"swatch",preview:"#22c55e",description:"KMind-like"},{id:"theme-simple",label:"Simple",actionId:"setTheme",value:"simple",controlType:"swatch",preview:"#f59e0b",description:"\u8F7B\u91CF\u4E3B\u9898"},{id:"theme-dark",label:"Dark",actionId:"setTheme",value:"dark",controlType:"swatch",preview:"#334155",description:"\u6DF1\u8272\u4E3B\u9898"}]},{id:"structure",kind:"structure",label:"\u7ED3\u6784",icon:"layout",runtimeCapability:"layout",phase:1,options:[{id:"layout-logical",label:"\u903B\u8F91\u7ED3\u6784\u56FE",actionId:"setLayout",value:"logicalStructure",controlType:"layoutCard",preview:"logicalStructure"},{id:"layout-mind-map",label:"\u601D\u7EF4\u5BFC\u56FE",actionId:"setLayout",value:"mindMap",controlType:"layoutCard",preview:"mindMap"},{id:"layout-organization",label:"\u7EC4\u7EC7\u7ED3\u6784\u56FE",actionId:"setLayout",value:"organizationStructure",controlType:"layoutCard",preview:"organizationStructure"},{id:"layout-catalog",label:"\u76EE\u5F55\u7EC4\u7EC7\u56FE",actionId:"setLayout",value:"catalogOrganization",controlType:"layoutCard",preview:"catalogOrganization"},{id:"layout-timeline",label:"\u65F6\u95F4\u8F74",actionId:"setLayout",value:"timeline",controlType:"layoutCard",preview:"timeline"},{id:"layout-fishbone",label:"\u9C7C\u9AA8\u56FE",actionId:"setLayout",value:"fishbone",controlType:"layoutCard",preview:"fishbone"}]},{id:"outline",kind:"outline",label:"\u5927\u7EB2",icon:"list-tree",runtimeCapability:"outline",phase:1,options:[]},{id:"shortcutKey",kind:"shortcutKey",label:"\u5FEB\u6377\u952E",icon:"sparkles",runtimeCapability:"shortcut-key",phase:1,options:[{id:"shortcut-insert-child",label:"Tab",description:"\u63D2\u5165\u5B50\u8282\u70B9",actionId:null,value:null,controlType:"treeItem",readonly:!0},{id:"shortcut-insert-sibling",label:"Enter",description:"\u63D2\u5165\u540C\u7EA7\u8282\u70B9",actionId:null,value:null,controlType:"treeItem",readonly:!0},{id:"shortcut-delete",label:"Delete",description:"\u5220\u9664\u8282\u70B9",actionId:null,value:null,controlType:"treeItem",readonly:!0}]},{id:"settings",kind:"settings",label:"\u8BBE\u7F6E",icon:"hexagon",runtimeCapability:"settings",phase:1,options:[{id:"settings-readonly-hint",label:"\u53EA\u8BFB\u6A21\u5F0F",description:"\u5BFC\u822A\u680F\u5207\u6362",actionId:null,value:null,controlType:"toggle",readonly:!0},{id:"settings-mouse",label:"\u9F20\u6807\u884C\u4E3A",description:"\u5DE6\u952E\u9009\u4E2D\uFF0C\u53F3\u952E\u62D6\u62FD",actionId:null,value:"leftSelectRightDrag",controlType:"select",readonly:!0}]}],navigatorItems:[{id:"stats",label:"\u7EDF\u8BA1",actionId:null,readOnly:!0,displayMode:"text"},{id:"centerRoot",label:"\u56DE\u6839\u8282\u70B9",actionId:"centerRoot",readOnly:!0,displayMode:"button"},{id:"search",label:"\u641C\u7D22",actionId:"search",readOnly:!0,displayMode:"button"},{id:"zoomOut",label:"\u7F29\u5C0F",actionId:"zoomOut",readOnly:!0,displayMode:"button"},{id:"zoom",label:"\u7F29\u653E",actionId:null,readOnly:!0,displayMode:"input"},{id:"zoomIn",label:"\u653E\u5927",actionId:"zoomIn",readOnly:!0,displayMode:"button"},{id:"fullscreen",label:"\u5168\u5C4F",actionId:"fullscreenCanvas",readOnly:!0,displayMode:"button"},{id:"readonly",label:"\u53EA\u8BFB",actionId:"readonly",readOnly:!0,displayMode:"button"}],contextMenuItems:[{id:"insertChild",label:"\u63D2\u5165\u5B50\u8282\u70B9",actionId:"insertChild",requiresNode:!0,phase:1},{id:"insertSiblingAfter",label:"\u63D2\u5165\u540C\u7EA7\u8282\u70B9",actionId:"insertSiblingAfter",requiresNode:!0,phase:1},{id:"deleteNode",label:"\u5220\u9664\u8282\u70B9",actionId:"deleteNode",requiresNode:!0,phase:1},{id:"summary",label:"\u6982\u8981",actionId:"summary",requiresNode:!0,phase:1},{id:"associativeLine",label:"\u5173\u8054\u7EBF",actionId:"associativeLine",requiresNode:!0,phase:1},{id:"expandCollapse",label:"\u5C55\u5F00/\u6536\u8D77",actionId:"expandCollapse",requiresNode:!0,phase:1},{id:"copyNodeText",label:"\u590D\u5236\u6587\u672C",actionId:"copyNodeText",requiresNode:!0,phase:1},{id:"centerRoot",label:"\u56DE\u6839\u8282\u70B9",actionId:"centerRoot",requiresNode:!1,phase:1},{id:"fitView",label:"\u9002\u5E94\u753B\u5E03",actionId:"fitView",requiresNode:!1,phase:1},{id:"search",label:"\u641C\u7D22",actionId:"search",requiresNode:!1,phase:1},{id:"readonly",label:"\u53EA\u8BFB\u5207\u6362",actionId:"readonly",requiresNode:!1,phase:1},{id:"showMenu",label:"\u663E\u793A\u83DC\u5355",actionId:"showMenu",requiresNode:!1,phase:1}]},pF=i=>{try{let e=new URL(i).searchParams.get(pb);return e==="1"||e==="true"}catch{return!1}};var gF=()=>{let i=[...ho.toolbarGroups.flatMap(e=>e.actions),...ho.navigatorItems.flatMap(e=>e.actionId?[e.actionId]:[]),...ho.contextMenuItems.map(e=>e.actionId),...qp().map(e=>e.actionId)];return[...new Set(i)]},xF=i=>{let e=i?.trim();return e||null},yF=i=>typeof i!="number"||!Number.isFinite(i)?100:Math.max(10,Math.min(500,Math.round(i))),vF=new Set(["tag","hyperlink","note","image","icon","associativeLine","formula","import","export"]),gb=(i={})=>({chromeVisibility:i.chromeVisibility??"visible",toolbarOverflow:{availableWidth:i.toolbarOverflow?.availableWidth??null,visibleActionIds:i.toolbarOverflow?.visibleActionIds??[],overflowActionIds:i.toolbarOverflow?.overflowActionIds??[],moreOpen:i.toolbarOverflow?.moreOpen??!1},fullscreen:{mode:i.fullscreen?.mode??"none",isFullscreen:i.fullscreen?.isFullscreen??!1,target:i.fullscreen?.target??"mindmap-root",apiAvailable:i.fullscreen?.apiAvailable??!0},sidebar:{triggerVisible:i.sidebar?.triggerVisible??!0,panelOpen:i.sidebar?.panelOpen??!0,activePanelId:i.sidebar?.activePanelId??ho.sidebarPanels[0]?.id??null,drawerWidth:i.sidebar?.drawerWidth??300,collapsedByToggle:i.sidebar?.collapsedByToggle??!1},navigator:{searchOpen:i.navigator?.searchOpen??!1,minimapOpen:i.navigator?.minimapOpen??!1,readonly:i.navigator?.readonly??!1,zoomPercent:yF(i.navigator?.zoomPercent),mouseBehavior:i.navigator?.mouseBehavior??"leftSelectRightDrag"}}),bF=(i,e)=>e==="pointerLeave"?"hiddenByPointerLeave":e==="pointerEnter"?i:e==="restoreClick"?"visible":e==="hideToggle"?"hiddenByToggle":e==="showToggle"?"visible":e==="enterFullscreen"?i==="visible"?"visible":"hiddenByFullscreen":e==="exitFullscreen"&&i==="hiddenByFullscreen"?"visible":i,wF=i=>{let e=xF(i.activeNodeId),t=i.runtimeCapabilities?new Set(i.runtimeCapabilities):null,r={},n=gb({...i.shell,navigator:{...i.shell?.navigator,readonly:i.readonly}});return gF().forEach(s=>{let a=Nc(s);if(!a){r[s]=!0;return}if(a.requiresActiveNode&&!e){r[s]=!0;return}if(i.readonly&&!a.readonlyAllowed){r[s]=!0;return}if(t&&!t.has(a.target)){r[s]=!0;return}if(vF.has(s)){r[s]=!0;return}r[s]=!1}),{activeNodeId:e,readonly:i.readonly,disabledActions:r,shell:n}};var jp={};tt(jp,{resolveMindmapShortcutAction:()=>MF,shouldInterceptMindmapShortcut:()=>TF});var MF=i=>i.ctrlKey||i.metaKey||i.altKey||i.shiftKey?null:i.key==="Enter"?"insertSiblingAfter":i.key==="Tab"||i.key==="Insert"?"insertChild":i.key==="Delete"||i.key==="Backspace"?"deleteNode":i.key==="F2"?"editNode":null,TF=i=>i.debugChromeEnabled||!i.bridgeReady||i.readonly||i.isComposing||i.isEditableTarget?!1:i.targetInsideRoot?!0:i.keyboardShortcutArmed;function d0(){return{status:"idle"}}function xb(i,e){return{status:"armed",armedAt:e,reason:i}}function yb(){return d0()}function vb(i,e,t=1500){return i.status!=="armed"?{state:i,suppressed:!1,expired:!1}:e-i.armedAt>t?{state:d0(),suppressed:!1,expired:!0}:{state:d0(),suppressed:!0,expired:!1}}function bb(i){return{endpoint:`/api/mindmap/${encodeURIComponent(i.documentId)}/${encodeURIComponent(i.mindmapId)}`,init:{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({data:i.data,createOnly:!0})}}}function Vn(i){let e=i;return e.default&&typeof e.default=="object"?e.default:i}var{applyMindmapCommandsLocally:NF,isLocallyApplicableMindmapCommandSet:EF}=Vn(Dc),{createLeptosMindmapAdapter:SF,executeMindmapCommandApply:Gp,executeMindmapCommandApplyAndRefreshProjection:AF,requestMindmapAdapterProjection:kF}=Vn(Dp),{createViewPatchCommand:CF,createCompatPayloadPatch:_F,diffMindmapRuntimeDataToKernelCommands:LF}=Vn(Fp),{getMindmapActionMapping:wb,mapMindmapActionToCommand:IF}=Vn(Hp),{canonicalizeMindmapData:vi}=Vn(Pc),{deriveMindmapUiState:Vp,reduceMindmapChromeVisibility:Ec}=Vn($p),{resolveMindmapShortcutAction:zF,shouldInterceptMindmapShortcut:RF}=Vn(jp),{isMindmapDebugChromeEnabled:DF,mindmapDefaultUiSchema:Sc,mindmapToolbarFileActionOrder:OF,mindmapToolbarPrimaryActionOrder:Xp}=Vn(Up);function BF(i){return typeof i=="string"&&i.length>0?{"data-block-id":i,id:i}:{}}function PF(i){if(i.mnoteBlockType!=="mindmap")return{};let e={"data-mnote-block-type":"mindmap"};return typeof i.mindmapId=="string"&&(e["data-mnote-mindmap-id"]=i.mindmapId),typeof i.rootNodeId=="string"&&(e["data-mnote-root-node-id"]=i.rootNodeId),typeof i.projectionVersion=="number"&&(e["data-mnote-projection-version"]=String(i.projectionVersion)),e}function Rs(i){return String(i??"").replaceAll("&","&").replaceAll("<","<").replaceAll(">",">").replaceAll('"',""").replaceAll("'","'")}function Wp(i){if(typeof i!="string")return null;let e=i.trim();return e.length>0?e:null}function FF(i){let e=window.location.pathname.match(/\/documents\/([^/?#]+)/);if(e?.[1])return decodeURIComponent(e[1]);let t=window.location.pathname.match(/\/mindmap\/([^/?#]+)\/([^/?#]+)/);if(t?.[1])return decodeURIComponent(t[1]);let r=Wp(i?.closest("[data-document-id]")?.getAttribute("data-document-id"));if(r)return r;let n=Wp(document.body?.getAttribute("data-document-id"));if(n)return n;let s=Wp(document.querySelector(".document-shell[data-document-id], [data-pane-document-id], [data-document-id]")?.getAttribute("data-document-id")??document.querySelector("[data-pane-document-id]")?.getAttribute("data-pane-document-id"));return s||null}function Mb(i,e){let r=vi(i.root).data?.uid;return typeof r=="string"&&r.trim().length>0?r.trim():e}function Yp(i){let t=vi(i.root).data?.text;return typeof t=="string"&&t.trim().length>0?t.trim():"\u672A\u547D\u540D\u8282\u70B9"}function Ds(i){if(typeof i=="string"&&i.trim().length>0)return i.trim();if(!i||typeof i!="object")return null;let e=i;if(typeof e.uid=="string"&&e.uid.trim().length>0)return e.uid.trim();let t=e.data&&typeof e.data=="object"&&!Array.isArray(e.data)?e.data:{};if(typeof t.uid=="string"&&t.uid.trim().length>0)return t.uid.trim();let r=e.getData;if(typeof r=="function")try{let n=r.call(e,"uid");if(typeof n=="string"&&n.trim().length>0)return n.trim()}catch{return null}return null}function Ac(i,e){let t=window,r=t.__MNOTE_LEPTOS_MINDMAP_BRIDGES__??{};t.__MNOTE_LEPTOS_MINDMAP_BRIDGES__=r,e?r[i]=e:delete r[i]}function Lt(i){return typeof i=="object"&&i!==null&&!Array.isArray(i)}function Os(i){return JSON.parse(JSON.stringify(i))}function qF(i){if(!(i instanceof HTMLElement))return!1;let e=i.tagName;return e==="INPUT"||e==="TEXTAREA"||e==="SELECT"?!0:i.isContentEditable?!i.matches(".ProseMirror"):!1}function HF(i){if(!Lt(i))return null;let e=i.view;return Lt(e)&&Lt(e.state)?e:null}function UF(i){if(!(i!=="insertChild"&&i!=="insertSiblingAfter"))return{uid:`node_${Date.now().toString(36)}_${Math.random().toString(36).slice(2,8)}`,text:"\u65B0\u8282\u70B9"}}function $F(i,e){let t=e?.trim();if(!t)return!1;let r=vi(i),n=!1,s=a=>{if(n)return;let o=vi(a);if(o.data?.uid===t){n=!0;return}(Array.isArray(o.children)?o.children:[]).forEach(s)};return s(r),n}function Tb(i){let e=vi(i),t=Array.isArray(e.children)?[...e.children]:[];for(;t.length>0;){let r=vi(t.shift()),n=r.data?.uid;if(typeof n=="string"&&n.trim().length>0)return n.trim();Array.isArray(r.children)&&t.push(...r.children)}return null}function jF(i,e){let t=e?.trim();if(!t)return null;let r=null,n=s=>{if(r)return;let a=vi(s);if(a.data?.uid===t){r=a;return}(Array.isArray(a.children)?a.children:[]).forEach(n)};return n(i),r}function GF(i,e){let t=jF(i,e),r=t&&Lt(t.data)?t.data.text:null;return typeof r=="string"?r:""}function VF(i,e=10){let t=vi(i),r=[],n=(s,a)=>{if(r.length>=e)return;let o=vi(s),l=typeof o.data?.text=="string"&&o.data.text.trim().length>0?o.data.text.trim():"\u672A\u547D\u540D\u8282\u70B9";r.push(`${" ".repeat(Math.max(a-1,0))}${l}`),(Array.isArray(o.children)?o.children:[]).forEach(d=>n(d,a+1))};return n(t,1),r}function Nb(i,e){let t=i?.trim();return t?t.includes("$active")?e?t.replaceAll("$active",e):null:t:null}var Eb={undo:"\u64A4\u9500",redo:"\u91CD\u505A",editNode:"\u7F16\u8F91\u8282\u70B9",insertSiblingAfter:"\u540C\u7EA7\u8282\u70B9",insertChild:"\u5B50\u8282\u70B9",deleteNode:"\u5220\u9664",tag:"\u6807\u7B7E",hyperlink:"\u94FE\u63A5",note:"\u5907\u6CE8",image:"\u56FE\u7247",icon:"\u56FE\u6807",summary:"\u6982\u8981",associativeLine:"\u5173\u8054\u7EBF",formula:"\u516C\u5F0F",painter:"\u683C\u5F0F\u5237",import:"\u5BFC\u5165",export:"\u5BFC\u51FA",setTheme:"\u4E3B\u9898",setLayout:"\u7ED3\u6784",zoomIn:"\u653E\u5927",zoomOut:"\u7F29\u5C0F",fitView:"\u9002\u5E94",centerRoot:"\u56DE\u6839",fullscreenCanvas:"\u5168\u5C4F",fullscreenPage:"\u5168\u9875",exitFullscreen:"\u9000\u51FA",search:"\u641C\u7D22",showMenu:"\u663E\u793A\u83DC\u5355",expandCollapse:"\u5C55\u5F00/\u6536\u8D77",copyNodeText:"\u590D\u5236\u6587\u672C",readonly:"\u53EA\u8BFB"},WF={undo:"\u21B6",redo:"\u21B7",editNode:"T",insertSiblingAfter:"\u21B5",insertChild:"+",deleteNode:"\xD7",tag:"#",hyperlink:"\u2301",note:"N",image:"\u25A1",icon:"\u2606",summary:"{",associativeLine:"\u2307",formula:"fx",painter:"\u25D0",import:"\u21E7",export:"\u21E9",centerRoot:"\u25CE",zoomOut:"-",zoomIn:"+",search:"\u2315",fullscreenCanvas:"\u26F6",fullscreenPage:"\u25A3",exitFullscreen:"\u2921",showMenu:"\u2630",expandCollapse:"\u2922",copyNodeText:"C",readonly:"\u9501"},YF={undo:"\u21B6",redo:"\u21B7",type:"T",sibling:"\u21B5",trash:"\xD7",child:"+",tag:"#",link:"\u2301",note:"N",image:"\u25A1",smile:"\u2606",summary:"{",route:"\u2307",formula:"fx",paintbrush:"\u25D0",import:"\u21E7",export:"\u21E9",target:"\u25CE",fullscreen:"\u26F6","fullscreen-page":"\u25A3","exit-fullscreen":"\u2921",menu:"\u2630","zoom-out":"-","zoom-in":"+",search:"\u2315",lock:"\u9501"},XF=i=>{let e=[...Xp];if(i===null||!Number.isFinite(i))return{availableWidth:null,visibleActionIds:e,overflowActionIds:[]};let t=118,r=96,n=50,s=Math.max(0,i-t-r),a=Math.max(2,Math.min(e.length,Math.floor(s/n)));if(a>=e.length)return{availableWidth:i,visibleActionIds:e,overflowActionIds:[]};let o=Math.max(1,a-1);return{availableWidth:i,visibleActionIds:e.slice(0,o),overflowActionIds:e.slice(o)}};function KF(i){return({node:e,getPos:t})=>{let r=e,n=0,s=null,a=null,o=null,l=null,h=null,d=null,c=d0(),f=null,m=!1,g=Sc.sidebarPanels[0]?.id??"nodeStyle",x=!0,y=!0,b=!1,S=300,A=null,C=null,I=!1,O=!1,q=null,P=!1,W=null,ne=null,re=null,oe=null,ke=null,Q=null,Y="visible",le=!1,ce=!1,at="canvas",It=null,bt="canvas",qi=0,zt=0,di=null,nn=null,Hi=null,co=0,Mr=0,tr=!1,sn=null,bi=null,Ui=!1,wi=DF(window.location.href),Je=document.createElement("div");Je.className="mnote-mindmap-placeholder",Je.dataset.mnoteBlockType="mindmap",Je.dataset.testid="mnote-mindmap-placeholder",Je.setAttribute("contenteditable","false");let ve=document.createElement("div");ve.className="mnote-mindmap-editor-root",ve.dataset.testid="mnote-mindmap-editor-root",ve.dataset.mnoteSceneQuery="mindmap.simple_mind_map_scene.get";let an=()=>FF(ve),R=document.createElement("div");R.dataset.testid="leptos-mindmap-island",R.dataset.runtime="simple-mind-map",R.dataset.stage="loading",R.dataset.debugChrome=wi?"true":"false",R.dataset.suppressRuntimeDiffState="idle",R.className="mnote-leptos-mindmap-shell";let Wn=()=>{R.dataset.suppressRuntimeDiffState=c.status,R.dataset.suppressRuntimeDiffReason=c.status==="armed"?c.reason:"",R.dataset.suppressRuntimeDiffSince=c.status==="armed"?String(c.armedAt):""},Bs=M=>{c=xb(M,Date.now()),Wn()},$i=()=>{c=yb(),Wn()},Yn=document.createElement("div");Yn.className="mnote-mindmap-workspace",Yn.dataset.debugChrome=wi?"true":"false",Yn.dataset.layout=wi?"debug-grid":"floating-overlay";let c0=document.createElement("div");c0.className="mnote-mindmap-canvas-layer",c0.dataset.testid="mindmap-canvas-layer";let Xn=document.createElement("div");Xn.className="mnote-mindmap-overlay-layer",Xn.dataset.testid="mindmap-overlay-layer";let mt=document.createElement("div");mt.className="mnote-leptos-mindmap-runtime",mt.dataset.testid="simple-mind-map-runtime",mt.dataset.runtime="simple-mind-map",mt.dataset.runtimeEngine="pending";let ir=document.createElement("div");ir.className="mnote-mindmap-rust-shell-mount",ir.dataset.testid="mindmap-rust-shell-mount",ir.dataset.uiShellSource="leptos-rust-shell";let Kn=document.createElement("div");Kn.className="mnote-mindmap-command-toolbar",Kn.dataset.testid="mindmap-command-toolbar";let Zn=document.createElement("aside");Zn.className="mnote-mindmap-side-panel",Zn.dataset.testid="mindmap-sidebar";let Qn=document.createElement("div");Qn.className="mnote-mindmap-bottom-bar",Qn.dataset.testid="mindmap-bottom-bar";let et=()=>{let M=typeof r.attrs.mindmapId=="string"&&r.attrs.mindmapId.length>0?r.attrs.mindmapId:"mindmap",L=typeof r.attrs.rootNodeId=="string"&&r.attrs.rootNodeId.length>0?r.attrs.rootNodeId:"root";return{mindmapId:M,rootNodeId:L}},Kp=M=>JSON.stringify({mnoteBlockType:M.attrs.mnoteBlockType??null,mindmapId:M.attrs.mindmapId??null,rootNodeId:M.attrs.rootNodeId??null,projectionVersion:M.attrs.projectionVersion??null}),u0=()=>{let{mindmapId:M,rootNodeId:L}=et();Je.dataset.mnoteMindmapId=M,Je.dataset.mnoteRootNodeId=L,Je.dataset.mnoteProjectionVersion=String(r.attrs.projectionVersion??1),ve.dataset.mnoteMindmapId=M,ve.dataset.mnoteRootNodeId=L,ve.dataset.mnoteProjectionVersion=String(r.attrs.projectionVersion??1),ve.dataset.mnoteSceneQuery="mindmap.simple_mind_map_scene.get"},f0=()=>{if(!(nn===null||!Hi)){try{Hi.unmount(nn)}catch{}nn=null,Hi=null,ir.replaceChildren()}},Zp=()=>{let M=s?.getSnapshot(),L=Lt(M)&&"root"in M?M.root:M??a?.root,D=0,F=0,K=te=>{if(!Lt(te))return;D+=1;let we=Lt(te.data)&&typeof te.data.text=="string"?te.data.text:"";F+=we.trim().length,(Array.isArray(te.children)?te.children:[]).forEach(K)};K(L);let ee=a?.view,ue=Lt(M)&&Lt(M.view)&&Lt(M.view.state)&&typeof M.view.state.scale=="number"?M.view.state.scale:Lt(ee)&&Lt(ee.state)&&typeof ee.state.scale=="number"?ee.state.scale:1;return{nodeCount:D,wordCount:F,zoomPercent:Math.round(ue*100)}},Sb=()=>a?{...vi(a.root),layout:a.layout,theme:a.theme,themeConfig:a.themeConfig,view:a.view,config:a.config,compatPayload:a.compatPayload}:null,Ab=(M,L)=>{a&&(a.root=Os(vi(M)),"layout"in M&&(a.layout=M.layout),"theme"in M&&(a.theme=M.theme),"themeConfig"in M&&(a.themeConfig=M.themeConfig),"view"in M&&(a.view=M.view),"config"in M&&(a.config=M.config),"compatPayload"in M&&(a.compatPayload=M.compatPayload),typeof L=="number"&&Number.isFinite(L)&&(a.kernelRevision=L),o=Os(a.root))},kb=()=>{let M=s?.getSnapshot(),L=Lt(M)&&"root"in M?M.root:M??a?.root;return VF(L)},m0=()=>{P&&(P=!1,Le())},Qp=(M,L)=>{let D=ve.getBoundingClientRect();qi=Math.round(M-D.left),zt=Math.round(L-D.top)},p0=()=>{!ce&&!R.dataset.contextMenuKind||(ce=!1,at="canvas",It=null,bt="canvas",delete R.dataset.contextMenuKind,delete R.dataset.contextMenuTargetNodeId,delete R.dataset.contextMenuTargetNodeKind,b0())},g0=M=>Ds(M),Cb=()=>{let M=s?.instance.renderer;return M?.activeNodeList?.[0]??M?.lastActiveNodeList?.[0]??M?.root??M?.renderTree?._node??null},x0=(M,L,D)=>{let F=Ds(L);return F&&$F(M.root,F)?F:Tb(M.root)??Mb(M,D)},kc=M=>{let L=Ds(M);return L||(g0(h)??g0(Cb())??null)},_b=M=>{if(!M||typeof M!="object")return"node";let L=M;return L.isRoot===!0?"root":L.isGeneralization===!0?"generalization":"node"},Lb=M=>{let L=s?.instance.renderer;if(L)try{L.clearActiveNodeList?.(),L.addNodeToActiveList?.(M,!0),L.emitNodeActiveEvent?.(M,[M])}catch{}},uo=M=>{let L=Ds(M),D=s?.instance.renderer,F=L?D?.findNodeByUid?.(L)??null:D?.activeNodeList?.[0]??D?.lastActiveNodeList?.[0]??D?.root??D?.renderTree?._node;if(F)try{D?.clearActiveNodeList?.(),D?.addNodeToActiveList?.(F,!0),D&&(D.lastActiveNodeList=[F]),D?.emitNodeActiveEvent?.(F,[F]),s?.instance.execCommand?.("SET_NODE_ACTIVE",F,!0),h=F;let ee=L??g0(F)??l??et().rootNodeId;return l=ee,d=ee,R.dataset.lastRuntimeSelectionSync=ee,!0}catch{}let K=s?.instance.execCommand;if(typeof K!="function"||!L)return!1;try{return K.call(s?.instance,"GO_TARGET_NODE",L),l=L,d=L,R.dataset.lastRuntimeSelectionSync=L,!0}catch{return!1}},Jp=M=>{let L=Ds(M),D=s?.instance.renderer;return{wantedNodeId:L,runtimeNode:L?D?.findNodeByUid?.(L)??null:D?.activeNodeList?.[0]??D?.lastActiveNodeList?.[0]??D?.root??D?.renderTree?._node}},Ib=async(M,L=3)=>{let D=Jp(M);for(let F=1;!D.runtimeNode&&Fwindow.requestAnimationFrame(()=>K())),D=Jp(M);return D},zb=async(M,L,D)=>{let F=M==="DELETE_NODE"?"REMOVE_NODE":M,K=s?.instance;if(!K||typeof K.execCommand!="function")return{ok:!1,command:F,error:"runtime_unavailable",message:"runtime_instance_missing"};let{wantedNodeId:ee,runtimeNode:ue}=await Ib(L);if(!ue)return{ok:!1,command:F,error:"command_failed",message:`runtime_node_missing:${ee??"unknown"}`};try{if(K.renderer?.clearActiveNodeList?.(),K.renderer?.addNodeToActiveList?.(ue,!0),K.renderer&&(K.renderer.lastActiveNodeList=[ue]),K.renderer?.emitNodeActiveEvent?.(ue,[ue]),K.execCommand("SET_NODE_ACTIVE",ue,!0),F==="REMOVE_NODE")return{ok:!0,command:F,result:K.execCommand("REMOVE_NODE",[ue])};if(F==="INSERT_CHILD_NODE"||F==="INSERT_NODE")return{ok:!0,command:F,result:K.execCommand(F,!1,[ue],D?{uid:D.uid,text:D.text}:null)}}catch{return{ok:!1,command:F,error:"command_failed",message:"runtime_command_throw"}}return s?.execCommand(F)??{ok:!1,command:F,error:"unsupported_command",message:"runtime_command_unsupported"}},Rb=(M,L,D)=>{Qp(M,L),at="node",It=g0(D),bt=_b(D),ce=!0,R.dataset.contextMenuKind="node",R.dataset.contextMenuTargetNodeId=It??"",R.dataset.contextMenuTargetNodeKind=bt,Lb(D),It&&(l=It,d=It),b0()},Db=(M,L)=>{Qp(M,L),at="canvas",It=null,bt="canvas",ce=!0,R.dataset.contextMenuKind="canvas",R.dataset.contextMenuTargetNodeId="",R.dataset.contextMenuTargetNodeKind="canvas",b0()},y0=M=>{Y!==M&&(Y=M,R.dataset.chromeVisibility=M,Le())},e3=()=>{le=!0,R.dataset.keyboardShortcutArmed="true"},Ob=()=>{le=!1,R.dataset.keyboardShortcutArmed="false"},v0=()=>document.fullscreenElement===ve||ve.contains(document.fullscreenElement),Bb=()=>{let M=s?.instance;try{typeof M?.resize=="function"?M.resize():typeof M?.view?.resize=="function"&&M.view.resize(),M?.renderer?.setRootNodeCenter?.()}catch{R.dataset.fullscreenResizeStatus="failed";return}R.dataset.fullscreenResizeStatus="success"},Cc=()=>{let M=v0();R.dataset.fullscreenActive=M?"true":"false",ve.dataset.fullscreenActive=M?"true":"false",window.setTimeout(()=>{Bb(),Le()},80)},_c=()=>{s?.instance.setMode?.(m?"readonly":"edit"),R.dataset.readonly=m?"true":"false"},t3=M=>{R.dataset.searchStatus="ready",R.dataset.lastSearchQuery=M,O=!0,Le()},Pb=M=>{let L=typeof M=="number"?M:Number(String(M??"").replace("%","").trim());if(!Number.isFinite(L)){R.dataset.zoomInputStatus="invalid",Le();return}let D=Math.max(10,Math.min(500,Math.round(L))),F=s?.instance.view;if(!F?.setScale){R.dataset.zoomInputStatus="unsupported",Le();return}F.setScale(D/100,ve.clientWidth/2,ve.clientHeight/2),R.dataset.zoomInputStatus="success",qs(s?.getSnapshot()),Le()},i3=async M=>{if(M==="exitFullscreen"){document.fullscreenElement&&await document.exitFullscreen(),Cc();return}if(!(M!=="fullscreenCanvas"&&M!=="fullscreenPage")){if(typeof ve.requestFullscreen!="function"){R.dataset.commandStatus="fullscreen-unavailable",R.dataset.disabledReason="fullscreen-unavailable";return}await ve.requestFullscreen(),Cc()}},Lc=(M,L)=>{let D=Sc.toolbarActionMeta[M],F=D?.shortLabel??Eb[M]??M,K=D?.longLabel??Eb[M]??M,ee=D?.iconKey??M;return{id:M,label:F,longLabel:K,icon:YF[ee]??WF[M]??F.slice(0,1),iconKey:ee,priority:D?.priority??999,overflowGroup:D?.overflowGroup??"view",cluster:D?.cluster??"main",disabled:L.disabledActions[M]??!0}},Fb=()=>{let M=window.__MNOTE_MINDMAP_RUST_SHELL__;if(!M){f0(),R.dataset.uiShell="typescript-nodeview-dom",ir.dataset.uiShellSource="missing-rust-shell",ir.innerHTML='
Leptos/Rust mindmap shell \u672A\u52A0\u8F7D
';return}let L=XF(q??ve.getBoundingClientRect().width),D=Vp({activeNodeId:l,readonly:m,runtimeCapabilities:["runtimeCommand","kernelCommand","compatPatch","localView"],shell:{chromeVisibility:Y,toolbarOverflow:{availableWidth:L.availableWidth,visibleActionIds:L.visibleActionIds,overflowActionIds:L.overflowActionIds,moreOpen:P&&L.overflowActionIds.length>0},fullscreen:{mode:v0()?"canvas":"none",isFullscreen:v0(),target:"mindmap-root",apiAvailable:typeof ve.requestFullscreen=="function"},sidebar:{activePanelId:g,panelOpen:x,triggerVisible:y,drawerWidth:S,collapsedByToggle:b},navigator:{searchOpen:O,minimapOpen:I,readonly:m,zoomPercent:Zp().zoomPercent}}}),{mindmapId:F}=et(),K=Zp(),ee=new Set(D.shell.toolbarOverflow.visibleActionIds),ue=new Set(D.shell.toolbarOverflow.overflowActionIds),te={mindmapId:F,toolbarGroups:[{id:"main",label:"\u4E3B\u5DE5\u5177",actions:Xp.filter(we=>ee.size===0||ee.has(we)).map(we=>Lc(we,D))},{id:"overflow",label:"\u66F4\u591A",actions:Xp.filter(we=>ue.has(we)).map(we=>Lc(we,D))},{id:"file",label:"\u6587\u4EF6",actions:OF.map(we=>Lc(we,D))}],sidebarPanels:Sc.sidebarPanels.map(we=>({id:we.id,kind:we.kind,label:we.label,icon:we.icon,active:we.id===g,bodyTitle:we.label,bodyCaption:we.runtimeCapability,options:we.options.map(Ge=>({id:Ge.id,label:Ge.label,actionId:Ge.actionId,value:Ge.value,controlType:Ge.controlType,preview:Ge.preview,description:Ge.description,readonly:Ge.readonly??!1,compatPath:Ge.compatPath})),bodyItems:we.id==="outline"?kb():[]})),navigator:{wordCount:K.wordCount,nodeCount:K.nodeCount,zoomPercent:K.zoomPercent,readonly:m,minimapOpen:I},shell:D.shell};f0(),ir.dataset.uiShellSource="leptos-rust-shell",Hi=M,nn=M.mount(ir,te),R.dataset.uiShell="leptos-rust-shell"},Le=()=>{if(u0(),!wi){Fb(),b0();return}f0(),R.dataset.uiShell="debug-fallback",Kn.dataset.testid="mindmap-command-toolbar",Kn.className="mnote-mindmap-command-toolbar",delete Kn.dataset.schemaSource,Zn.dataset.testid="mindmap-sidebar",Zn.className="mnote-mindmap-side-panel",delete Zn.dataset.schemaSource,Qn.dataset.testid="mindmap-bottom-bar",Qn.className="mnote-mindmap-bottom-bar",delete Qn.dataset.schemaSource;let M=a?Yp(a):"",L=f??M,F=s!==null?"":" disabled",K=["\u8282\u70B9\u6837\u5F0F","\u5BFC\u56FE\u6837\u5F0F","\u4E3B\u9898","\u7ED3\u6784","\u5927\u7EB2"].map((ee,ue)=>``).join("");Kn.innerHTML=`
`,Zn.innerHTML=`${K}
\u7ECF\u5178\u4E3B\u9898 \xB7 \u903B\u8F91\u7ED3\u6784
`,Qn.innerHTML=`runtime \u8282\u70B9100%`},b0=()=>{let M=Xn.querySelector('[data-testid="mindmap-schema-context-menu"]');if(!ce){M?.remove();return}M||(M=document.createElement("div"),M.className="mnote-mindmap-context-menu",M.dataset.testid="mindmap-schema-context-menu",M.dataset.uiSource="typescript-nodeview-bridge",Xn.append(M)),M.dataset.contextMenuKind=at,M.dataset.contextMenuTargetNodeKind=bt;let D=Vp({activeNodeId:at==="node"?It??l:l,readonly:m,runtimeCapabilities:["runtimeCommand","kernelCommand","compatPatch","localView"]}),F=Sc.contextMenuItems.filter(te=>te.requiresNode===(at==="node")),K=te=>D.disabledActions[te]?!0:at!=="node"?!1:bt==="root"&&(te==="insertSiblingAfter"||te==="deleteNode")||bt==="generalization"&&(te==="insertChild"||te==="insertSiblingAfter"||te==="deleteNode"||te==="summary"||te==="associativeLine"||te==="expandCollapse");M.innerHTML=F.map(te=>{let we=K(te.actionId);return``}).join("");let ee=Math.min(Math.max(8,qi),Math.max(8,ve.clientWidth-M.offsetWidth-8)),ue=Math.min(Math.max(8,zt),Math.max(8,ve.clientHeight-M.offsetHeight-8));M.style.left=`${ee}px`,M.style.top=`${ue}px`},r3=()=>{u0(),R.dataset.stage="loading",mt.dataset.runtimeEngine="loading",mt.replaceChildren(),Le()},n3=M=>M==="mnote-web-tree-live"||M==="externalTreeLive"||M==="nodeViewUpdate",qb=M=>M==="mnote-web-tree-live"||M==="externalTreeLive",Hb=()=>R.dataset.stage==="ready"&&mt.dataset.runtimeReady==="true"&&a!==null,Ub=()=>{if(s?.instance.renderer?.textEdit?.isShowTextEdit?.())return!0;let L=document.activeElement;if(L instanceof HTMLElement&&L.closest(".smm-node-edit-wrap"))return!0;let D=document.querySelector('.smm-node-edit-wrap[contenteditable="true"]');return D instanceof HTMLElement&&D.style.display!=="none"&&D.getClientRects().length>0},s3=M=>{let L=s?.instance.renderer?.textEdit;if(!L?.isShowTextEdit?.())return!1;let D=Ds(L.getCurrentEditNode?.())??l??d,F=L.getEditText?.();if(R.dataset.lastRuntimeTextEditFlushReason=M,D&&typeof F=="string"&&F.length>0){R.dataset.lastRuntimeTextEditFlushNodeId=D,R.dataset.lastRuntimeTextEditFlushText=F;let{mindmapId:K}=et(),ee=an();ee&&a&&Gp({documentId:ee,mindmapId:K,commands:[{type:"updateText",mindmapId:K,nodeId:D,text:F}],projectionRevision:a.kernelRevision}).catch(te=>{R.dataset.lastRuntimeTextEditFlushError=te instanceof Error?te.message:String(te)})}return L.hideEditTextBox?.(),!0},a3=(M,L="text_edit")=>{R.dataset.runtimeProjectionDeferred=L,R.dataset.lastRuntimeProjectionDeferReason=M,R.dataset.runtimeProjectionDeferredAt=String(Date.now())},o3=()=>{R.dataset.runtimeProjectionDeferred="",R.dataset.lastRuntimeProjectionDeferReason="",R.dataset.runtimeProjectionDeferredAt=""},Ps=(M,L="")=>{R.dataset.backgroundProjectionRefresh=M,R.dataset.backgroundProjectionRefreshMessage=L,Le()},on=(M,L)=>{let{mindmapId:D}=et();u0(),R.dataset.stage=M,mt.dataset.runtimeEngine="error",mt.innerHTML=`
${Rs(M)}${Rs(D)}${Rs(L)}
`,Le()},Fs=async(M,L)=>{if(R.dataset.commandCount=String(M.length),R.dataset.commandHasProjection=a?"true":"false",R.dataset.commandFailedAction="",R.dataset.commandFailedMessage="",M.length===0||!a){R.dataset.commandStatus="skipped";return}let{mindmapId:D}=et(),F=an();if(!F){on("command_failed",`mindmapId=${D}; documentId_missing`);return}R.dataset.commandStatus="pending";try{let K=await AF({documentId:F,mindmapId:D,commands:M,projectionRevision:a.kernelRevision});if(Ui)return;let ee=L?x0(K,L,et().rootNodeId):void 0;await d3(K,"commandRefresh",ee),f=null,R.dataset.commandStatus="success"}catch(K){let ee=K instanceof Error?K.message:String(K);R.dataset.commandStatus="failed",R.dataset.commandFailedAction=R.dataset.lastSchemaAction??"",R.dataset.commandFailedMessage=ee,on("command_failed",`mindmapId=${D}; ${ee}`)}},l3=async(M,L)=>{if(R.dataset.commandCount=String(M.length),R.dataset.commandHasProjection=a?"true":"false",R.dataset.commandFailedAction="",R.dataset.commandFailedMessage="",R.dataset.commandApplyMode="",R.dataset.commandLocalApplyErrors="",M.length===0||!a){R.dataset.commandStatus="skipped",R.dataset.commandApplyMode="skipped",$i();return}if(!EF(M)){R.dataset.commandApplyMode="refresh:unsupported",$i(),await Fs(M,L);return}let D=Sb();if(!D){R.dataset.commandApplyMode="refresh:no_blob",$i(),await Fs(M,L);return}let F=NF(D,M);if(F.errors.length>0){R.dataset.commandApplyMode="refresh:local_errors",R.dataset.commandLocalApplyErrors=F.errors.join("|"),$i(),await Fs(M,L);return}let{mindmapId:K}=et(),ee=an();if(!ee){on("command_failed",`mindmapId=${K}; documentId_missing`);return}R.dataset.commandStatus="pending",R.dataset.commandApplyMode="local";try{let ue=await Gp({documentId:ee,mindmapId:K,commands:M,projectionRevision:a.kernelRevision});if(Ui)return;if(Ab(F.data,ue.kernelRevision),L){let te=x0(a,L,et().rootNodeId);l=te,d=te,window.setTimeout(()=>{uo(te)},0)}f=null,R.dataset.commandStatus="success",Le()}catch(ue){let te=ue instanceof Error?ue.message:String(ue);R.dataset.commandStatus="failed",R.dataset.commandFailedAction=R.dataset.lastSchemaAction??"",R.dataset.commandFailedMessage=te,$i(),on("command_failed",`mindmapId=${K}; ${te}`)}},$b=async M=>{if(!a)return;let{mindmapId:L}=et(),D=an();if(!D){on("command_failed",`mindmapId=${L}; documentId_missing`);return}R.dataset.viewCommandStatus="pending";try{if(await Gp({documentId:D,mindmapId:L,commands:[CF(L,M)],projectionRevision:a.kernelRevision}),Ui)return;a.view=M,R.dataset.viewCommandStatus="success"}catch(F){on("command_failed",`mindmapId=${L}; ${F instanceof Error?F.message:String(F)}`)}},qs=M=>{let L=HF(M);L&&(A=L,R.dataset.lastViewPatch=JSON.stringify(L),C!==null&&window.clearTimeout(C),C=window.setTimeout(()=>{C=null;let D=A;A=null,D&&$b(D)},350))},jb=M=>{if(s){if(M==="editNode"){let L=s.instance.renderer,D=s.instance.keyCommand,F=L?.textEdit,K=h??L?.activeNodeList?.[0]??null;if(K&&typeof F?.show=="function")F.show({node:K,isFromKeyDown:!1}),R.dataset.editNodeStatus="opened";else if(typeof D?.getShortcutFn=="function"){let ee=D.getShortcutFn("F2")[0];typeof ee=="function"?(ee(),R.dataset.editNodeStatus="opened"):R.dataset.editNodeStatus="unsupported"}else R.dataset.editNodeStatus="unsupported";return}if(M==="centerRoot"&&s.instance.renderer?.setRootNodeCenter?.(),M==="zoomOut"&&s.instance.view?.narrow?.(),M==="zoomIn"&&s.instance.view?.enlarge?.(),M==="fitView"&&s.instance.view?.reset?.(),M==="fullscreenCanvas"||M==="fullscreenPage"||M==="exitFullscreen"){i3(M);return}if(M==="search"){O?(O=!1,R.dataset.lastSearchQuery="",R.dataset.searchStatus="closed",Le()):t3("");return}if(M==="showMenu"){y0("visible"),p0();return}if(M==="expandCollapse"){s.instance.renderer?.toggleActiveExpand?.(),R.dataset.contextMenuActionStatus="success",R.dataset.lastContextMenuAction=M,Le();return}if(M==="copyNodeText"){let L=It??l,D=a?GF(a.root,L):"";R.dataset.copiedNodeText=D,R.dataset.contextMenuActionStatus="success",R.dataset.lastContextMenuAction=M;return}M==="readonly"&&(m=!m,_c()),qs(s.getSnapshot()),Le()}},Gb=(M,L,D)=>{if(!s)return;let F=Nb(M,D);if(!F)return;let K=Lt(s.instance.getThemeConfig?.())?s.instance.getThemeConfig?.():{};if(D&&F.startsWith(`nodes.${D}.data.`)&&h){let ee=F.split(".data.")[1]??"";if(ee==="shape"){h.setShape?.(L);return}if(ee){let ue=ee==="fontWeight"&&L===!0?"bold":ee==="fontStyle"&&L===!0?"italic":L;h.setData?.({[ee]:ue});return}}if(F.startsWith("style.map.")){let ee=F.slice(10);if(!ee)return;s.instance.setThemeConfig?.({...K,[ee]:L},!1)}},Vb=(M,L,D)=>{if(!(!s||L.source!=="sidebar")){if(M==="setLayout"){s.instance.setLayout?.(L.value);return}if(M==="setTheme"){s.instance.setTheme?.(L.value);return}M==="painter"&&typeof L.compatPath=="string"&&Gb(L.compatPath,L.value,D)}},w0=async(M,L={})=>{let D=wb(M);if(!D||Vp({activeNodeId:at==="node"?It??l:l,readonly:m,runtimeCapabilities:["runtimeCommand","kernelCommand","compatPatch","localView"]}).disabledActions[M])return;if(R.dataset.lastSchemaAction=M,D.target==="localView"){jb(M);return}let K=UF(M),ee=at==="node"?It??d??l:d??l;if(D.requiresActiveNode){let Ge=kc(ee);Ge&&(ee=Ge,l=Ge,d=Ge)}R.dataset.lastSchemaActiveNodeId=ee??"";let ue=et().mindmapId;Vb(M,L,ee);let te=IF({actionId:M,mindmapId:ue,activeNodeId:ee,node:K,value:L.value??(M==="editNode"?Yp(a):!0),source:L.source,runtimeRevision:a?.kernelRevision??null,kernelRevision:a?.kernelRevision??null}),we=Nb(L.compatPath,ee);if(we&&(te={command:_F({mindmapId:ue,path:we,value:L.value??!0,source:L.source??"sidebar",actionId:M,runtimeRevision:a?.kernelRevision??null,kernelRevision:a?.kernelRevision??null})}),R.dataset.lastSchemaCommand=te?.command?JSON.stringify(te.command):"",R.dataset.lastSchemaRuntimeCommand=te?.runtimeCommand??"",!!te){if(D.target==="runtimeCommand"&&te.runtimeCommand){if(te.command){Bs(te.runtimeCommand),D.requiresActiveNode&&uo(ee);let Ge=await zb(te.runtimeCommand,ee,K),u3=K?.uid??(M==="deleteNode"?et().rootNodeId:ee);if(Ge?.ok){K?.uid&&(te.runtimeCommand==="INSERT_CHILD_NODE"||te.runtimeCommand==="INSERT_NODE")&&window.setTimeout(()=>{uo(K.uid)},0),l3([te.command],u3);return}R.dataset.commandApplyMode="refresh:runtime_failed",R.dataset.commandRuntimeError=Ge?.error??"unknown",R.dataset.commandRuntimeMessage=Ge&&"message"in Ge?Ge.message??"":"",$i(),Fs([te.command],u3);return}s?.execCommand(te.runtimeCommand);return}te.command&&Fs([te.command])}},Wb=M=>{let L=M.target instanceof Node&&ve.contains(M.target);return RF({debugChromeEnabled:wi,bridgeReady:!!s,readonly:m,isComposing:M.isComposing,isEditableTarget:qF(M.target),targetInsideRoot:L,keyboardShortcutArmed:le})},Yb=M=>{if(!Wb(M))return;let L=zF(M);if(!L)return;M.preventDefault(),M.stopPropagation(),M.stopImmediatePropagation(),R.dataset.lastShortcutKey=M.key,R.dataset.lastShortcutAction=L;let D=a?Mb(a,et().rootNodeId):et().rootNodeId;if(wb(L)?.requiresActiveNode){let K=kc(d??l);if(K===D&&(L==="insertSiblingAfter"||L==="deleteNode")){let ee=kc(null);ee&&ee!==D?K=ee:a&&(K=Tb(a.root)??K)}if(K)l=K,d=K;else{R.dataset.lastShortcutActionBlocked=L;return}}if((d??l)===D&&(L==="insertSiblingAfter"||L==="deleteNode")){R.dataset.lastShortcutActionBlocked=L;return}w0(L,{source:"toolbar"})},Xb=M=>{if(M.type==="node_active"){let K=l;l=Ds(M.args[0])??l,h=typeof M.args[0]=="object"&&M.args[0]!==null?M.args[0]:null,(!d||d===K)&&(d=l),Le();return}if(M.type==="view_data_change"||M.type==="scale"||M.type==="translate"){R.dataset.lastViewEvent=M.type,qs(M.snapshot??s?.getSnapshot());return}if(M.type!=="data_change"||!a||!M.snapshot)return;let L=Os(M.snapshot);R.dataset.lastDataChangeSeenAt=String(Date.now());let D=vb(c,Date.now());if(c=D.state,Wn(),R.dataset.lastDataChangeSuppressed=D.suppressed?"true":"false",R.dataset.lastDataChangeSuppressionExpired=D.expired?"true":"false",D.suppressed){o=L;return}let F=LF({mindmapId:et().mindmapId,previous:o??a.root,next:L});if(o=L,R.dataset.lastDataChangeDiffCommandCount=String(F.commands.length),R.dataset.lastDataChangeCompatPatchCount=String(F.compatPatches.length),F.commands.length===0&&F.compatPatches.length===0){R.dataset.lastDataChangeRefreshTriggered="false";return}R.dataset.lastDataChangeRefreshTriggered="false",l3([...F.commands,...F.compatPatches])},h3=M=>typeof M=="string"&&M.trim().length>0?M.trim():Lt(M)&&typeof M.template=="string"&&M.template.trim().length>0?M.template.trim():null,Kb=(M,L,D)=>{if(!s)return!1;let{mindmapId:F,rootNodeId:K}=et();if(R.dataset.mountedMindmapId&&R.dataset.mountedMindmapId!==F)return!1;let ee=vi(M.root);if(typeof s.instance.setData!="function")return!1;a={...M,root:Os(ee)},l=x0(M,D??l,K),h=null,d=l,o=Os(a.root);let ue=h3(M.layout),te=h3(M.theme),we=Lt(M.themeConfig)?M.themeConfig:null;return ue&&s.instance.setLayout?.(ue,!0),te&&s.instance.setTheme?.(te,!0),we&&s.instance.setThemeConfig?.(we,!0),s.instance.setData(ee),Mr+=1,R.dataset.runtimeProjectionApplyCount=String(Mr),R.dataset.lastRuntimeProjectionApplyReason=L,mt.dataset.runtimeEngine="simple-mind-map",mt.dataset.runtimeReady="true",R.dataset.stage="ready",Ac(F,s),_c(),window.setTimeout(()=>{uo(l)},0),Le(),!0},d3=async(M,L,D)=>{let{mindmapId:F,rootNodeId:K}=et();if(di&&(s?.instance.off?.("node_contextmenu",di),di=null),s?.destroy(),Ac(F,null),a={...M,root:Os(M.root)},l=x0(M,D,K),h=null,d=l,o=Os(a.root),co+=1,R.dataset.runtimeMountCount=String(co),R.dataset.lastRuntimeMountReason=L,R.dataset.mountedMindmapId=F,R.dataset.mountedRootNodeId=K,mt.dataset.runtimeEngine="simple-mind-map",mt.dataset.runtimeReady="false",mt.replaceChildren(),Le(),s=await SF({el:mt,projection:M,mode:m?"readonly":"edit",runtimeOptions:{fit:!0,mousewheelAction:"zoom",enableFreeDrag:!0,enableCtrlKeyNodeSelection:!0,useLeftKeySelectionRightKeyDrag:!0,createNewNodeBehavior:"activeOnly"},onEvent:Xb}),Ui){s.destroy();return}s.refreshProjection=c3,di=(...ee)=>{let[ue,te]=ee;ue instanceof MouseEvent&&(ue.preventDefault(),ue.stopPropagation(),Rb(ue.clientX,ue.clientY,te))},s.instance.on?.("node_contextmenu",di),mt.dataset.runtimeReady="true",R.dataset.stage="ready",Ac(F,s),_c(),window.setTimeout(()=>{uo(l)},0),Le()},M0=async(M="fetchScene",L=0)=>{let D=++n,{mindmapId:F,rootNodeId:K}=et();bi!==null&&(window.clearTimeout(bi),bi=null);let ee=an();if(!ee){if(R.dataset.documentIdResolveStatus="missing",R.dataset.documentIdResolveRetryCount=String(L),L<30){n3(M)||r3(),bi=window.setTimeout(()=>{bi=null,Ui||M0(M,L+1)},100);return}on("projection_load_failed",`mindmapId=${F}; documentId_missing`);return}R.dataset.documentIdResolveStatus="ready",R.dataset.lastFetchSceneReason=M;let ue=n3(M)&&Hb();if(ue&&qb(M)){a3(M,"live_signal"),Ps("deferred","usability_first");return}ue?Ps("pending"):r3();try{if(D!==n)return;let te=await kF({documentId:ee,mindmapId:F,endpoint:`/api/mindmap/${encodeURIComponent(ee)}/${encodeURIComponent(F)}?view=simple_mind_map_scene&queryName=mindmap.simple_mind_map_scene.get&rootNodeId=${encodeURIComponent(K)}`});if(D!==n||Ui)return;try{let we=bb({documentId:ee,mindmapId:F,data:vi(te.root)}),Ge=await fetch(we.endpoint,we.init);R.dataset.initialCreateOnlyStatus=Ge.ok?"success":"failed"}catch{R.dataset.initialCreateOnlyStatus="failed"}if(ue&&Ub()){a3(M),Ps("deferred","text_edit");return}if(ue&&Kb(te,M)){o3(),Ps("success");return}await d3(te,M),o3(),Ps("success")}catch(te){if(ue){Ps("failed",te instanceof Error?te.message:String(te));return}on("projection_load_failed",`mindmapId=${F}; ${te instanceof Error?te.message:String(te)}`)}},c3=async(M="externalTreeLive")=>{if(!Ui){if(tr){sn=M;return}tr=!0;try{await M0(M)}finally{tr=!1;let L=sn;sn=null,L&&!Ui&&c3(L)}}};return Je.addEventListener("pointerdown",M=>M.stopPropagation()),Je.addEventListener("mousedown",M=>M.stopPropagation()),ve.addEventListener("pointerleave",()=>{m0(),!ce&&Y==="visible"&&y0(Ec(Y,"pointerLeave"))}),ve.addEventListener("pointerenter",()=>{Y=Ec(Y,"pointerEnter"),R.dataset.chromeVisibility=Y}),ve.addEventListener("pointerdown",()=>{e3(),Y!=="visible"&&y0(Ec(Y,"restoreClick"))},!0),ve.addEventListener("click",()=>{e3(),Y!=="visible"&&y0(Ec(Y,"restoreClick"))}),Je.addEventListener("mnote:mindmap-shell:action",M=>{let L=M.detail,D=L?.actionId;if(D==="search"){if(typeof L?.value=="string"){t3(L.value);return}if(L?.value===!1){O=!1,R.dataset.lastSearchQuery="",R.dataset.searchStatus="closed",Le();return}}D&&(m0(),w0(D,L))}),Je.addEventListener("mnote:mindmap-shell:zoom",M=>{let L=M.detail;Pb(L?.percent)}),Je.addEventListener("mnote:mindmap-shell:minimap",M=>{let L=M.detail;I=typeof L?.open=="boolean"?L.open:!I,R.dataset.minimapOpen=I?"true":"false",Le()}),Je.addEventListener("mnote:mindmap-shell:toolbar-overflow",M=>{let L=M.detail;P=typeof L?.moreOpen=="boolean"?L.moreOpen:!P,Le()}),Je.addEventListener("mnote:mindmap-shell:panel",M=>{let L=M.detail,D=L?.event??"togglePanel";if(D==="restoreTrigger"){y=!0,x=!0,b=!1,Le();return}if(D==="hideTrigger"){y=!1,x=!1,b=!0,Le();return}if(D==="closeDrawer"){x=!1,b=!1,Le();return}L?.panelId&&(L.panelId===g?x=!x:(g=L.panelId,x=!0),y=!0,b=!1,Le())}),ke=()=>Cc(),document.addEventListener("fullscreenchange",ke),Q=()=>{s3("page_lifecycle")},window.addEventListener("pagehide",Q),window.addEventListener("beforeunload",Q),Je.addEventListener("input",M=>{if(!wi)return;let L=M.target;L instanceof HTMLInputElement&&L.dataset.testid==="mindmap-command-text-input"&&(f=L.value)}),typeof ResizeObserver<"u"&&(W=new ResizeObserver(M=>{let L=M[0]?.contentRect.width??null;L===null||Math.abs(L-(q??0))<1||(q=L,wi||Le())}),W.observe(ve)),ne=M=>{let L=M.target;if((!(L instanceof Node)||!ve.contains(L))&&Ob(),ce){if(L instanceof Node){let D=Xn.querySelector('[data-testid="mindmap-schema-context-menu"]');if(D instanceof HTMLElement&&D.contains(L))return}p0()}P&&(L instanceof Node&&ir.contains(L)||m0())},re=M=>{if(M.key==="Escape"){if(ce){M.preventDefault(),p0();return}if(v0()){M.preventDefault(),i3("exitFullscreen");return}P&&(M.preventDefault(),m0())}},oe=M=>Yb(M),document.addEventListener("pointerdown",ne),document.addEventListener("keydown",re),document.addEventListener("keydown",oe,!0),Je.addEventListener("click",M=>{M.stopPropagation();let L=M.target;if(!(L instanceof HTMLElement))return;let D=L.closest("button");if(D instanceof HTMLButtonElement){if(!wi){M.preventDefault();let F=D.dataset.mindmapContextActionId;if(F){if(D.disabled||D.dataset.disabled==="true")return;p0(),w0(F);return}if(D.closest('[data-testid="mindmap-rust-shell"]'))return;let K=D.dataset.mindmapActionId;K&&w0(K);let ee=D.dataset.mindmapSidebarPanelId;ee&&(g=ee,Le());return}if(D.dataset.testid==="mindmap-command-update-text"){M.preventDefault();let F=ve.querySelector('[data-testid="mindmap-command-text-input"]'),K=f??(F instanceof HTMLInputElement?F.value:Yp(a));R.dataset.lastCommandText=K,Fs([{type:"updateText",mindmapId:et().mindmapId,nodeId:l??et().rootNodeId,text:K}])}D.dataset.testid==="mindmap-command-add-child"&&(M.preventDefault(),s?.execCommand("INSERT_CHILD_NODE")),D.dataset.testid==="mindmap-command-add-sibling-after"&&(M.preventDefault(),s?.execCommand("INSERT_NODE")),D.dataset.testid==="mindmap-command-delete-node"&&(M.preventDefault(),s?.execCommand("REMOVE_NODE")),D.dataset.testid==="mindmap-toolbar-undo"&&(M.preventDefault(),s?.execCommand("BACK")),D.dataset.testid==="mindmap-toolbar-redo"&&(M.preventDefault(),s?.execCommand("FORWARD")),D.dataset.testid==="mindmap-toolbar-summary"&&(M.preventDefault(),s?.execCommand("ADD_GENERALIZATION")),D.dataset.testid==="mindmap-bottom-center-root"&&(M.preventDefault(),s?.instance.renderer?.setRootNodeCenter?.(),qs(s?.getSnapshot())),D.dataset.testid==="mindmap-bottom-zoom-out"&&(M.preventDefault(),s?.instance.view?.narrow?.(),qs(s?.getSnapshot())),D.dataset.testid==="mindmap-bottom-zoom-in"&&(M.preventDefault(),s?.instance.view?.enlarge?.(),qs(s?.getSnapshot()))}}),Je.addEventListener("contextmenu",M=>{if(wi)return;let L=M.target;if(L instanceof Element&&(L.closest(".smm-node")||L.closest('[class*="generalization_"]'))){M.preventDefault();return}M.preventDefault(),M.stopPropagation(),Db(M.clientX,M.clientY)},!0),c0.append(mt),wi?(Yn.append(mt,Zn),R.append(Kn,Yn,Qn)):(Xn.append(ir),Yn.append(c0,Xn),R.append(Yn)),ve.append(R),Je.append(ve),M0("initial"),{dom:Je,update(M){if(M.type.name!==r.type.name)return!1;let L=Kp(r);return r=M,r.attrs.mnoteBlockType!=="mindmap"?!1:(u0(),Kp(r)!==L&&M0("nodeViewUpdate"),!0)},stopEvent:()=>!0,ignoreMutation:()=>!0,destroy(){s3("node_view_destroy"),Ui=!0,bi!==null&&window.clearTimeout(bi),C!==null&&window.clearTimeout(C),W?.disconnect(),ne&&document.removeEventListener("pointerdown",ne),re&&document.removeEventListener("keydown",re),oe&&document.removeEventListener("keydown",oe,!0),ke&&document.removeEventListener("fullscreenchange",ke),Q&&(window.removeEventListener("pagehide",Q),window.removeEventListener("beforeunload",Q)),di&&s?.instance.off?.("node_contextmenu",di);let{mindmapId:M}=et();Ac(M,null),s?.destroy(),f0(),s=null}}}}function ZF(){return({node:i})=>{let e=i,t=document.createElement("p"),r=()=>{let n=e.attrs.blockId;typeof n=="string"&&n.length>0?(t.dataset.blockId=n,t.id=n):(t.removeAttribute("data-block-id"),t.removeAttribute("id"))};return r(),{dom:t,contentDOM:t,update(n){return n.type.name!==e.type.name||n.attrs.mnoteBlockType==="mindmap"?!1:(e=n,r(),!0)}}}}var QF=p3.extend({addAttributes(){return{...this.parent?.(),blockId:{default:null,parseHTML:i=>i.getAttribute("data-block-id"),renderHTML:i=>BF(i.blockId)},mnoteBlockType:{default:null,parseHTML:i=>i.getAttribute("data-mnote-block-type"),renderHTML:i=>PF(i)},mindmapId:{default:null,parseHTML:i=>i.getAttribute("data-mnote-mindmap-id"),renderHTML:()=>({})},rootNodeId:{default:null,parseHTML:i=>i.getAttribute("data-mnote-root-node-id"),renderHTML:()=>({})},projectionVersion:{default:null,parseHTML:i=>Number(i.getAttribute("data-mnote-projection-version")??1),renderHTML:()=>({})}}},addNodeView(){let i=KF(this.editor),e=ZF();return({node:t,getPos:r})=>t.attrs.mnoteBlockType==="mindmap"?i({node:t,getPos:r}):e({node:t})}}),JF={name:"paragraph",create:()=>QF,commands:{set_paragraph:i=>i.chain().focus().setParagraph().run()},selection_keys:["paragraph"],selection_state:i=>({paragraph:i.isActive("paragraph")})};function Jne(){y3(JF)}export{Jne as register_paragraph}; +`)}addCss(){this.cssEl=document.createElement("style"),this.cssEl.type="text/css",this.cssEl.innerHTML=this.joinCss(),document.head.appendChild(this.cssEl)}removeCss(){this.cssEl&&document.head.removeChild(this.cssEl)}checkEditNodeClassIndex(e){return this.editNodeClassList.findIndex(t=>t===e)}addEditNodeClass(e){this.checkEditNodeClassIndex(e)===-1&&this.editNodeClassList.push(e)}deleteEditNodeClass(e){let t=this.checkEditNodeClassIndex(e);t!==-1&&this.editNodeClassList.splice(t,1)}render(e,t=""){this.initTheme(),this.renderer.render(e,t)}reRender(e,t=""){this.renderer.reRender=!0,this.renderer.clearCache(),this.clearDraw(),this.render(e,t)}getElRectInfo(){if(this.elRect=this.el.getBoundingClientRect(),this.width=this.elRect.width,this.height=this.elRect.height,this.width<=0||this.height<=0)throw new Error("\u5BB9\u5668\u5143\u7D20el\u7684\u5BBD\u9AD8\u4E0D\u80FD\u4E3A0")}resize(){let e=this.width,t=this.height;this.getElRectInfo(),this.svg.size(this.width,this.height),(e!==this.width||t!==this.height)&&(this.demonstrate?this.demonstrate.isInDemonstrate||this.render():this.render()),this.emit("resize")}on(e,t){this.event.on(e,t)}emit(e,...t){this.event.emit(e,...t)}off(e,t){this.event.off(e,t)}initCache(){this.commonCaches={measureCustomNodeContentSizeEl:null,measureRichtextNodeTextSizeEl:null}}initTheme(){this.themeConfig=wu(jn[this.opt.theme]||jn.default,this.opt.themeConfig),qo.setBackgroundStyle(this.el,this.themeConfig)}setTheme(e,t=!1){this.execCommand("CLEAR_ACTIVE_NODE"),this.opt.theme=e,t||this.render(null,k.CHANGE_THEME),this.emit("view_theme_change",e)}getTheme(){return this.opt.theme}setThemeConfig(e,t=!1){let r=N2(this.themeConfig,e);if(this.opt.themeConfig=e,!t){let n=u2(r);this.render(null,n?"":k.CHANGE_THEME)}}getCustomThemeConfig(){return this.opt.themeConfig}getThemeConfig(e){return e===void 0?this.themeConfig:this.themeConfig[e]}getConfig(e){return e===void 0?this.opt:this.opt[e]}updateConfig(e={}){this.emit("before_update_config",this.opt);let t={...this.opt};this.opt=this.handleOpt(Cp.default.all([kp,this.opt,e])),this.emit("after_update_config",this.opt,t)}getLayout(){return this.opt.layout}setLayout(e,t=!1){$c.includes(e)||(e=k.LAYOUT.LOGICAL_STRUCTURE),this.opt.layout=e,this.view.reset(),this.renderer.setLayout(),t||this.render(null,k.CHANGE_LAYOUT),this.emit("layout_change",e)}execCommand(...e){this.command.exec(...e)}updateData(e){e=this.handleData(e),this.emit("before_update_data",e),this.renderer.setData(e),this.render(),this.command.addHistory(),this.emit("update_data",e)}setData(e){e=this.handleData(e),this.emit("before_set_data",e),this.opt.data=e,this.execCommand("CLEAR_ACTIVE_NODE"),this.command.clearHistory(),this.command.addHistory(),this.renderer.setData(e),this.reRender(),this.emit("set_data",e)}setFullData(e){e.root&&this.setData(e.root),e.layout&&this.setLayout(e.layout),e.theme&&(e.theme.template&&this.setTheme(e.theme.template),e.theme.config&&this.setThemeConfig(e.theme.config)),e.view&&this.view.setTransformData(e.view)}getData(e){let t=this.command.getCopyData(),r={};return e?r={layout:this.getLayout(),root:t,theme:{template:this.getTheme(),config:this.getCustomThemeConfig()},view:this.view.getTransformData()}:r=t,Kt(r)}async export(...e){try{if(!this.doExport)throw new Error("\u8BF7\u6CE8\u518CExport\u63D2\u4EF6\uFF01");return await this.doExport.export(...e)}catch(t){this.opt.errorHandler(Mi.EXPORT_ERROR,t)}}toPos(e,t){return{x:e-this.elRect.left,y:t-this.elRect.top}}setMode(e){if(![k.MODE.READONLY,k.MODE.EDIT].includes(e))return;let t=e===k.MODE.READONLY;t!==this.opt.readonly&&(t&&(this.renderer.textEdit.isShowTextEdit()&&(this.renderer.textEdit.hideEditTextBox(),this.command.originAddHistory()),this.execCommand("CLEAR_ACTIVE_NODE")),this.opt.readonly=t,!t&&this.command.history.length<=0&&this.command.originAddHistory(),this.emit("mode_change",e))}getSvgData({paddingX:e=0,paddingY:t=0,ignoreWatermark:r=!1,addContentToHeader:n,addContentToFooter:s,node:a}={}){let{watermarkConfig:o,openPerformance:l}=this.opt;l&&this.renderer.forceLoadNode(a);let{cssTextList:h,header:d,headerHeight:c,footer:f,footerHeight:m}=R2({addContentToHeader:n,addContentToFooter:s}),g=this.svg,x=this.draw,y=g.width(),b=g.height(),S=x.transform(),A=this.elRect;x.scale(1/S.scaleX,1/S.scaleY);let C=x.rbox(),I=null;a&&(I=bu(a,C.x,C.y,e,t));let O=0;C.width+=e*2,C.height+=t*2+O+c+m,x.translate(e,t),g.size(C.width,C.height),x.translate(-C.x+A.left,-C.y+A.top);let q=g.clone(),P=this.watermark&&this.watermark.hasWatermark();if(!r&&P){this.watermark.isInExport=!0;let{onlyExport:re}=o;C.width>y||C.height>b?(this.width=C.width,this.height=C.height,this.watermark.onResize(),q=g.clone(),this.width=y,this.height=b,this.watermark.onResize()):re&&(this.watermark.onResize(),q=g.clone()),re&&this.watermark.clear(),this.watermark.isInExport=!1}[this.joinCss(),...h].forEach(re=>{q.add(Ae(``))}),d&&c>0&&(q.findOne(".smm-container").translate(0,c),d.width(C.width),d.y(t),q.add(d,0)),f&&m>0&&(f.width(C.width),f.y(C.height-t-m),q.add(f));let W=g.find("defs"),ne=q.find("defs");return W.forEach((re,oe)=>{let ke=ne[oe];if(!ke)return;let Q=re.children(),Y=ke.children();for(let le=0;ler.name===e.name)||this.extendShapeList.push(e)}removeShape(e){let t=this.extendShapeList.findIndex(r=>r.name===e);t!==-1&&this.extendShapeList.splice(t,1)}getSvgObjects(){return{SVG:Ae,G:_e,Rect:Ve}}addPlugin(e,t){i.hasPlugin(e)===-1&&i.usePlugin(e,t),this.initPlugin(e)}removePlugin(e){let t=i.hasPlugin(e);t!==-1&&(i.pluginList.splice(t,1),this[e.instanceName]&&(this[e.instanceName].beforePluginRemove&&this[e.instanceName].beforePluginRemove(),delete this[e.instanceName]))}initPlugin(e){this[e.instanceName]||(this[e.instanceName]=new e({mindMap:this,pluginOpt:e.pluginOpt}))}destroy(){this.emit("beforeDestroy"),this.renderer.textEdit.hideEditTextBox(),this.renderer.textEdit.removeTextEditEl(),[...i.pluginList].forEach(e=>{this[e.instanceName]&&this[e.instanceName].beforePluginDestroy&&this[e.instanceName].beforePluginDestroy(),this[e.instanceName]=null}),this.event.unbind(),this.svg.remove(),qo.removeBackgroundStyle(this.el),this.el.classList.remove("smm-mind-map-container"),this.el.innerHTML="",this.el=null,this.removeCss(),i.instanceCount--}},_p=[];_t.extendNodeDataNoStylePropList=(i=[])=>{_p.push(...i),$s.push(...i)};_t.resetNodeDataNoStylePropList=()=>{_p.forEach(i=>{let e=$s.findIndex(t=>t===i);e!==-1&&$s.splice(e,1)}),_p=[]};_t.pluginList=[];_t.usePlugin=(i,e={})=>(_t.hasPlugin(i)!==-1||(i.pluginOpt=e,_t.pluginList.push(i)),_t);_t.hasPlugin=i=>_t.pluginList.findIndex(e=>e===i);_t.instanceCount=0;_t.defineTheme=(i,e={})=>{if(jn[i])return new Error("\u8BE5\u4E3B\u9898\u540D\u79F0\u5DF2\u5B58\u5728");jn[i]=wu(B0,e)};_t.removeTheme=i=>{jn[i]&&(jn[i]=null)};HP=_t});var Ic=globalThis.__LEPTOS_TIPTAP_BRIDGE__;if(Ic==null||Ic.modules==null)throw new Error("leptos-tiptap bridge bindings are unavailable");var j=Ic.modules["@tiptap/core"];if(j==null)throw new Error('leptos-tiptap bridge module "@tiptap/core" is unavailable');var tq=j.CommandManager,iq=j.Editor,rq=j.Extension,nq=j.InputRule,sq=j.Mark,f3=j.Node,aq=j.NodePos,oq=j.NodeView,lq=j.PasteRule,hq=j.Tracker,dq=j.callOrReturn,cq=j.canInsertNode,uq=j.combineTransactionSteps,fq=j.createChainableState,mq=j.createDocument,pq=j.createNodeFromContent,gq=j.createStyleTag,xq=j.defaultBlockAt,yq=j.deleteProps,vq=j.elementFromString,bq=j.escapeForRegEx,wq=j.extensions,Mq=j.findChildren,Tq=j.findChildrenInRange,Nq=j.findDuplicates,Eq=j.findParentNode,Sq=j.findParentNodeClosestToPos,Aq=j.fromString,kq=j.generateHTML,Cq=j.generateJSON,_q=j.generateText,Lq=j.getAttributes,Iq=j.getAttributesFromExtensions,zq=j.getChangedRanges,Rq=j.getDebugJSON,Dq=j.getExtensionField,Oq=j.getHTMLFromFragment,Bq=j.getMarkAttributes,Pq=j.getMarkRange,Fq=j.getMarkType,qq=j.getMarksBetween,Hq=j.getNodeAtPosition,Uq=j.getNodeAttributes,$q=j.getNodeType,jq=j.getRenderedAttributes,Gq=j.getSchema,Vq=j.getSchemaByResolvedExtensions,Wq=j.getSchemaTypeByName,Yq=j.getSchemaTypeNameByName,Xq=j.getSplittedAttributes,Kq=j.getText,Zq=j.getTextBetween,Qq=j.getTextContentFromNodes,Jq=j.getTextSerializersFromSchema,eH=j.injectExtensionAttributesToParseRule,tH=j.inputRulesPlugin,iH=j.isActive,rH=j.isAtEndOfNode,nH=j.isAtStartOfNode,sH=j.isEmptyObject,aH=j.isExtensionRulesEnabled,oH=j.isFunction,lH=j.isList,hH=j.isMacOS,dH=j.isMarkActive,cH=j.isNodeActive,uH=j.isNodeEmpty,fH=j.isNodeSelection,mH=j.isNumber,pH=j.isPlainObject,gH=j.isRegExp,xH=j.isSafari,yH=j.isString,vH=j.isTextSelection,bH=j.isiOS,wH=j.markInputRule,MH=j.markPasteRule,m3=j.mergeAttributes,TH=j.mergeDeep,NH=j.minMax,EH=j.nodeInputRule,SH=j.nodePasteRule,AH=j.objectIncludes,kH=j.pasteRulesPlugin,CH=j.posToDOMRect,_H=j.removeDuplicates,LH=j.resolveFocusPosition,IH=j.rewriteUnknownContent,zH=j.selectionToInsertionEnd,RH=j.splitExtensions,DH=j.textInputRule,OH=j.textPasteRule,BH=j.textblockTypeInputRule,PH=j.wrappingInputRule;var p3=f3.create({name:"paragraph",priority:1e3,addOptions(){return{HTMLAttributes:{}}},group:"block",content:"inline*",parseHTML(){return[{tag:"p"}]},renderHTML({HTMLAttributes:i}){return["p",m3(this.options.HTMLAttributes,i),0]},addCommands(){return{setParagraph:()=>({commands:i})=>i.setNode(this.name)}},addKeyboardShortcuts(){return{"Mod-Alt-0":()=>this.editor.commands.setParagraph()}}});var g3="__LEPTOS_TIPTAP_BRIDGE__";function nw(){let i=globalThis,e=i[g3];if(e!=null)return e;let t={modules:{},registerExtension:()=>{throw new Error("leptos-tiptap bridge runtime is not initialized")}};return i[g3]=t,t}function x3(){return nw()}function y3(i){x3().registerExtension(i)}var Dc={};tt(Dc,{applyMetadataOnlyMindmapCommands:()=>M3,applyMindmapCommandsLocally:()=>hw,isLocallyApplicableMindmapCommandSet:()=>lw,isMetadataOnlyMindmapCommandSet:()=>ow});var sw={data:{text:"\u4E2D\u5FC3\u4E3B\u9898"},children:[]},gt=i=>typeof i=="object"&&i!==null&&!Array.isArray(i),nr=i=>JSON.parse(JSON.stringify(i)),b3=i=>{let e=gt(i)?nr(i):nr(sw);return gt(e.data)||(e.data={text:"\u4E2D\u5FC3\u4E3B\u9898"}),Array.isArray(e.children)||(e.children=[]),e},w3=(i,e)=>{if(!gt(i)||!gt(e))return nr(e);let t={...i};return Object.entries(e).forEach(([r,n])=>{if(n===null){delete t[r];return}t[r]=gt(t[r])&>(n)?w3(t[r],n):nr(n)}),t},zc=(i,e,t)=>{let r=e.split(".").filter(Boolean);if(r.length===0)return!1;let n=i;for(let s of r.slice(0,-1))gt(n[s])||(n[s]={}),n=n[s];return n[r[r.length-1]]=nr(t),!0},N0=(i,e)=>{if(!gt(i))return null;let t=gt(i.data)?i.data:null;if(typeof t?.uid=="string"&&t.uid===e)return i;let r=Array.isArray(i.children)?i.children:[];for(let n of r){let s=N0(n,e);if(s)return s}return null},Rc=(i,e)=>{for(let t=0;t({data:{uid:i.uid??`node_${Date.now().toString(36)}`,text:i.text,...typeof i.hyperlink=="string"?{hyperlink:i.hyperlink}:{},...typeof i.note=="string"?{note:i.note}:{},...Array.isArray(i.refs)?{refs:nr(i.refs)}:{}},children:[]}),aw=(i,e)=>{let t=String(e.path||"").trim();if(!t)return!1;if(t.startsWith("root.data.")){let n=t.slice(10),s=gt(i.data)?i.data:i.data={};return zc(s,n,e.value)}if(t.startsWith("nodes.")){let[,n,s,...a]=t.split(".");if(!n||s!=="data"||a.length===0)return!1;let o=N0(i,n);if(!o)return!1;let l=gt(o.data)?o.data:o.data={};return zc(l,a.join("."),e.value)}let r=gt(i.compatPayload)?i.compatPayload:i.compatPayload={};return zc(r,t,e.value)},ow=i=>Array.isArray(i)&&i.every(e=>!gt(e)||typeof e.type!="string"?!1:["setLayout","setTheme","patchView","compatPayloadPatch"].includes(e.type)),lw=i=>Array.isArray(i)&&i.every(e=>!gt(e)||typeof e.type!="string"?!1:["updateText","insertChild","insertSiblingAfter","deleteNode","setLayout","setTheme","patchView","compatPayloadPatch"].includes(e.type)),M3=(i,e)=>{let t=b3(i),r=[],n=0;return e.forEach(s=>{if(s.type==="setLayout"){t.layout=nr(s.layout),n+=1;return}if(s.type==="setTheme"){t.theme=nr(s.theme),s.themeConfig!==void 0&&s.themeConfig!==null&&(t.themeConfig=nr(s.themeConfig)),n+=1;return}if(s.type==="patchView"){t.view=w3(t.view??{},s.patch),n+=1;return}s.type==="compatPayloadPatch"&&(aw(t,s)?n+=1:r.push(`\u65E0\u6CD5\u5E94\u7528 compatPayloadPatch:${s.path}`))}),{applied:n,data:t,errors:r}},hw=(i,e)=>{let t=b3(i),r=[],n=0;return e.forEach(s=>{if(s.type==="setLayout"||s.type==="setTheme"||s.type==="patchView"||s.type==="compatPayloadPatch"){let a=M3(t,[s]);Object.assign(t,a.data),n+=a.applied,r.push(...a.errors);return}if(s.type==="updateText"){let a=N0(t,s.nodeId);if(!a){r.push(`\u672A\u627E\u5230\u8282\u70B9:${s.nodeId}`);return}let o=gt(a.data)?a.data:a.data={};o.text=s.text,n+=1;return}if(s.type==="insertChild"){let a=N0(t,s.parentNodeId);if(!a){r.push(`\u672A\u627E\u5230\u7236\u8282\u70B9:${s.parentNodeId}`);return}(Array.isArray(a.children)?a.children:a.children=[]).push(v3(s.node)),n+=1;return}if(s.type==="insertSiblingAfter"){let a=Rc([t],s.targetNodeId);if(!a){r.push(`\u672A\u627E\u5230\u540C\u7EA7\u8282\u70B9:${s.targetNodeId}`);return}if(a.parentChildren[a.index]===t){r.push("\u6839\u8282\u70B9\u4E0D\u652F\u6301\u63D2\u5165\u540C\u7EA7\u8282\u70B9");return}a.parentChildren.splice(a.index+1,0,v3(s.node)),n+=1;return}if(s.type==="deleteNode"){let a=Rc([t],s.nodeId);if(!a){r.push(`\u672A\u627E\u5230\u5220\u9664\u8282\u70B9:${s.nodeId}`);return}if(a.parentChildren[a.index]===t){r.push("\u6839\u8282\u70B9\u4E0D\u652F\u6301\u5220\u9664");return}a.parentChildren.splice(a.index,1),n+=1}}),{applied:n,data:t,errors:r}};var Dp={};tt(Dp,{MindmapCommandBridgeError:()=>lo,createLeptosMindmapAdapter:()=>rF,createMindmapAdapterProjectionEndpoint:()=>lb,createMindmapCommandApplyEndpoint:()=>zp,executeMindmapCommandApply:()=>hb,executeMindmapCommandApplyAndRefreshProjection:()=>tF,executeMindmapDataPutAndRefreshProjection:()=>iF,isMindmapAdapterProjection:()=>Ip,requestMindmapAdapterProjection:()=>Rp});var Pc={};tt(Pc,{DEFAULT_MINDMAP_LAYOUT:()=>E0,DEFAULT_MINDMAP_THEME:()=>S0,buildMindmapEditorScene:()=>cw,buildMindmapProjection:()=>dw,buildMindmapSimpleMindMapScene:()=>uw,canonicalizeMindmapData:()=>ci,createDefaultMindmapThemeConfig:()=>A0,defaultMindmapData:()=>Us,extractMindmapTitle:()=>Bc,normalizeMindmapData:()=>Oc,summarizeMindmapProjectionNodes:()=>T3});var Us={data:{text:"\u4E2D\u5FC3\u4E3B\u9898"},children:[]},E0="logicalStructure",S0="default",A0=()=>({lineColor:"#7aa2ff",lineStyle:"curve",rootLineKeepSameInCurve:!0,rootLineStartPositionKeepSameInCurve:!0,generalizationLineColor:"#ef6a5b",backgroundColor:"#f6f8fc",root:{fillColor:"#e25563",color:"#ffffff",fontWeight:"bold",borderColor:"transparent",borderWidth:0,borderRadius:8},second:{fillColor:"#4f7df3",color:"#ffffff",borderColor:"transparent",borderWidth:0,borderRadius:8},node:{fillColor:"transparent",color:"#315aa9",borderColor:"transparent",borderWidth:0},generalization:{fillColor:"#ffffff",color:"#ef6a5b",borderColor:"#ef6a5b",borderWidth:1,borderRadius:8}}),Hs=i=>typeof i=="object"&&i!==null&&!Array.isArray(i),Oc=i=>{if(!i||typeof i!="object")return Us;let e=i.root??i,t=r=>{if(!Hs(r))return;let n=Hs(r.data)?r.data:{};r.data=n;let s=n.text;n.text=typeof s=="string"?s:String(s??"");let a=n.generalization,o=l=>{if(!Hs(l))return;let h=l.text;l.text=typeof h=="string"?h:String(h??"")};Array.isArray(a)?a.forEach(o):o(a),Array.isArray(r.children)&&r.children.forEach(t)};return t(e),i},ci=i=>{let e=Oc(i)??Us,t=e&&typeof e=="object"&&"root"in e?e.root:e;return Oc(t)??Us},Bc=i=>{let t=ci(i)?.data?.text;return typeof t=="string"&&t.trim()?t.trim():"\u672A\u547D\u540D\u5BFC\u56FE"},T3=i=>{let t=[{node:ci(i),depth:0}],r=[];for(;t.length>0;){let n=t.shift();if(!n)break;let s=n.node?.data?.uid,a=n.node?.data?.text,o=Array.isArray(n.node?.children)?n.node.children:[];r.push({uid:typeof s=="string"&&s.trim()?s:`depth:${n.depth}:index:${r.length}`,text:typeof a=="string"&&a.trim()?a.trim():"\u672A\u547D\u540D\u8282\u70B9",depth:n.depth,childCount:o.length}),o.forEach(l=>{t.push({node:ci(l),depth:n.depth+1})})}return r},dw=i=>{let e=ci(i.data),t=T3(e),r=Hs(i.meta)?i.meta:null,n=t[0]?.uid??null;return{schema:"mnote.mindmap_projection.v1",projectionId:`mindmap_projection:${i.documentId}:${i.mindmapId}`,projection:"mindmap_subtree",source:i.source??"rust-kernel",owner:i.owner??"rust-kernel",documentId:i.documentId,mindmapId:i.mindmapId,rootNodeId:n,title:Bc(e),nodeCount:t.length,nodes:t,data:e,meta:r}},cw=i=>{let e=ci(i.data),t=Hs(i.meta)?i.meta:null,r=[],n=[],s=(l,h,d)=>{let c=l?.data?.uid,f=l?.data?.text,m=typeof c=="string"&&c.trim()?c.trim():`depth:${h}:index:${r.length}`,g=Array.isArray(l?.children)?l.children:[];r.push({id:m,uid:m,text:typeof f=="string"&&f.trim()?f.trim():"\u672A\u547D\u540D\u8282\u70B9",depth:h,childCount:g.length,parentId:d}),d&&n.push({id:`${d}->${m}`,source:d,target:m}),g.forEach(x=>{s(ci(x),h+1,m)})};s(e,0,null);let a=r[0]?.id??null,o=i.rootNodeId&&i.rootNodeId.trim()?i.rootNodeId.trim():a;return{schema:"mnote.mindmap_editor_scene.v1",source:i.source??"rust-kernel",owner:i.owner??"rust-kernel",documentId:i.documentId,mindmapId:i.mindmapId,rootNodeId:o,title:Bc(e),nodes:r,edges:n,capabilities:{canEditText:!0,canAddChild:!0,canAddSiblingAfter:!0,canDeleteNode:!0},data:e,meta:t}},uw=i=>{let e=ci(i.data),t=Hs(i.meta)?i.meta:null,r=e?.data?.uid,n=typeof r=="string"&&r.trim()?r.trim():"root";return e.data.uid||(e.data.uid=n),{schema:"mnote.mindmap.simple_mind_map_scene.v1",runtime:"simple-mind-map",documentId:i.documentId,mindmapId:i.mindmapId,rootNodeId:n,root:e,layout:E0,theme:S0,themeConfig:A0(),view:{x:0,y:0,scale:1},config:{},compatPayload:{},kernelRevision:1,source:i.source??"rust-kernel",owner:i.owner??"rust-kernel",meta:t}};var UP=["Drag","KeyboardNavigation","Export","Select","AssociativeLine","Search","OuterFrame"],$P=["Scrollbar","MiniMap","Painter","Formula"],jP=["data_change","view_data_change","node_active","back_forward","scale","translate"],GP=new Set(["BACK","FORWARD","INSERT_NODE","INSERT_CHILD_NODE","REMOVE_NODE","DELETE_NODE","ADD_GENERALIZATION","ADD_OUTER_FRAME","SET_NOTATION"]),ao=i=>typeof i=="object"&&i!==null&&!Array.isArray(i),nb=(i,e)=>{if(typeof i=="string"&&i.trim())return i.trim();if(ao(i)){let t=i.template;if(typeof t=="string"&&t.trim())return t.trim()}return e},sb=i=>ao(i)?i:{},ab=i=>!ao(i)||Object.keys(i).length===0?A0():i,VP=i=>!ao(i)||!ao(i.state)?null:{...i,state:i.state,transform:ao(i.transform)?i.transform:{}},WP=i=>{let e=ci(i.projection.root??Us),t=sb(i.projection.config),r=sb(i.runtimeOptions);return{...t,...r,el:i.el,data:e,fit:typeof r.fit=="boolean"?r.fit:typeof t.fit=="boolean"?t.fit:!0,layout:nb(i.projection.layout,E0),theme:nb(i.projection.theme,S0),themeConfig:ab(i.projection.themeConfig),viewData:VP(i.projection.view),initRootNodePosition:["center","center"]}},YP={Drag:()=>Promise.resolve().then(()=>(J2(),Q2)),KeyboardNavigation:()=>Promise.resolve().then(()=>(t6(),e6)),Export:()=>Promise.resolve().then(()=>(d6(),h6)),Select:()=>Promise.resolve().then(()=>(u6(),c6)),AssociativeLine:()=>Promise.resolve().then(()=>(b6(),v6)),Search:()=>Promise.resolve().then(()=>(M6(),w6)),OuterFrame:()=>Promise.resolve().then(()=>(k6(),A6)),Scrollbar:()=>Promise.resolve().then(()=>(_6(),C6)),MiniMap:()=>Promise.resolve().then(()=>(I6(),L6)),Painter:()=>Promise.resolve().then(()=>(R6(),z6)),Formula:()=>Promise.resolve().then(()=>(wv(),bv))},XP=()=>[...UP,...$P],KP=async(i=XP())=>{if(typeof window>"u"||typeof document>"u")throw new Error("simple_mind_map_browser_required");let[{default:e},t]=await Promise.all([Promise.resolve().then(()=>(rb(),ib)),Promise.all(i.map(async s=>[s,(await YP[s]()).default]))]),r=e,n=[];return t.forEach(([s,a])=>{if(!a||typeof r.usePlugin!="function")return;typeof r.hasPlugin=="function"&&r.hasPlugin(a)!==-1||r.usePlugin(a),n.push(s)}),{MindMap:r,registeredPlugins:n}},ZP=i=>(e,...t)=>{if(!GP.has(e))return{ok:!1,command:e,error:"unsupported_command"};if(typeof i.execCommand!="function")return{ok:!1,command:e,error:"runtime_unavailable"};try{return{ok:!0,command:e,result:i.execCommand(e,...t)}}catch{return{ok:!1,command:e,error:"command_failed"}}},QP=i=>{let e=[];return jP.forEach(t=>{let r=(...n)=>{let s=t==="data_change"?i.instance.getData?.():t==="view_data_change"?i.instance.getData?.(!0)??i.instance.getData?.():void 0;i.onEvent?.({type:t,args:n,snapshot:s,kernelRevision:i.projection.kernelRevision})};i.instance.on?.(t,r),e.push(()=>i.instance.off?.(t,r))}),()=>e.splice(0).forEach(t=>t())},ob=async i=>{let e=await KP(i.pluginNames),t=ab(i.projection.themeConfig),r=WP({el:i.el,projection:i.projection,runtimeOptions:i.runtimeOptions}),n=new e.MindMap(r);n.setThemeConfig?.(t),i.mode&&n.setMode?.(i.mode);let s=QP({instance:n,projection:i.projection,onEvent:i.onEvent}),a=ZP(n);return{instance:n,execCommand:a,getSnapshot:()=>n.getData?.(!0)??n.getData?.(),destroy:()=>{n.renderer?.textEdit?.hideEditTextBox?.(),s(),n.destroy?.()}}};var lo=class extends Error{constructor(e,t=null){super(e),this.name="MindmapCommandBridgeError",this.code="command_failed",this.status=t}},oo=i=>typeof i=="object"&&i!==null&&!Array.isArray(i),Lp=i=>{if(!oo(i))return"";let e=[i.error,i.message,i.details].map(t=>typeof t=="string"?t.trim():Array.isArray(t)&&t.length>0?t.join("|"):"").find(Boolean);return e?`:${e}`:""},JP=async i=>{if(typeof i.text!="function"){let t=await i.json().catch(()=>null);return Lp(t)}let e=await i.text().catch(()=>"");if(!e.trim())return"";try{return Lp(JSON.parse(e))||`:${e.trim()}`}catch{return`:${e.trim()}`}},Ip=i=>oo(i)?i.schema==="mnote.mindmap.simple_mind_map_scene.v1"&&i.runtime==="simple-mind-map"&&"root"in i&&typeof i.kernelRevision=="number":!1,lb=(i,e)=>{let t=encodeURIComponent(i),r=encodeURIComponent(e);return`/api/mindmap/${t}/${r}?view=simple_mind_map_scene&queryName=mindmap.simple_mind_map_scene.get`},zp=(i,e)=>{let t=encodeURIComponent(i),r=encodeURIComponent(e);return`/api/mindmap/${t}/${r}`},Rp=async i=>{let e=i.fetcher??fetch,t=i.endpoint??lb(i.documentId,i.mindmapId),r=await e(t,{headers:{Accept:"application/json"}});if(!r.ok)throw new Error(`projection_load_failed:${r.status}`);let n=await r.json(),s=oo(n)&&"result"in n?n.result:n;if(!Ip(s))throw new Error("projection_load_failed:invalid_adapter_projection");return s},eF=i=>{if(!oo(i))return null;let e=[i.kernelRevision,i.projectionRevision,oo(i.result)?i.result.kernelRevision:null,oo(i.result)?i.result.projectionRevision:null];for(let t of e)if(typeof t=="number"&&Number.isFinite(t))return t;return null},hb=async i=>{if(i.commands.length===0)throw new lo("command_failed:empty_commands");let e=i.fetcher??fetch,t=i.endpoint??zp(i.documentId,i.mindmapId),r=JSON.stringify({commandName:"mindmap.command.apply",documentId:i.documentId,mindmapId:i.mindmapId,commands:i.commands,projectionRevision:i.projectionRevision??null}),n=await e(t,{method:"POST",keepalive:r.length<=6e4,headers:{Accept:"application/json","Content-Type":"application/json"},body:r}),s=await n.json().catch(()=>null);if(!n.ok){let a=Lp(s);throw new lo(`command_failed:${n.status}${a}`,n.status)}return{ok:!0,kernelRevision:eF(s),raw:s}},tF=async i=>(await hb(i),Rp({documentId:i.documentId,mindmapId:i.mindmapId,endpoint:i.projectionEndpoint,fetcher:i.fetcher})),iF=async i=>{let e=i.fetcher??fetch,t=i.endpoint??zp(i.documentId,i.mindmapId),r=await e(t,{method:"POST",headers:{Accept:"application/json","Content-Type":"application/json"},body:JSON.stringify({data:i.data,createOnly:!1})});if(!r.ok){let n=await JP(r);throw new lo(`command_failed:${r.status}${n}`,r.status)}return await r.json().catch(()=>null),Rp({documentId:i.documentId,mindmapId:i.mindmapId,endpoint:i.projectionEndpoint,fetcher:e})},rF=async i=>{if(!Ip(i.projection))throw new Error("adapter_init_failed:invalid_projection");return ob(i)};var Fp={};tt(Fp,{createCompatPayloadPatch:()=>Tc,createLayoutCommand:()=>Pp,createThemeCommand:()=>Bp,createToolbarKernelCommand:()=>Op,createViewPatchCommand:()=>nF,diffMindmapRuntimeDataToKernelCommands:()=>hF});var db=i=>({text:i?.text?.trim()||"\u65B0\u8282\u70B9",...i?.uid?{uid:i.uid}:{},...i?.hyperlink?{hyperlink:i.hyperlink}:{},...i?.note?{note:i.note}:{},...i?.refs?{refs:i.refs}:{}}),Op=i=>{let e=i.activeNodeId?.trim();switch(i.runtimeCommand){case"INSERT_CHILD_NODE":return e?{type:"insertChild",mindmapId:i.mindmapId,parentNodeId:e,node:db(i.newNode)}:null;case"INSERT_NODE":return e?{type:"insertSiblingAfter",mindmapId:i.mindmapId,targetNodeId:e,node:db(i.newNode)}:null;case"REMOVE_NODE":case"DELETE_NODE":return e?{type:"deleteNode",mindmapId:i.mindmapId,nodeId:e}:null;case"ADD_GENERALIZATION":return{type:"compatPayloadPatch",mindmapId:i.mindmapId,path:"root.data.generalization",value:{text:"\u6982\u8981"},source:"toolbar"};case"ADD_OUTER_FRAME":return{type:"compatPayloadPatch",mindmapId:i.mindmapId,path:"outerFrame",value:{enabled:!0,activeNodeId:e},source:"toolbar"};default:return null}},nF=(i,e)=>({type:"patchView",mindmapId:i,patch:e}),Bp=(i,e,t)=>({type:"setTheme",mindmapId:i,theme:e,themeConfig:t}),Pp=(i,e)=>({type:"setLayout",mindmapId:i,layout:e}),Tc=i=>({type:"compatPayloadPatch",mindmapId:i.mindmapId,path:i.path,value:i.value,source:i.source,...i.actionId?{actionId:i.actionId}:{},...typeof i.runtimeRevision=="number"?{runtimeRevision:i.runtimeRevision}:{},...typeof i.kernelRevision=="number"?{kernelRevision:i.kernelRevision}:{}}),sF=i=>typeof i=="object"&&i!==null&&!Array.isArray(i),aF=(i,e)=>{let t=i.data?.uid;return typeof t=="string"&&t.trim()?t.trim():e},oF=i=>{let e=i.data?.text;return typeof e=="string"?e:String(e??"")},cb=i=>{let e=ci(i),t=new Map,r=(n,s,a,o)=>{let l=aF(n,o),h=sF(n.data)?n.data:{};t.set(l,{uid:l,parentUid:s,order:a,text:oF(n),data:h,node:n}),(Array.isArray(n.children)?n.children:[]).forEach((c,f)=>{r(ci(c),l,f,`${o}.${f}`)})};return r(e,null,0,"root"),t},ub=i=>JSON.stringify(i??null),fb=i=>({uid:i.uid,text:i.text||"\u65B0\u8282\u70B9",...typeof i.data.hyperlink=="string"?{hyperlink:i.data.hyperlink}:{},...typeof i.data.note=="string"?{note:i.data.note}:{},...Array.isArray(i.data.refs)?{refs:i.data.refs}:{}}),lF=(i,e,t,r)=>{let n=new Set(["uid","text","refs"]);new Set([...Object.keys(e.data),...Object.keys(t.data)]).forEach(a=>{n.has(a)||ub(e.data[a])!==ub(t.data[a])&&r.push(Tc({mindmapId:i,path:`nodes.${t.uid}.data.${a}`,value:t.data[a],source:"adapter-diff"}))})},hF=i=>{let e=cb(i.previous),t=cb(i.next),r=[],n=[];return t.forEach((s,a)=>{let o=e.get(a);if(!o){let l=Array.from(t.values()).find(h=>h.parentUid===s.parentUid&&h.order===s.order-1&&e.has(h.uid));l?r.push({type:"insertSiblingAfter",mindmapId:i.mindmapId,targetNodeId:l.uid,node:fb(s)}):s.parentUid&&r.push({type:"insertChild",mindmapId:i.mindmapId,parentNodeId:s.parentUid,node:fb(s)});return}o.text!==s.text&&r.push({type:"updateText",mindmapId:i.mindmapId,nodeId:a,text:s.text}),(o.parentUid!==s.parentUid||o.order!==s.order)&&r.push({type:"moveNode",mindmapId:i.mindmapId,nodeId:a,newParentNodeId:s.parentUid??"",order:s.order}),lF(i.mindmapId,o,s,n)}),e.forEach((s,a)=>{!t.has(a)&&s.parentUid&&r.push({type:"deleteNode",mindmapId:i.mindmapId,nodeId:a})}),{commands:r,compatPatches:n}};var Hp={};tt(Hp,{getMindmapActionMapping:()=>Nc,listMindmapActionMappings:()=>qp,mapMindmapActionToCommand:()=>cF});var zs=i=>e=>e?`nodes.${e}.data.${i}`:null,mb=[{actionId:"undo",target:"runtimeCommand",runtimeCommand:"BACK",requiresActiveNode:!1,readonlyAllowed:!1},{actionId:"redo",target:"runtimeCommand",runtimeCommand:"FORWARD",requiresActiveNode:!1,readonlyAllowed:!1},{actionId:"editNode",target:"localView",requiresActiveNode:!0,readonlyAllowed:!1},{actionId:"insertSiblingAfter",target:"runtimeCommand",runtimeCommand:"INSERT_NODE",requiresActiveNode:!0,readonlyAllowed:!1},{actionId:"insertChild",target:"runtimeCommand",runtimeCommand:"INSERT_CHILD_NODE",requiresActiveNode:!0,readonlyAllowed:!1},{actionId:"deleteNode",target:"runtimeCommand",runtimeCommand:"REMOVE_NODE",requiresActiveNode:!0,readonlyAllowed:!1},{actionId:"summary",target:"runtimeCommand",runtimeCommand:"ADD_GENERALIZATION",requiresActiveNode:!0,readonlyAllowed:!1},{actionId:"associativeLine",target:"compatPatch",runtimeCommand:"ADD_ASSOCIATIVE_LINE",requiresActiveNode:!0,readonlyAllowed:!1},{actionId:"setTheme",target:"kernelCommand",requiresActiveNode:!1,readonlyAllowed:!1},{actionId:"setLayout",target:"kernelCommand",requiresActiveNode:!1,readonlyAllowed:!1},{actionId:"tag",target:"compatPatch",requiresActiveNode:!0,readonlyAllowed:!1,compatPath:zs("tag")},{actionId:"hyperlink",target:"compatPatch",requiresActiveNode:!0,readonlyAllowed:!1,compatPath:zs("hyperlink")},{actionId:"note",target:"compatPatch",requiresActiveNode:!0,readonlyAllowed:!1,compatPath:zs("note")},{actionId:"image",target:"compatPatch",requiresActiveNode:!0,readonlyAllowed:!1,compatPath:zs("image")},{actionId:"icon",target:"compatPatch",requiresActiveNode:!0,readonlyAllowed:!1,compatPath:zs("icon")},{actionId:"formula",target:"compatPatch",requiresActiveNode:!0,readonlyAllowed:!1,compatPath:zs("formula")},{actionId:"painter",target:"compatPatch",requiresActiveNode:!0,readonlyAllowed:!1,compatPath:zs("style")},{actionId:"import",target:"compatPatch",requiresActiveNode:!1,readonlyAllowed:!1,compatPath:()=>"import"},{actionId:"export",target:"localView",requiresActiveNode:!1,readonlyAllowed:!0},{actionId:"centerRoot",target:"localView",runtimeMethod:"centerRoot",requiresActiveNode:!1,readonlyAllowed:!0},{actionId:"zoomIn",target:"localView",runtimeMethod:"zoomIn",requiresActiveNode:!1,readonlyAllowed:!0},{actionId:"zoomOut",target:"localView",runtimeMethod:"zoomOut",requiresActiveNode:!1,readonlyAllowed:!0},{actionId:"fitView",target:"localView",runtimeMethod:"fitView",requiresActiveNode:!1,readonlyAllowed:!0},{actionId:"fullscreenCanvas",target:"localView",requiresActiveNode:!1,readonlyAllowed:!0},{actionId:"fullscreenPage",target:"localView",requiresActiveNode:!1,readonlyAllowed:!0},{actionId:"exitFullscreen",target:"localView",requiresActiveNode:!1,readonlyAllowed:!0},{actionId:"search",target:"localView",requiresActiveNode:!1,readonlyAllowed:!0},{actionId:"showMenu",target:"localView",requiresActiveNode:!1,readonlyAllowed:!0},{actionId:"expandCollapse",target:"localView",requiresActiveNode:!0,readonlyAllowed:!1},{actionId:"copyNodeText",target:"localView",requiresActiveNode:!0,readonlyAllowed:!0},{actionId:"readonly",target:"localView",requiresActiveNode:!1,readonlyAllowed:!0}],qp=()=>mb,Nc=i=>mb.find(e=>e.actionId===i)??null,dF=i=>{let e=i?.trim();return e||null},cF=i=>{let e=Nc(i.actionId);if(!e)return null;let t=dF(i.activeNodeId);if(e.requiresActiveNode&&!t)return null;if(i.actionId==="setTheme")return{command:Bp(i.mindmapId,i.value,i.themeConfig)};if(i.actionId==="setLayout")return{command:Pp(i.mindmapId,i.value)};if(i.actionId==="editNode")return{command:{type:"updateText",mindmapId:i.mindmapId,nodeId:t??"root",text:typeof i.value=="string"?i.value:""}};if(e.runtimeCommand&&["INSERT_CHILD_NODE","INSERT_NODE","DELETE_NODE","REMOVE_NODE"].includes(e.runtimeCommand)){let r=Op({mindmapId:i.mindmapId,runtimeCommand:e.runtimeCommand,activeNodeId:t,newNode:i.node});return r?{runtimeCommand:e.runtimeCommand,command:r}:{runtimeCommand:e.runtimeCommand}}if(e.compatPath){let r=e.compatPath(t);return r?{command:Tc({mindmapId:i.mindmapId,path:r,value:i.value??!0,source:i.source??"toolbar",actionId:i.actionId,runtimeRevision:i.runtimeRevision,kernelRevision:i.kernelRevision})}:null}return e.runtimeCommand?{runtimeCommand:e.runtimeCommand}:{}};var $p={};tt($p,{createDefaultMindmapShellInteractionState:()=>gb,deriveMindmapUiState:()=>wF,reduceMindmapChromeVisibility:()=>bF});var Up={};tt(Up,{MINDMAP_DEBUG_CHROME_QUERY_PARAM:()=>pb,MINDMAP_TOOLBAR_MORE_ACTION_ID:()=>uF,isMindmapDebugChromeEnabled:()=>pF,mindmapDefaultUiSchema:()=>ho,mindmapToolbarFileActionOrder:()=>mF,mindmapToolbarPrimaryActionOrder:()=>fF});var pb="mnoteMindmapDebugChrome",uF="more",fF=["undo","redo","editNode","insertSiblingAfter","deleteNode","insertChild","tag","hyperlink","note","image","icon","summary","associativeLine","formula"],mF=["import","export"],Ee=(i,e,t,r,n,s,a)=>({id:i,iconKey:e,shortLabel:t,longLabel:r,priority:n,overflowGroup:s,cluster:a}),ho={toolbarGroups:[{id:"history",label:"\u5386\u53F2",actions:["undo","redo"],collapsePriority:4},{id:"node",label:"\u8282\u70B9",actions:["editNode","insertSiblingAfter","deleteNode","insertChild"],collapsePriority:1},{id:"insert",label:"\u63D2\u5165",actions:["tag","hyperlink","note","image","icon","summary","associativeLine","formula"],collapsePriority:2},{id:"file",label:"\u6587\u4EF6",actions:["import","export"],collapsePriority:5},{id:"view",label:"\u89C6\u56FE",actions:["painter","centerRoot","zoomOut","zoomIn","search","readonly"],collapsePriority:3}],toolbarActionMeta:{undo:Ee("undo","undo","\u64A4\u9500","\u64A4\u9500",10,"history","main"),redo:Ee("redo","redo","\u91CD\u505A","\u91CD\u505A",20,"history","main"),editNode:Ee("editNode","type","\u7F16\u8F91","\u7F16\u8F91\u8282\u70B9",30,"node","main"),insertSiblingAfter:Ee("insertSiblingAfter","sibling","\u540C\u7EA7","\u63D2\u5165\u540C\u7EA7\u8282\u70B9",40,"node","main"),deleteNode:Ee("deleteNode","trash","\u5220\u9664","\u5220\u9664\u8282\u70B9",50,"node","main"),insertChild:Ee("insertChild","child","\u5B50\u7EA7","\u63D2\u5165\u5B50\u8282\u70B9",60,"node","main"),tag:Ee("tag","tag","\u6807\u7B7E","\u6807\u7B7E",70,"insert","main"),hyperlink:Ee("hyperlink","link","\u94FE\u63A5","\u8D85\u94FE\u63A5",80,"insert","main"),note:Ee("note","note","\u5907\u6CE8","\u5907\u6CE8",90,"insert","main"),image:Ee("image","image","\u56FE\u7247","\u56FE\u7247",100,"insert","main"),icon:Ee("icon","smile","\u56FE\u6807","\u56FE\u6807",110,"insert","main"),summary:Ee("summary","summary","\u6982\u8981","\u6982\u8981",120,"insert","main"),associativeLine:Ee("associativeLine","route","\u5173\u8054","\u5173\u8054\u7EBF",130,"insert","main"),formula:Ee("formula","formula","\u516C\u5F0F","\u516C\u5F0F",140,"insert","main"),painter:Ee("painter","paintbrush","\u683C\u5F0F","\u683C\u5F0F\u5237",210,"view","view"),import:Ee("import","import","\u5BFC\u5165","\u5BFC\u5165",310,"file","file"),export:Ee("export","export","\u5BFC\u51FA","\u5BFC\u51FA",320,"file","file"),setTheme:Ee("setTheme","palette","\u4E3B\u9898","\u4E3B\u9898",410,"view","view"),setLayout:Ee("setLayout","layout","\u7ED3\u6784","\u7ED3\u6784",420,"view","view"),zoomIn:Ee("zoomIn","zoom-in","\u653E\u5927","\u653E\u5927",510,"view","view"),zoomOut:Ee("zoomOut","zoom-out","\u7F29\u5C0F","\u7F29\u5C0F",500,"view","view"),fitView:Ee("fitView","fit","\u9002\u5E94","\u9002\u5E94\u753B\u5E03",520,"view","view"),centerRoot:Ee("centerRoot","target","\u56DE\u6839","\u56DE\u5230\u6839\u8282\u70B9",490,"view","view"),fullscreenCanvas:Ee("fullscreenCanvas","fullscreen","\u5168\u5C4F","\u5168\u5C4F\u67E5\u770B",550,"view","view"),fullscreenPage:Ee("fullscreenPage","fullscreen-page","\u5168\u9875","\u5168\u5C4F\u7F16\u8F91",560,"view","view"),exitFullscreen:Ee("exitFullscreen","exit-fullscreen","\u9000\u51FA","\u9000\u51FA\u5168\u5C4F",570,"view","view"),search:Ee("search","search","\u641C\u7D22","\u641C\u7D22",530,"view","view"),showMenu:Ee("showMenu","menu","\u83DC\u5355","\u663E\u793A\u83DC\u5355",580,"view","view"),expandCollapse:Ee("expandCollapse","expand","\u5C55\u5F00","\u5C55\u5F00/\u6536\u8D77",610,"view","view"),copyNodeText:Ee("copyNodeText","copy","\u590D\u5236","\u590D\u5236\u6587\u672C",620,"view","view"),readonly:Ee("readonly","lock","\u53EA\u8BFB","\u53EA\u8BFB",540,"view","view")},sidebarPanels:[{id:"nodeStyle",kind:"nodeStyle",label:"\u8282\u70B9\u6837\u5F0F",icon:"palette",runtimeCapability:"node-style",phase:1,options:[{id:"node-fill-blue",label:"\u6D77\u84DD",actionId:"painter",value:"#dbeafe",controlType:"swatch",preview:"#dbeafe",compatPath:"nodes.$active.data.fillColor"},{id:"node-fill-green",label:"\u8584\u8377",actionId:"painter",value:"#dcfce7",controlType:"swatch",preview:"#dcfce7",compatPath:"nodes.$active.data.fillColor"},{id:"node-fill-amber",label:"\u6696\u9EC4",actionId:"painter",value:"#fef3c7",controlType:"swatch",preview:"#fef3c7",compatPath:"nodes.$active.data.fillColor"},{id:"node-text-dark",label:"\u6DF1\u8272\u6587\u5B57",actionId:"painter",value:"#0f172a",controlType:"swatch",preview:"#0f172a",compatPath:"nodes.$active.data.color"},{id:"node-text-blue",label:"\u84DD\u8272\u6587\u5B57",actionId:"painter",value:"#1d4ed8",controlType:"swatch",preview:"#1d4ed8",compatPath:"nodes.$active.data.color"},{id:"node-font-14",label:"14 px",actionId:"painter",value:14,controlType:"segmented",compatPath:"nodes.$active.data.fontSize"},{id:"node-font-18",label:"18 px",actionId:"painter",value:18,controlType:"segmented",compatPath:"nodes.$active.data.fontSize"},{id:"node-font-bold",label:"\u52A0\u7C97",actionId:"painter",value:!0,controlType:"toggle",compatPath:"nodes.$active.data.fontWeight"},{id:"node-font-italic",label:"\u659C\u4F53",actionId:"painter",value:!0,controlType:"toggle",compatPath:"nodes.$active.data.fontStyle"},{id:"node-shape-round",label:"\u5706\u89D2\u77E9\u5F62",actionId:"painter",value:"roundedRectangle",controlType:"button",compatPath:"nodes.$active.data.shape"},{id:"node-shape-rect",label:"\u77E9\u5F62",actionId:"painter",value:"rectangle",controlType:"button",compatPath:"nodes.$active.data.shape"},{id:"node-border-blue",label:"\u84DD\u8272\u8FB9\u6846",actionId:"painter",value:"#60a5fa",controlType:"swatch",preview:"#60a5fa",compatPath:"nodes.$active.data.borderColor"},{id:"node-line-teal",label:"\u9752\u8272\u5206\u652F\u7EBF",actionId:"painter",value:"#14b8a6",controlType:"swatch",preview:"#14b8a6",compatPath:"nodes.$active.data.lineColor"},{id:"node-line-width-2",label:"\u8FB9\u7EBF 2",actionId:"painter",value:2,controlType:"segmented",compatPath:"nodes.$active.data.lineWidth"}]},{id:"baseStyle",kind:"baseStyle",label:"\u5BFC\u56FE\u6837\u5F0F",icon:"sliders",runtimeCapability:"base-style",phase:1,options:[{id:"base-curve-line",label:"\u66F2\u7EBF",actionId:"painter",value:"curve",controlType:"segmented",compatPath:"style.map.lineStyle"},{id:"base-direct-line",label:"\u76F4\u7EBF",actionId:"painter",value:"straight",controlType:"segmented",compatPath:"style.map.lineStyle"},{id:"base-rainbow-lines",label:"\u5F69\u8679\u7EBF\u6761",actionId:"painter",value:{enabled:!0},controlType:"toggle",compatPath:"style.map.rainbowLines"},{id:"base-line-width-2",label:"\u7EBF\u5BBD 2",actionId:"painter",value:2,controlType:"segmented",compatPath:"style.map.lineWidth"},{id:"base-line-width-4",label:"\u7EBF\u5BBD 4",actionId:"painter",value:4,controlType:"segmented",compatPath:"style.map.lineWidth"},{id:"base-background-light",label:"\u6D45\u8272\u80CC\u666F",actionId:"painter",value:"#f8fafc",controlType:"swatch",preview:"#f8fafc",compatPath:"style.map.backgroundColor"},{id:"base-node-spacing-36",label:"\u8282\u70B9\u95F4\u8DDD 36",actionId:"painter",value:36,controlType:"numberInput",compatPath:"style.map.nodeSpacing"},{id:"base-summary-bracket",label:"\u62EC\u53F7\u6982\u8981",actionId:"painter",value:"bracket",controlType:"select",compatPath:"style.map.summaryStyle"}]},{id:"theme",kind:"theme",label:"\u4E3B\u9898",icon:"swatch",runtimeCapability:"theme",phase:1,options:[{id:"theme-classic",label:"Classic",actionId:"setTheme",value:"classic",controlType:"swatch",preview:"#60a5fa",description:"\u9ED8\u8BA4\u4E3B\u9898"},{id:"theme-classic4",label:"KMind",actionId:"setTheme",value:"classic4",controlType:"swatch",preview:"#22c55e",description:"KMind-like"},{id:"theme-simple",label:"Simple",actionId:"setTheme",value:"simple",controlType:"swatch",preview:"#f59e0b",description:"\u8F7B\u91CF\u4E3B\u9898"},{id:"theme-dark",label:"Dark",actionId:"setTheme",value:"dark",controlType:"swatch",preview:"#334155",description:"\u6DF1\u8272\u4E3B\u9898"}]},{id:"structure",kind:"structure",label:"\u7ED3\u6784",icon:"layout",runtimeCapability:"layout",phase:1,options:[{id:"layout-logical",label:"\u903B\u8F91\u7ED3\u6784\u56FE",actionId:"setLayout",value:"logicalStructure",controlType:"layoutCard",preview:"logicalStructure"},{id:"layout-mind-map",label:"\u601D\u7EF4\u5BFC\u56FE",actionId:"setLayout",value:"mindMap",controlType:"layoutCard",preview:"mindMap"},{id:"layout-organization",label:"\u7EC4\u7EC7\u7ED3\u6784\u56FE",actionId:"setLayout",value:"organizationStructure",controlType:"layoutCard",preview:"organizationStructure"},{id:"layout-catalog",label:"\u76EE\u5F55\u7EC4\u7EC7\u56FE",actionId:"setLayout",value:"catalogOrganization",controlType:"layoutCard",preview:"catalogOrganization"},{id:"layout-timeline",label:"\u65F6\u95F4\u8F74",actionId:"setLayout",value:"timeline",controlType:"layoutCard",preview:"timeline"},{id:"layout-fishbone",label:"\u9C7C\u9AA8\u56FE",actionId:"setLayout",value:"fishbone",controlType:"layoutCard",preview:"fishbone"}]},{id:"outline",kind:"outline",label:"\u5927\u7EB2",icon:"list-tree",runtimeCapability:"outline",phase:1,options:[]},{id:"shortcutKey",kind:"shortcutKey",label:"\u5FEB\u6377\u952E",icon:"sparkles",runtimeCapability:"shortcut-key",phase:1,options:[{id:"shortcut-insert-child",label:"Tab",description:"\u63D2\u5165\u5B50\u8282\u70B9",actionId:null,value:null,controlType:"treeItem",readonly:!0},{id:"shortcut-insert-sibling",label:"Enter",description:"\u63D2\u5165\u540C\u7EA7\u8282\u70B9",actionId:null,value:null,controlType:"treeItem",readonly:!0},{id:"shortcut-delete",label:"Delete",description:"\u5220\u9664\u8282\u70B9",actionId:null,value:null,controlType:"treeItem",readonly:!0}]},{id:"settings",kind:"settings",label:"\u8BBE\u7F6E",icon:"hexagon",runtimeCapability:"settings",phase:1,options:[{id:"settings-readonly-hint",label:"\u53EA\u8BFB\u6A21\u5F0F",description:"\u5BFC\u822A\u680F\u5207\u6362",actionId:null,value:null,controlType:"toggle",readonly:!0},{id:"settings-mouse",label:"\u9F20\u6807\u884C\u4E3A",description:"\u5DE6\u952E\u9009\u4E2D\uFF0C\u53F3\u952E\u62D6\u62FD",actionId:null,value:"leftSelectRightDrag",controlType:"select",readonly:!0}]}],navigatorItems:[{id:"stats",label:"\u7EDF\u8BA1",actionId:null,readOnly:!0,displayMode:"text"},{id:"centerRoot",label:"\u56DE\u6839\u8282\u70B9",actionId:"centerRoot",readOnly:!0,displayMode:"button"},{id:"search",label:"\u641C\u7D22",actionId:"search",readOnly:!0,displayMode:"button"},{id:"zoomOut",label:"\u7F29\u5C0F",actionId:"zoomOut",readOnly:!0,displayMode:"button"},{id:"zoom",label:"\u7F29\u653E",actionId:null,readOnly:!0,displayMode:"input"},{id:"zoomIn",label:"\u653E\u5927",actionId:"zoomIn",readOnly:!0,displayMode:"button"},{id:"fullscreen",label:"\u5168\u5C4F",actionId:"fullscreenCanvas",readOnly:!0,displayMode:"button"},{id:"readonly",label:"\u53EA\u8BFB",actionId:"readonly",readOnly:!0,displayMode:"button"}],contextMenuItems:[{id:"insertChild",label:"\u63D2\u5165\u5B50\u8282\u70B9",actionId:"insertChild",requiresNode:!0,phase:1},{id:"insertSiblingAfter",label:"\u63D2\u5165\u540C\u7EA7\u8282\u70B9",actionId:"insertSiblingAfter",requiresNode:!0,phase:1},{id:"deleteNode",label:"\u5220\u9664\u8282\u70B9",actionId:"deleteNode",requiresNode:!0,phase:1},{id:"summary",label:"\u6982\u8981",actionId:"summary",requiresNode:!0,phase:1},{id:"associativeLine",label:"\u5173\u8054\u7EBF",actionId:"associativeLine",requiresNode:!0,phase:1},{id:"expandCollapse",label:"\u5C55\u5F00/\u6536\u8D77",actionId:"expandCollapse",requiresNode:!0,phase:1},{id:"copyNodeText",label:"\u590D\u5236\u6587\u672C",actionId:"copyNodeText",requiresNode:!0,phase:1},{id:"centerRoot",label:"\u56DE\u6839\u8282\u70B9",actionId:"centerRoot",requiresNode:!1,phase:1},{id:"fitView",label:"\u9002\u5E94\u753B\u5E03",actionId:"fitView",requiresNode:!1,phase:1},{id:"search",label:"\u641C\u7D22",actionId:"search",requiresNode:!1,phase:1},{id:"readonly",label:"\u53EA\u8BFB\u5207\u6362",actionId:"readonly",requiresNode:!1,phase:1},{id:"showMenu",label:"\u663E\u793A\u83DC\u5355",actionId:"showMenu",requiresNode:!1,phase:1}]},pF=i=>{try{let e=new URL(i).searchParams.get(pb);return e==="1"||e==="true"}catch{return!1}};var gF=()=>{let i=[...ho.toolbarGroups.flatMap(e=>e.actions),...ho.navigatorItems.flatMap(e=>e.actionId?[e.actionId]:[]),...ho.contextMenuItems.map(e=>e.actionId),...qp().map(e=>e.actionId)];return[...new Set(i)]},xF=i=>{let e=i?.trim();return e||null},yF=i=>typeof i!="number"||!Number.isFinite(i)?100:Math.max(10,Math.min(500,Math.round(i))),vF=new Set(["tag","hyperlink","note","image","icon","associativeLine","formula","import","export"]),gb=(i={})=>({chromeVisibility:i.chromeVisibility??"visible",toolbarOverflow:{availableWidth:i.toolbarOverflow?.availableWidth??null,visibleActionIds:i.toolbarOverflow?.visibleActionIds??[],overflowActionIds:i.toolbarOverflow?.overflowActionIds??[],moreOpen:i.toolbarOverflow?.moreOpen??!1},fullscreen:{mode:i.fullscreen?.mode??"none",isFullscreen:i.fullscreen?.isFullscreen??!1,target:i.fullscreen?.target??"mindmap-root",apiAvailable:i.fullscreen?.apiAvailable??!0},sidebar:{triggerVisible:i.sidebar?.triggerVisible??!0,panelOpen:i.sidebar?.panelOpen??!0,activePanelId:i.sidebar?.activePanelId??ho.sidebarPanels[0]?.id??null,drawerWidth:i.sidebar?.drawerWidth??300,collapsedByToggle:i.sidebar?.collapsedByToggle??!1},navigator:{searchOpen:i.navigator?.searchOpen??!1,minimapOpen:i.navigator?.minimapOpen??!1,readonly:i.navigator?.readonly??!1,zoomPercent:yF(i.navigator?.zoomPercent),mouseBehavior:i.navigator?.mouseBehavior??"leftSelectRightDrag"}}),bF=(i,e)=>e==="pointerLeave"?"hiddenByPointerLeave":e==="pointerEnter"?i:e==="restoreClick"?"visible":e==="hideToggle"?"hiddenByToggle":e==="showToggle"?"visible":e==="enterFullscreen"?i==="visible"?"visible":"hiddenByFullscreen":e==="exitFullscreen"&&i==="hiddenByFullscreen"?"visible":i,wF=i=>{let e=xF(i.activeNodeId),t=i.runtimeCapabilities?new Set(i.runtimeCapabilities):null,r={},n=gb({...i.shell,navigator:{...i.shell?.navigator,readonly:i.readonly}});return gF().forEach(s=>{let a=Nc(s);if(!a){r[s]=!0;return}if(a.requiresActiveNode&&!e){r[s]=!0;return}if(i.readonly&&!a.readonlyAllowed){r[s]=!0;return}if(t&&!t.has(a.target)){r[s]=!0;return}if(vF.has(s)){r[s]=!0;return}r[s]=!1}),{activeNodeId:e,readonly:i.readonly,disabledActions:r,shell:n}};var jp={};tt(jp,{resolveMindmapShortcutAction:()=>MF,shouldInterceptMindmapShortcut:()=>TF});var MF=i=>i.ctrlKey||i.metaKey||i.altKey||i.shiftKey?null:i.key==="Enter"?"insertSiblingAfter":i.key==="Tab"||i.key==="Insert"?"insertChild":i.key==="Delete"||i.key==="Backspace"?"deleteNode":i.key==="F2"?"editNode":null,TF=i=>i.debugChromeEnabled||!i.bridgeReady||i.readonly||i.isComposing||i.isEditableTarget?!1:i.targetInsideRoot?!0:i.keyboardShortcutArmed;function d0(){return{status:"idle"}}function xb(i,e){return{status:"armed",armedAt:e,reason:i}}function yb(){return d0()}function vb(i,e,t=1500){return i.status!=="armed"?{state:i,suppressed:!1,expired:!1}:e-i.armedAt>t?{state:d0(),suppressed:!1,expired:!0}:{state:d0(),suppressed:!0,expired:!1}}function bb(i){return{endpoint:`/api/mindmap/${encodeURIComponent(i.documentId)}/${encodeURIComponent(i.mindmapId)}`,init:{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({data:i.data,createOnly:!0})}}}function Vn(i){let e=i;return e.default&&typeof e.default=="object"?e.default:i}var{applyMindmapCommandsLocally:NF,isLocallyApplicableMindmapCommandSet:EF}=Vn(Dc),{createLeptosMindmapAdapter:SF,executeMindmapCommandApply:Gp,executeMindmapCommandApplyAndRefreshProjection:AF,requestMindmapAdapterProjection:kF}=Vn(Dp),{createViewPatchCommand:CF,createCompatPayloadPatch:_F,diffMindmapRuntimeDataToKernelCommands:LF}=Vn(Fp),{getMindmapActionMapping:wb,mapMindmapActionToCommand:IF}=Vn(Hp),{canonicalizeMindmapData:vi}=Vn(Pc),{deriveMindmapUiState:Vp,reduceMindmapChromeVisibility:Ec}=Vn($p),{resolveMindmapShortcutAction:zF,shouldInterceptMindmapShortcut:RF}=Vn(jp),{isMindmapDebugChromeEnabled:DF,mindmapDefaultUiSchema:Sc,mindmapToolbarFileActionOrder:OF,mindmapToolbarPrimaryActionOrder:Xp}=Vn(Up);function BF(i){return typeof i=="string"&&i.length>0?{"data-block-id":i,id:i}:{}}function PF(i){if(i.mnoteBlockType!=="mindmap")return{};let e={"data-mnote-block-type":"mindmap"};return typeof i.mindmapId=="string"&&(e["data-mnote-mindmap-id"]=i.mindmapId),typeof i.rootNodeId=="string"&&(e["data-mnote-root-node-id"]=i.rootNodeId),typeof i.projectionVersion=="number"&&(e["data-mnote-projection-version"]=String(i.projectionVersion)),e}function Rs(i){return String(i??"").replaceAll("&","&").replaceAll("<","<").replaceAll(">",">").replaceAll('"',""").replaceAll("'","'")}function Wp(i){if(typeof i!="string")return null;let e=i.trim();return e.length>0?e:null}function FF(i){let e=Wp(i?.closest("[data-document-id]")?.getAttribute("data-document-id"));if(e)return e;if(i&&"isConnected"in i&&i.isConnected===!1)return null;let t=window.location.pathname.match(/\/documents\/([^/?#]+)/);if(t?.[1])return decodeURIComponent(t[1]);let r=window.location.pathname.match(/\/mindmap\/([^/?#]+)\/([^/?#]+)/);if(r?.[1])return decodeURIComponent(r[1]);let n=Wp(document.body?.getAttribute("data-document-id"));if(n)return n;let s=Wp(document.querySelector(".document-shell[data-document-id], [data-pane-document-id], [data-document-id]")?.getAttribute("data-document-id")??document.querySelector("[data-pane-document-id]")?.getAttribute("data-pane-document-id"));return s||null}function Mb(i,e){let r=vi(i.root).data?.uid;return typeof r=="string"&&r.trim().length>0?r.trim():e}function Yp(i){let t=vi(i.root).data?.text;return typeof t=="string"&&t.trim().length>0?t.trim():"\u672A\u547D\u540D\u8282\u70B9"}function Ds(i){if(typeof i=="string"&&i.trim().length>0)return i.trim();if(!i||typeof i!="object")return null;let e=i;if(typeof e.uid=="string"&&e.uid.trim().length>0)return e.uid.trim();let t=e.data&&typeof e.data=="object"&&!Array.isArray(e.data)?e.data:{};if(typeof t.uid=="string"&&t.uid.trim().length>0)return t.uid.trim();let r=e.getData;if(typeof r=="function")try{let n=r.call(e,"uid");if(typeof n=="string"&&n.trim().length>0)return n.trim()}catch{return null}return null}function Ac(i,e){let t=window,r=t.__MNOTE_LEPTOS_MINDMAP_BRIDGES__??{};t.__MNOTE_LEPTOS_MINDMAP_BRIDGES__=r,e?r[i]=e:delete r[i]}function Lt(i){return typeof i=="object"&&i!==null&&!Array.isArray(i)}function Os(i){return JSON.parse(JSON.stringify(i))}function qF(i){if(!(i instanceof HTMLElement))return!1;let e=i.tagName;return e==="INPUT"||e==="TEXTAREA"||e==="SELECT"?!0:i.isContentEditable?!i.matches(".ProseMirror"):!1}function HF(i){if(!Lt(i))return null;let e=i.view;return Lt(e)&&Lt(e.state)?e:null}function UF(i){if(!(i!=="insertChild"&&i!=="insertSiblingAfter"))return{uid:`node_${Date.now().toString(36)}_${Math.random().toString(36).slice(2,8)}`,text:"\u65B0\u8282\u70B9"}}function $F(i,e){let t=e?.trim();if(!t)return!1;let r=vi(i),n=!1,s=a=>{if(n)return;let o=vi(a);if(o.data?.uid===t){n=!0;return}(Array.isArray(o.children)?o.children:[]).forEach(s)};return s(r),n}function Tb(i){let e=vi(i),t=Array.isArray(e.children)?[...e.children]:[];for(;t.length>0;){let r=vi(t.shift()),n=r.data?.uid;if(typeof n=="string"&&n.trim().length>0)return n.trim();Array.isArray(r.children)&&t.push(...r.children)}return null}function jF(i,e){let t=e?.trim();if(!t)return null;let r=null,n=s=>{if(r)return;let a=vi(s);if(a.data?.uid===t){r=a;return}(Array.isArray(a.children)?a.children:[]).forEach(n)};return n(i),r}function GF(i,e){let t=jF(i,e),r=t&&Lt(t.data)?t.data.text:null;return typeof r=="string"?r:""}function VF(i,e=10){let t=vi(i),r=[],n=(s,a)=>{if(r.length>=e)return;let o=vi(s),l=typeof o.data?.text=="string"&&o.data.text.trim().length>0?o.data.text.trim():"\u672A\u547D\u540D\u8282\u70B9";r.push(`${" ".repeat(Math.max(a-1,0))}${l}`),(Array.isArray(o.children)?o.children:[]).forEach(d=>n(d,a+1))};return n(t,1),r}function Nb(i,e){let t=i?.trim();return t?t.includes("$active")?e?t.replaceAll("$active",e):null:t:null}var Eb={undo:"\u64A4\u9500",redo:"\u91CD\u505A",editNode:"\u7F16\u8F91\u8282\u70B9",insertSiblingAfter:"\u540C\u7EA7\u8282\u70B9",insertChild:"\u5B50\u8282\u70B9",deleteNode:"\u5220\u9664",tag:"\u6807\u7B7E",hyperlink:"\u94FE\u63A5",note:"\u5907\u6CE8",image:"\u56FE\u7247",icon:"\u56FE\u6807",summary:"\u6982\u8981",associativeLine:"\u5173\u8054\u7EBF",formula:"\u516C\u5F0F",painter:"\u683C\u5F0F\u5237",import:"\u5BFC\u5165",export:"\u5BFC\u51FA",setTheme:"\u4E3B\u9898",setLayout:"\u7ED3\u6784",zoomIn:"\u653E\u5927",zoomOut:"\u7F29\u5C0F",fitView:"\u9002\u5E94",centerRoot:"\u56DE\u6839",fullscreenCanvas:"\u5168\u5C4F",fullscreenPage:"\u5168\u9875",exitFullscreen:"\u9000\u51FA",search:"\u641C\u7D22",showMenu:"\u663E\u793A\u83DC\u5355",expandCollapse:"\u5C55\u5F00/\u6536\u8D77",copyNodeText:"\u590D\u5236\u6587\u672C",readonly:"\u53EA\u8BFB"},WF={undo:"\u21B6",redo:"\u21B7",editNode:"T",insertSiblingAfter:"\u21B5",insertChild:"+",deleteNode:"\xD7",tag:"#",hyperlink:"\u2301",note:"N",image:"\u25A1",icon:"\u2606",summary:"{",associativeLine:"\u2307",formula:"fx",painter:"\u25D0",import:"\u21E7",export:"\u21E9",centerRoot:"\u25CE",zoomOut:"-",zoomIn:"+",search:"\u2315",fullscreenCanvas:"\u26F6",fullscreenPage:"\u25A3",exitFullscreen:"\u2921",showMenu:"\u2630",expandCollapse:"\u2922",copyNodeText:"C",readonly:"\u9501"},YF={undo:"\u21B6",redo:"\u21B7",type:"T",sibling:"\u21B5",trash:"\xD7",child:"+",tag:"#",link:"\u2301",note:"N",image:"\u25A1",smile:"\u2606",summary:"{",route:"\u2307",formula:"fx",paintbrush:"\u25D0",import:"\u21E7",export:"\u21E9",target:"\u25CE",fullscreen:"\u26F6","fullscreen-page":"\u25A3","exit-fullscreen":"\u2921",menu:"\u2630","zoom-out":"-","zoom-in":"+",search:"\u2315",lock:"\u9501"},XF=i=>{let e=[...Xp];if(i===null||!Number.isFinite(i))return{availableWidth:null,visibleActionIds:e,overflowActionIds:[]};let t=118,r=96,n=50,s=Math.max(0,i-t-r),a=Math.max(2,Math.min(e.length,Math.floor(s/n)));if(a>=e.length)return{availableWidth:i,visibleActionIds:e,overflowActionIds:[]};let o=Math.max(1,a-1);return{availableWidth:i,visibleActionIds:e.slice(0,o),overflowActionIds:e.slice(o)}};function KF(i){return({node:e,getPos:t})=>{let r=e,n=0,s=null,a=null,o=null,l=null,h=null,d=null,c=d0(),f=null,m=!1,g=Sc.sidebarPanels[0]?.id??"nodeStyle",x=!0,y=!0,b=!1,S=300,A=null,C=null,I=!1,O=!1,q=null,P=!1,W=null,ne=null,re=null,oe=null,ke=null,Q=null,Y="visible",le=!1,ce=!1,at="canvas",It=null,bt="canvas",qi=0,zt=0,di=null,nn=null,Hi=null,co=0,Mr=0,tr=!1,sn=null,bi=null,Ui=!1,wi=DF(window.location.href),Je=document.createElement("div");Je.className="mnote-mindmap-placeholder",Je.dataset.mnoteBlockType="mindmap",Je.dataset.testid="mnote-mindmap-placeholder",Je.setAttribute("contenteditable","false");let ve=document.createElement("div");ve.className="mnote-mindmap-editor-root",ve.dataset.testid="mnote-mindmap-editor-root",ve.dataset.mnoteSceneQuery="mindmap.simple_mind_map_scene.get";let an=()=>FF(ve),R=document.createElement("div");R.dataset.testid="leptos-mindmap-island",R.dataset.runtime="simple-mind-map",R.dataset.stage="loading",R.dataset.debugChrome=wi?"true":"false",R.dataset.suppressRuntimeDiffState="idle",R.className="mnote-leptos-mindmap-shell";let Wn=()=>{R.dataset.suppressRuntimeDiffState=c.status,R.dataset.suppressRuntimeDiffReason=c.status==="armed"?c.reason:"",R.dataset.suppressRuntimeDiffSince=c.status==="armed"?String(c.armedAt):""},Bs=M=>{c=xb(M,Date.now()),Wn()},$i=()=>{c=yb(),Wn()},Yn=document.createElement("div");Yn.className="mnote-mindmap-workspace",Yn.dataset.debugChrome=wi?"true":"false",Yn.dataset.layout=wi?"debug-grid":"floating-overlay";let c0=document.createElement("div");c0.className="mnote-mindmap-canvas-layer",c0.dataset.testid="mindmap-canvas-layer";let Xn=document.createElement("div");Xn.className="mnote-mindmap-overlay-layer",Xn.dataset.testid="mindmap-overlay-layer";let mt=document.createElement("div");mt.className="mnote-leptos-mindmap-runtime",mt.dataset.testid="simple-mind-map-runtime",mt.dataset.runtime="simple-mind-map",mt.dataset.runtimeEngine="pending";let ir=document.createElement("div");ir.className="mnote-mindmap-rust-shell-mount",ir.dataset.testid="mindmap-rust-shell-mount",ir.dataset.uiShellSource="leptos-rust-shell";let Kn=document.createElement("div");Kn.className="mnote-mindmap-command-toolbar",Kn.dataset.testid="mindmap-command-toolbar";let Zn=document.createElement("aside");Zn.className="mnote-mindmap-side-panel",Zn.dataset.testid="mindmap-sidebar";let Qn=document.createElement("div");Qn.className="mnote-mindmap-bottom-bar",Qn.dataset.testid="mindmap-bottom-bar";let et=()=>{let M=typeof r.attrs.mindmapId=="string"&&r.attrs.mindmapId.length>0?r.attrs.mindmapId:"mindmap",L=typeof r.attrs.rootNodeId=="string"&&r.attrs.rootNodeId.length>0?r.attrs.rootNodeId:"root";return{mindmapId:M,rootNodeId:L}},Kp=M=>JSON.stringify({mnoteBlockType:M.attrs.mnoteBlockType??null,mindmapId:M.attrs.mindmapId??null,rootNodeId:M.attrs.rootNodeId??null,projectionVersion:M.attrs.projectionVersion??null}),u0=()=>{let{mindmapId:M,rootNodeId:L}=et();Je.dataset.mnoteMindmapId=M,Je.dataset.mnoteRootNodeId=L,Je.dataset.mnoteProjectionVersion=String(r.attrs.projectionVersion??1),ve.dataset.mnoteMindmapId=M,ve.dataset.mnoteRootNodeId=L,ve.dataset.mnoteProjectionVersion=String(r.attrs.projectionVersion??1),ve.dataset.mnoteSceneQuery="mindmap.simple_mind_map_scene.get"},f0=()=>{if(!(nn===null||!Hi)){try{Hi.unmount(nn)}catch{}nn=null,Hi=null,ir.replaceChildren()}},Zp=()=>{let M=s?.getSnapshot(),L=Lt(M)&&"root"in M?M.root:M??a?.root,D=0,F=0,K=te=>{if(!Lt(te))return;D+=1;let we=Lt(te.data)&&typeof te.data.text=="string"?te.data.text:"";F+=we.trim().length,(Array.isArray(te.children)?te.children:[]).forEach(K)};K(L);let ee=a?.view,ue=Lt(M)&&Lt(M.view)&&Lt(M.view.state)&&typeof M.view.state.scale=="number"?M.view.state.scale:Lt(ee)&&Lt(ee.state)&&typeof ee.state.scale=="number"?ee.state.scale:1;return{nodeCount:D,wordCount:F,zoomPercent:Math.round(ue*100)}},Sb=()=>a?{...vi(a.root),layout:a.layout,theme:a.theme,themeConfig:a.themeConfig,view:a.view,config:a.config,compatPayload:a.compatPayload}:null,Ab=(M,L)=>{a&&(a.root=Os(vi(M)),"layout"in M&&(a.layout=M.layout),"theme"in M&&(a.theme=M.theme),"themeConfig"in M&&(a.themeConfig=M.themeConfig),"view"in M&&(a.view=M.view),"config"in M&&(a.config=M.config),"compatPayload"in M&&(a.compatPayload=M.compatPayload),typeof L=="number"&&Number.isFinite(L)&&(a.kernelRevision=L),o=Os(a.root))},kb=()=>{let M=s?.getSnapshot(),L=Lt(M)&&"root"in M?M.root:M??a?.root;return VF(L)},m0=()=>{P&&(P=!1,Le())},Qp=(M,L)=>{let D=ve.getBoundingClientRect();qi=Math.round(M-D.left),zt=Math.round(L-D.top)},p0=()=>{!ce&&!R.dataset.contextMenuKind||(ce=!1,at="canvas",It=null,bt="canvas",delete R.dataset.contextMenuKind,delete R.dataset.contextMenuTargetNodeId,delete R.dataset.contextMenuTargetNodeKind,b0())},g0=M=>Ds(M),Cb=()=>{let M=s?.instance.renderer;return M?.activeNodeList?.[0]??M?.lastActiveNodeList?.[0]??M?.root??M?.renderTree?._node??null},x0=(M,L,D)=>{let F=Ds(L);return F&&$F(M.root,F)?F:Tb(M.root)??Mb(M,D)},kc=M=>{let L=Ds(M);return L||(g0(h)??g0(Cb())??null)},_b=M=>{if(!M||typeof M!="object")return"node";let L=M;return L.isRoot===!0?"root":L.isGeneralization===!0?"generalization":"node"},Lb=M=>{let L=s?.instance.renderer;if(L)try{L.clearActiveNodeList?.(),L.addNodeToActiveList?.(M,!0),L.emitNodeActiveEvent?.(M,[M])}catch{}},uo=M=>{let L=Ds(M),D=s?.instance.renderer,F=L?D?.findNodeByUid?.(L)??null:D?.activeNodeList?.[0]??D?.lastActiveNodeList?.[0]??D?.root??D?.renderTree?._node;if(F)try{D?.clearActiveNodeList?.(),D?.addNodeToActiveList?.(F,!0),D&&(D.lastActiveNodeList=[F]),D?.emitNodeActiveEvent?.(F,[F]),s?.instance.execCommand?.("SET_NODE_ACTIVE",F,!0),h=F;let ee=L??g0(F)??l??et().rootNodeId;return l=ee,d=ee,R.dataset.lastRuntimeSelectionSync=ee,!0}catch{}let K=s?.instance.execCommand;if(typeof K!="function"||!L)return!1;try{return K.call(s?.instance,"GO_TARGET_NODE",L),l=L,d=L,R.dataset.lastRuntimeSelectionSync=L,!0}catch{return!1}},Jp=M=>{let L=Ds(M),D=s?.instance.renderer;return{wantedNodeId:L,runtimeNode:L?D?.findNodeByUid?.(L)??null:D?.activeNodeList?.[0]??D?.lastActiveNodeList?.[0]??D?.root??D?.renderTree?._node}},Ib=async(M,L=3)=>{let D=Jp(M);for(let F=1;!D.runtimeNode&&Fwindow.requestAnimationFrame(()=>K())),D=Jp(M);return D},zb=async(M,L,D)=>{let F=M==="DELETE_NODE"?"REMOVE_NODE":M,K=s?.instance;if(!K||typeof K.execCommand!="function")return{ok:!1,command:F,error:"runtime_unavailable",message:"runtime_instance_missing"};let{wantedNodeId:ee,runtimeNode:ue}=await Ib(L);if(!ue)return{ok:!1,command:F,error:"command_failed",message:`runtime_node_missing:${ee??"unknown"}`};try{if(K.renderer?.clearActiveNodeList?.(),K.renderer?.addNodeToActiveList?.(ue,!0),K.renderer&&(K.renderer.lastActiveNodeList=[ue]),K.renderer?.emitNodeActiveEvent?.(ue,[ue]),K.execCommand("SET_NODE_ACTIVE",ue,!0),F==="REMOVE_NODE")return{ok:!0,command:F,result:K.execCommand("REMOVE_NODE",[ue])};if(F==="INSERT_CHILD_NODE"||F==="INSERT_NODE")return{ok:!0,command:F,result:K.execCommand(F,!1,[ue],D?{uid:D.uid,text:D.text}:null)}}catch{return{ok:!1,command:F,error:"command_failed",message:"runtime_command_throw"}}return s?.execCommand(F)??{ok:!1,command:F,error:"unsupported_command",message:"runtime_command_unsupported"}},Rb=(M,L,D)=>{Qp(M,L),at="node",It=g0(D),bt=_b(D),ce=!0,R.dataset.contextMenuKind="node",R.dataset.contextMenuTargetNodeId=It??"",R.dataset.contextMenuTargetNodeKind=bt,Lb(D),It&&(l=It,d=It),b0()},Db=(M,L)=>{Qp(M,L),at="canvas",It=null,bt="canvas",ce=!0,R.dataset.contextMenuKind="canvas",R.dataset.contextMenuTargetNodeId="",R.dataset.contextMenuTargetNodeKind="canvas",b0()},y0=M=>{Y!==M&&(Y=M,R.dataset.chromeVisibility=M,Le())},e3=()=>{le=!0,R.dataset.keyboardShortcutArmed="true"},Ob=()=>{le=!1,R.dataset.keyboardShortcutArmed="false"},v0=()=>document.fullscreenElement===ve||ve.contains(document.fullscreenElement),Bb=()=>{let M=s?.instance;try{typeof M?.resize=="function"?M.resize():typeof M?.view?.resize=="function"&&M.view.resize(),M?.renderer?.setRootNodeCenter?.()}catch{R.dataset.fullscreenResizeStatus="failed";return}R.dataset.fullscreenResizeStatus="success"},Cc=()=>{let M=v0();R.dataset.fullscreenActive=M?"true":"false",ve.dataset.fullscreenActive=M?"true":"false",window.setTimeout(()=>{Bb(),Le()},80)},_c=()=>{s?.instance.setMode?.(m?"readonly":"edit"),R.dataset.readonly=m?"true":"false"},t3=M=>{R.dataset.searchStatus="ready",R.dataset.lastSearchQuery=M,O=!0,Le()},Pb=M=>{let L=typeof M=="number"?M:Number(String(M??"").replace("%","").trim());if(!Number.isFinite(L)){R.dataset.zoomInputStatus="invalid",Le();return}let D=Math.max(10,Math.min(500,Math.round(L))),F=s?.instance.view;if(!F?.setScale){R.dataset.zoomInputStatus="unsupported",Le();return}F.setScale(D/100,ve.clientWidth/2,ve.clientHeight/2),R.dataset.zoomInputStatus="success",qs(s?.getSnapshot()),Le()},i3=async M=>{if(M==="exitFullscreen"){document.fullscreenElement&&await document.exitFullscreen(),Cc();return}if(!(M!=="fullscreenCanvas"&&M!=="fullscreenPage")){if(typeof ve.requestFullscreen!="function"){R.dataset.commandStatus="fullscreen-unavailable",R.dataset.disabledReason="fullscreen-unavailable";return}await ve.requestFullscreen(),Cc()}},Lc=(M,L)=>{let D=Sc.toolbarActionMeta[M],F=D?.shortLabel??Eb[M]??M,K=D?.longLabel??Eb[M]??M,ee=D?.iconKey??M;return{id:M,label:F,longLabel:K,icon:YF[ee]??WF[M]??F.slice(0,1),iconKey:ee,priority:D?.priority??999,overflowGroup:D?.overflowGroup??"view",cluster:D?.cluster??"main",disabled:L.disabledActions[M]??!0}},Fb=()=>{let M=window.__MNOTE_MINDMAP_RUST_SHELL__;if(!M){f0(),R.dataset.uiShell="typescript-nodeview-dom",ir.dataset.uiShellSource="missing-rust-shell",ir.innerHTML='
Leptos/Rust mindmap shell \u672A\u52A0\u8F7D
';return}let L=XF(q??ve.getBoundingClientRect().width),D=Vp({activeNodeId:l,readonly:m,runtimeCapabilities:["runtimeCommand","kernelCommand","compatPatch","localView"],shell:{chromeVisibility:Y,toolbarOverflow:{availableWidth:L.availableWidth,visibleActionIds:L.visibleActionIds,overflowActionIds:L.overflowActionIds,moreOpen:P&&L.overflowActionIds.length>0},fullscreen:{mode:v0()?"canvas":"none",isFullscreen:v0(),target:"mindmap-root",apiAvailable:typeof ve.requestFullscreen=="function"},sidebar:{activePanelId:g,panelOpen:x,triggerVisible:y,drawerWidth:S,collapsedByToggle:b},navigator:{searchOpen:O,minimapOpen:I,readonly:m,zoomPercent:Zp().zoomPercent}}}),{mindmapId:F}=et(),K=Zp(),ee=new Set(D.shell.toolbarOverflow.visibleActionIds),ue=new Set(D.shell.toolbarOverflow.overflowActionIds),te={mindmapId:F,toolbarGroups:[{id:"main",label:"\u4E3B\u5DE5\u5177",actions:Xp.filter(we=>ee.size===0||ee.has(we)).map(we=>Lc(we,D))},{id:"overflow",label:"\u66F4\u591A",actions:Xp.filter(we=>ue.has(we)).map(we=>Lc(we,D))},{id:"file",label:"\u6587\u4EF6",actions:OF.map(we=>Lc(we,D))}],sidebarPanels:Sc.sidebarPanels.map(we=>({id:we.id,kind:we.kind,label:we.label,icon:we.icon,active:we.id===g,bodyTitle:we.label,bodyCaption:we.runtimeCapability,options:we.options.map(Ge=>({id:Ge.id,label:Ge.label,actionId:Ge.actionId,value:Ge.value,controlType:Ge.controlType,preview:Ge.preview,description:Ge.description,readonly:Ge.readonly??!1,compatPath:Ge.compatPath})),bodyItems:we.id==="outline"?kb():[]})),navigator:{wordCount:K.wordCount,nodeCount:K.nodeCount,zoomPercent:K.zoomPercent,readonly:m,minimapOpen:I},shell:D.shell};f0(),ir.dataset.uiShellSource="leptos-rust-shell",Hi=M,nn=M.mount(ir,te),R.dataset.uiShell="leptos-rust-shell"},Le=()=>{if(u0(),!wi){Fb(),b0();return}f0(),R.dataset.uiShell="debug-fallback",Kn.dataset.testid="mindmap-command-toolbar",Kn.className="mnote-mindmap-command-toolbar",delete Kn.dataset.schemaSource,Zn.dataset.testid="mindmap-sidebar",Zn.className="mnote-mindmap-side-panel",delete Zn.dataset.schemaSource,Qn.dataset.testid="mindmap-bottom-bar",Qn.className="mnote-mindmap-bottom-bar",delete Qn.dataset.schemaSource;let M=a?Yp(a):"",L=f??M,F=s!==null?"":" disabled",K=["\u8282\u70B9\u6837\u5F0F","\u5BFC\u56FE\u6837\u5F0F","\u4E3B\u9898","\u7ED3\u6784","\u5927\u7EB2"].map((ee,ue)=>``).join("");Kn.innerHTML=`
`,Zn.innerHTML=`${K}
\u7ECF\u5178\u4E3B\u9898 \xB7 \u903B\u8F91\u7ED3\u6784
`,Qn.innerHTML=`runtime \u8282\u70B9100%`},b0=()=>{let M=Xn.querySelector('[data-testid="mindmap-schema-context-menu"]');if(!ce){M?.remove();return}M||(M=document.createElement("div"),M.className="mnote-mindmap-context-menu",M.dataset.testid="mindmap-schema-context-menu",M.dataset.uiSource="typescript-nodeview-bridge",Xn.append(M)),M.dataset.contextMenuKind=at,M.dataset.contextMenuTargetNodeKind=bt;let D=Vp({activeNodeId:at==="node"?It??l:l,readonly:m,runtimeCapabilities:["runtimeCommand","kernelCommand","compatPatch","localView"]}),F=Sc.contextMenuItems.filter(te=>te.requiresNode===(at==="node")),K=te=>D.disabledActions[te]?!0:at!=="node"?!1:bt==="root"&&(te==="insertSiblingAfter"||te==="deleteNode")||bt==="generalization"&&(te==="insertChild"||te==="insertSiblingAfter"||te==="deleteNode"||te==="summary"||te==="associativeLine"||te==="expandCollapse");M.innerHTML=F.map(te=>{let we=K(te.actionId);return``}).join("");let ee=Math.min(Math.max(8,qi),Math.max(8,ve.clientWidth-M.offsetWidth-8)),ue=Math.min(Math.max(8,zt),Math.max(8,ve.clientHeight-M.offsetHeight-8));M.style.left=`${ee}px`,M.style.top=`${ue}px`},r3=()=>{u0(),R.dataset.stage="loading",mt.dataset.runtimeEngine="loading",mt.replaceChildren(),Le()},n3=M=>M==="mnote-web-tree-live"||M==="externalTreeLive"||M==="nodeViewUpdate",qb=M=>M==="mnote-web-tree-live"||M==="externalTreeLive",Hb=()=>R.dataset.stage==="ready"&&mt.dataset.runtimeReady==="true"&&a!==null,Ub=()=>{if(s?.instance.renderer?.textEdit?.isShowTextEdit?.())return!0;let L=document.activeElement;if(L instanceof HTMLElement&&L.closest(".smm-node-edit-wrap"))return!0;let D=document.querySelector('.smm-node-edit-wrap[contenteditable="true"]');return D instanceof HTMLElement&&D.style.display!=="none"&&D.getClientRects().length>0},s3=M=>{let L=s?.instance.renderer?.textEdit;if(!L?.isShowTextEdit?.())return!1;let D=Ds(L.getCurrentEditNode?.())??l??d,F=L.getEditText?.();if(R.dataset.lastRuntimeTextEditFlushReason=M,D&&typeof F=="string"&&F.length>0){R.dataset.lastRuntimeTextEditFlushNodeId=D,R.dataset.lastRuntimeTextEditFlushText=F;let{mindmapId:K}=et(),ee=an();ee&&a&&Gp({documentId:ee,mindmapId:K,commands:[{type:"updateText",mindmapId:K,nodeId:D,text:F}],projectionRevision:a.kernelRevision}).catch(te=>{R.dataset.lastRuntimeTextEditFlushError=te instanceof Error?te.message:String(te)})}return L.hideEditTextBox?.(),!0},a3=(M,L="text_edit")=>{R.dataset.runtimeProjectionDeferred=L,R.dataset.lastRuntimeProjectionDeferReason=M,R.dataset.runtimeProjectionDeferredAt=String(Date.now())},o3=()=>{R.dataset.runtimeProjectionDeferred="",R.dataset.lastRuntimeProjectionDeferReason="",R.dataset.runtimeProjectionDeferredAt=""},Ps=(M,L="")=>{R.dataset.backgroundProjectionRefresh=M,R.dataset.backgroundProjectionRefreshMessage=L,Le()},on=(M,L)=>{let{mindmapId:D}=et();u0(),R.dataset.stage=M,mt.dataset.runtimeEngine="error",mt.innerHTML=`
${Rs(M)}${Rs(D)}${Rs(L)}
`,Le()},Fs=async(M,L)=>{if(R.dataset.commandCount=String(M.length),R.dataset.commandHasProjection=a?"true":"false",R.dataset.commandFailedAction="",R.dataset.commandFailedMessage="",M.length===0||!a){R.dataset.commandStatus="skipped";return}let{mindmapId:D}=et(),F=an();if(!F){on("command_failed",`mindmapId=${D}; documentId_missing`);return}R.dataset.commandStatus="pending";try{let K=await AF({documentId:F,mindmapId:D,commands:M,projectionRevision:a.kernelRevision});if(Ui)return;let ee=L?x0(K,L,et().rootNodeId):void 0;await d3(K,"commandRefresh",ee),f=null,R.dataset.commandStatus="success"}catch(K){let ee=K instanceof Error?K.message:String(K);R.dataset.commandStatus="failed",R.dataset.commandFailedAction=R.dataset.lastSchemaAction??"",R.dataset.commandFailedMessage=ee,on("command_failed",`mindmapId=${D}; ${ee}`)}},l3=async(M,L)=>{if(R.dataset.commandCount=String(M.length),R.dataset.commandHasProjection=a?"true":"false",R.dataset.commandFailedAction="",R.dataset.commandFailedMessage="",R.dataset.commandApplyMode="",R.dataset.commandLocalApplyErrors="",M.length===0||!a){R.dataset.commandStatus="skipped",R.dataset.commandApplyMode="skipped",$i();return}if(!EF(M)){R.dataset.commandApplyMode="refresh:unsupported",$i(),await Fs(M,L);return}let D=Sb();if(!D){R.dataset.commandApplyMode="refresh:no_blob",$i(),await Fs(M,L);return}let F=NF(D,M);if(F.errors.length>0){R.dataset.commandApplyMode="refresh:local_errors",R.dataset.commandLocalApplyErrors=F.errors.join("|"),$i(),await Fs(M,L);return}let{mindmapId:K}=et(),ee=an();if(!ee){on("command_failed",`mindmapId=${K}; documentId_missing`);return}R.dataset.commandStatus="pending",R.dataset.commandApplyMode="local";try{let ue=await Gp({documentId:ee,mindmapId:K,commands:M,projectionRevision:a.kernelRevision});if(Ui)return;if(Ab(F.data,ue.kernelRevision),L){let te=x0(a,L,et().rootNodeId);l=te,d=te,window.setTimeout(()=>{uo(te)},0)}f=null,R.dataset.commandStatus="success",Le()}catch(ue){let te=ue instanceof Error?ue.message:String(ue);R.dataset.commandStatus="failed",R.dataset.commandFailedAction=R.dataset.lastSchemaAction??"",R.dataset.commandFailedMessage=te,$i(),on("command_failed",`mindmapId=${K}; ${te}`)}},$b=async M=>{if(!a)return;let{mindmapId:L}=et(),D=an();if(!D){on("command_failed",`mindmapId=${L}; documentId_missing`);return}R.dataset.viewCommandStatus="pending";try{if(await Gp({documentId:D,mindmapId:L,commands:[CF(L,M)],projectionRevision:a.kernelRevision}),Ui)return;a.view=M,R.dataset.viewCommandStatus="success"}catch(F){on("command_failed",`mindmapId=${L}; ${F instanceof Error?F.message:String(F)}`)}},qs=M=>{let L=HF(M);L&&(A=L,R.dataset.lastViewPatch=JSON.stringify(L),C!==null&&window.clearTimeout(C),C=window.setTimeout(()=>{C=null;let D=A;A=null,D&&$b(D)},350))},jb=M=>{if(s){if(M==="editNode"){let L=s.instance.renderer,D=s.instance.keyCommand,F=L?.textEdit,K=h??L?.activeNodeList?.[0]??null;if(K&&typeof F?.show=="function")F.show({node:K,isFromKeyDown:!1}),R.dataset.editNodeStatus="opened";else if(typeof D?.getShortcutFn=="function"){let ee=D.getShortcutFn("F2")[0];typeof ee=="function"?(ee(),R.dataset.editNodeStatus="opened"):R.dataset.editNodeStatus="unsupported"}else R.dataset.editNodeStatus="unsupported";return}if(M==="centerRoot"&&s.instance.renderer?.setRootNodeCenter?.(),M==="zoomOut"&&s.instance.view?.narrow?.(),M==="zoomIn"&&s.instance.view?.enlarge?.(),M==="fitView"&&s.instance.view?.reset?.(),M==="fullscreenCanvas"||M==="fullscreenPage"||M==="exitFullscreen"){i3(M);return}if(M==="search"){O?(O=!1,R.dataset.lastSearchQuery="",R.dataset.searchStatus="closed",Le()):t3("");return}if(M==="showMenu"){y0("visible"),p0();return}if(M==="expandCollapse"){s.instance.renderer?.toggleActiveExpand?.(),R.dataset.contextMenuActionStatus="success",R.dataset.lastContextMenuAction=M,Le();return}if(M==="copyNodeText"){let L=It??l,D=a?GF(a.root,L):"";R.dataset.copiedNodeText=D,R.dataset.contextMenuActionStatus="success",R.dataset.lastContextMenuAction=M;return}M==="readonly"&&(m=!m,_c()),qs(s.getSnapshot()),Le()}},Gb=(M,L,D)=>{if(!s)return;let F=Nb(M,D);if(!F)return;let K=Lt(s.instance.getThemeConfig?.())?s.instance.getThemeConfig?.():{};if(D&&F.startsWith(`nodes.${D}.data.`)&&h){let ee=F.split(".data.")[1]??"";if(ee==="shape"){h.setShape?.(L);return}if(ee){let ue=ee==="fontWeight"&&L===!0?"bold":ee==="fontStyle"&&L===!0?"italic":L;h.setData?.({[ee]:ue});return}}if(F.startsWith("style.map.")){let ee=F.slice(10);if(!ee)return;s.instance.setThemeConfig?.({...K,[ee]:L},!1)}},Vb=(M,L,D)=>{if(!(!s||L.source!=="sidebar")){if(M==="setLayout"){s.instance.setLayout?.(L.value);return}if(M==="setTheme"){s.instance.setTheme?.(L.value);return}M==="painter"&&typeof L.compatPath=="string"&&Gb(L.compatPath,L.value,D)}},w0=async(M,L={})=>{let D=wb(M);if(!D||Vp({activeNodeId:at==="node"?It??l:l,readonly:m,runtimeCapabilities:["runtimeCommand","kernelCommand","compatPatch","localView"]}).disabledActions[M])return;if(R.dataset.lastSchemaAction=M,D.target==="localView"){jb(M);return}let K=UF(M),ee=at==="node"?It??d??l:d??l;if(D.requiresActiveNode){let Ge=kc(ee);Ge&&(ee=Ge,l=Ge,d=Ge)}R.dataset.lastSchemaActiveNodeId=ee??"";let ue=et().mindmapId;Vb(M,L,ee);let te=IF({actionId:M,mindmapId:ue,activeNodeId:ee,node:K,value:L.value??(M==="editNode"?Yp(a):!0),source:L.source,runtimeRevision:a?.kernelRevision??null,kernelRevision:a?.kernelRevision??null}),we=Nb(L.compatPath,ee);if(we&&(te={command:_F({mindmapId:ue,path:we,value:L.value??!0,source:L.source??"sidebar",actionId:M,runtimeRevision:a?.kernelRevision??null,kernelRevision:a?.kernelRevision??null})}),R.dataset.lastSchemaCommand=te?.command?JSON.stringify(te.command):"",R.dataset.lastSchemaRuntimeCommand=te?.runtimeCommand??"",!!te){if(D.target==="runtimeCommand"&&te.runtimeCommand){if(te.command){Bs(te.runtimeCommand),D.requiresActiveNode&&uo(ee);let Ge=await zb(te.runtimeCommand,ee,K),u3=K?.uid??(M==="deleteNode"?et().rootNodeId:ee);if(Ge?.ok){K?.uid&&(te.runtimeCommand==="INSERT_CHILD_NODE"||te.runtimeCommand==="INSERT_NODE")&&window.setTimeout(()=>{uo(K.uid)},0),l3([te.command],u3);return}R.dataset.commandApplyMode="refresh:runtime_failed",R.dataset.commandRuntimeError=Ge?.error??"unknown",R.dataset.commandRuntimeMessage=Ge&&"message"in Ge?Ge.message??"":"",$i(),Fs([te.command],u3);return}s?.execCommand(te.runtimeCommand);return}te.command&&Fs([te.command])}},Wb=M=>{let L=M.target instanceof Node&&ve.contains(M.target);return RF({debugChromeEnabled:wi,bridgeReady:!!s,readonly:m,isComposing:M.isComposing,isEditableTarget:qF(M.target),targetInsideRoot:L,keyboardShortcutArmed:le})},Yb=M=>{if(!Wb(M))return;let L=zF(M);if(!L)return;M.preventDefault(),M.stopPropagation(),M.stopImmediatePropagation(),R.dataset.lastShortcutKey=M.key,R.dataset.lastShortcutAction=L;let D=a?Mb(a,et().rootNodeId):et().rootNodeId;if(wb(L)?.requiresActiveNode){let K=kc(d??l);if(K===D&&(L==="insertSiblingAfter"||L==="deleteNode")){let ee=kc(null);ee&&ee!==D?K=ee:a&&(K=Tb(a.root)??K)}if(K)l=K,d=K;else{R.dataset.lastShortcutActionBlocked=L;return}}if((d??l)===D&&(L==="insertSiblingAfter"||L==="deleteNode")){R.dataset.lastShortcutActionBlocked=L;return}w0(L,{source:"toolbar"})},Xb=M=>{if(M.type==="node_active"){let K=l;l=Ds(M.args[0])??l,h=typeof M.args[0]=="object"&&M.args[0]!==null?M.args[0]:null,(!d||d===K)&&(d=l),Le();return}if(M.type==="view_data_change"||M.type==="scale"||M.type==="translate"){R.dataset.lastViewEvent=M.type,qs(M.snapshot??s?.getSnapshot());return}if(M.type!=="data_change"||!a||!M.snapshot)return;let L=Os(M.snapshot);R.dataset.lastDataChangeSeenAt=String(Date.now());let D=vb(c,Date.now());if(c=D.state,Wn(),R.dataset.lastDataChangeSuppressed=D.suppressed?"true":"false",R.dataset.lastDataChangeSuppressionExpired=D.expired?"true":"false",D.suppressed){o=L;return}let F=LF({mindmapId:et().mindmapId,previous:o??a.root,next:L});if(o=L,R.dataset.lastDataChangeDiffCommandCount=String(F.commands.length),R.dataset.lastDataChangeCompatPatchCount=String(F.compatPatches.length),F.commands.length===0&&F.compatPatches.length===0){R.dataset.lastDataChangeRefreshTriggered="false";return}R.dataset.lastDataChangeRefreshTriggered="false",l3([...F.commands,...F.compatPatches])},h3=M=>typeof M=="string"&&M.trim().length>0?M.trim():Lt(M)&&typeof M.template=="string"&&M.template.trim().length>0?M.template.trim():null,Kb=(M,L,D)=>{if(!s)return!1;let{mindmapId:F,rootNodeId:K}=et();if(R.dataset.mountedMindmapId&&R.dataset.mountedMindmapId!==F)return!1;let ee=vi(M.root);if(typeof s.instance.setData!="function")return!1;a={...M,root:Os(ee)},l=x0(M,D??l,K),h=null,d=l,o=Os(a.root);let ue=h3(M.layout),te=h3(M.theme),we=Lt(M.themeConfig)?M.themeConfig:null;return ue&&s.instance.setLayout?.(ue,!0),te&&s.instance.setTheme?.(te,!0),we&&s.instance.setThemeConfig?.(we,!0),s.instance.setData(ee),Mr+=1,R.dataset.runtimeProjectionApplyCount=String(Mr),R.dataset.lastRuntimeProjectionApplyReason=L,mt.dataset.runtimeEngine="simple-mind-map",mt.dataset.runtimeReady="true",R.dataset.stage="ready",Ac(F,s),_c(),window.setTimeout(()=>{uo(l)},0),Le(),!0},d3=async(M,L,D)=>{let{mindmapId:F,rootNodeId:K}=et();if(di&&(s?.instance.off?.("node_contextmenu",di),di=null),s?.destroy(),Ac(F,null),a={...M,root:Os(M.root)},l=x0(M,D,K),h=null,d=l,o=Os(a.root),co+=1,R.dataset.runtimeMountCount=String(co),R.dataset.lastRuntimeMountReason=L,R.dataset.mountedMindmapId=F,R.dataset.mountedRootNodeId=K,mt.dataset.runtimeEngine="simple-mind-map",mt.dataset.runtimeReady="false",mt.replaceChildren(),Le(),s=await SF({el:mt,projection:M,mode:m?"readonly":"edit",runtimeOptions:{fit:!0,mousewheelAction:"zoom",enableFreeDrag:!0,enableCtrlKeyNodeSelection:!0,useLeftKeySelectionRightKeyDrag:!0,createNewNodeBehavior:"activeOnly"},onEvent:Xb}),Ui){s.destroy();return}s.refreshProjection=c3,di=(...ee)=>{let[ue,te]=ee;ue instanceof MouseEvent&&(ue.preventDefault(),ue.stopPropagation(),Rb(ue.clientX,ue.clientY,te))},s.instance.on?.("node_contextmenu",di),mt.dataset.runtimeReady="true",R.dataset.stage="ready",Ac(F,s),_c(),window.setTimeout(()=>{uo(l)},0),Le()},M0=async(M="fetchScene",L=0)=>{let D=++n,{mindmapId:F,rootNodeId:K}=et();bi!==null&&(window.clearTimeout(bi),bi=null);let ee=an();if(!ee){if(R.dataset.documentIdResolveStatus="missing",R.dataset.documentIdResolveRetryCount=String(L),L<30){n3(M)||r3(),bi=window.setTimeout(()=>{bi=null,Ui||M0(M,L+1)},100);return}on("projection_load_failed",`mindmapId=${F}; documentId_missing`);return}R.dataset.documentIdResolveStatus="ready",R.dataset.lastFetchSceneReason=M;let ue=n3(M)&&Hb();if(ue&&qb(M)){a3(M,"live_signal"),Ps("deferred","usability_first");return}ue?Ps("pending"):r3();try{if(D!==n)return;let te=await kF({documentId:ee,mindmapId:F,endpoint:`/api/mindmap/${encodeURIComponent(ee)}/${encodeURIComponent(F)}?view=simple_mind_map_scene&queryName=mindmap.simple_mind_map_scene.get&rootNodeId=${encodeURIComponent(K)}`});if(D!==n||Ui)return;try{let we=bb({documentId:ee,mindmapId:F,data:vi(te.root)}),Ge=await fetch(we.endpoint,we.init);R.dataset.initialCreateOnlyStatus=Ge.ok?"success":"failed"}catch{R.dataset.initialCreateOnlyStatus="failed"}if(ue&&Ub()){a3(M),Ps("deferred","text_edit");return}if(ue&&Kb(te,M)){o3(),Ps("success");return}await d3(te,M),o3(),Ps("success")}catch(te){if(ue){Ps("failed",te instanceof Error?te.message:String(te));return}on("projection_load_failed",`mindmapId=${F}; ${te instanceof Error?te.message:String(te)}`)}},c3=async(M="externalTreeLive")=>{if(!Ui){if(tr){sn=M;return}tr=!0;try{await M0(M)}finally{tr=!1;let L=sn;sn=null,L&&!Ui&&c3(L)}}};return Je.addEventListener("pointerdown",M=>M.stopPropagation()),Je.addEventListener("mousedown",M=>M.stopPropagation()),ve.addEventListener("pointerleave",()=>{m0(),!ce&&Y==="visible"&&y0(Ec(Y,"pointerLeave"))}),ve.addEventListener("pointerenter",()=>{Y=Ec(Y,"pointerEnter"),R.dataset.chromeVisibility=Y}),ve.addEventListener("pointerdown",()=>{e3(),Y!=="visible"&&y0(Ec(Y,"restoreClick"))},!0),ve.addEventListener("click",()=>{e3(),Y!=="visible"&&y0(Ec(Y,"restoreClick"))}),Je.addEventListener("mnote:mindmap-shell:action",M=>{let L=M.detail,D=L?.actionId;if(D==="search"){if(typeof L?.value=="string"){t3(L.value);return}if(L?.value===!1){O=!1,R.dataset.lastSearchQuery="",R.dataset.searchStatus="closed",Le();return}}D&&(m0(),w0(D,L))}),Je.addEventListener("mnote:mindmap-shell:zoom",M=>{let L=M.detail;Pb(L?.percent)}),Je.addEventListener("mnote:mindmap-shell:minimap",M=>{let L=M.detail;I=typeof L?.open=="boolean"?L.open:!I,R.dataset.minimapOpen=I?"true":"false",Le()}),Je.addEventListener("mnote:mindmap-shell:toolbar-overflow",M=>{let L=M.detail;P=typeof L?.moreOpen=="boolean"?L.moreOpen:!P,Le()}),Je.addEventListener("mnote:mindmap-shell:panel",M=>{let L=M.detail,D=L?.event??"togglePanel";if(D==="restoreTrigger"){y=!0,x=!0,b=!1,Le();return}if(D==="hideTrigger"){y=!1,x=!1,b=!0,Le();return}if(D==="closeDrawer"){x=!1,b=!1,Le();return}L?.panelId&&(L.panelId===g?x=!x:(g=L.panelId,x=!0),y=!0,b=!1,Le())}),ke=()=>Cc(),document.addEventListener("fullscreenchange",ke),Q=()=>{s3("page_lifecycle")},window.addEventListener("pagehide",Q),window.addEventListener("beforeunload",Q),Je.addEventListener("input",M=>{if(!wi)return;let L=M.target;L instanceof HTMLInputElement&&L.dataset.testid==="mindmap-command-text-input"&&(f=L.value)}),typeof ResizeObserver<"u"&&(W=new ResizeObserver(M=>{let L=M[0]?.contentRect.width??null;L===null||Math.abs(L-(q??0))<1||(q=L,wi||Le())}),W.observe(ve)),ne=M=>{let L=M.target;if((!(L instanceof Node)||!ve.contains(L))&&Ob(),ce){if(L instanceof Node){let D=Xn.querySelector('[data-testid="mindmap-schema-context-menu"]');if(D instanceof HTMLElement&&D.contains(L))return}p0()}P&&(L instanceof Node&&ir.contains(L)||m0())},re=M=>{if(M.key==="Escape"){if(ce){M.preventDefault(),p0();return}if(v0()){M.preventDefault(),i3("exitFullscreen");return}P&&(M.preventDefault(),m0())}},oe=M=>Yb(M),document.addEventListener("pointerdown",ne),document.addEventListener("keydown",re),document.addEventListener("keydown",oe,!0),Je.addEventListener("click",M=>{M.stopPropagation();let L=M.target;if(!(L instanceof HTMLElement))return;let D=L.closest("button");if(D instanceof HTMLButtonElement){if(!wi){M.preventDefault();let F=D.dataset.mindmapContextActionId;if(F){if(D.disabled||D.dataset.disabled==="true")return;p0(),w0(F);return}if(D.closest('[data-testid="mindmap-rust-shell"]'))return;let K=D.dataset.mindmapActionId;K&&w0(K);let ee=D.dataset.mindmapSidebarPanelId;ee&&(g=ee,Le());return}if(D.dataset.testid==="mindmap-command-update-text"){M.preventDefault();let F=ve.querySelector('[data-testid="mindmap-command-text-input"]'),K=f??(F instanceof HTMLInputElement?F.value:Yp(a));R.dataset.lastCommandText=K,Fs([{type:"updateText",mindmapId:et().mindmapId,nodeId:l??et().rootNodeId,text:K}])}D.dataset.testid==="mindmap-command-add-child"&&(M.preventDefault(),s?.execCommand("INSERT_CHILD_NODE")),D.dataset.testid==="mindmap-command-add-sibling-after"&&(M.preventDefault(),s?.execCommand("INSERT_NODE")),D.dataset.testid==="mindmap-command-delete-node"&&(M.preventDefault(),s?.execCommand("REMOVE_NODE")),D.dataset.testid==="mindmap-toolbar-undo"&&(M.preventDefault(),s?.execCommand("BACK")),D.dataset.testid==="mindmap-toolbar-redo"&&(M.preventDefault(),s?.execCommand("FORWARD")),D.dataset.testid==="mindmap-toolbar-summary"&&(M.preventDefault(),s?.execCommand("ADD_GENERALIZATION")),D.dataset.testid==="mindmap-bottom-center-root"&&(M.preventDefault(),s?.instance.renderer?.setRootNodeCenter?.(),qs(s?.getSnapshot())),D.dataset.testid==="mindmap-bottom-zoom-out"&&(M.preventDefault(),s?.instance.view?.narrow?.(),qs(s?.getSnapshot())),D.dataset.testid==="mindmap-bottom-zoom-in"&&(M.preventDefault(),s?.instance.view?.enlarge?.(),qs(s?.getSnapshot()))}}),Je.addEventListener("contextmenu",M=>{if(wi)return;let L=M.target;if(L instanceof Element&&(L.closest(".smm-node")||L.closest('[class*="generalization_"]'))){M.preventDefault();return}M.preventDefault(),M.stopPropagation(),Db(M.clientX,M.clientY)},!0),c0.append(mt),wi?(Yn.append(mt,Zn),R.append(Kn,Yn,Qn)):(Xn.append(ir),Yn.append(c0,Xn),R.append(Yn)),ve.append(R),Je.append(ve),M0("initial"),{dom:Je,update(M){if(M.type.name!==r.type.name)return!1;let L=Kp(r);return r=M,r.attrs.mnoteBlockType!=="mindmap"?!1:(u0(),Kp(r)!==L&&M0("nodeViewUpdate"),!0)},stopEvent:()=>!0,ignoreMutation:()=>!0,destroy(){s3("node_view_destroy"),Ui=!0,bi!==null&&window.clearTimeout(bi),C!==null&&window.clearTimeout(C),W?.disconnect(),ne&&document.removeEventListener("pointerdown",ne),re&&document.removeEventListener("keydown",re),oe&&document.removeEventListener("keydown",oe,!0),ke&&document.removeEventListener("fullscreenchange",ke),Q&&(window.removeEventListener("pagehide",Q),window.removeEventListener("beforeunload",Q)),di&&s?.instance.off?.("node_contextmenu",di);let{mindmapId:M}=et();Ac(M,null),s?.destroy(),f0(),s=null}}}}function ZF(){return({node:i})=>{let e=i,t=document.createElement("p"),r=()=>{let n=e.attrs.blockId;typeof n=="string"&&n.length>0?(t.dataset.blockId=n,t.id=n):(t.removeAttribute("data-block-id"),t.removeAttribute("id"))};return r(),{dom:t,contentDOM:t,update(n){return n.type.name!==e.type.name||n.attrs.mnoteBlockType==="mindmap"?!1:(e=n,r(),!0)}}}}var QF=p3.extend({addAttributes(){return{...this.parent?.(),blockId:{default:null,parseHTML:i=>i.getAttribute("data-block-id"),renderHTML:i=>BF(i.blockId)},mnoteBlockType:{default:null,parseHTML:i=>i.getAttribute("data-mnote-block-type"),renderHTML:i=>PF(i)},mindmapId:{default:null,parseHTML:i=>i.getAttribute("data-mnote-mindmap-id"),renderHTML:()=>({})},rootNodeId:{default:null,parseHTML:i=>i.getAttribute("data-mnote-root-node-id"),renderHTML:()=>({})},projectionVersion:{default:null,parseHTML:i=>Number(i.getAttribute("data-mnote-projection-version")??1),renderHTML:()=>({})}}},addNodeView(){let i=KF(this.editor),e=ZF();return({node:t,getPos:r})=>t.attrs.mnoteBlockType==="mindmap"?i({node:t,getPos:r}):e({node:t})}}),JF={name:"paragraph",create:()=>QF,commands:{set_paragraph:i=>i.chain().focus().setParagraph().run()},selection_keys:["paragraph"],selection_state:i=>({paragraph:i.isActive("paragraph")})};function Jne(){y3(JF)}export{Jne as register_paragraph,FF as resolveCurrentDocumentId}; /*! Bundled license information: @svgdotjs/svg.js/dist/svg.esm.js: diff --git a/rust/target/.rustc_info.json b/rust/target/.rustc_info.json index b3a530bb..b88732b1 100644 --- a/rust/target/.rustc_info.json +++ b/rust/target/.rustc_info.json @@ -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":{}} \ No newline at end of file +{"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":{}} \ No newline at end of file diff --git a/scripts/task169-mindmap-realtime-smoke.js b/scripts/task169-mindmap-realtime-smoke.js index 1238be54..cbf58dd1 100644 --- a/scripts/task169-mindmap-realtime-smoke.js +++ b/scripts/task169-mindmap-realtime-smoke.js @@ -126,7 +126,7 @@ function attachNetworkCapture(page, label, records) { url, status, requestBody: request.postData() || null, - responseText: responseText ? responseText.slice(0, 4000) : null, + responseText: responseText ? responseText.slice(0, 12000) : null, }); }); page.on("requestfailed", (request) => { @@ -178,6 +178,28 @@ async function readPageState(page) { const assetRows = Array.from(document.querySelectorAll("#sidebar-file-tree-root [data-asset-id]")) .map((node) => (node instanceof HTMLElement ? node.getAttribute("data-asset-id") || "" : "")) .filter(Boolean); + const fileTreeAssetRowElements = Array.from(document.querySelectorAll("#sidebar-file-tree-root [data-asset-id]")) + .map((node) => (node instanceof HTMLElement ? node.closest(".tree-row") || node : null)) + .filter((node, index, rows) => node instanceof HTMLElement && rows.indexOf(node) === index); + const mindmapRows = fileTreeAssetRowElements + .filter((node) => { + if (!(node instanceof HTMLElement)) return false; + const objectKind = node.getAttribute("data-object-kind") || ""; + const objectIdentity = node.getAttribute("data-object-identity") || ""; + const assetId = node.getAttribute("data-asset-id") || ""; + return objectKind === "mindmap" || objectIdentity.includes('"objectKind":"mindmap"') || assetId.startsWith("mindmap"); + }) + .map((node) => { + const element = node; + return { + rowId: element.getAttribute("data-row-id") || "", + documentId: element.getAttribute("data-document-id") || element.getAttribute("data-doc-id") || "", + assetId: element.getAttribute("data-asset-id") || "", + objectKind: element.getAttribute("data-object-kind") || "", + objectIdentity: element.getAttribute("data-object-identity") || "", + title: element.textContent || "", + }; + }); return { url: window.location.href, bodyText: (document.body?.innerText || "").slice(0, 8000), @@ -212,6 +234,7 @@ async function readPageState(page) { mindmapLoadingEvents: window.__MNOTE_MINDMAP_LOADING_EVENTS__ || [], fileTreeText: (document.getElementById("sidebar-file-tree-root")?.textContent || "").slice(0, 4000), fileTreeAssetIds: assetRows, + fileTreeMindmapRows: mindmapRows, treeEvents: window.__MNOTE_SMOKE_TREE_EVENTS__ || [], }; }); @@ -260,6 +283,24 @@ function stableJson(value) { return JSON.stringify(value ?? null); } +async function waitForMindmapRuntimeViewSettled(page, mindmapId, label) { + let previous = await readMindmapRuntimeStabilityState(page, mindmapId); + for (let index = 0; index < 12; index += 1) { + await page.waitForTimeout(350); + const current = await readMindmapRuntimeStabilityState(page, mindmapId); + if ( + stableJson(current.viewTransform) === stableJson(previous.viewTransform) && + stableJson(current.topicRect) === stableJson(previous.topicRect) && + current.runtimeMountCount === previous.runtimeMountCount && + current.runtimeProjectionApplyCount === previous.runtimeProjectionApplyCount + ) { + return current; + } + previous = current; + } + throw new Error(`mindmap_runtime_view_not_settled:${label}`); +} + function rectCenter(rect) { if (!rect) return null; return { @@ -271,7 +312,7 @@ function rectCenter(rect) { function centerDistance(a, b) { const ca = rectCenter(a); const cb = rectCenter(b); - if (!ca || !cb) return Number.POSITIVE_INFINITY; + if (!ca || !cb) return 0; return Math.hypot(ca.x - cb.x, ca.y - cb.y); } @@ -324,6 +365,7 @@ async function dispatchSyntheticMindmapTreeSignal(page, documentId, mindmapId, e } async function assertMindmapRuntimeDoesNotRefreshForLiveSignals(page, documentId, mindmapId, failures, label) { + await waitForMindmapRuntimeViewSettled(page, mindmapId, label); const before = await readPageState(page); const beforeRuntime = await readMindmapRuntimeStabilityState(page, mindmapId); if (before.hasMindmapError || before.hasMindmapLoading || !before.runtimeReady) { @@ -551,6 +593,56 @@ async function assertFileTreeMindmapOpenUsesObjectShell(page, documentId, mindma return { opened, state: readyState }; } +async function assertSingleMindmapAssetRowStable(page, documentId, mindmapId, expectedObjectIdentity, failures, label) { + await openFilesystemView(page); + const state = await readPageState(page); + const rows = state.fileTreeMindmapRows.filter((row) => row.documentId === documentId); + if (rows.length !== 1) { + failures.push({ + code: "filetree_mindmap_asset_row_count_changed", + label, + expectedCount: 1, + actualCount: rows.length, + rows, + state, + }); + return { ok: false, rows, state, objectIdentity: expectedObjectIdentity }; + } + const row = rows[0]; + if (row.assetId !== mindmapId) { + failures.push({ + code: "filetree_mindmap_asset_id_changed", + label, + expectedAssetId: mindmapId, + row, + state, + }); + } + if (!row.objectIdentity.includes(`"objectKind":"mindmap"`) || !row.objectIdentity.includes(`"assetId":"${mindmapId}"`)) { + failures.push({ + code: "filetree_mindmap_asset_identity_invalid", + label, + row, + state, + }); + } + if (expectedObjectIdentity && row.objectIdentity !== expectedObjectIdentity) { + failures.push({ + code: "filetree_mindmap_asset_identity_changed", + label, + expectedObjectIdentity, + row, + state, + }); + } + return { + ok: failures.length === 0, + rows, + state, + objectIdentity: row.objectIdentity, + }; +} + async function assertFileTreeIndexOpenUsesPageAggregate(page, documentId, mindmapId, failures) { await openFilesystemView(page); const opened = await page.evaluate( @@ -942,6 +1034,77 @@ function findBadRecord(records) { }); } +function parseJsonMaybe(value) { + if (typeof value !== "string" || !value.trim()) return null; + try { + return JSON.parse(value); + } catch { + return null; + } +} + +function assertMindmapCommandApplyArtifactsDoNotTouchTreeResource(records, failures) { + const commandApplyResponses = records.filter((record) => { + if (record.type !== "response" || record.method !== "POST" || !record.url.includes("/api/mindmap/")) { + return false; + } + const requestBody = parseJsonMaybe(record.requestBody); + return requestBody?.commandName === "mindmap.command.apply"; + }); + if (commandApplyResponses.length === 0) { + failures.push({ code: "mindmap_command_apply_artifact_response_missing" }); + return { checked: 0, artifacts: [] }; + } + + const artifacts = []; + let parsedCount = 0; + let skippedReadFailures = 0; + for (const record of commandApplyResponses) { + const responseBody = parseJsonMaybe(record.responseText); + const eventType = String(responseBody?.artifacts?.domainEvent?.eventType || ""); + const streamDelta = responseBody?.artifacts?.domainEvent?.payload?.streamDelta || null; + const streamOp = String(streamDelta?.op || ""); + const summary = { + url: record.url, + commandName: responseBody?.commandName || "", + eventType, + streamOp, + }; + artifacts.push(summary); + if (!responseBody) { + if (String(record.responseText || "").startsWith("< { + 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); + }); +} diff --git a/scripts/task178-page-ai-local-subtree-context-smoke.js b/scripts/task178-page-ai-local-subtree-context-smoke.js new file mode 100644 index 00000000..047eb581 --- /dev/null +++ b/scripts/task178-page-ai-local-subtree-context-smoke.js @@ -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); + }); +} diff --git a/scripts/task179-tree-create-delete-no-reload-smoke.js b/scripts/task179-tree-create-delete-no-reload-smoke.js new file mode 100644 index 00000000..5594292c --- /dev/null +++ b/scripts/task179-tree-create-delete-no-reload-smoke.js @@ -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; +}); diff --git a/scripts/task180-mindmap-ghost-candidate-audit.js b/scripts/task180-mindmap-ghost-candidate-audit.js new file mode 100644 index 00000000..f104b8fc --- /dev/null +++ b/scripts/task180-mindmap-ghost-candidate-audit.js @@ -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 ''", + " node scripts/task180-mindmap-ghost-candidate-audit.js --url 'http://127.0.0.1:3000/api/tree/projections/file?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, +}; diff --git a/scripts/task180-mindmap-ghost-candidate-audit.test.js b/scripts/task180-mindmap-ghost-candidate-audit.test.js new file mode 100644 index 00000000..c412c22d --- /dev/null +++ b/scripts/task180-mindmap-ghost-candidate-audit.test.js @@ -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"); diff --git a/wolai-frontend/convex/bridgeLogs.ts b/wolai-frontend/convex/bridgeLogs.ts index 1021031f..d9c4d060 100644 --- a/wolai-frontend/convex/bridgeLogs.ts +++ b/wolai-frontend/convex/bridgeLogs.ts @@ -38,6 +38,10 @@ function decodeCursor(raw: string | null | undefined) { } } +function stripDomainEventCursorPrefix(id: string) { + return id.startsWith("domain_event:") ? id.slice("domain_event:".length) : id; +} + function encodeCursor(row: { created_at?: string | null; id?: string | null } | null) { if (!row?.created_at || !row?.id) return null; return JSON.stringify({ @@ -46,6 +50,25 @@ function encodeCursor(row: { created_at?: string | null; id?: string | null } | }); } +function encodeDomainEventCursor(row: { created_at?: string | null; id?: string | null } | null) { + if (!row?.created_at || !row?.id) return null; + return JSON.stringify({ + createdAt: row.created_at, + id: `domain_event:${row.id}`, + }); +} + +function encodeOverviewNextCursor( + commandLogs: Array<{ created_at?: string | null; id?: string | null }>, + domainEvents: Array<{ created_at?: string | null; id?: string | null }>, +) { + // overview 的分页主轴仍以 command log 为准;没有 command log 时才回退到 domain event cursor。 + // 实时流的 live tail 每轮查最新窗口,不依赖这个 next_cursor。 + const oldestCommand = commandLogs[commandLogs.length - 1] ?? null; + const oldestEvent = domainEvents[domainEvents.length - 1] ?? null; + return encodeCursor(oldestCommand) ?? encodeDomainEventCursor(oldestEvent); +} + function matchesCursor>( row: T, cursor: { createdAt: string; id: string } | null, @@ -53,10 +76,11 @@ function matchesCursor>( if (!cursor) return true; const createdAt = String(row.created_at ?? row.finished_at ?? ""); const id = String(row.id ?? ""); + const cursorId = stripDomainEventCursorPrefix(cursor.id); if (!createdAt || !id) return false; if (createdAt < cursor.createdAt) return true; if (createdAt > cursor.createdAt) return false; - return id < cursor.id; + return id < cursorId; } function normalizeStatusFilter(raw: string | null | undefined) { @@ -69,6 +93,128 @@ function normalizeObjectFilter(raw: string | null | undefined) { return normalized.length > 0 ? normalized : null; } +type Cursor = { createdAt: string; id: string } | null; + +const OVERVIEW_SCAN_MULTIPLIER = 4; + +function overviewScanLimit(limit: number) { + return Math.min(500, Math.max(limit + 1, limit * OVERVIEW_SCAN_MULTIPLIER)); +} + +function applyCursorUpperBound(query: any, cursor: Cursor) { + return cursor ? query.lte("created_at", cursor.createdAt) : query; +} + +async function fetchCommandLogWindow(ctx: any, args: { + workspaceId: string; + limit: number; + cursor: Cursor; + commandStatus: string | null; + targetPageId: string | null; + targetBlockId: string | null; +}) { + const scanLimit = overviewScanLimit(args.limit); + let query; + if (args.targetBlockId) { + query = ctx.db + .query("command_logs") + .withIndex("by_workspace_target_block_created_at", (q: any) => + applyCursorUpperBound( + q.eq("workspace_id", args.workspaceId).eq("target_block_id", args.targetBlockId), + args.cursor, + ), + ); + } else if (args.targetPageId) { + query = ctx.db + .query("command_logs") + .withIndex("by_workspace_target_page_created_at", (q: any) => + applyCursorUpperBound( + q.eq("workspace_id", args.workspaceId).eq("target_page_id", args.targetPageId), + args.cursor, + ), + ); + } else if (args.commandStatus) { + query = ctx.db + .query("command_logs") + .withIndex("by_workspace_status_created_at", (q: any) => + applyCursorUpperBound( + q.eq("workspace_id", args.workspaceId).eq("status", args.commandStatus), + args.cursor, + ), + ); + } else { + query = ctx.db + .query("command_logs") + .withIndex("by_workspace_created_at", (q: any) => + applyCursorUpperBound(q.eq("workspace_id", args.workspaceId), args.cursor), + ); + } + + const rows = await query.order("desc").take(scanLimit); + const filteredRows = rows.filter((row: any) => { + if (args.commandStatus && row.status !== args.commandStatus) return false; + if (args.targetPageId && String(row.target_page_id ?? "") !== args.targetPageId) return false; + if (args.targetBlockId && String(row.target_block_id ?? "") !== args.targetBlockId) return false; + return matchesCursor(row, args.cursor); + }); + return { + rows: filteredRows.slice(0, args.limit), + hasMore: filteredRows.length > args.limit || rows.length === scanLimit, + }; +} + +async function fetchDomainEventWindow(ctx: any, args: { + workspaceId: string; + limit: number; + cursor: Cursor; + eventStatus: string | null; + aggregateType: string | null; + aggregateId: string | null; +}) { + const scanLimit = overviewScanLimit(args.limit); + let query; + if (args.aggregateType && args.aggregateId) { + query = ctx.db + .query("domain_events") + .withIndex("by_workspace_aggregate_created_at", (q: any) => + applyCursorUpperBound( + q + .eq("workspace_id", args.workspaceId) + .eq("aggregate_type", args.aggregateType) + .eq("aggregate_id", args.aggregateId), + args.cursor, + ), + ); + } else if (args.eventStatus) { + query = ctx.db + .query("domain_events") + .withIndex("by_workspace_status_created_at", (q: any) => + applyCursorUpperBound( + q.eq("workspace_id", args.workspaceId).eq("status", args.eventStatus), + args.cursor, + ), + ); + } else { + query = ctx.db + .query("domain_events") + .withIndex("by_workspace_created_at", (q: any) => + applyCursorUpperBound(q.eq("workspace_id", args.workspaceId), args.cursor), + ); + } + + const rows = await query.order("desc").take(scanLimit); + const filteredRows = rows.filter((row: any) => { + if (args.eventStatus && row.status !== args.eventStatus) return false; + if (args.aggregateType && String(row.aggregate_type ?? "") !== args.aggregateType) return false; + if (args.aggregateId && String(row.aggregate_id ?? "") !== args.aggregateId) return false; + return matchesCursor(row, args.cursor); + }); + return { + rows: filteredRows.slice(0, args.limit), + hasMore: filteredRows.length > args.limit || rows.length === scanLimit, + }; +} + export const recordCommandLog = mutation({ args: { workspaceId: v.string(), @@ -280,44 +426,33 @@ export const listWorkspaceOverview = query({ const aggregateType = normalizeObjectFilter(args.aggregateType); const aggregateId = normalizeObjectFilter(args.aggregateId); - const allCommandLogs = await ctx.db - .query("command_logs") - .withIndex("by_workspace_request", (q) => q.eq("workspace_id", args.workspaceId)) - .collect(); - const allDomainEvents = await ctx.db - .query("domain_events") - .withIndex("by_workspace_request", (q) => q.eq("workspace_id", args.workspaceId)) - .collect(); + const commandWindow = await fetchCommandLogWindow(ctx, { + workspaceId: args.workspaceId, + limit, + cursor, + commandStatus, + targetPageId, + targetBlockId, + }); + const domainEventWindow = await fetchDomainEventWindow(ctx, { + workspaceId: args.workspaceId, + limit, + cursor, + eventStatus, + aggregateType, + aggregateId, + }); - const filteredCommandLogs = sortByNewest( - allCommandLogs.filter((row: any) => { - if (commandStatus && row.status !== commandStatus) return false; - if (targetPageId && String(row.target_page_id ?? "") !== targetPageId) return false; - if (targetBlockId && String(row.target_block_id ?? "") !== targetBlockId) return false; - return matchesCursor(row, cursor); - }), - ); - - const pageCommandLogs = filteredCommandLogs.slice(0, limit); - const nextCursor = encodeCursor(pageCommandLogs[pageCommandLogs.length - 1] ?? null); - const commandIds = new Set(pageCommandLogs.map((row: any) => String(row.command_id))); - - const domainEvents = sortByNewest( - allDomainEvents.filter((row: any) => { - if (commandIds.size > 0 && !commandIds.has(String(row.command_id ?? ""))) return false; - if (eventStatus && row.status !== eventStatus) return false; - if (aggregateType && String(row.aggregate_type ?? "") !== aggregateType) return false; - if (aggregateId && String(row.aggregate_id ?? "") !== aggregateId) return false; - return true; - }), - ); + const pageCommandLogs = sortByNewest(commandWindow.rows); + const domainEvents = sortByNewest(domainEventWindow.rows); + const nextCursor = encodeOverviewNextCursor(pageCommandLogs, domainEvents); return { workspace_id: args.workspaceId, command_logs: pageCommandLogs, domain_events: domainEvents, next_cursor: nextCursor, - has_more: filteredCommandLogs.length > pageCommandLogs.length, + has_more: commandWindow.hasMore || domainEventWindow.hasMore, filters: { command_status: commandStatus, event_status: eventStatus, diff --git a/wolai-frontend/convex/schema.ts b/wolai-frontend/convex/schema.ts index 6259c10f..c1ec1550 100644 --- a/wolai-frontend/convex/schema.ts +++ b/wolai-frontend/convex/schema.ts @@ -474,7 +474,11 @@ export default defineSchema({ .index("by_command_log_id", ["id"]) .index("by_workspace_request", ["workspace_id", "request_id"]) .index("by_workspace_trace", ["workspace_id", "trace_id"]) - .index("by_workspace_command", ["workspace_id", "command_id"]), + .index("by_workspace_command", ["workspace_id", "command_id"]) + .index("by_workspace_created_at", ["workspace_id", "created_at", "id"]) + .index("by_workspace_status_created_at", ["workspace_id", "status", "created_at", "id"]) + .index("by_workspace_target_page_created_at", ["workspace_id", "target_page_id", "created_at", "id"]) + .index("by_workspace_target_block_created_at", ["workspace_id", "target_block_id", "created_at", "id"]), domain_events: defineTable({ id: v.string(), @@ -495,5 +499,14 @@ export default defineSchema({ .index("by_domain_event_id", ["id"]) .index("by_workspace_request", ["workspace_id", "request_id"]) .index("by_workspace_trace", ["workspace_id", "trace_id"]) - .index("by_workspace_command", ["workspace_id", "command_id"]), + .index("by_workspace_command", ["workspace_id", "command_id"]) + .index("by_workspace_created_at", ["workspace_id", "created_at", "id"]) + .index("by_workspace_status_created_at", ["workspace_id", "status", "created_at", "id"]) + .index("by_workspace_aggregate_created_at", [ + "workspace_id", + "aggregate_type", + "aggregate_id", + "created_at", + "id", + ]), }); diff --git a/wolai-frontend/src/app/api/mindmap-ai/expand-node/route.test.ts b/wolai-frontend/src/app/api/mindmap-ai/expand-node/route.test.ts new file mode 100644 index 00000000..34f026c7 --- /dev/null +++ b/wolai-frontend/src/app/api/mindmap-ai/expand-node/route.test.ts @@ -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"); + }); +}); diff --git a/wolai-frontend/src/app/api/mindmap-ai/expand-node/route.ts b/wolai-frontend/src/app/api/mindmap-ai/expand-node/route.ts index e5865ad3..c8a32d9b 100644 --- a/wolai-frontend/src/app/api/mindmap-ai/expand-node/route.ts +++ b/wolai-frontend/src/app/api/mindmap-ai/expand-node/route.ts @@ -1,357 +1,23 @@ import { NextResponse } from "next/server"; -import { loadOnlineAiConfig } from "@/lib/ai/onlineAiConfig"; -import { openAiCompatibleChat, tryExtractJsonObject } from "@/lib/ai/openaiCompatibleChat"; -import { buildDocumentBridgeContextWithActor } from "@/lib/documents/bridge"; -import { executeRustBridgeTool } from "@/lib/documents/rust-runtime"; -import { readMindmapLocal } from "@/lib/mindmap/mindmapLocalStore"; -type RequestPayload = { - documentId: string; - mindmapId: string; - targetUid: string; - instruction?: string; - sources?: { searxng?: boolean; rag?: boolean }; -}; +export const dynamic = "force-dynamic"; -type SearxResult = { title: string; url: string; snippet?: string; engine?: string }; - -const defaultMindmapData = { - data: { text: "中心主题" }, - children: [], -}; - -const findNodeByUid = (root: any, uid: string): any | null => { - const target = String(uid || ""); - if (!target) return null; - const walk = (node: any): any | null => { - const nuid = String(node?.data?.uid || node?.uid || ""); - if (nuid === target) return node; - const children = Array.isArray(node?.children) ? node.children : []; - for (const c of children) { - const hit = walk(c); - if (hit) return hit; - } - return null; - }; - return walk(root); -}; - -const safeUrlOrNull = (value: unknown) => { - const s = typeof value === "string" ? value.trim() : ""; - if (!s) return null; - try { - const u = new URL(s); - if (u.protocol !== "http:" && u.protocol !== "https:") return null; - return u.toString(); - } catch { - return null; - } -}; - -const searchSearxng = async (q: string, count = 5): Promise => { - 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) => { - 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) => { - const ops = (raw as any)?.ops; - return Array.isArray(ops) ? ops : []; -}; - -export async function POST(request: Request) { - const payload = (await request.json().catch(() => null)) as RequestPayload | null; - if (!payload?.documentId || !payload?.mindmapId || !payload?.targetUid) { - return NextResponse.json({ error: "缺少 documentId/mindmapId/targetUid" }, { status: 400 }); - } - - return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 }); - - /* - const supabase = await createSupabaseRouteClient(); - const { - data: { session }, - } = await supabase.auth.getSession(); - if (!session) { - return NextResponse.json({ error: "未登录" }, { status: 401 }); - } - - // 校验页面归属 - const { data: doc, error: docError } = await supabase - .from("documents") - .select("id,title,mindmap_data") - .eq("id", payload.documentId) - .eq("user_id", session.user.id) - .maybeSingle(); - if (docError) { - return NextResponse.json({ error: docError.message }, { status: 400 }); - } - if (!doc) { - return NextResponse.json({ error: "页面不存在" }, { status: 404 }); - } - - const local = await readMindmapLocal(payload.documentId, payload.mindmapId); - const baseData = (local.ok ? local.data : (doc.mindmap_data ?? defaultMindmapData)) as MindmapTreeNode; - - const target = findNodeByUid(baseData, payload.targetUid); - if (!target) { - return NextResponse.json({ error: "未找到目标节点(uid 不存在)" }, { status: 404 }); - } - - const targetText = String(target?.data?.text ?? "").trim(); - const currentChildren = Array.isArray(target?.children) - ? target.children - .map((c: any) => String(c?.data?.text ?? "").trim()) - .filter(Boolean) - .slice(0, 20) - : []; - - const instruction = String(payload.instruction ?? "").trim(); - const query = [targetText, instruction].filter(Boolean).join(" "); - - const useSearx = payload.sources?.searxng !== false; - const searxResults = useSearx && query ? await searchSearxng(query, 6).catch(() => []) : []; - - const cfg = await loadOnlineAiConfig().catch(() => null); - if (!cfg) { - return NextResponse.json({ error: "未找到在线 AI 配置(ai.md 或环境变量)" }, { status: 500 }); - } - - const system = [ - "你是一个“思维导图补完器”。", - "你必须输出严格 JSON(不要 Markdown/不要代码块/不要解释)。", - "你只能输出 {\"ops\": MindmapOp[]} 这一个对象。", - "默认策略:为 targetUid 新增 3~6 个子节点(addChild)。", - "每个新增节点必须提供 text,并尽量提供 hyperlink 与 refs(引用来自搜索结果 URL)。", - "不要删除/改名已有节点;不要输出 addSiblingAfter/updateText/deleteNode。", - ].join("\n"); - - const user = [ - `documentId=${payload.documentId}`, - `mindmapId=${payload.mindmapId}`, - `targetUid=${payload.targetUid}`, - "", - `目标节点:${targetText || "(empty)"}`, - currentChildren.length ? `当前子节点(供去重):${currentChildren.join(";")}` : "", - instruction ? `用户要求:${instruction}` : "", - "", - "可用证据(搜索结果):", - ...(searxResults.length - ? searxResults.map((r, idx) => { - const snip = (r.snippet || "").replace(/\s+/g, " ").slice(0, 180); - return `${idx + 1}. ${r.title}\n url: ${r.url}\n snippet: ${snip}`; - }) - : ["(无)"]), - "", - "MindmapOp JSON Schema(仅供理解):", - '{ "ops": [ { "op": "addChild", "parentUid": string, "node": { "text": string, "hyperlink"?: string, "refs"?: NodeRef[] } } ] }', - 'NodeRef 示例:{ "kind": "url", "fileUrl": "https://example.com", "title": "来源标题", "snippet": "..." }', - "", - "硬性约束:", - "- 仅输出 addChild;parentUid 必须等于 targetUid。", - "- 新增节点 text 不要与当前子节点重复。", - "- hyperlink 必须是 http(s) URL。", - "- 每个新增节点 refs 至少 1 条(kind=url, fileUrl=来源url)。", - "- 输出规模控制:最多 6 个节点。", - ] - .filter(Boolean) - .join("\n"); - - let finishReason = ""; - let ops: MindmapOp[] = []; - try { - const { text, raw } = await openAiCompatibleChat( - [ - { role: "system", content: system }, - { role: "user", content: user }, - ], - { - baseUrl: cfg.baseUrl, - apiKey: cfg.apiKey, - model: cfg.model, - timeoutMs: 40_000, - maxTokens: 1800, - maxCompletionTokens: 1800, - responseFormat: "json_object", - }, - ); - - finishReason = String((raw as any)?.choices?.[0]?.finish_reason ?? ""); - - const json = tryExtractJsonObject(text); - if (json) { - ops = coerceOpsFromAiJson(json); - // 安全收敛:仅允许 addChild 且 parentUid==targetUid - ops = ops - .filter((op) => op && typeof op === "object" && (op as any).op === "addChild") - .filter((op) => String((op as any).parentUid || "") === payload.targetUid) - .slice(0, 8); - } - } catch { - // ignore: 后续走兜底策略 - ops = []; - } - - // 再做一次补齐/校验:refs/hyperlink - const fallbackRefsFrom = (r: SearxResult): NodeRef[] => [ +export async function POST() { + return NextResponse.json( { - kind: "url", - fileUrl: r.url, - title: r.title, - snippet: r.snippet ? r.snippet.slice(0, 300) : undefined, - }, - ]; - - const existed = new Set(currentChildren); - const fixed: MindmapOp[] = []; - for (const op of ops) { - const node = (op as any).node ?? {}; - const textVal = String(node.text ?? "").trim(); - if (!textVal) continue; - if (existed.has(textVal)) continue; - existed.add(textVal); - - const href = safeUrlOrNull(node.hyperlink) ?? safeUrlOrNull(node.url) ?? null; - const refs = Array.isArray(node.refs) ? (node.refs as NodeRef[]) : []; - const hasRefUrl = refs.some((x) => x && x.kind === "url" && safeUrlOrNull((x as any).fileUrl)); - - let finalRefs = refs; - if (!hasRefUrl && searxResults.length) { - finalRefs = fallbackRefsFrom(searxResults[0]); - } - - fixed.push({ - op: "addChild", - parentUid: payload.targetUid, - node: { - text: textVal, - ...(href ? { hyperlink: href } : {}), - ...(finalRefs.length ? { refs: finalRefs } : {}), + error: "Next /api/mindmap-ai/expand-node 已退场,请改用 AI Agent 内置 mindmap_expand_node 工具。", + code: "mindmap-expand-node-route-retired", + replacement: { + kind: "ai-agent-tool", + toolName: "mindmap_expand_node", + route: "/api/ai-agent/run", }, - }); - if (fixed.length >= 6) break; - } - - // 兜底:若 AI 没产出有效 ops,则直接用搜索结果生成节点(保证功能可用 + 可追溯) - if (!fixed.length && searxResults.length) { - for (const r of searxResults.slice(0, 6)) { - const title = String(r.title || "").trim(); - const url = safeUrlOrNull(r.url); - if (!title || !url) continue; - if (existed.has(title)) continue; - existed.add(title); - fixed.push({ - op: "addChild", - parentUid: payload.targetUid, - node: { - text: title, - hyperlink: url, - refs: fallbackRefsFrom(r), - }, - }); - if (fixed.length >= 6) break; - } - } - - if (!fixed.length) { - // 最后兜底:至少给出 3 个“待核验”节点(无引用) - const base = targetText || "补完"; - for (let i = 1; i <= 3; i++) { - fixed.push({ - op: "addChild", - parentUid: payload.targetUid, - node: { - text: `${base}(待核验)${i}`, - }, - }); - } - } - - const context = buildDocumentBridgeContextWithActor({ - request, - actor: { - actorType: "service", - actorId: "mindmap-expand-node", - sessionId: null, }, - workspaceId: (doc as any).workspace_id ?? null, - source: { - channel: "mindmap-expand-route", - client: "wolai-frontend", + { + status: 410, + headers: { + "x-mnote-compat-boundary": "mindmap-expand-node-route-retired", + }, }, - }); - - const result = await executeRustBridgeTool<{ - ok: boolean; - applied?: number; - errors?: string[]; - ops?: unknown[]; - data?: unknown; - meta?: unknown; - }>({ - context, - toolName: "mindmap_expand_node", - invocationKind: "command", - args: { - documentId: payload.documentId, - mindmapId: payload.mindmapId, - targetUid: payload.targetUid, - instruction: payload.instruction ?? "", - ops: fixed, - searchResults: searxResults.map((r) => ({ - title: r.title, - url: r.url, - snippet: r.snippet ?? "", - })), - reason: payload.instruction ?? "", - }, - data: { - data: baseData, - source: "mindmap-expand-node", - }, - mode: "result", - }); - - return NextResponse.json({ - ok: true, - providerUsed: "online", - applied: (result.result as any)?.applied ?? 0, - errors: (result.result as any)?.errors ?? [], - ops: (result.result as any)?.ops ?? fixed, - data: (result.result as any)?.data ?? baseData, - meta: { - finishReason, - searched: useSearx, - searxCount: searxResults.length, - }, - }); - */ + ); } diff --git a/wolai-frontend/src/components/app-layout-shell.tsx b/wolai-frontend/src/components/app-layout-shell.tsx index 874a45d3..5dbbebf0 100644 --- a/wolai-frontend/src/components/app-layout-shell.tsx +++ b/wolai-frontend/src/components/app-layout-shell.tsx @@ -24,6 +24,7 @@ export function AppLayoutShell({ initialData, children }: AppLayoutShellProps) { sidebarQueryData: sidebarQuery.data, treeStreamData: treeStream.data, treeStreamStatus: treeStream.status, + treeStreamCursor: treeStream.cursor, }); return ( diff --git a/wolai-frontend/src/components/editor/DocumentAiAgentPanel.runtime.tsx b/wolai-frontend/src/components/editor/DocumentAiAgentPanel.runtime.tsx index cad42d5f..3c0727ab 100644 --- a/wolai-frontend/src/components/editor/DocumentAiAgentPanel.runtime.tsx +++ b/wolai-frontend/src/components/editor/DocumentAiAgentPanel.runtime.tsx @@ -815,6 +815,7 @@ export function DocumentAiAgentPanelRuntime({ subtree: contextSubtree, outline: contextOutline, evidence: contextEvidence, + pageSubtreeSource: pageAggregateSnapshot.pageSubtreeSource ?? "none", pageOptions: pageAggregateSnapshot.pageOptions, }, options: { diff --git a/wolai-frontend/src/components/editor/DocumentAiAgentPanel.tsx b/wolai-frontend/src/components/editor/DocumentAiAgentPanel.tsx index 0e5fc7c6..932369eb 100644 --- a/wolai-frontend/src/components/editor/DocumentAiAgentPanel.tsx +++ b/wolai-frontend/src/components/editor/DocumentAiAgentPanel.tsx @@ -11,6 +11,7 @@ import { useAiAgentUiStore } from "@/store/ai-agent-ui"; export type PageAggregateAiSnapshot = { blocks: Json | null; pageSubtree: PageSubtreeProjection | null; + pageSubtreeSource?: "server" | "local" | "none"; persistedMeta: { workspaceId: string | null; revision: number | null; diff --git a/wolai-frontend/src/components/editor/blocks/MindmapSidebar.tsx b/wolai-frontend/src/components/editor/blocks/MindmapSidebar.tsx index 373746cb..ac1b1c54 100644 --- a/wolai-frontend/src/components/editor/blocks/MindmapSidebar.tsx +++ b/wolai-frontend/src/components/editor/blocks/MindmapSidebar.tsx @@ -45,6 +45,9 @@ type SidebarProps = { activeTab: SidebarPanel | null; onClose: () => void; }; + +// 旧 Next route 已退场;补完节点能力保留在 AI Agent 的 mindmap_expand_node 工具中。 +const MINDMAP_LEGACY_EXPAND_NODE_ENTRY_ENABLED = false; const ColorInput = ({ value, @@ -1631,41 +1634,43 @@ const AiPanel = ({ {docDebug &&
{docDebug}
} -
-
- -
- setExpandUseSearx(Boolean(v))} - size="sm" - className="text-xs" - > - - 联网 - + {MINDMAP_LEGACY_EXPAND_NODE_ENTRY_ENABLED ? ( +
+
+ +
+ setExpandUseSearx(Boolean(v))} + size="sm" + className="text-xs" + > + + 联网 + +
+