归档本轮 P0/P1 bug 修复、设计审查迁移、AI selection scope 收口与 stream contract 调整,并保留当前 05 主线迁移起点。
24 KiB
5-11 [done][bug] Mindmap 幽灵附件增长与树命令卡顿 v1
更新时间:2026-05-14
分类归属:
05-editor-mainline/done- 涉及边界:
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 条。
同一轮反馈还指出:删除页面和新建页面都很慢,表现为树操作后明显卡顿。
这不是单纯的图标显示问题。当前症状同时暴露两条链路风险:
- mindmap 编辑/初始化/保存链路会多次向资产层广播同一个资源存在,且缺少“页面正文 block 与 mindmap asset 关系唯一”的硬约束。
- tree shell 的页面新建、删除、重命名、移动仍在命令成功后强制整页刷新,和当前 live projection/SSE 机制重复,导致用户感知卡顿。
2. 真实现象
已观察到的用户现象:
- 新页面下初始只有
index.md和少量 mindmap 附件。 - 用户只是编辑 mindmap,不是主动新建 mindmap。
- 等一会儿或再次修改后,同一页面下又出现新的
mindmap-mindmap...行。 - 页面新建和删除动作响应慢,像是页面/树整体重新加载。
期望结果:
- 一个页面内的一个 mindmap block 只对应一个稳定
mindmapId和一个 file tree asset row。 - mindmap 保存只能更新既有资源,不应创建新的 mindmap 资产行。
- file tree projection 应按
{documentId, blockId, assetId}或明确 object identity 去重。 - 页面新建/删除成功后应优先消费 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 时写入mindmapIdrust/crates/mnote-web/src/routes/web_shell.rs:983到:1009:TipTap 节点转 editor block 时若 attrs 缺失则用 blockId fallbackwolai-frontend/src/lib/documents/tiptap-content-converter.ts:188到:213:mindmapReferenceProps会在缺少mindmapId时 fallback 到 blockIdwolai-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(),不存在则 insertwolai-frontend/convex/sidebar.ts:79到:100:mindmap_id被映射为id和mindmap-{mindmap_id}.jsonwolai-frontend/convex/sidebar.ts:193到:220:所有 active mindmaps 都进入mindmap_assetsrust/crates/bridge-runtime/src/lib.rs:7569到:7647:file tree projection 从mindmap_assets构建资源行,并只按 asset id 去重
因此,当前有两个需要实测区分的分支:
- 同一视觉对象被保存成多个不同
mindmapId,每个都被合法展示为一个资源。 - 数据表中已经存在同一
(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.puttree.resource.mindmap.updatedstreamDelta: resync_required
这会把“导图 object 内部内容变化”提升成“资源树需要重新同步”。从 design/10-review/05-tree.md 的对象边界看,mindmap 是页面内 block 关联的独立 object / asset editor,不是 index.md 正文替身;普通节点文字、结构、样式变化应属于 mindmap object 内容事件,而不是 file tree 资源关系事件。
因此,artifact 本身仍然需要,但语义需要分层:
- 修改 mindmap 节点、文字、样式、折叠状态:应记录
mindmap.content.updated/mindmap.node.updated这类 object 内容事件,不应默认触发 file tree resource resync。 - 创建、挂载、解绑、删除、移动、重命名 mindmap 资源:才应记录
tree.resource.*/tree.asset.*事件,并影响 file tree projection。 resync_required只能作为无法应用精确 delta 时的保守 fallback,不应成为普通节点编辑的默认输出。
当前过宽 artifact 会放大本 bug:只要底层存在 ghost mindmap 行,或同一视觉 mindmap 被重新分配了新的 mindmapId,普通编辑触发的 tree resync 就会把重复资源更快暴露到文件树,并带来额外刷新成本。
3.6 次级入口收口进展
2026-05-14 已完成 design/10-review 第 9 项的入口一致性收口:
- Mindmap toolbar
export不再映射到未受控的EXPORTruntime command,UI state 继续禁用该入口。 - 旧
/api/mindmap-ai/expand-nodeNext 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.updateddomain event 与noopstream 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_requireddelta。
已通过验证:
- RED:
cargo test -p bridge-runtime mindmaps_put_existing_content_update_uses_object_event_without_tree_resync -- --nocapture修改前失败,旧行为为resync_required。 - RED:
cargo test -p mnote-web mindmap_command_apply_returns_object_artifacts_without_tree_resync -- --nocapture修改前失败,旧行为为tree.resource.mindmap.put。 - GREEN:
cargo test -p bridge-runtime mindmap -- --nocapture,17 个 mindmap 相关测试通过。 - GREEN:
cargo test -p mnote-web mindmap -- --nocapture,6 个 mindmap 相关测试通过。 - 已通过:
node --check scripts/task169-mindmap-realtime-smoke.js。 - 已通过:
node scripts/task169-mindmap-realtime-smoke.js,最新结果ok=true、failures=[];6 条可解析普通mindmap.command.apply响应均为eventType=mindmap.content.updated、streamOp=noop,另有 1 条 Playwright response body 读取失败被记录为skippedReadFailures=1;同一页面最终仍只有 1 条 mindmap asset row,mindmapId=mindmap_1778698542703。
仍未完成:
- 本轮未执行历史幽灵副本清理;若后续需要清理 Convex 中未被正文引用的旧 mindmap 行,必须先经用户确认并保留清理前后证据。
3.9 2026-05-14 历史幽灵副本只读审计入口
本轮新增只读审计脚本,用于在清理前生成候选证据,但不删除任何用户数据:
scripts/task180-mindmap-ghost-candidate-audit.js:支持从/api/sidebar、导出的 sidebar JSON、/api/tree/projections/file?workspaceId=<workspaceId>或导出的 file projection JSON 读取数据,报告同页多 active mindmap、重复 active asset id、缺失 document 的 active mindmap asset、以及 file tree 中同一 mindmap object 的重复 row。scripts/task180-mindmap-ghost-candidate-audit.test.js:覆盖候选识别逻辑,确认脚本只输出 candidate summary;同时覆盖 Rust file projection 的{ ok, result: { items } }包装形态和直接items形态。- 已通过:
node scripts/task180-mindmap-ghost-candidate-audit.test.js。 - 已通过:
node --check scripts/task180-mindmap-ghost-candidate-audit.js与node --check scripts/task180-mindmap-ghost-candidate-audit.test.js。 - 已通过真实只读审计:
node scripts/task180-mindmap-ghost-candidate-audit.js --url 'http://127.0.0.1:3000/api/tree/projections/file?workspaceId=tree_1777430834634_3' --output tmp/task180-mindmap-ghost-candidate-audit/runtime-file-projection-result.json。结果ok=true、readOnly=true、fileTreeMindmapRows=10、duplicateFileTreeMindmapRows=0、candidateCount=0。 - 已确认 endpoint 可用:
/api/tree/projections/file?workspaceId=tree_1777430834634_3返回200 OK,projection item 数量为 90。
该脚本只解决“清理前证据如何生成”的问题,不代表历史数据已经清理。真正删除 Convex 历史幽灵副本仍需用户明确授权,并应另行保留清理前后审计结果。
需要注意:file projection 输入只包含投影行,能审计 File Tree 内同一 mindmap object 的重复 row;它不包含 mindmapAssets 与 documents 全量数据,因此不能单独判断同页多 active mindmap、重复 active asset id 或 orphan asset,这三类仍需 /api/sidebar 或等价导出的完整数据。
4. 页面新建/删除慢的证据
tree shell 在多处命令成功后调用 scheduleRefresh(),而 scheduleRefresh() 的实现是 80ms 后整页 reload 或带 renameRowId 重新 assign:
rust/crates/mnote-web/src/routes/tree.rs:2976到:2986
调用点包括:
- 文件树删除:
rust/crates/mnote-web/src/routes/tree.rs:2791到:2824 - 新建页面:
rust/crates/mnote-web/src/routes/tree.rs:4309到:4340 - 重命名:
rust/crates/mnote-web/src/routes/tree.rs:4373到:4406 - 移动:
rust/crates/mnote-web/src/routes/tree.rs:4448到:4502
与此同时,主页面已经有 tree live controller 接收 snapshot / delta / resync:
rust/crates/mnote-web/src/ssr/pages/layout.rs:3881到:3917rust/crates/mnote-web/src/ssr/pages/layout.rs:3939到:4050
这会造成两种低效叠加:
- 命令结果本身已经返回
tree.node.created/tree.node.archived等 delta hint。 - 前端仍然整页刷新,重新加载 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. 当前判断
当前根因尚需实测最终确认,但静态代码已经能支持以下判断:
- mindmap ghost asset 增长的高概率根因是
mindmapId稳定性没有被端到端锁死。只要编辑/保存/重挂载过程中 attrs 丢失或重新创建 block,就会插入新mindmap_{timestamp},后端会合法保存,file tree 会合法展示。 - 另一个可疑根因是
mindmaps表没有唯一约束,put只 patch.first(),无法清除或阻止同键重复行。 - 资产广播入口过多会放大问题。它们让新 mindmapId 或重复数据几乎立即进入 sidebar 本地 state 和 Convex projection,用户就会看到“自己又新增了一个”。
- file tree projection 只按
asset.id去重,无法识别“同一 document + 同一正文 block 的多个 mindmapId 其实是幽灵副本”,也无法处理同 id 多行投影的上游异常。 - 普通 mindmap 节点编辑当前仍会走 artifact,但语义已从
tree.resource.* + resync_required收窄为mindmap.content.updated + noop,不再默认推动 file tree projection resync。 - 页面新建/删除慢不是 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;本文只保留关联证据。 - 2026-05-14 已修复 NodeView document id 误归属导致的 ghost asset 增长高风险链路;真实
task169已证明连续编辑后同一页面下只保留 1 条 mindmap asset row。 - 2026-05-14 已收窄普通 mindmap 内容编辑 artifact 语义;剩余边界主要是历史幽灵副本清理必须显式授权,不能在本 bug 修复中擅自删除用户数据。
6. 建议复现与验证
建议补一条失败优先 smoke,先不要直接修代码:
- 使用真实测试账号登录
http://localhost:3000/auth。 - 新建测试页面,记录
documentId。 - 插入一个 mindmap,记录首个
mindmapId。 - 连续修改 mindmap 中心主题或新增节点 3 次,每次等待保存完成。
- 切到文件树,统计该页面下
asset_type=mindmap的行数和data-mnote-object-identity。 - 刷新页面后再次统计。
- 断言同一页面下 mindmap asset 数量仍为 1,且
mindmapId未变化。 - 检查
bridgeLogs:listWorkspaceOverview中连续 mindmap 节点编辑产生的 artifact,确认普通内容编辑不应默认产生tree.resource.* + resync_required。 - 同一脚本计时新建页面、删除页面从点击到 DOM 稳定的耗时,并记录是否触发
window.location.reload()/ navigation。
建议同时检查 Convex 数据:
mindmaps中同一document_id下是否出现多个mindmap_id。- 新增出来的
mindmap_id是否都形如mindmap_<timestamp>。 - 页面正文 TipTap JSON 中是否只有一个 mindmap paragraph,且 attrs.mindmapId 是否随保存变化。
7. 建议修复方向
阶段一:防止继续增长
- mindmap block 创建时生成一次稳定
blockId与mindmapId,后续保存、转换、重挂载必须保留。 documents.save/ TipTap converter 对mnoteBlockType=mindmap增加 contract:缺少mindmapId时不得静默新建时间戳 ID,应先从 block identity / object identity 恢复,恢复不了则报可观测错误。mindmaps.put或上层 command 增加可选blockId/blockAssetRelation,对同一{documentId, blockId}已有关联 mindmap 时拒绝插入第二个 mindmapId。- asset 广播入口收口:保存成功、initial createOnly、实例就绪不应都伪造 asset;前端应优先消费 kernel/file tree projection 返回的 object identity。
- 收窄普通 mindmap 节点编辑的 artifact:内容编辑记录
mindmap.content.updated/mindmap.node.updated,只有资源关系变化才记录tree.resource.*。 - 普通 mindmap 节点编辑不应默认输出
resync_required;只有缺少精确 delta、projection 丢失或 reducer 无法应用时才触发保守 resync。
阶段二:清理幽灵副本
- 提供只读诊断脚本:列出同一页面下多个 mindmapId、对应 updated_at、正文引用的 mindmapId。
- 只有在用户确认后,才可清理未被页面正文引用的 mindmap 行。
- 清理必须进入 bug 修复 checklist,不能在本缺陷说明阶段直接删除数据。
阶段三:树命令性能收口
tree.node.create成功后用 command result / delta 局部插入节点,而不是 reload。tree.node.archive成功后用remove_documentdelta 局部移除节点。- 只有 projection 丢失、SSE 断线或 reducer 无法应用时才走 resync/reload fallback。
- smoke 增加“无整页 reload”断言和耗时阈值。
8. 流转条件
当前状态:done
只有满足以下条件后才能移动到 bugs/05-editor-mainline/done/:
- 已有 smoke 能稳定覆盖 ghost asset 防回归。
task169增强后断言同一页面最终只保留 1 条 mindmap asset row,并检查 object identity。 - 同一 mindmap 连续编辑保存后,
mindmapId和 file tree object identity 保持稳定。task169最新结果中mindmapId=mindmap_1778698542703,最终 File Tree mindmap row 只剩同一 object identity。 - 同一页面下未主动新建多个 mindmap 时,file tree 只出现一个 mindmap asset row。
task169结果ok=true、failures=[]。 - 普通 mindmap 节点编辑产生 object 内容 artifact,不默认产生
tree.resource.* + resync_required。task169最新结果已验证 6 条可解析普通mindmap.command.apply均为mindmap.content.updated + noop;1 条 Playwright response body 读取失败只计入采样跳过,不作为应用语义失败。 - 只有 mindmap 创建、挂载、解绑、删除、移动、重命名这类资源关系变化才影响 file tree projection。
mindmaps.put createOnly=true保留资源事件;createOnly=false与mindmap.command.apply改为 object 内容事件。 - 新建页面和删除页面不再默认整页 reload,或至少有明确 fallback 条件。
task179已验证 Rust/treeconvex_workspaceFile Tree create/delete 与 Page Tree create 对应 mode 内 navigation 为 0。 - 浏览器实测记录新建/删除耗时,并明显低于当前整页 reload 体验。
task179记录filetree.createMs=142、filetree.deleteMs=180、page.createMs=126。 - 历史幽灵副本的只读候选审计已完成,且当前真实 File Tree projection 未发现 duplicate row。本轮已新增
task180只读候选审计脚本,并补齐/api/tree/projections/file只读投影输入支持;已完成真实只读审计,candidateCount=0,因此本轮不需要执行历史数据清理,也不会删除用户数据。