feat(rag): harden post-LightRAG runtime

Retire legacy OCR/media/evidence fallbacks, add local-folder event bus and Page Aggregate guards, and archive completed design checklists.

Validation: cargo test -p mnote-web -- --test-threads=1; cargo test --workspace -- --test-threads=1; git diff --check; codegraph sync .; codegraph_status.
This commit is contained in:
lix-2026
2026-06-07 10:35:21 +08:00
parent 22a92edcda
commit 9551d4c1dc
59 changed files with 4053 additions and 5249 deletions
@@ -0,0 +1,165 @@
# 3-24 Local-folder browser event bus v1
> 创建时间:2026-06-07
>
> 状态:`done`
>
> Owner03-rust-web / 05-editor-mainline / 07-ai
>
> 来源:`design/10-review/done/20-post-lightrag-runtime-hardening-checklist-v1.md` P2
>
> 上位依据:
> - `design/03-rust-web/done/3-3-rust-web-tree-realtime-event-stream-v1.md`
> - `design/03-rust-web/done/3-14-rust-web-tree-realtime-ws-push-v1.md`
> - `design/03-rust-web/process/3-23-sidebar-local-folder-resource-runtime-followup-v1.md`
## 1. 第一结论
当前 local-folder 浏览器侧有多条独立刷新链:
- `tree-live-controller.js` 对 local-folder 使用 `/api/local-folder/events` EventSource,并派发 `tree:local-folder-watch-batch`
- `sidebar-tree-live-apply-runtime.js` 订阅 `tree:local-folder-watch-batch` 后刷新 filetree parent,必要时再刷新 sidebar projection。
- `document-session-runtime.js` 也直接打开 `/api/local-folder/events`,用于当前文档内容刷新。
- `document-resource-tab-runtime.js` 对资源 tab 直接打开 `/api/local-folder/events`,并在部分保存后合成 `tree:local-folder-watch-batch`
- `sidebar-page-ai-runtime.js` 从 agent receipt 合成 `tree:local-folder-watch-batch``mnote:page-ai-tool-write-completed`
这会造成同一 `workspaceId/rootUri` 页面内多个 EventSource、receipt + watcher echo 双刷新、sidebar projection 和 filetree parent 重复请求。P2 不改 Rust watcher 协议,先在浏览器侧增加单例 event bus,把连接、事件归一、订阅和刷新编排收口。
## 2. 非目标
- 不改变 `/api/local-folder/events``/api/realtime/ws``/api/tree/events` 的服务端协议。
- 不在本稿恢复轮询 fallback。
- 不把 event bus 变成新的树/文件/文档事实源;它只转发和归一事件。
- 不一次性重写 sidebar、document session、resource tab、Page AI runtime;按订阅方逐步切换。
- 不改变 Page AI agent 文件编辑的 audit / receipt 语义。
## 3. 输入源
统一入口:新增 `rust/crates/mnote-web/browser/local-folder-event-bus-runtime.js`,挂到 `window.__mnoteLocalFolderEventBus`
输入源:
- `watcher_sse``/api/local-folder/events?rootUri=...&treeLive=true`,当前 local-folder 主输入。
- `tree_ws``/api/realtime/ws`,只在非 local-folder tree live 或未来 local-folder WS 化时接入。
- `tree_sse``/api/tree/events`,作为 tree projection fallback,不直接替代 local-folder watcher。
- `synthetic_page_ai_receipt`Page AI receipt 合成事件。
- `synthetic_resource_write`resource tab 保存后合成事件。
- `explicit_resync`:命令结果或 runtime 主动要求重新取 sidebar/filetree/document。
约束:
- 同一 `workspaceId/rootUri` 页面内只允许一个 local-folder watcher 连接。
- bus 连接 key 以页面内 `rootUri` 为准;`workspaceId` 只作为事件元信息。原因是 tree live bootstrap、document session、resource tab 在 local-folder 页面可能分别拿到空 workspaceId / local-ws workspaceId,若把 workspaceId 放入连接 key 会在同一 rootUri 内重复建 watcher。
- 不同 rootUri 可以有不同 bus instance;默认页面只注册当前 rootUri。
- bus 输出事件必须带 `source``reason``rootUri``workspaceId``revision``changedPaths``affectedParents`
## 4. 输出事件
bus 派发浏览器事件:
- `mnote:local-folder:event-bus-ready`
- `mnote:local-folder:watch-batch`
- `mnote:local-folder:document-changed`
- `mnote:local-folder:resource-changed`
- `mnote:local-folder:filetree-parent-changed`
- `mnote:local-folder:knowledge-rag-source-updated`
- `mnote:local-folder:resync-required`
兼容桥:
- 短期继续派发旧 `tree:local-folder-watch-batch`,但 detail 增加 `viaEventBus=true`
- 短期继续派发旧 `mnote:page-ai-tool-write-completed`document session 可逐步改订阅新事件。
## 5. 订阅方职责
- Sidebar / FileTree
- 订阅 `filetree-parent-changed` 刷新局部 parent。
- 订阅 `resync-required` 刷新 sidebar projection。
- 不直接拥有 watcher 连接。
- Document session
- 订阅 `document-changed`,仅当当前文档路径命中才刷新当前 buffer。
- 不因任意 watch batch 刷新当前文档。
- Resource tab
- 订阅 `resource-changed`,仅当当前资源路径命中才刷新 stat/read。
- resource save 完成后只向 bus 发 synthetic event,不直接广播全局 watch batch。
- Page AI
- receipt 只向 bus 发 `synthetic_page_ai_receipt`
- 由 bus 去重 watcher echo 与 receipt refresh,避免重复 sidebar projection。
- Knowledge RAG UI
- 订阅 `knowledge-rag-source-updated`,只刷新资料库来源状态,不触发文档正文刷新。
## 6. 实施 Checklist
Phase A:只加 bus,不切换行为。
- [x] 新增 `local-folder-event-bus-runtime.js`
- [x] 在 SSR layout 中加载 bus runtime,保证早于 tree live controllerdocument session / resource tab 后续切订阅时继续复用该入口。
- [x] bus 可以接管 `tree-live-controller.js` local-folder EventSource 创建;同一 rootUri 二次 start 返回同一连接。
- [x] bus 继续兼容派发 `tree:local-folder-watch-batch`,并标记 `viaEventBus=true`
- [x] DOM diagnostics
- `data-mnote-local-folder-event-bus="ready"`
- `data-mnote-local-folder-event-bus-connections`
- `data-mnote-local-folder-event-bus-last-source`
- `data-mnote-local-folder-event-bus-last-reason`
Phase A 结果:
- 新增 `/api/mnote-browser-runtime/local-folder-event-bus-runtime.js` runtime asset。
- `tree-live-controller.js` 的 local-folder 分支优先调用 `window.__mnoteLocalFolderEventBus.startLocalFolderWatcher(...)`bus 不可用时保留原 `/api/local-folder/events` 直连 fallback。
- bus 连接 key 已按 `rootUri` 收口,避免 tree live / resource tab 因 workspaceId 解析差异重复建连接。
- 当前只收敛 tree live controller 的 local-folder watcher`document-session-runtime.js``document-resource-tab-runtime.js` 仍在 Phase C 切换,P2 总体验收尚未完成。
Phase B:刷新编排。
- [x] `refreshLocalFolderSidebarSnapshot` 外包到 bus orchestrator:同一 tick 合并 reason 和 parent 列表。
- [x] bus 维护 `pendingFileTreeParents`,同一 parent 在同一 tick 只刷新一次。
- [x] fallback / resync 仍允许刷新 sidebar projection,但必须记录 reason。
- [x] `sidebar-tree-live-apply-runtime.js` 由直接处理 watch batch 改为订阅 bus 输出。
Phase B 结果:
- bus 新增 `mnote:local-folder:sidebar-refresh-requested`,同一 tick 合并 `changedPaths``affectedParents``reasons``resyncRequired`
- `sidebar-tree-live-apply-runtime.js` 订阅 `mnote:local-folder:sidebar-refresh-requested` 后复用 `applyLocalFolderWatchBatch(...)`,并跳过 `viaEventBus=true` 的旧 `tree:local-folder-watch-batch` 兼容事件,避免同一 bus 事件被 sidebar 处理两次。
- `task540` 会连续发两次同 tick `synthetic_page_ai_receipt`,验证最终只产生一次 filetree parent projection 请求,且 content-only receipt 不触发 sidebar projection。
Phase C:订阅方切换。
- [x] `document-session-runtime.js` 不再直接打开 `/api/local-folder/events`;改订阅 `document-changed`
- 正常 bus 可用路径已改订阅 `mnote:local-folder:document-changed` 并复用 `startLocalFolderWatcher(...)`;保留 bus 不可用时旧 EventSource fallback,作为退路而非主路径。
- [x] `document-resource-tab-runtime.js` 不再为每个 resource tab 直接打开 EventSource;改订阅 `resource-changed`
- passive resource tab 已在 bus 可用时订阅 `mnote:local-folder:resource-changed`,并以 `data-mnote-resource-watch-ready="event-bus"` 暴露诊断;保留 bus 不可用时旧 EventSource fallback。
- [x] resource write 完成后向 bus 发 `synthetic_resource_write`
- [x] Page AI receipt 向 bus 发 `synthetic_page_ai_receipt`
Phase Dsmoke。
- [x] 新增 `task540-local-folder-event-bus-single-connection-smoke.js`
- [x] 同一页面打开 sidebar + document + resource tab + Page AI 后,只存在一个 local-folder watcher EventSource。
- Phase D-1`task540` 当前覆盖 sidebar/tree live + document session 同页只创建一个 `/api/local-folder/events` EventSource,并验证 synthetic event 兼容派发带 `viaEventBus=true`resource tab + Page AI 全组合请求计数仍待后续扩展。
- Phase D-2`task540` 已扩展到 passive resource tab,并验证 resource tab watch ready 走 `event-bus``synthetic_page_ai_receipt` 事件经 bus 派发、同页仍只有一个 `/api/local-folder/events` EventSource。尚未覆盖真实 Page AI 面板点击到 receipt 的完整 UI 链路。
- [x] 外部修改当前 `.md` 后,tiptap 可见刷新。
- [x] 外部新增 / 删除文件后,filetree 只刷新受影响 parent。
- [x] Page AI receipt 后,sidebar projection 不重复请求。
- [x] 复跑 `task446``task447``task448` 或相邻 tree realtime smoke。
- `task446` / `task447` / `task448` 当前仍依赖无 workspace 的 `/api/tree/commands` cloud/Convex 旧入口,运行结果为 `convex_retired`,不适合作为 local-folder event bus 验收证据。
- 已改用相邻 local-folder runtime smoke`task435``task436``task535``task540`
Phase C-1 / D-1 结果:
- `document-session-runtime.js` 在 event bus 可用时不再新建按 documentId/resourcePath 分裂的 EventSource,改为共享 root 级 bus 并按 documentId / relativePath 过滤。
- passive `document-resource-tab-runtime.js` 在 event bus 可用时改订阅 `resource-changed`,旧 per-resource EventSource 仅作为 fallback。
- `document-resource-tab-runtime.js` 的 local OCR sidecar refresh 改为优先 `synthetic_resource_write` 发给 bus,旧 `tree:local-folder-watch-batch` 只作为 bus 不可用 fallback。
- `sidebar-page-ai-runtime.js` 的 agent receipt refresh 改为优先 `emitSyntheticWatchBatch(...)`,兼容事件由 bus 统一派发。
- 扩展 `task540` 后发现同 rootUri 下 tree live 与 resource tab 的 workspaceId 解析差异会导致重复连接;已把 bus 连接 key 收口为 `rootUri``workspaceId` 只作为事件元信息。
- `task540` 在 3300 最新 mnote-web 通过,验证当前文档页 + passive resource tab 只创建一个 local-folder watcher EventSource、bus diagnostics ready、`synthetic_page_ai_receipt` source/reason 保留、兼容事件带 `viaEventBus=true`,且 resource-changed 触发当前资源 stat 刷新。
- `task535` 在 3300 最新 mnote-web 通过,验证真实 Page AI clean agent receipt 后当前文档和 filetree 均刷新,且不调用旧保存/写入工具。
- `task436` 在 3300 最新 mnote-web 通过,验证当前打开 local `.md` 外部修改后 tiptap 可见刷新,并覆盖 dirty 冲突保护。
- `task435` 在 3300 最新 mnote-web 通过,验证外部创建/重命名/删除 Markdown 与非 Markdown 资源后 page tree / filetree 原地更新且无 reload。
## 7. 验收与归档条件
- 页面内同一 `workspaceId/rootUri` 只有一个 local-folder watcher 连接。
- sidebar、document session、resource tab、Page AI receipt 都通过 event bus 收敛。
- `tree:local-folder-watch-batch` 兼容事件仍可用,但 detail 标明 `viaEventBus=true`
- P2 smoke 全部通过,并记录请求计数证据。
- 本稿完成后移动到 `design/03-rust-web/done/`
@@ -0,0 +1,48 @@
# 5-36 Page Aggregate local-first hard guard v1
> 创建时间:2026-06-07
>
> 状态:`done`
>
> Owner05-editor-mainline
>
> 上位 checklist`design/10-review/done/20-post-lightrag-runtime-hardening-checklist-v1.md` P3
## 1. 目标
防止 local-first `.md` 文档页静默回落到 `compat.legacy_content` 影子正文路径。local folder 的正常读链必须是:
1. 本地 Markdown 文件是真相。
2. Rust Page Aggregate 输出 `body.projectionSource=local_markdown.content`
3. Page Aggregate 输出 `body.blockDocument`
4. 浏览器 tiptap runtime 优先消费 `page_aggregate.block_document`
## 2. 非目标
- 不删除 cloud / legacy / explicit compat 的 `compat.legacy_content` fallback。
- 不改写 Page Aggregate 保存命令族。
- 不扩大 legacy blocks / `documents.content` 的 local-first 使用面。
## 3. Checklist
- [x] 复核 `document-tiptap-conversion-runtime.js` source 判定:`blockDocument` 优先于 `content`
- [x] 给文档 editor root 增加 DOM diagnostic
- `data-mnote-page-body-source`
- `data-mnote-page-body-local-compat-fallback`
- `data-mnote-page-body-hard-guard`
- [x] local-first browser smoke 断言 `.md` 文档页为 `page_aggregate.block_document`,且 `local-compat-fallback=false`
- [x] `task522` contract 保留 explicit compat fallback 正例。
- [x] `task167` API smoke 继续证明 local-first Page Aggregate 输出 `local_markdown.content` + `blockDocument`
## 4. 验证结果
- `node scripts/task522-page-aggregate-compat-fallback-contract.js` 通过。
- `node scripts/task167-local-markdown-title-body-options-no-convex-smoke.js` 通过。
- `node scripts/task541-page-aggregate-local-first-hard-guard-smoke.js` 通过:真实浏览器打开 local `.md`editor root 诊断为 `data-mnote-page-body-source=page_aggregate.block_document``data-mnote-page-body-local-compat-fallback=false``data-mnote-page-body-hard-guard=local_ok`
- `cargo test -p mnote-web document_editor_adapter_runtime_contains_host_contracts -- --test-threads=1` 通过。
## 5. 归档条件
- [x] `design/10-review/done/20-post-lightrag-runtime-hardening-checklist-v1.md` P3 全部勾选。
- [x] P3 targeted smoke 和 contract test 通过。
- [x] CodeGraph 同步无 pending。
@@ -2,7 +2,7 @@
> 创建时间:2026-06-06
>
> 状态:`process`
> 状态:`done`
>
> Owner07-ai / 05-editor-mainline / 03-rust-web
>
@@ -117,7 +117,7 @@ Checklist
验收:
- [x] `cargo test -p mnote-web local_agent_audit -- --test-threads=1` 或等价 targeted tests 通过。
- [ ] clean smoke 中 audit snapshot 只包含目标文件及必要 changed file。
- [x] clean smoke 中 audit snapshot 只包含目标文件及必要 changed file。
## 8. Phase E:文档与 manifest 退役口径
@@ -133,7 +133,11 @@ Checklist
验收:
- [x] `7-18-local-first-agent-file-editing-control-plane-v1.md` Phase B/C 可勾选。
- [ ] 本 checklist 可移动到 `design/07-ai/done/`,父设计 `7-18` 仅剩跨 workspace / 多 target 的产品确认项。
- [x] 本 checklist 可移动到 `design/07-ai/done/`,父设计 `7-18` 仅剩跨 workspace / 多 target 的产品确认项。
补充证据:
- 2026-06-07`task539-local-agent-audit-scope-contract.js` 复跑 `task535` 并断言 `targetPackage.allowedFiles=["AgentClean.md"]``runTargetSnapshot.frozenAt` 存在、未调用 `/api/documents/save` / `mnote.doc.markdown_edit` / `mnote.page.save`;同时运行 `cargo test -p mnote-web local_agent_audit -- --test-threads=1`,覆盖 `auditScope.scope=allowed_files``fileCount=1`
## 9. 推荐执行顺序
@@ -0,0 +1,186 @@
# 7-51 LightRAG post-commit hardening v1
> 创建时间:2026-06-07
>
> 状态:`done`
>
> Owner07-ai / knowledge-rag / 03-rust-web / plugin-ui
>
> 来源:`design/10-review/done/20-post-lightrag-runtime-hardening-checklist-v1.md` P0 审计
>
> 上位依据:
> - `design/07-ai/done/7-50-lightrag-knowledge-rag-provider-v1.md`
> - `design/10-review/done/20-post-lightrag-runtime-hardening-checklist-v1.md`
## 1. 第一结论
7-50 作为 LightRAG 资料库问答主线可以保持 `done`,但 post-commit audit 发现若干需要单独硬化的边界:
- watcher stale sync 在 best-effort 删除失败时会吞掉错误,并且已经清空 registry 中的 `lightRagDocId`,后续难以重试删除 provider orphan doc。
- `sourcePaths` 当前是 provider 检索后过滤 MNote mapped references,不是 provider 预过滤;HTTP API 仍返回未过滤 `raw`,存在调用方误用 raw 的风险。
- sidecar locator 在 quote 未命中时会 fallback 到 `first_positioned_block`,可能生成错误 page/bbox,却表现为非 degraded locator。
- `mnote-knowledge-rag` skill 文案对 degraded citation 过强,容易让 agent 误以为不能使用返回的降级 resourceTab 链接。
- Reasonix wrapper 仍手写工具表,能力注册漂移问题还没有真正解决。
本稿不恢复旧 evidence / LiteParse / local OCR 路径,不开发第二套 RAG,只硬化 7-50 已选定的 LightRAG provider 边界。
## 2. 非目标
- 不重建 `mnote.evidence.*` / `mnote.index.*` agent 工具。
- 不把 source scope 伪装成 LightRAG provider 原生过滤,除非实际实现了 provider 级约束。
- 不清空用户资料库、不删除用户原始 source、不自动大规模 reindex。
- 不把 LightRAG sidecar / chunk / graph 当作 MNote 正文真相。
- 不在本稿内实施完整 capability registry;只补必要漂移测试或文案约束,完整 registry 仍按 `7-47` / `20 P4` 推进。
## 3. Phase ADelete retry / orphan doc guard
目标:source 删除或 hash 变化后,即使 LightRAG 删除失败,也不能丢失重试 provider doc idprune 不能隐藏仍可能参与回答的 orphan doc。
Checklist
- [x] 修改 `sync_registry_source_state(...)`source missing / hash changed 时不要立即清空 `lightRagDocId`;应进入 `stale=true` + `lightRagStatus=delete_submitted``delete_retry_required`
- [x] `sync_registry_with_documents(...)` 调用 LightRAG delete 失败时,把失败写入 registry entry 的可见状态,而不是吞掉后只保留 stale。
- [x] 只有确认 LightRAG `/documents` 不再包含该 doc 后,才清空 `lightRagDocId` 并标记 `delete_completed`
- [x] `prune_registry` 不清理仍带 `lightRagDocId` 且 delete 未确认完成的 stale entry。
- [x] `mapped_references(...)` 对 missing registry 的 provider reference 需要区分:
- provider orphan / unknown source:默认不作为有效 MNote citation 返回,或明确返回 degraded + unmapped 分类,但不得进入 scoped answer citation。
- registry 命中但 stale / deleted:继续过滤。
- [x]`delete-source` route 保持 `delete_file=false`,并保留“不删除用户原始 source”的断言。
验证:
- [x] 单测:LightRAG delete 失败时 registry 保留 doc id 和 retry 状态。
- [x] 单测:delete completed 后才清 doc id。
- [x] 单测:prune 不删除 `delete_submitted` / `delete_retry_required` entry。
- [x] 单测:missing registry provider reference 不会作为正常 citation 返回。
- [x] `cargo test -p mnote-web knowledge_rag -- --nocapture`
实施记录:
- 2026-06-07`sync_registry_source_state(...)` 在 source missing / hash changed 时保留 `lightRagDocId`,并把状态置为 `delete_submitted`
- 2026-06-07`sync_registry_with_documents(...)` 会重试 `delete_submitted` / `delete_retry_required` doc idLightRAG delete 失败时写入 `delete_retry_required`,成功提交后保持 `delete_submitted`,等 `/documents` 消失后才标记 `delete_completed` 并清空 doc id。
- 2026-06-07:当前 registry schema 先用 `lightRagStatus=delete_retry_required` 表达失败状态,不额外扩写错误 message 字段,避免扩大持久化迁移面。
## 4. Phase BSource scope 语义收紧
目标:避免调用方误以为 `sourcePaths` 已限制 LightRAG provider 检索范围。
Checklist
- [x]`/api/knowledge-rag/query` 返回中新增 `sourceScopeMode`,当前值明确为 `post_filter_mapped_references`
- [x]`sourcePaths` 非空,默认不向普通 agent / Page AI 暴露未过滤 `raw` chunks;至少 compact tool output 继续不暴露 raw chunks。
- [x] HTTP API 如仍保留 `raw`,必须标注 `rawScopeFiltered=false` 或等价字段,提醒 raw 未受 source scope 过滤。
- [x] `mnote.knowledge_rag.query` manifest / Reasonix wrapper / skill 文案从“限制检索来源”改为“按 MNote source 过滤返回 referencesprovider raw 可能仍为全局检索结果”。
- [x] Page AI final answer 只允许引用 filtered `references/citations`,不得引用 raw LightRAG chunks。
验证:
- [x] 单测:`sourcePaths=[alpha]``references` 只含 alpha。
- [x] 运行态 smoke`sourceScopeMode=post_filter_mapped_references``rawScopeFiltered=false`
- [x] 单测或 wrapper selftestagent compact result 不含 raw chunks。
- [x] `task538-knowledge-rag-source-scope-api-smoke.js` 更新并通过。
- [x] `task534-knowledge-rag-source-management-scope-smoke.js` UI source management 部分已修复并通过。
实施记录:
- 2026-06-07`/api/knowledge-rag/query` 返回 `sourceScopeMode=post_filter_mapped_references``rawScopeFiltered=false`
- 2026-06-07`mnote.knowledge_rag.query` compact agent result 继续只暴露 filtered `references/citations`,不暴露 `raw.chunks`
- 2026-06-07manifest、Reasonix wrapper、`skills/mnote-knowledge-rag/SKILL.md` 已统一 sourcePaths post-filter 口径;新增 `task538-knowledge-rag-source-scope-api-smoke.js` 覆盖 API scope metadata、filtered references、`delete-source` 不删原始 source。
- 2026-06-07`task534` 修正为打开资料库设置后切到“全部”来源,避免默认“需处理”过滤隐藏已索引 source;在 3300 最新构建通过。
## 5. Phase CLocator strict match / degradation
目标:没有真实匹配到 quote / chunk 对应 sidecar block 时,不生成看似精确的 page/bbox。
Checklist
- [x] 修改 `find_lightrag_sidecar_block(...)`:移除或限制 `first_positioned_block` fallback;只有 quote 与 block content 达到明确匹配阈值时才返回 page/bbox locator。
- [x] 若只找到 source resource 但没有精确 block match,返回 clickable degraded resourceTab citation,但 `locatorDegraded=true`
- [x] `citationMarkdown` 对 degraded resourceTab 使用稳定文案,例如 `来源定位降级:<file>`,不包含伪造页码。
- [x] 对 PDF / image / DOCX fallback citation 分别保留打开能力,但不伪造 bbox。
- [x] 记录 LightRAG chunk 缺 `refs` 的当前边界;未来若 provider 暴露 refs,再改为 refs 优先、文本匹配 fallback。
验证:
- [x] 单测:quote 不匹配 sidecar block 时不返回 page/bbox locator。
- [x] 单测:quote 匹配 sidecar block 时仍返回 page/bbox locator。
- [x] 单测:known resource + no locator 返回 clickable degraded citation。
- [x] `task529-knowledge-rag-citation-resource-tab-smoke.js` 更新为当前 fixture 可重复的正例,并通过。
实施记录:
- 2026-06-07`find_lightrag_sidecar_block(...)` 已移除 `first_positioned_block` fallback;未匹配 quote 时返回降级 resourceTab citation,不生成 page / bbox。
- 2026-06-07:当前 LightRAG `/query/data` chunks 未提供可稳定回跳 MNote sidecar block 的 provider `refs`;MNote 暂以文本匹配做严格定位,未匹配时只给 degraded resourceTab。
- 2026-06-07`task529` 改为当前 image source 降级 citation 正例,验证 `open_reference` 与 citation URL 能打开 image resource tab,且不携带伪造 page locator。
## 6. Phase DAgent / UI degraded citation 口径统一
目标:agent 可以使用 MNote 返回的 degraded citation,但不能编造页码、bbox 或 provider 内部路径。
Checklist
- [x] 更新 `skills/mnote-knowledge-rag/SKILL.md``locatorDegraded=true` 时,可以引用返回的 `citationMarkdown`,但必须说明来源定位降级;禁止编造 page / bbox。
- [x] 更新 Reasonix system prompt 中 knowledge-rag 能力说明,保持同一口径。
- [x] 更新 Page AI final answer smoke,增加 degraded citation 场景:最终回答可以含降级 citation,但不能说成 p.N / bbox。
- [x] 更新 dashboard smoke,不再依赖固定 `Completed (5)` / `Fail (4)` / 固定 doc id;改为验证 Documents / Graph / Retrieval 入口与当前 documents summary 可见。
验证:
- [x] `MNOTE_REASONIX_ACP_SELFTEST=1 node scripts/reasonix-acp-wrapper.mjs`
- [x] `node scripts/task530-knowledge-rag-page-ai-final-answer-smoke.js`
- [x] `node scripts/task531-lightrag-dashboard-ui-smoke.js`
## 7. Phase EReasonix / manifest drift guard
目标:在完整 7-47 capability registry 实施前,先降低 Reasonix wrapper 与 Rust manifest 的漂移风险。
Checklist
- [x] 增加一个轻量 selftestRust manifest 中 `mnote.knowledge_rag.*` 三个工具必须都存在于 Reasonix wrapper 映射。
- [x] 增加一个反向 selftestReasonix wrapper 暴露的 `mnote_knowledge_rag_*` 必须能映射到 Rust manifest tool name。
- [x] 保留手写工具表作为短期现实,但在 7-47 中继续推进 manifest 动态注册。
- [x] 若发现旧 `mnote.evidence.*` / `mnote.index.*` 函数被 grep 误判,给 manifest 源码附近加注释:dead code 仅保留历史对照,不在 manifest vector 中注册。
验证:
- [x] `MNOTE_REASONIX_ACP_SELFTEST=1 node scripts/reasonix-acp-wrapper.mjs`
- [x] `cargo test -p mnote-web hermes_client_tools_uses_manifest_and_profile_disabled_state -- --test-threads=1`
实施记录:
- 2026-06-07Reasonix wrapper selftest 会读取 Rust `hermes_tools/manifest.rs`,校验 `mnote.knowledge_rag.*` manifest tools 与 wrapper `MNOTE_TOOL_NAMES` / `REASONIX_TOOL_TO_MNOTE_TOOL` 双向一致。
## 8. Phase Flegacy OCR / Convex media visible runtime 退役跟进
目标:LightRAG 成为资料库主线后,继续清掉仍可能被浏览器 runtime 或 worker 误读为 active fallback 的旧 OCR / Convex Files 链路。
Checklist
- [x] `/api/local-folder/events` 不再转发 `local_ocr.job.updated`resource tab 不再依赖 local OCR SSE 或 `synthetic_resource_write` 刷新 filetree。
- [x] UI preference 不再接受 `localOcr.*` 写入;历史 effective preference 强制为 `false` / retired。
- [x] FileTree 右键入口不再保留 `local-ocr` action alias;资料索引只走 `knowledge-rag-index``/api/knowledge-rag/ingest`
- [x] 浏览器 runtime 不再调用 `/api/media/sign` / `/api/media/upload`;旧 Convex Files 前端链路只标记 retired guard。
- [x] `AppState` 移除 `local_ocr_job_tx` / `local_ocr_active_jobs`,旧 local OCR helper 不再广播 runtime event。
- [x] local search incremental refresh 遇到 OCR sidecar 时只清理旧 evidence sqlite 投影,不再重建 OCR evidence。
- [x] `routes/local_ocr.rs` 从历史 HTTP job / MinerU runtime 实现收缩为历史 OCR sidecar 识别、frontmatter 解析和索引读取 helper;旧 ignored HTTP route tests 已移除。
- [x] resource tab 后台任务抽屉、toolbar 与 CSS 从 `mnote-local-ocr-*` 重命名为 `mnote-knowledge-rag-*`Knowledge RAG ingest/delete 不再挂旧 OCR DOM / function 名。
- [x] Page AI 目标包删除空实现的 OCR sidecar context enrichmentlocal-first agent 只携带当前文件/selection/allowed roots,不再保留 `ocrContext` / `ocrRootRelativePath` 注入点。
- [x] Sidebar 旧 `open-ocr-settings` / `toggle-ocr-tasks` / `data-local-ocr-*` 委托移除;历史 `.ocr/*.ocr.md` sidecar 仅以 `retired-ocr-sidecar:*` 资源身份打开,不再伪装为 active local OCR。
验证:
- [x] `cargo test -p mnote-web local_ocr -- --test-threads=1`
- [x] `cargo test -p mnote-web local_search_ocr_sidecar_is_hidden_after_lightrag_retirement -- --test-threads=1`
- [x] `cargo test -p mnote-web -- --test-threads=1`
- [x] `cargo test --workspace -- --test-threads=1`
- [x] `git diff --check`
- [x] `codegraph sync .` / `codegraph_status`
## 9. 归档条件
- delete retry / orphan doc guard 有单测覆盖,prune 不会隐藏未确认删除的 provider doc。
- `sourcePaths` 的 post-filter 语义在 API、skill、wrapper 和 tests 中一致。
- locator 不再用不匹配的 first positioned block 生成非 degraded page/bbox。
- degraded citation 的 agent / UI 文案统一。
- `task529` / `task530` / `task531` 不依赖已漂移的固定历史 LightRAG documents 数量或 doc id。
- 7-50 保持 `done`,本稿完成后移动到 `design/07-ai/done/`
@@ -3,13 +3,22 @@
> 状态:process
>
> 目标:把当前分裂的 MNote builtin skill、mnote tool manifest、Hermes plugin、Reasonix wrapper 和 Page AI UI 开关收口为同一个“AI 能力”模型。对用户来说 skill / plugin / tool 都是“授予 AI 的能力”,UI 不应暴露实现层分类;实现层再把一个能力映射到说明书、工具、runtime adapter 和权限策略。本轮只整理 MNote 公共能力;Reasonix / Hermes 自带的 skills/plugins 维持现状,不纳入统一注册表迁移范围。
>
> 2026-06-07 口径更新:7-50 后资料库问答主线已切到 LightRAG,第一批能力包试点从旧 `mnote-local-index` 改为 `mnote-knowledge-rag`。`mnote-document-evidence` / `mnote-local-index` 只作为兼容 alias 映射到 `mnote-knowledge-rag`,不再恢复 `mnote.evidence.*` / `mnote.index.*` 作为 active tool。
## 当前实施状态
- Phase A 已落地:`rust/crates/mnote-web/src/hermes_tools/skill.rs` 已有 `MnoteCapabilityPack` / `CAPABILITY_PACKS``manifest.rs` 已输出 `capabilities[]` 并给 tools 标注 `capabilityId` / `capabilityIds`
- Phase B 已落地:`/api/hermes/client/capabilities` 已存在,Page AI `runtime=mnote` 技能目录优先请求 capabilities,能力 payload 内含 tools、readOnly、contextRefs、enabled/status。
- Phase C 已落地:`/api/hermes/client/capabilities/toggle` 会同步 capability skill preference 和 profile tool disabled policy;直接调用被关闭 tool 会走 `mnote_tool_disabled` 硬拒绝。
- Phase D 已完成 Reasonix 侧最小收口:`scripts/reasonix-acp-wrapper.mjs` 启动时优先读取 `/api/hermes/tools/mnote/manifest` 动态注册 MNote tool specsmnote-web 不可用时才回落到静态 fallbackHermes Python plugin 生成仍作为后续独立收口。
## 0. 用户口径
用户不需要理解 skill、plugin、tool 的区别。Page AI 设置中统一展示为“AI 能力”:
- `当前页读取`
- `本地索引与证据检索`
- `资料库问答`
- `本地文件编辑`
- `思维导图`
- `ONLYOFFICE 实时编辑`
@@ -35,7 +44,7 @@
现有内置 skill
- `mnote-current-page`
- `mnote-local-index`
- `mnote-knowledge-rag`
- `mnote-local-file`
- `mnote-onlyoffice-live`
- `mnote-mindmap`
@@ -46,7 +55,7 @@
- skill 是 Rust 静态注册,正文来自 `skills/*/SKILL.md`
- `/api/hermes/client/skills?runtime=mnote&agentId=...` 通过 `mnote_builtin_skills_payload()` 输出到 UI。
- 每个 skill 现在已经带 `toolNames``requiresContextRefs`,但这些只是弱引用,不是一个正式 capability/plugin 合同。
- `mnote.skill.read` 能懒加载正文;旧 `mnote-document-evidence` 已作为兼容别名映射到 `mnote-local-index`
- `mnote.skill.read` 能懒加载正文;旧 `mnote-document-evidence` / `mnote-local-index` 只作为兼容别名映射到 `mnote-knowledge-rag`,不恢复旧 evidence / index 工具
### 1.2 MNote tools
@@ -55,7 +64,8 @@
现有工具大类:
- skill/context`mnote.skill.read``mnote.context.*`
- evidence/index`mnote.evidence.*``mnote.index.*`
- knowledge-rag`mnote.knowledge_rag.status``mnote.knowledge_rag.query``mnote.knowledge_rag.open_reference`
- retired evidence/index`mnote.evidence.*``mnote.index.*` 只保留历史对照,不作为 active manifest / capability 示例
- doc/block/page/artifact`mnote.doc.*``mnote.block.*``mnote.page.*``mnote.artifact.*`
- mindmap`mnote.mindmap.*`
- office/onlyoffice`mnote.office.*``mnote.onlyoffice.*`
@@ -178,25 +188,22 @@ pub struct MnoteCapabilityPack {
"title": "MNote",
"capabilities": [
{
"id": "mnote-local-index",
"title": "MNote local index",
"description": "Search local documents with evidence locators and manage local index scopes.",
"id": "mnote-knowledge-rag",
"title": "资料库问答",
"description": "Ask the LightRAG-backed knowledge library and open returned MNote source references.",
"enabled": true,
"toggleable": true,
"readOnly": false,
"skillId": "mnote-local-index",
"skillId": "mnote-knowledge-rag",
"toolNames": [
"mnote.evidence.search",
"mnote.evidence.read",
"mnote.evidence.open",
"mnote.index.status",
"mnote.index.refresh",
"mnote.index.update_settings"
"mnote.knowledge_rag.status",
"mnote.knowledge_rag.query",
"mnote.knowledge_rag.open_reference"
],
"tools": [
{
"name": "mnote.index.update_settings",
"kind": "write",
"name": "mnote.knowledge_rag.query",
"kind": "read",
"status": "available",
"enabled": true,
"requiresWritePermission": true
@@ -221,7 +228,7 @@ pub struct MnoteCapabilityPack {
{
"runtime": "mnote",
"profile": "reasonix",
"id": "mnote-local-index",
"id": "mnote-knowledge-rag",
"enabled": true
}
```
@@ -240,16 +247,16 @@ pub struct MnoteCapabilityPack {
{
"capabilities": [
{
"id": "mnote-local-index",
"skillId": "mnote-local-index",
"toolNames": ["mnote.evidence.search", "mnote.index.status"]
"id": "mnote-knowledge-rag",
"skillId": "mnote-knowledge-rag",
"toolNames": ["mnote.knowledge_rag.status", "mnote.knowledge_rag.query", "mnote.knowledge_rag.open_reference"]
}
],
"tools": [
{
"name": "mnote.index.status",
"capabilityId": "mnote-local-index",
"capabilityScope": ["index.read", "evidence.read"]
"name": "mnote.knowledge_rag.query",
"capabilityId": "mnote-knowledge-rag",
"capabilityScope": ["knowledge_rag.read", "evidence.read"]
}
]
}
@@ -261,8 +268,8 @@ pub struct MnoteCapabilityPack {
在 Page AI 的 Skills 页中,用户看到的是统一“AI 能力”列表,不再分 skill / plugin / tool
- 行标题:`MNote local index`
- 副标题:`索引 / 证据检索 · 6 tools · 需要 folder`
- 行标题:`资料库问答`
- 副标题:`知识库 / LightRAG · 3 tools · 需要 folder`
- 状态 chip`只读` / `可写` / `部分工具关闭` / `只读上下文不可写`
- 主开关:启用/关闭整个能力包
- 展开项:列出 tools,显示 read/write、enabled、status
@@ -281,12 +288,12 @@ Runtime 页继续保留 `mnote tools`,但作为高级调试面:
### 4.3 索引面板与能力包关系
Sidebar 的“索引设置”仍是用户直接管理索引范围的产品 UIPage AI 的 `mnote-local-index` capability 是 agent 能力开关。
Sidebar 的“资料库 / 知识库设置”仍是用户直接管理 LightRAG source、索引范围和服务状态的产品 UIPage AI 的 `mnote-knowledge-rag` capability 是 agent 能力开关。
两者职责不同:
- 索引设置面板:用户手动新增/删除/刷新索引范围
- MNote local index capability:允许 agent 使用工具帮用户查看、新增、刷新、删除索引范围
- 知识库设置面板:用户手动新增/删除/刷新 source,查看 LightRAG 服务和 source registry 状态
- MNote knowledge-rag capability:允许 agent 使用工具查询资料库、查看状态和打开返回来源;sourcePaths 当前只过滤返回 references,不声称 provider 层预过滤 raw chunks
## 5. Runtime 适配
@@ -302,8 +309,8 @@ Sidebar 的“索引设置”仍是用户直接管理索引范围的产品 UI;
- 删除 `REASONIX_TOOL_TO_MNOTE_TOOL` 手写表。
- 工具名转换统一由函数生成:
- `mnote.index.status` -> `mnote_index_status`
- `mnote.evidence.search` -> `mnote_evidence_search`
- `mnote.knowledge_rag.status` -> `mnote_knowledge_rag_status`
- `mnote.knowledge_rag.open_reference` -> `mnote_knowledge_rag_open_reference`
### 5.2 Hermes ACP
@@ -332,7 +339,7 @@ Sidebar 的“索引设置”仍是用户直接管理索引范围的产品 UI;
验收:
- `mnote-local-index``/client/skills?runtime=mnote` 可见
- `mnote-knowledge-rag``/client/skills?runtime=mnote` `/client/capabilities?runtime=mnote` 可见,`mnote-local-index` 不再作为 active capability 暴露
- `/api/hermes/tools/mnote/manifest` 可看到 `capabilities[]`
-`mnote.skill.read` 不破。
@@ -345,8 +352,8 @@ Sidebar 的“索引设置”仍是用户直接管理索引范围的产品 UI;
验收:
- Page AI Skills 页中 `MNote local index` 像公共 skill 一样可见。
- 展开能看到 `mnote.evidence.*` `mnote.index.*`
- Page AI Skills / 能力页中 `资料库问答` 像公共 skill 一样可见。
- 展开能看到 `mnote.knowledge_rag.*`,看不到 `mnote.evidence.*` / `mnote.index.*` active tools
- 开关 capability 后,下一次 run 的 `skillPreferences.mnote` 同步变化。
### Phase C:能力包开关驱动工具开关
@@ -357,8 +364,8 @@ Sidebar 的“索引设置”仍是用户直接管理索引范围的产品 UI;
验收:
- 关闭 `mnote-local-index` 后 agent 不再看到该 skill 摘要。
- 关闭后直接调用 `mnote.index.status` 返回 `mnote_tool_disabled` 或 capability disabled。
- 关闭 `mnote-knowledge-rag` 后 agent 不再看到该 skill 摘要。
- 关闭后直接调用 `mnote.knowledge_rag.query` 返回 `mnote_tool_disabled` 或 capability disabled。
- 再打开后恢复。
### Phase D:生成 Reasonix/Hermes adapters
@@ -373,22 +380,19 @@ Sidebar 的“索引设置”仍是用户直接管理索引范围的产品 UI;
- `node scripts/reasonix-acp-wrapper.mjs` selftest 覆盖 manifest 动态注册。
- `hermes plugins list` / Hermes tool list 显示与 Rust manifest 一致。
## 7. 对 mnote-local-index 的落地形态
## 7. 对 mnote-knowledge-rag 的落地形态
`mnote-local-index` 是第一批公共能力包试点:
`mnote-knowledge-rag` 是第一批公共能力包试点:
- skill`skills/mnote-local-index/SKILL.md`
- skill`skills/mnote-knowledge-rag/SKILL.md`
- tools
- `mnote.evidence.search`
- `mnote.evidence.read`
- `mnote.evidence.open`
- `mnote.index.status`
- `mnote.index.refresh`
- `mnote.index.update_settings`
- `mnote.knowledge_rag.status`
- `mnote.knowledge_rag.query`
- `mnote.knowledge_rag.open_reference`
- requiresContextRefs`folder`
- readOnly`false`
- write guard`mnote.index.update_settings` 写设置;必须 `dryRun/idempotencyKey`;共享只读禁止写
- UI 文案:`索引 / 证据检索 · 可管理索引范围`
- readOnly`true`
- write guard本能力包不写用户 sourcesource 管理 UI / API 另归知识库设置面,不通过 agent capability 暴露写入
- UI 文案:`知识库 / LightRAG · 可回跳来源`
## 8. 非目标
@@ -1,8 +1,11 @@
# 7-46 [process] Document Evidence Retrieval Kernel v1
# 7-46 [reference] Document Evidence Retrieval Kernel v1
> 创建时间:2026-06-03
>
> 当前状态:`PROCESS`
> 当前状态:`REFERENCE`
>
> 2026-06-07 状态治理:7-50 LightRAG 已覆盖默认资料库问答主线,`mnote.evidence.*` / LiteParse / evidence.sqlite 不再作为 active agent capability 或资料库 fallback。本文件只保留为历史迁移、locator 合同和旧实现审计参考,不再从 checklist 中派发新任务。
> 文内未勾选项均为历史状态,不作为当前 `process` 任务。
>
> Owner07-ai / 03-rust-web / 01-tree-first-graph-kernel
>
@@ -1,8 +1,11 @@
# 7-48 [process] Paperless-ngx Reference: Resource Ingestion / Job Ledger / Evidence Index v1
# 7-48 [reference] Paperless-ngx Reference: Resource Ingestion / Job Ledger / Evidence Index v1
> 创建时间:2026-06-05
>
> 当前状态:`PROCESS`
> 当前状态:`REFERENCE`
>
> 2026-06-07 状态治理:本文只作为 Paperless-ngx 对 LightRAG source registry、job ledger、索引可重建性和历史 evidence.sqlite 设计的参考材料,不再作为 active evidence.sqlite / LiteParse 主线任务源。
> 文内未勾选项均为历史候选,不作为当前 `process` 任务。
>
> Owner07-ai / 03-rust-web / control-plane / 01-tree-first-graph-kernel
>
@@ -15,7 +18,7 @@
> 上位依据:
> - `/mnt/Data1T/mnote/ARCHITECTURE.md`
> - `/mnt/Data1T/mnote/CURRENT_ARCHITECTURE.md`
> - `/mnt/Data1T/mnote/design/07-ai/process/7-46-document-evidence-retrieval-kernel-v1.md`
> - `/mnt/Data1T/mnote/design/07-ai/reference/7-46-document-evidence-retrieval-kernel-v1.md`
> - `/mnt/Data1T/mnote/design/03-rust-web/done/3-25-local-folder-mineru-ocr-sidecar-v1.md`
> - `/mnt/Data1T/mnote/design/07-ai/process/7-18-local-first-agent-file-editing-control-plane-v1.md`
@@ -405,7 +408,7 @@ Owner07-ai / 03-rust-web
- [ ] 在 `core-protocol` 增加 `ResourceWorkJob` / `ResourceWorkJobStatus` / `ResourceWorkTriggerSource` 合同。
- [ ] 在 job 合同中加入 `stage / stageLabel / progressCurrent / progressTotal`,作为前台任务中心稳定字段。
- [ ] 明确 `local_ocr.job.updated` 与新 `resource_work.job.updated` 的兼容关系。
- [ ] `7-46` 里引用本设计作为 job / index lifecycle 执行补充。
- [x] 本设计已作为 `7-46` 历史 job / index lifecycle 参考,不再作为 active 执行补充。
验收:
@@ -2,7 +2,7 @@
> 创建时间:2026-06-06
>
> 状态:`process`
> 状态:`done`
>
> Owner10-review / 03-rust-web / 05-editor-mainline / 07-ai
@@ -30,28 +30,35 @@
### P1Local-folder event bus
- [ ] 新建 `03-rust-web` process:浏览器端 local-folder event bus。
- [ ] 目标是 sidebar、document session、resource tab 订阅同一 `/api/local-folder/events` 连接
- [ ] 验收要统计同一页面只有一个 local-folder EventSource,且后台写入仍能触发 document refresh 与 localized filetree refresh。
- [x] 已完成并归档 `design/03-rust-web/done/3-24-local-folder-browser-event-bus-v1.md`
- [x] sidebar、document session、resource tab 订阅同一 local-folder event bus
- [x] `task540` 已验证同一页面只有一个 local-folder EventSource,且后台写入仍能触发 document refresh 与 localized filetree refresh。
### P2Page Aggregate local-first hard guard
- [ ] 新建或更新 `05-editor-mainline` processlocal-first 文档页必须优先消费 `page_aggregate.block_document`
- [ ] 对 `compat.legacy_content` fallback 增加 local-first telemetry 或负向 smoke,确保它只出现在 cloud/legacy/explicit compat 场景
- [ ] 保存链路继续向 `EditorBlockDocument` / `tiptapDocument` 单一主 payload 收口
- [x] 已完成并归档 `design/05-editor-mainline/done/5-36-page-aggregate-local-first-hard-guard-v1.md`
- [x] 对 `compat.legacy_content` fallback 增加 local-first DOM diagnostic`task541` 断言 local `.md``page_aggregate.block_document`
- [x] 保存链路未扩大 legacyBlocks 使用面,`task167` 覆盖 local-first title/body/options 写入回读
### P37-18 agent edit clean/dirty smoke
- [x] 在不碰 LightRAG 的前提下,拆出 `design/07-ai/process/7-18-agent-edit-clean-dirty-smoke-checklist-v1.md`
- [ ] 验 clean bufferagent 原生 patch 修改当前 `.md` 后,watcher -> BufferStore -> Page Aggregate -> ProseMirror 可见更新。
- [ ] 验 dirty buffer:外部 agent 写入不会静默覆盖,必须出现冲突或 review 状态。
- [ ] 验 readonly:写入型 run 在 ACP/tool 层前置拒绝,而不是只靠审计后置记录。
- [x] 在不碰 LightRAG 的前提下,拆出并完成 `design/07-ai/done/7-18-agent-edit-clean-dirty-smoke-checklist-v1.md`
- [x] 验 clean bufferagent 原生 patch 修改当前 `.md` 后,watcher -> BufferStore -> Page Aggregate -> ProseMirror 可见更新。
- [x] 验 dirty buffer:外部 agent 写入不会静默覆盖,必须出现冲突或 review 状态。
- [x] 验 readonly:写入型 run 在 ACP/tool 层前置拒绝,而不是只靠审计后置记录。
- [x] 剩余归档项已在 `design/10-review/done/20-post-lightrag-runtime-hardening-checklist-v1.md` P1 完成:补真实后端 auditScope / allowed root 外写入拒绝证据后归档 7-18。
### P4LightRAG diff review gate
### P4LightRAG post-commit audit gate
- [ ] 等另一个 agent 完成后,只读检查 LightRAG diff
- [ ] 重点核对 source registry、watcher stale/delete/reindex、query scope、`locatorDegraded`、job ledger 与 smoke 是否互相一致
- [ ] 若 LightRAG diff 已稳定,再决定是否新建 `7-51` 或更新现有 `7-48`
- [x] LightRAG 已提交并统一为 7-50 主线,旧 LiteParse / evidence / local OCR 默认路径已退役
- [x] 只读检查 7-50 post-commit 状态
- [x] 重点核对 source registry、watcher stale/delete/reindex、query scope、`locatorDegraded`、status bridge、image wrapper 与 smoke 是否互相一致
- [x] 发现的硬化项已拆到 `20-post-lightrag-runtime-hardening-checklist-v1.md` 并完成 `design/07-ai/done/7-51-lightrag-post-commit-hardening-v1.md`
### P57-47 capability registry / local search / design governance follow-up
- [x] `20-post-lightrag-runtime-hardening-checklist-v1.md` 已继续完成 capability registry 口径重写、local search 边界和 design 状态治理。
- [x] `7-46` / `7-48` 已降级到 `design/07-ai/reference/`,避免 worker 继续按旧 evidence / LiteParse 主线派活。
## 4. 非目标
@@ -0,0 +1,335 @@
# 20 Post-LightRAG / Agent Edit / Runtime hardening checklist v1
> 创建时间:2026-06-07
>
> 状态:`done`
>
> Owner10-review / 07-ai / 03-rust-web / 05-editor-mainline
>
> 上位依据:
> - `design/07-ai/done/7-50-lightrag-knowledge-rag-provider-v1.md`
> - `design/07-ai/done/7-51-lightrag-post-commit-hardening-v1.md`
> - `design/07-ai/done/7-18-agent-edit-clean-dirty-smoke-checklist-v1.md`
> - `design/07-ai/process/7-18-local-first-agent-file-editing-control-plane-v1.md`
> - `design/07-ai/process/7-47-mnote-public-capability-plugin-registry-v1.md`
> - `design/10-review/done/19-non-lightrag-design-cleanup-followup-checklist-v1.md`
>
> 口径更新:LightRAG 7-50 已成为资料库问答主线,旧 LiteParse / evidence / local OCR 默认路径已退役。后续不再按“扩 RAG”理解,而是按“post-commit audit、边界硬化、减少 runtime 竞态和能力注册漂移”推进。
## 1. 当前工作树边界
当前执行本 checklist 前,需要先确认工作树中已有未提交改动的归属,避免覆盖他人或历史提交后的本地调整:
- `rust/crates/mnote-web/src/routes/search.rs`
- `scripts/task452-local-search-index-browser-smoke.js`
- `scripts/task531-lightrag-dashboard-ui-smoke.js`
本 checklist 默认不修改上述文件,除非本轮任务明确转入 local search / dashboard smoke 收口。
## 2. 执行原则
- 不回滚 7-50 的主线口径:资料库问答、PDF / Office / 图片 OCR、跨资料 citation 默认走 LightRAG。
- 不恢复 `mnote.evidence.*``mnote.index.*`、旧 local OCR API 或 LiteParse provider 作为 agent fallback。
- 不把 LightRAG storage、input wrapper、sidecar、chunk、vector、graph 当成用户正文真相;MNote local-folder source 仍是真相。
- 不在普通页面搜索、文件树、编辑器打开路径中引入对 LightRAG 可用性的硬依赖。
- 发现需要更细粒度实施时,先在对应 `design/*/process/` 下拆子 checklist,再实施代码。
- 可以使用 subagent,但 subagent 只能做受控叶子任务:限定读写范围、验证命令、交付文件、禁止再派生 runner / subagent / worktree;主控必须复核 diff 和验证结果。
## 3. P0LightRAG 7-50 post-commit audit
目标:确认 7-50 作为 `done` 主线是稳定的,找出需要补的硬边界,不重复建设第二套 RAG。
Checklist
- [x] 只读 review 7-50 相关改动面:
- `rust/crates/mnote-web/src/routes/knowledge_rag.rs`
- `rust/crates/mnote-web/src/hermes_tools/knowledge_rag.rs`
- `rust/crates/mnote-web/src/hermes_tools/manifest.rs`
- `rust/crates/mnote-web/src/hermes_tools/skill.rs`
- `scripts/reasonix-acp-wrapper.mjs`
- `skills/mnote-knowledge-rag/SKILL.md`
- 资料库设置 UI、FileTree 状态灯、Page AI final answer smoke。
- [x] 核对 source truthregistry 必须以 MNote source path 为真相;symlink / input wrapper / parsed cache 只作为派生物。
- [x] 核对 delete / prune 语义:`delete-source`、watcher stale sync、prune registry 不得删除用户原始 source。
- [x] 核对 stale / deleted sourcesource 删除、hash 变化、rename / move 后,旧 LightRAG reference 不得继续作为有效 citation 暴露。
- [x] 核对 status bridgeingest / reindex 后的状态同步必须是有界 backoff;不得形成长期无界前台 polling。
- [x] 核对 image wrapper:图片 source 生成 Markdown wrapper 后,registry 和 citation 仍映射回原始图片 source,而不是把 wrapper 当用户资料真相。
- [x] 核对 query scope`sourcePaths` 当前是否只是 query 后过滤 MNote mapped references;若 provider 层未能预过滤,UI / agent 文档必须明确这是“provider 检索后 MNote scope 过滤”。
- [x] 核对 locator 降级:无 page / bbox / sidecar 命中时必须返回并展示 `locatorDegraded=true`,不得伪造页码、bbox 或 block id。
- [x] 核对 LightRAG dashboardMNote 只打开或嵌入 dashboard,不接管 LightRAG 内部图谱 / 文档任务真相。
审计结论:
- source truth、image wrapper、delete 不删用户 source、status bridge 有界 backoff、旧 evidence/index tool retired 口径均有代码和测试证据。
- 发现的缺口已拆到并完成 `design/07-ai/done/7-51-lightrag-post-commit-hardening-v1.md`
- watcher stale sync 删除失败时可能丢失 `lightRagDocId`,后续难以重试 provider orphan doc 删除;Phase A 已补保留 doc id、retry 状态、delete confirmed 后清 doc id 和 unmapped reference 过滤。
- `sourcePaths` 是 provider 检索后过滤 mapped referencesHTTP API 仍返回未过滤 `raw`;已显式返回 `sourceScopeMode=post_filter_mapped_references``rawScopeFiltered=false`
- sidecar locator 的 `first_positioned_block` fallback 可能在 quote 不匹配时生成错误 page/bbox;已移除 fallback,未匹配时降级到 resourceTab citation。
- degraded citation 的 skill / wrapper / UI 文案已统一,并由 Page AI final answer smoke 覆盖。
- Reasonix wrapper 仍是手写工具表;已补短期 drift guard,长期按 7-47 动态注册。
验证:
- [x] `cargo test -p mnote-web knowledge_rag -- --nocapture`
- [x] `MNOTE_REASONIX_ACP_SELFTEST=1 node scripts/reasonix-acp-wrapper.mjs`
- [x] `node --check scripts/task529-knowledge-rag-citation-resource-tab-smoke.js`
- [x] `node --check scripts/task530-knowledge-rag-page-ai-final-answer-smoke.js`
- [x] `node --check scripts/task531-lightrag-dashboard-ui-smoke.js`
- [x] `node --check scripts/task532-knowledge-rag-docx-ingestion-smoke.js`
- [x] `node --check scripts/task533-knowledge-rag-source-watcher-sync-smoke.js`
- [x] `node --check scripts/task534-knowledge-rag-source-management-scope-smoke.js`
- [x] `node --check scripts/task538-knowledge-rag-source-scope-api-smoke.js`
- [x] `node scripts/task529-knowledge-rag-citation-resource-tab-smoke.js`
- [x] `node scripts/task530-knowledge-rag-page-ai-final-answer-smoke.js`
- [x] `node scripts/task531-lightrag-dashboard-ui-smoke.js`
- [x] `node scripts/task532-knowledge-rag-docx-ingestion-smoke.js`
- [x] `node scripts/task533-knowledge-rag-source-watcher-sync-smoke.js`
- [x] `MNOTE_WEB_SMOKE_BASE_URL=http://127.0.0.1:3300 node scripts/task534-knowledge-rag-source-management-scope-smoke.js`
- [x] `MNOTE_WEB_SMOKE_BASE_URL=http://127.0.0.1:3300 node scripts/task538-knowledge-rag-source-scope-api-smoke.js`
运行态验证结果:
- `cargo test -p mnote-web knowledge_rag -- --nocapture` 通过 27 项。
- Reasonix ACP selftest 通过。
- `task529` 已改为当前可重复的 image source 降级 citation 正例;2026-06-07 在当前 3000 通过,验证 query/open_reference citationUrl 能打开 image resource tab,且不伪造 page/bbox。
- `task531` 已改为读取当前 status documents summary,不再硬编码 `Completed (5)` / `Fail (4)` / 固定扫描 PDF doc id2026-06-07 在当前 3000 通过,验证 Documents / Graph / Retrieval 入口。
- `task530` 已改为当前可重复的 image source degraded citation 场景;2026-06-07 在当前 3000 通过,验证 Page AI Reasonix 最终回答可引用降级 citation,且不编造 p.N / bbox、不泄漏 raw JSON / tool 名。
- `task538` 新增 API-only source scope smoke2026-06-07 在 3300 新构建通过,验证 `sourceScopeMode=post_filter_mapped_references``rawScopeFiltered=false`、references 过滤和 `delete-source` 不删除原始 source。
- `task534` 已修复默认过滤导致 source row 不可见的问题;2026-06-07 在 3300 最新构建通过,覆盖 UI source management、删除索引不删原始 source、删除后 scoped query 不再返回该 source。
- `task532` 当前 3000 通过:DOCX ingestion、degraded citation、Office resource tab 打开均验证成功。
- `task533` 当前 3000 通过:watcher Remove(File) batch、registry stale/delete 标记、query 不继续暴露 deleted source 已验证。
- `7-51` Phase A/B/C/D/E 已完成主要代码硬化和单测/selftest;已移动到 `design/07-ai/done/`
产出:
- [x] 若只发现小问题,直接在本 checklist 记录 audit 结果和 follow-up。
- [x] 若发现需要代码硬化,新建 `design/07-ai/process/7-51-lightrag-post-commit-hardening-v1.md`,拆 Phase A/B/C 后再实施;完成后已归档到 `design/07-ai/done/7-51-lightrag-post-commit-hardening-v1.md`
## 4. P17-18 agent file edit 归档收尾
目标:把 local-first 普通 Markdown agent 原生文件编辑从 `process` 收到可归档状态。
Checklist
- [x] 补 clean smoke 的真实后端 audit 证据:不要只依赖 mocked SSE receipt;要证明后端 `local_agent_audit``auditScope` 使用 `allowed_files`
- [x]`task535` 或新 `task539` 中断言 `auditScope.fileCount=1` 或等价目标文件范围证据。
- [x] smoke 继续断言不调用 `/api/documents/save``mnote.doc.markdown_edit``mnote.page.save`
- [x] 补 allowed roots 外写入拒绝验证:run payload 或 agent result 涉及 root 外路径时,后端 / wrapper / 审计层必须拒绝,不得返回成功 receipt。
- [x] 补发送后 frozen target 摘要:消息或 run 记录里能看到当次冻结 target;用户切换 tab 不影响已启动 run 的目标可解释性。
- [x] 多 tab / 跨 workspace / dirty write 确认先拆设计,不强行塞进当前 7-18 归档。
- [x] 更新 `design/07-ai/process/7-18-agent-edit-clean-dirty-smoke-checklist-v1.md` 状态。
- [x] 满足归档条件后移动到 `design/07-ai/done/`
验证:
- [x] `cargo test -p mnote-web local_agent_audit -- --test-threads=1`
- [x] `node scripts/task535-page-ai-local-agent-clean-edit-smoke.js`
- [x] `node scripts/task536-page-ai-local-agent-dirty-guard-smoke.js`
- [x] `node scripts/task537-page-ai-local-agent-readonly-write-guard-smoke.js`
- [x] `node scripts/task539-local-agent-audit-scope-contract.js`
- [x] 新增 allowed-root 外写入 smoke 或 targeted test。
运行态验证结果:
- `task535` 当前 3000 通过:clean buffer 下 agent 原生文件 patch 后,receipt refresh 触发当前文档和 filetree 刷新,tiptap 可见更新,且未调用旧保存/写入工具。
- `task536` 当前 3000 通过:dirty buffer 在 `/runs` 前阻塞,磁盘未变,编辑器 dirty 文本仍可见。
- `task537` 当前 3000 通过:readonly 写入在 `/runs` 前阻塞,磁盘未变。
- `task539` 当前 3000 通过:复跑 `task535`,断言 frozen target / allowedFiles;同时跑 `cargo test -p mnote-web local_agent_audit -- --test-threads=1`,证明后端 auditScope 使用 `allowed_files` / `fileCount=1`,并覆盖 client 注入 root 外 `allowedRoots` 会被 SQLite grant 改写。
## 5. P2Local-folder browser event bus
目标:减少 sidebar、document session、resource tab、Page AI receipt 对 local-folder watcher / realtime / refresh 的重复连接和竞态。
先拆设计:
- [x] 新建并完成归档 `design/03-rust-web/done/3-24-local-folder-browser-event-bus-v1.md`
- [x] 盘点现有入口:
- `rust/crates/mnote-web/browser/tree-live-controller.js`
- `rust/crates/mnote-web/browser/sidebar-tree-live-apply-runtime.js`
- `rust/crates/mnote-web/browser/document-session-runtime.js`
- `rust/crates/mnote-web/browser/document-resource-tab-runtime.js`
- `rust/crates/mnote-web/browser/sidebar-page-ai-runtime.js`
- [x] 明确 event bus 的输入源:`/api/realtime/ws` 主链、`/api/tree/events` SSE fallback、Page AI receipt synthesized batch。
- [x] 明确 event bus 的输出事件:
- `watch_batch`
- `document_changed`
- `resource_changed`
- `filetree_parent_changed`
- `knowledge_rag_source_updated`
- `resync_required`
- [x] 明确订阅方职责:sidebar 只刷新 projection / filetree parentdocument session 只刷新当前文档;resource tab 只刷新当前资源;Page AI 只派发 receipt refresh。
设计产出:
- `design/03-rust-web/done/3-24-local-folder-browser-event-bus-v1.md` 已记录当前重复连接来源、bus 输入/输出事件、订阅方职责、分 Phase 实施和 smoke 验收。
实施 Checklist
- [x] 新增浏览器 singleton:同一 `workspaceId/rootUri` 只允许一个 realtime 连接。
- Phase A 已新增 `local-folder-event-bus-runtime.js`,并让 `tree-live-controller.js` 的 local-folder 分支优先通过 `window.__mnoteLocalFolderEventBus.startLocalFolderWatcher(...)` 创建 watcher`document-session-runtime.js` / `document-resource-tab-runtime.js` 仍待 Phase C 切换后才能勾选总体验收。
- Phase C-1 已让 document session 与 passive resource tab 在 bus 可用时复用 root 级 watcher`task540` 已验证 sidebar/tree live + document session 同页只创建 1 个 `/api/local-folder/events` EventSource。resource tab + Page AI 全组合仍待 smoke 扩展后勾选总项。
- 扩展 `task540` 覆盖 resource tab 后发现 tree live / resource tab 的 workspaceId 解析可能不一致,已把 bus 连接 key 收口为页面内 `rootUri`,避免同一 root 重复建 watcher。
- `task540` 当前在 3300 通过,已覆盖 document page + passive resource tab + `synthetic_page_ai_receipt` 同页只创建 1 个 `/api/local-folder/events`;真实 Page AI 面板点击到 receipt 的完整 UI 链路仍待扩展。
- [x] 保留 WS 主链和 SSE fallback,但连接创建由 event bus 统一管理。
- 非 local-folder tree live 仍保留 `tree-live-ws` / `tree-live-sse`local-folder watcher 正常路径由 event bus 统一管理,bus 不可用时保留旧 EventSource fallback。
- [x] `refreshLocalFolderSidebarSnapshot` 增加 orchestrator:同一 tick 合并请求、记录 reason、避免重复 sidebar projection 和 filetree parent refresh。
- [x] sidebar live apply 改为订阅 event bus,不直接拥有全部连接与刷新策略。
- [x] document session 改为订阅当前文档变更事件,不直接假设所有 watch batch 都要刷新。
- [x] resource tab 改为订阅资源路径变更事件,不与 document session 重复处理。
- [x] Page AI receipt 转为 event bus synthetic event,避免 receipt + watcher echo 双刷新。
- 当前仍保留 bus 不可用时的旧 `tree:local-folder-watch-batch` fallback。
验证:
- [x] 新增 smoke:同一页面只存在一个 WS 或 EventSource local-folder realtime 连接。
- `scripts/task540-local-folder-event-bus-single-connection-smoke.js` 已新增并在 3300 通过;当前覆盖 sidebar/tree live + document session + passive resource tab + Page AI synthetic receipt,尚未覆盖真实 Page AI 面板点击完整链路。
- [x] 外部修改当前 `.md` 后,tiptap 可见刷新。
- [x] 外部新增 / 删除文件后,filetree 只做局部 parent refresh。
- [x] Page AI agent file edit receipt 后,不重复请求 sidebar projection。
- [x] 复跑 tree realtime 基线:`task446``task447``task448` 或相邻 smoke。
- `task446` / `task447` / `task448` 当前仍请求无 workspace 的 `/api/tree/commands` cloud/Convex 旧入口,结果为 `convex_retired`,不作为 local-folder event bus 证据。
- 相邻 local-folder runtime smoke 已通过:`task435``task436``task535``task540`
运行态验证结果:
- `task540` 在 3300 最新 mnote-web 通过:document page + passive resource tab + `synthetic_page_ai_receipt` 同页只创建 1 个 `/api/local-folder/events`;连续两个同 tick receipt 只触发 1 次 filetree parent projectionsidebar projection 为 0。
- `task535` 在 3300 最新 mnote-web 通过:真实 Page AI clean agent receipt 后当前文档和 filetree 均刷新,且未调用 `/api/documents/save``mnote.doc.markdown_edit``mnote.page.save`
- `task436` 在 3300 最新 mnote-web 通过:当前打开 local `.md` 外部修改后 tiptap 可见刷新,dirty 文档进入冲突保护。
- `task435` 在 3300 最新 mnote-web 通过:外部创建/重命名/删除 Markdown 与非 Markdown 资源后 page tree / filetree 原地更新且无 reload。
## 6. P3Page Aggregate local-first hard guard
目标:防止 local-first 文档页回落到 legacy block / compat content 影子路径。
Checklist
- [x] 新建或更新 `design/05-editor-mainline/process/*page-aggregate-local-first-hard-guard*.md`;完成后已归档为 `design/05-editor-mainline/done/5-36-page-aggregate-local-first-hard-guard-v1.md`
- [x] 复核 `document-tiptap-conversion-runtime.js` 的 source 判定:local-first 正常路径必须优先 `page_aggregate.block_document`
- [x]`compat.legacy_content` fallback 增加 telemetry 或 DOM diagnostic,便于 smoke 断言。
- [x] local-first `.md` 文档页 smoke 断言不得出现 `compat.legacy_content`
- [x] cloud / legacy / explicit compat 场景继续允许 fallback,并保留独立 contract test。
- [x] 保存链路继续向 `EditorBlockDocument` / `tiptapDocument` 单一主 payload 收口,不扩大 legacyBlocks 使用面。
验证:
- [x] `node scripts/task522-page-aggregate-compat-fallback-contract.js`
- [x] `node scripts/task167-local-markdown-title-body-options-no-convex-smoke.js`
- [x] 新增并通过 `node scripts/task541-page-aggregate-local-first-hard-guard-smoke.js`
- [x] 涉及保存链路时补跑 Page Aggregate body / options smoke;本轮未改保存链路,已用 `task167` 覆盖 local-first title/body/options 写入与回读。
运行态验证结果:
- `task522` 通过:`blockDocument` 优先于 legacy contentexplicit compat legacy-only 仍允许 `compat.legacy_content`
- `task167` 通过:local-first Page Aggregate 保持 `projectionSource=local_markdown.content` 且输出 `blockDocument`,标题、正文、页面设置写入后不退回 legacy。
- `task541` 通过:真实浏览器打开 local `.md`editor root 诊断为 `data-mnote-page-body-source=page_aggregate.block_document``data-mnote-page-body-local-compat-fallback=false``data-mnote-page-body-hard-guard=local_ok`
- `cargo test -p mnote-web document_editor_adapter_runtime_contains_host_contracts -- --test-threads=1` 通过,覆盖 runtime source 诊断合同。
## 7. P47-47 capability registry 口径重写与实施
目标:减少 Hermes manifest、Reasonix wrapper、Page AI UI、tool disabled policy 的手写漂移。
先修订设计:
- [x] 更新 `design/07-ai/process/7-47-mnote-public-capability-plugin-registry-v1.md`,把试点从 `mnote-local-index` 改为 `mnote-knowledge-rag`
- [x] 删除或改写 `mnote.evidence.*``mnote.index.*` 作为 active capability 示例的内容。
- [x] 明确旧 alias`mnote-document-evidence` / `mnote-local-index` 只映射到 `mnote-knowledge-rag`,不恢复旧工具。
Phase A:只加 registry,不改 UI 行为。
- [x] 新增或整理 capability pack 单一注册表。
- [x] Rust manifest tools 自动带 `capabilityId`
- [x] `/api/hermes/tools/mnote/manifest` 输出 `capabilities[]`
- [x]`/client/skills``/client/tools` 响应兼容。
Phase BUI 消费 capability pack。
- [x] `/api/hermes/client/capabilities` 成为 Page AI “能力”页数据源。
- [x] 能力行展示 tools 数、read/write、contextRefs、enabled 状态。
- [x] Runtime 高级页 tools 按 capability 分组。
Phase C:能力包开关驱动工具硬策略。
- [x] `/api/hermes/client/capabilities/toggle` 同步 skill preference 与 profile tool disabled policy。
- [x] `page_ai_capability_policy()` 过滤 disabled capability。
- [x] `execute_mnote_tool_call()` 对 disabled capability 下的 tool 继续硬拒绝。
Phase D:减少 Reasonix / Hermes adapter 手写漂移。
- [x] Reasonix wrapper 启动时优先读取 Rust manifest 动态注册工具。
- [x] 手写工具表降为 mnote-web 未启动时的 fallback。
- [x] Hermes plugin 若仍需要本机 Python plugin,则从 Rust manifest 生成或同步;当前 repo 主链不直接写 `/home/lix/.hermes/plugins/mnote`,7-47 已记录为后续独立生成条件。
验证:
- [x] 新增 Rust tool 后,只改 manifest / capability packPage AI 能力页与 Reasonix 工具可见性同步变化。
- [x] 关闭 `mnote-knowledge-rag` 后,agent 不看到该能力摘要。
- [x] 关闭后直接调用 `mnote.knowledge_rag.query` 返回 disabled / capability disabled。
- [x] `MNOTE_REASONIX_ACP_SELFTEST=1 node scripts/reasonix-acp-wrapper.mjs` 通过。
运行态验证结果:
- `cargo test -p mnote-web page_ai_capabilities_expose_knowledge_rag_and_toggle_tools -- --test-threads=1` 通过:能力页不暴露 `mnote-local-index`,暴露 `mnote-knowledge-rag`toggle 后 `mnote.knowledge_rag.query` 在 tools 响应中为 disabled。
- `cargo test -p mnote-web hermes_tools_manifest_exposes_mnote_capability_packs -- --test-threads=1` 通过:manifest 输出 `capabilities[]`knowledge-rag tools 带 `capabilityIds`,旧 `mnote.index.status` 不注册。
- `MNOTE_REASONIX_ACP_SELFTEST=1 node scripts/reasonix-acp-wrapper.mjs` 通过。
- `timeout 3s env MNOTE_REASONIX_ACP_DEBUG=1 MNOTE_WEB_URL=http://127.0.0.1:3000 node scripts/reasonix-acp-wrapper.mjs </dev/null` 启动探针显示 `registered 7 mnote tools from runtime_manifest`
## 8. P5Local search residual boundary
目标:LightRAG 退役旧 evidence 后,保留 MNote 普通搜索,但不让它重新变成资料库问答或 OCR fallback。
Checklist
- [x] 先审当前未提交的 `search.rs` / `task452` 改动归属:`search.rs` 原有 diff 主要是格式化;`task452` 原有 diff 是本地索引 settings API / backlink / tag API smoke 扩展,本轮继续按 P5 收口。
- [x] 明确 `/api/search/documents` 只服务 Markdown 页面、资源标题、tag、backlink、普通 local search。
- [x] 明确 `/api/search/documents` 不读旧 OCR sidecar,也不调用 LiteParse / evidence.sqlite 作为资料库问答 fallback。
- [x] `task452` 继续验证本地搜索 settings API / tag / backlink API,但不恢复旧 local-index UI 入口,也不暗示它是资料库问答入口。
- [x] 更新 `scripts/TESTING_REFERENCE.md` 中 local search 与 LightRAG 的边界。
验证:
- [x] `MNOTE_WEB_SMOKE_BASE_URL=http://127.0.0.1:3300 node scripts/task452-local-search-index-browser-smoke.js`
- [x] `cargo test -p mnote-web search_documents_local_folder -- --test-threads=1`
- [x] `cargo test -p mnote-web local_search_ocr_sidecar_is_hidden_after_lightrag_retirement -- --test-threads=1`
运行态验证结果:
- `/api/search/documents` local-folder 响应 meta 新增 boundary`knowledgeRag=false``evidenceSqliteFallback=false``liteParseFallback=false``ocrSidecarFallback=false`,并返回空 `evidence` 数组。
- 新增/更新 targeted test 证明:普通本地搜索不再把 stale `evidence.sqlite` 命中提升为搜索结果,也不把旧 OCR sidecar 当搜索结果。
- `task452` 在 3300 最新构建通过:搜索、settings API、backlinks/tags API 仍可用;旧 `mnote-local-index-settings-toggle` 不复活,页面保留 `mnote-knowledge-rag-settings-toggle` 作为资料库入口。
- 宽泛 `cargo test -p mnote-web search -- --test-threads=1` 未作为 P5 通过证据:其中仍包含退役 evidence / LiteParse 旧测试和当前 local-index settings 历史失败,后续 P6 设计治理会处理旧 process / retired 测试口径。
## 9. P6Design 状态治理
目标:减少后续 worker 从旧 process / old process 中误派活。
Checklist
- [x] 更新并归档 `design/10-review/done/19-non-lightrag-design-cleanup-followup-checklist-v1.md`P1/P2/P3/P4/P5 均已回填完成证据。
- [x] 处理 `design/07-ai/process/7-46-document-evidence-retrieval-kernel-v1.md`:因 7-50 已覆盖默认资料库主线,已移到 `design/07-ai/reference/7-46-document-evidence-retrieval-kernel-v1.md` 并标注仅作历史 / migration 说明。
- [x] 处理 `design/07-ai/process/7-48-paperless-ngx-reference-resource-ingestion-job-index-v1.md`:已移到 `design/07-ai/reference/7-48-paperless-ngx-reference-resource-ingestion-job-index-v1.md` 并标注仅作为 LightRAG / job ledger 参考,不再作为 active evidence.sqlite 主线。
- [x]`design/old/README.md` 下增加说明:old 下的 `process` 不作为 active 任务源,除非用户显式要求历史审计。
- [x] 每次新建 checklist 必须写明 owner、非目标、验收命令和归档条件;本轮新增 `3-24` / `5-36` / `7-51` / `20` 均按该格式回填。
运行态验证结果:
- `find design/07-ai/process` 当前不再包含 `7-46` / `7-48`
- `design/old/README.md` 已明确 old/process 非 active 任务源。
- `design/10-review/process/19...` 已移动到 `design/10-review/done/19...`
## 10. 推荐执行顺序
1. P0LightRAG 7-50 post-commit audit。
2. P17-18 agent file edit 归档收尾。
3. P2Local-folder browser event bus。
4. P47-47 capability registry 口径重写与实施。
5. P3Page Aggregate local-first hard guard。
6. P5 / P6:按当前脏改和设计误读风险穿插推进。
## 11. 总体验收
- LightRAG 作为资料库问答主线稳定,source truth / stale / delete / locatorDegraded / query scope 都有证据。
- 7-18 local-first 普通 Markdown agent 文件编辑 checklist 可归档。
- local-folder browser runtime 不再因为多个模块各自连接和刷新导致重复请求或竞态。
- local-first Page Aggregate 不再静默回落到 legacy content 主链。
- MNote AI capability 注册表能减少 manifest / wrapper / UI 漂移。
- 旧 evidence / LiteParse / local OCR 不再被 worker 误当作 active fallback。
+2
View File
@@ -6,6 +6,8 @@
> - `process/`:已废弃,但属于历史上的草稿、方案、spike、路线稿
> - `done/`:已废弃,但属于历史上的定稿、审计、报告、边界说明
>
> 任务源规则:`design/old/**/process/` 不作为 active 任务源。worker 只能在用户明确要求“历史审计 / 迁移对照 / recycle 复盘”时读取这些文件;默认不得从 old/process 的 `[ ]` checklist 继续派活。
>
> 大类说明:
> - `01-tree-first-graph-kernel/`:已被新清单替代的旧内核稿
> - `03-rust-web/`:已被新清单替代的旧 Rust Web 稿
+98 -66
View File
@@ -143,9 +143,9 @@ pub fn build_query_request<T>(
context: &BridgeContext,
query: &QueryEnvelope<T>,
) -> BridgeResult<RetiredQueryRequest> {
let function_name = legacy_query_function_name(&query.name)?;
legacy_query_function_name(&query.name)?;
Ok(RetiredQueryRequest {
function_name: function_name.to_string(),
function_name: query.name.clone(),
deployment_id: context.deployment_id.clone(),
project_id: context.project_id.clone(),
workspace_id: context.workspace_id.clone(),
@@ -160,9 +160,9 @@ pub fn build_write_request<T>(
context: &BridgeContext,
command: &CommandEnvelope<T>,
) -> BridgeResult<RetiredMutationRequest> {
let function_name = legacy_command_function_name(&command.name)?;
legacy_command_function_name(&command.name)?;
Ok(RetiredMutationRequest {
function_name: function_name.to_string(),
function_name: command.name.clone(),
deployment_id: context.deployment_id.clone(),
project_id: context.project_id.clone(),
workspace_id: context.workspace_id.clone(),
@@ -182,26 +182,55 @@ fn legacy_query_function_name(name: &str) -> BridgeResult<&'static str> {
match name {
"documents.content.get" => Ok("documents:getContent"),
"documents.meta.get" => Ok("documents:getMeta"),
"blocks.get" => Ok("blocks:getById"),
"mindmaps.get" => Ok("mindmaps:get"),
"search.documents" => Ok("documents:listSearchDataByWorkspace"),
"search.documents" => Ok("search:documents"),
"search_blocks" => Ok("documents:searchBlocks"),
"sidebar.dataset.list" => Ok("sidebar:datasetList"),
"bridge.request.get" => Ok("bridgeLogs:listByRequest"),
"bridge.trace.get" => Ok("bridgeLogs:listByTrace"),
"bridge.command.get" => Ok("bridgeLogs:listByCommand"),
"bridge.workspace.overview" => Ok("bridgeLogs:listWorkspaceOverview"),
_ => Err(retired_bridge_error()),
}
}
pub fn retired_query_transport_function_name(name: &str) -> BridgeResult<&'static str> {
legacy_query_function_name(name)
}
fn legacy_command_function_name(name: &str) -> BridgeResult<&'static str> {
match name {
"documents.title.update" => Ok("documents:updateTitle"),
"page.head.updateTitle" | "tree.node.rename" => Ok("documents:updateTitle"),
"documents.save" => Ok("documents:updateContent"),
"documents.create" => Ok("documents:createWithParentReference"),
"documents.move" => Ok("documents:move"),
"documents.delete" => Ok("documents:softDelete"),
"documents.restore" => Ok("documents:restore"),
"insert_block" | "blocks.patch" | "blocks.move" | "blocks.embed" => {
Ok("documents:updateContent")
"page.body.save" => Ok("documents:updateContent"),
"documents.options.update" | "page.layout.updateOptions" => Ok("documents:updateOptions"),
"documents.stats.update" => Ok("documents:updateStats"),
"documents.create" | "tree.node.create" => Ok("documents:createWithParentReference"),
"documents.move" | "tree.node.move" | "tree.subtree.move" => Ok("documents:move"),
"documents.delete" | "tree.node.archive" => Ok("documents:softDelete"),
"documents.restore" | "tree.node.restore" => Ok("documents:restore"),
"documents.purge" | "tree.node.purge" => Ok("documents:purge"),
"documents.copy_tree" | "tree.subtree.copy" => Ok("documents:copyTree"),
"documents.duplicate" => Ok("documents:duplicateWithMindmaps"),
"documents.embed" | "tree.node.embed" => Ok("documents:updateContent"),
"documents.emptyTrashByWorkspace" | "tree.trash.emptyWorkspace" => {
Ok("documents:emptyTrashByWorkspace")
}
"insert_block" | "blocks.patch" => Ok("documents:updateContent"),
"blocks.move" => Ok("blocks:move"),
"blocks.embed" => Ok("blocks:insert"),
"mindmaps.put" => Ok("mindmaps:put"),
"mindmap.command.apply" => Ok("mindmaps:applyCommand"),
"media.assets.replace_storage" => Ok("mediaAssets:replaceStorageFromUpload"),
"tree.filetree.drop.preflight" => Ok("tree:fileTreeDropPreflight"),
"tree.filetree.delete.preflight" => Ok("tree:fileTreeDeletePreflight"),
"tree.filetree.paste.preflight" => Ok("tree:fileTreePastePreflight"),
"tree.filetree.upload-target.preflight" => Ok("tree:fileTreeUploadTargetPreflight"),
"tree.resource.copy" => Ok("mediaAssets:batchCopy"),
"tree.resource.move" => Ok("mediaAssets:batchMove"),
"tree.resource.upload" => Ok("mediaAssets:createWithStorage"),
"tree.resource.archive" => Ok("treeResource:archive"),
"tree.resource.restore" => Ok("treeResource:restore"),
"tree.resource.purge" => Ok("treeResource:purge"),
@@ -210,6 +239,10 @@ fn legacy_command_function_name(name: &str) -> BridgeResult<&'static str> {
}
}
pub fn retired_command_transport_function_name(name: &str) -> BridgeResult<&'static str> {
legacy_command_function_name(name)
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase", tag = "kind")]
pub enum RuntimeInput {
@@ -1857,7 +1890,7 @@ fn build_resource_lifecycle_plan(
Ok(plan)
}
fn resource_lifecycle_convex_function(
fn retired_resource_lifecycle_transport_function(
action: &str,
resource_kind: &str,
) -> Result<&'static str, BridgeError> {
@@ -4639,7 +4672,7 @@ fn build_tool_plan_steps(
steps.push(RuntimeToolPlanStep {
kind: "write".into(),
name: "media.assets.replace_storage".into(),
function_name: Some("mediaAssets:replaceStorageFromUpload".into()),
function_name: Some("media.assets.replace_storage".into()),
description:
"callback 下载并上传文件后,会继续通过统一 bridge 命令写回附件 storage 绑定"
.into(),
@@ -4754,7 +4787,7 @@ fn build_tool_plan_steps(
steps.push(RuntimeToolPlanStep {
kind: "write".into(),
name: "mindmaps.put".into(),
function_name: Some("mindmaps:put".into()),
function_name: Some("mindmaps.put".into()),
description: "用完整思维导图树覆盖当前导图".into(),
args_json: tool_wire.args_json.clone(),
});
@@ -11384,8 +11417,7 @@ fn execute_command(
validate_only: command_wire.validate_only,
};
let request = build_write_request(&context, &command)?;
let function_name =
resource_lifecycle_convex_function(action, &lifecycle_plan.resource_kind)?;
retired_resource_lifecycle_transport_function(action, &lifecycle_plan.resource_kind)?;
let stream_delta_hint = resource_lifecycle_stream_delta_hint(&lifecycle_plan);
let event_type = resource_lifecycle_event_type(action);
@@ -11448,7 +11480,7 @@ fn execute_command(
Ok(RuntimeExecutionPlan::Command(RuntimeCommandExecutionPlan {
command_name: command.name,
command_id: command.command_id,
function_name: function_name.into(),
function_name: request.function_name,
workspace_id: request.workspace_id,
request_id: request.request_id,
trace_id: request.trace_id,
@@ -12568,7 +12600,7 @@ mod tests {
match plan {
RuntimeExecutionPlan::Query(plan) => {
assert_eq!(plan.function_name, "blocks:getById");
assert_eq!(plan.function_name, "blocks.get");
assert_eq!(
plan.args_json,
json!({
@@ -12628,7 +12660,7 @@ mod tests {
match plan {
RuntimeExecutionPlan::Command(plan) => {
assert_eq!(plan.function_name, "documents:updateContent");
assert_eq!(plan.function_name, "blocks.patch");
assert_eq!(plan.command_name, "blocks.patch");
assert_eq!(
plan.args_json,
@@ -12965,7 +12997,7 @@ mod tests {
match plan {
RuntimeExecutionPlan::Query(plan) => {
assert_eq!(plan.function_name, "sidebar:datasetList");
assert_eq!(plan.function_name, "sidebar.dataset.list");
assert_eq!(plan.args_json, json!({ "workspaceId": "ws_1" }));
}
RuntimeExecutionPlan::Command(_) => panic!("expected query plan"),
@@ -12999,7 +13031,7 @@ mod tests {
match plan {
RuntimeExecutionPlan::Query(plan) => {
assert_eq!(plan.function_name, "search:documents");
assert_eq!(plan.function_name, "search.documents");
assert_eq!(
plan.args_json,
json!({
@@ -13969,7 +14001,7 @@ mod tests {
match plan {
RuntimeExecutionPlan::Query(plan) => {
assert_eq!(plan.query_name, "mindmaps.get");
assert_eq!(plan.function_name, "mindmaps:get");
assert_eq!(plan.function_name, "mindmaps.get");
assert_eq!(plan.args_json["docId"], json!("doc_1"));
assert_eq!(plan.args_json["mindmapId"], json!("mind_1"));
}
@@ -14022,7 +14054,7 @@ mod tests {
match plan {
RuntimeExecutionPlan::Command(plan) => {
assert_eq!(plan.command_name, "mindmap.command.apply");
assert_eq!(plan.function_name, "mindmaps:applyCommand");
assert_eq!(plan.function_name, "mindmap.command.apply");
assert_eq!(
plan.args_json["canonicalCommand"],
json!("mindmap.command.apply")
@@ -14301,7 +14333,7 @@ mod tests {
match plan {
RuntimeExecutionPlan::Query(plan) => {
assert_eq!(plan.function_name, "mindmaps:get");
assert_eq!(plan.function_name, "mindmaps.get");
assert_eq!(
plan.args_json,
json!({
@@ -14360,7 +14392,7 @@ mod tests {
match plan {
RuntimeExecutionPlan::Command(plan) => {
assert_eq!(plan.function_name, "mindmaps:put");
assert_eq!(plan.function_name, "mindmaps.put");
assert_eq!(plan.args_json["docId"], json!("doc_1"));
assert_eq!(plan.args_json["mindmapId"], json!("mind_1"));
assert_eq!(
@@ -14963,7 +14995,7 @@ mod tests {
panic!("expected command plan");
};
assert_eq!(plan.function_name, "documents:updateContent");
assert_eq!(plan.function_name, "documents.save");
assert_eq!(plan.args_json.get("id"), Some(&json!("doc_1")));
assert_eq!(
plan.args_json.pointer("/editorDocument/rootBlockIds/0"),
@@ -15507,7 +15539,7 @@ mod tests {
panic!("expected command plan");
};
assert_eq!(plan.command_name, "page.body.save");
assert_eq!(plan.function_name, "documents:updateContent");
assert_eq!(plan.function_name, "page.body.save");
assert_eq!(plan.args_json["expectedRevision"], json!(1));
assert_eq!(plan.args_json["conflictDetectionKey"], json!("doc_1:1"));
}
@@ -15551,7 +15583,7 @@ mod tests {
panic!("expected command plan");
};
assert_eq!(plan.command_name, "page.head.updateTitle");
assert_eq!(plan.function_name, "documents:updateTitle");
assert_eq!(plan.function_name, "page.head.updateTitle");
}
#[test]
@@ -15594,7 +15626,7 @@ mod tests {
panic!("expected command plan");
};
assert_eq!(plan.command_name, "page.layout.updateOptions");
assert_eq!(plan.function_name, "documents:updateOptions");
assert_eq!(plan.function_name, "page.layout.updateOptions");
}
#[test]
@@ -15892,7 +15924,7 @@ mod tests {
panic!("expected command plan");
};
assert_eq!(plan.function_name, "documents:updateContent");
assert_eq!(plan.function_name, "documents.save");
assert_eq!(
plan.args_json.pointer("/content/0/content"),
Some(&json!("来自 editorDocument"))
@@ -15960,7 +15992,7 @@ mod tests {
panic!("expected command plan");
};
assert_eq!(plan.function_name, "documents:updateContent");
assert_eq!(plan.function_name, "page.body.save");
assert_eq!(
plan.args_json.pointer("/content/0/id"),
Some(&json!("legacy_content_1"))
@@ -16276,7 +16308,7 @@ mod tests {
match plan {
RuntimeExecutionPlan::Command(plan) => {
assert_eq!(plan.function_name, "blocks:move");
assert_eq!(plan.function_name, "blocks.move");
assert_eq!(plan.command_name, "blocks.move");
assert_eq!(
plan.args_json,
@@ -16372,7 +16404,7 @@ mod tests {
match plan {
RuntimeExecutionPlan::Command(plan) => {
assert_eq!(plan.function_name, "blocks:insert");
assert_eq!(plan.function_name, "blocks.embed");
assert_eq!(plan.command_name, "blocks.embed");
assert_eq!(
plan.args_json,
@@ -16631,7 +16663,7 @@ mod tests {
match plan {
RuntimeExecutionPlan::Command(plan) => {
assert_eq!(plan.function_name, "documents:updateContent");
assert_eq!(plan.function_name, "documents.embed");
assert_eq!(plan.command_name, "documents.embed");
assert_eq!(
plan.args_json,
@@ -16677,7 +16709,7 @@ mod tests {
let cases = [
(
"tree.node.archive",
"documents:softDelete",
"tree.node.archive",
json!({
"documentId": "doc_1",
"workspaceId": "ws_1",
@@ -16719,7 +16751,7 @@ mod tests {
),
(
"tree.node.restore",
"documents:restore",
"tree.node.restore",
json!({
"documentId": "doc_1",
"workspaceId": "ws_1",
@@ -16761,7 +16793,7 @@ mod tests {
),
(
"tree.node.purge",
"documents:purge",
"tree.node.purge",
json!({
"documentId": "doc_1",
}),
@@ -17305,7 +17337,7 @@ mod tests {
};
assert_eq!(plan.command_name, "tree.subtree.move");
assert_eq!(plan.function_name, "documents:move");
assert_eq!(plan.function_name, "tree.subtree.move");
assert_eq!(plan.args_json["sortOrder"], json!(-2));
assert_eq!(
plan.args_json["commandProtocol"],
@@ -17793,7 +17825,7 @@ mod tests {
match create_plan {
RuntimeExecutionPlan::Command(plan) => {
assert_eq!(plan.command_name, "tree.node.create");
assert_eq!(plan.function_name, "documents:createWithParentReference");
assert_eq!(plan.function_name, "tree.node.create");
assert_eq!(
plan.args_json["streamDeltaHint"],
json!({
@@ -17872,7 +17904,7 @@ mod tests {
match rename_plan {
RuntimeExecutionPlan::Command(plan) => {
assert_eq!(plan.command_name, "tree.node.rename");
assert_eq!(plan.function_name, "documents:updateTitle");
assert_eq!(plan.function_name, "tree.node.rename");
assert_eq!(
plan.args_json["streamDeltaHint"],
json!({
@@ -17978,7 +18010,7 @@ mod tests {
match embed_plan {
RuntimeExecutionPlan::Command(plan) => {
assert_eq!(plan.function_name, "documents:updateContent");
assert_eq!(plan.function_name, "tree.node.embed");
assert_eq!(plan.command_name, "tree.node.embed");
assert_eq!(
plan.args_json,
@@ -18105,7 +18137,7 @@ mod tests {
match copy_plan {
RuntimeExecutionPlan::Command(plan) => {
assert_eq!(plan.function_name, "documents:copyTree");
assert_eq!(plan.function_name, "tree.subtree.copy");
assert_eq!(plan.command_name, "tree.subtree.copy");
assert_eq!(
plan.args_json,
@@ -18199,7 +18231,7 @@ mod tests {
match plan {
RuntimeExecutionPlan::Command(plan) => {
assert_eq!(plan.command_name, "documents.emptyTrashByWorkspace");
assert_eq!(plan.function_name, "documents:emptyTrashByWorkspace");
assert_eq!(plan.function_name, "documents.emptyTrashByWorkspace");
assert_eq!(plan.args_json["workspaceId"], json!("ws_1"));
assert_eq!(
plan.args_json["streamDeltaHint"],
@@ -18284,7 +18316,7 @@ mod tests {
match plan {
RuntimeExecutionPlan::Command(plan) => {
assert_eq!(plan.function_name, "documents:duplicateWithMindmaps");
assert_eq!(plan.function_name, "documents.duplicate");
assert_eq!(plan.command_name, "documents.duplicate");
assert_eq!(
plan.args_json["streamDeltaHint"],
@@ -18361,7 +18393,7 @@ mod tests {
match plan {
RuntimeExecutionPlan::Command(plan) => {
assert_eq!(plan.command_name, "documents.options.update");
assert_eq!(plan.function_name, "documents:updateOptions");
assert_eq!(plan.function_name, "documents.options.update");
assert_eq!(plan.args_json["id"], json!("doc_1"));
assert_eq!(
plan.args_json["options"],
@@ -18453,7 +18485,7 @@ mod tests {
match plan {
RuntimeExecutionPlan::Command(plan) => {
assert_eq!(plan.command_name, "documents.stats.update");
assert_eq!(plan.function_name, "documents:updateStats");
assert_eq!(plan.function_name, "documents.stats.update");
assert_eq!(
plan.args_json,
json!({
@@ -18517,7 +18549,7 @@ mod tests {
match plan {
RuntimeExecutionPlan::Command(plan) => {
assert_eq!(plan.command_name, "media.assets.replace_storage");
assert_eq!(plan.function_name, "mediaAssets:replaceStorageFromUpload");
assert_eq!(plan.function_name, "media.assets.replace_storage");
assert_eq!(
plan.args_json,
json!({
@@ -18539,13 +18571,13 @@ mod tests {
let cases = [
(
"tree.resource.copy",
"mediaAssets:batchCopy",
"tree.resource.copy",
"copy",
"tree.resource.copied",
),
(
"tree.resource.move",
"mediaAssets:batchMove",
"tree.resource.move",
"move",
"tree.resource.moved",
),
@@ -18642,7 +18674,7 @@ mod tests {
let cases = [
(
"tree.resource.archive",
"mediaAssets:patchById",
"tree.resource.archive",
"archive",
"tree.resource.archived",
json!({
@@ -18690,7 +18722,7 @@ mod tests {
),
(
"tree.resource.restore",
"mediaAssets:patchById",
"tree.resource.restore",
"restore",
"tree.resource.restored",
json!({
@@ -18738,7 +18770,7 @@ mod tests {
),
(
"tree.resource.purge",
"mediaAssets:purgeById",
"tree.resource.purge",
"purge",
"tree.resource.purged",
json!({
@@ -18786,7 +18818,7 @@ mod tests {
),
(
"tree.resource.rename",
"mediaAssets:patchById",
"tree.resource.rename",
"rename",
"tree.resource.renamed",
json!({
@@ -18891,7 +18923,7 @@ mod tests {
(
"tree.resource.archive",
"mindmap",
"mindmaps:softDelete",
"tree.resource.archive",
json!({
"resourceKind": "mindmap",
"documentId": "doc_1",
@@ -18915,7 +18947,7 @@ mod tests {
(
"tree.resource.restore",
"mindmap",
"mindmaps:restore",
"tree.resource.restore",
json!({
"resourceKind": "mindmap",
"documentId": "doc_1",
@@ -18939,7 +18971,7 @@ mod tests {
(
"tree.resource.purge",
"mindmap",
"mindmaps:purge",
"tree.resource.purge",
json!({
"resourceKind": "mindmap",
"documentId": "doc_1",
@@ -18963,7 +18995,7 @@ mod tests {
(
"tree.resource.archive",
"table",
"tables:remove",
"tree.resource.archive",
json!({
"resourceKind": "table",
"tableId": "table_1",
@@ -18986,7 +19018,7 @@ mod tests {
(
"tree.resource.restore",
"table",
"tables:restore",
"tree.resource.restore",
json!({
"resourceKind": "table",
"tableId": "table_1",
@@ -19009,7 +19041,7 @@ mod tests {
(
"tree.resource.purge",
"table",
"tables:purge",
"tree.resource.purge",
json!({
"resourceKind": "table",
"tableId": "table_1",
@@ -19263,7 +19295,7 @@ mod tests {
};
assert_eq!(plan.command_name, "tree.filetree.drop.preflight");
assert_eq!(plan.function_name, "tree:fileTreeDropPreflight");
assert_eq!(plan.function_name, "tree.filetree.drop.preflight");
assert_eq!(
plan.args_json["fileTreeDropPlan"],
json!({
@@ -19677,7 +19709,7 @@ mod tests {
};
assert_eq!(plan.command_name, "tree.filetree.delete.preflight");
assert_eq!(plan.function_name, "tree:fileTreeDeletePreflight");
assert_eq!(plan.function_name, "tree.filetree.delete.preflight");
assert_eq!(
plan.args_json["fileTreeDeletePlan"],
json!({
@@ -19773,7 +19805,7 @@ mod tests {
};
assert_eq!(plan.command_name, "tree.filetree.paste.preflight");
assert_eq!(plan.function_name, "tree:fileTreePastePreflight");
assert_eq!(plan.function_name, "tree.filetree.paste.preflight");
assert_eq!(
plan.args_json["fileTreePastePlan"],
json!({
@@ -19864,7 +19896,7 @@ mod tests {
};
assert_eq!(plan.command_name, "tree.filetree.upload-target.preflight");
assert_eq!(plan.function_name, "tree:fileTreeUploadTargetPreflight");
assert_eq!(plan.function_name, "tree.filetree.upload-target.preflight");
assert_eq!(
plan.args_json["fileTreeUploadTargetPlan"],
json!({
@@ -19926,7 +19958,7 @@ mod tests {
};
assert_eq!(plan.command_name, "tree.resource.upload");
assert_eq!(plan.function_name, "mediaAssets:createWithStorage");
assert_eq!(plan.function_name, "tree.resource.upload");
assert_eq!(
plan.args_json,
json!({
@@ -20093,7 +20125,7 @@ mod tests {
match plan {
RuntimeExecutionPlan::Query(plan) => {
assert_eq!(plan.function_name, "bridgeLogs:listByRequest");
assert_eq!(plan.function_name, "bridge.request.get");
assert_eq!(plan.args_json["workspaceId"], json!("ws_1"));
assert_eq!(plan.args_json["requestId"], json!("req_lookup_1"));
assert_eq!(plan.args_json["commandId"], json!("cmd_lookup_1"));
@@ -20129,7 +20161,7 @@ mod tests {
assert_eq!(plan.steps[0].name, "bridge.trace.get");
assert_eq!(
plan.steps[0].function_name.as_deref(),
Some("bridgeLogs:listByTrace")
Some("bridge.trace.get")
);
}
_ => panic!("expected tool plan"),
+26 -25
View File
@@ -571,7 +571,7 @@ pub fn plan_page_get(
"workspaceId": workspace_id,
}),
CliTransportPlan {
kind: "convex_query".into(),
kind: "runtime_query_plan".into(),
function_name: request.function_name,
payload_json: request.payload_json,
args_json: json!({
@@ -622,7 +622,7 @@ pub fn plan_page_title(
"title": title,
}),
CliTransportPlan {
kind: "convex_mutation".into(),
kind: "runtime_command_plan".into(),
function_name: request.function_name,
payload_json: request.payload_json,
args_json: json!({
@@ -682,7 +682,7 @@ pub fn plan_page_save(
"conflictDetectionKey": conflict_detection_key,
}),
CliTransportPlan {
kind: "convex_mutation".into(),
kind: "runtime_command_plan".into(),
function_name: request.function_name,
payload_json: request.payload_json,
args_json: json!({
@@ -737,7 +737,7 @@ pub fn plan_page_create(ctx: &CliContext, args: &PageCreateArgs<'_>) -> CliResul
"content": content_value,
}),
CliTransportPlan {
kind: "convex_mutation".into(),
kind: "runtime_command_plan".into(),
function_name: request.function_name,
payload_json: request.payload_json,
args_json: json!({
@@ -790,7 +790,7 @@ pub fn plan_page_move(ctx: &CliContext, args: &PageMoveArgs<'_>) -> CliResult<Cl
"sortOrder": args.sort_order,
}),
CliTransportPlan {
kind: "convex_mutation".into(),
kind: "runtime_command_plan".into(),
function_name: request.function_name,
payload_json: request.payload_json,
args_json: json!({
@@ -836,7 +836,7 @@ pub fn plan_page_delete(ctx: &CliContext, args: &PageDeleteArgs<'_>) -> CliResul
"workspaceId": args.workspace_id,
}),
CliTransportPlan {
kind: "convex_mutation".into(),
kind: "runtime_command_plan".into(),
function_name: request.function_name,
payload_json: request.payload_json,
args_json: json!({
@@ -880,7 +880,7 @@ pub fn plan_page_restore(ctx: &CliContext, args: &PageRestoreArgs<'_>) -> CliRes
"workspaceId": args.workspace_id,
}),
CliTransportPlan {
kind: "convex_mutation".into(),
kind: "runtime_command_plan".into(),
function_name: request.function_name,
payload_json: request.payload_json,
args_json: json!({
@@ -940,7 +940,7 @@ pub fn plan_block_insert(
"prevBlockId": prev_block_id,
}),
CliTransportPlan {
kind: "convex_mutation".into(),
kind: "runtime_command_plan".into(),
function_name: request.function_name,
payload_json: request.payload_json,
args_json: json!({
@@ -1006,7 +1006,7 @@ pub fn plan_block_patch(
"conflictDetectionKey": conflict_detection_key,
}),
CliTransportPlan {
kind: "convex_mutation".into(),
kind: "runtime_command_plan".into(),
function_name: request.function_name,
payload_json: request.payload_json,
args_json: json!({
@@ -1058,7 +1058,7 @@ pub fn plan_block_move(ctx: &CliContext, args: &BlockMoveArgs<'_>) -> CliResult<
"targetDocumentId": args.target_document_id,
}),
CliTransportPlan {
kind: "convex_mutation".into(),
kind: "runtime_command_plan".into(),
function_name: request.function_name,
payload_json: request.payload_json,
args_json: json!({
@@ -1109,7 +1109,7 @@ pub fn plan_block_embed(ctx: &CliContext, args: &BlockEmbedArgs<'_>) -> CliResul
"targetBlockId": args.target_block_id,
}),
CliTransportPlan {
kind: "convex_mutation".into(),
kind: "runtime_command_plan".into(),
function_name: request.function_name,
payload_json: request.payload_json,
args_json: json!({
@@ -1174,7 +1174,7 @@ pub fn plan_search_documents(
},
}),
CliTransportPlan {
kind: "convex_query".into(),
kind: "runtime_query_plan".into(),
function_name: request.function_name,
payload_json: request.payload_json,
args_json: json!({
@@ -1230,7 +1230,7 @@ pub fn plan_search_blocks(
},
}),
CliTransportPlan {
kind: "convex_query".into(),
kind: "runtime_query_plan".into(),
function_name: request.function_name,
payload_json: request.payload_json,
args_json: json!({
@@ -1262,7 +1262,7 @@ pub fn plan_sidebar_dataset(ctx: &CliContext, workspace_id: &str) -> CliResult<C
"workspaceId": workspace_id,
}),
CliTransportPlan {
kind: "convex_query".into(),
kind: "runtime_query_plan".into(),
function_name: request.function_name,
payload_json: request.payload_json,
args_json: json!({
@@ -1306,7 +1306,7 @@ pub fn plan_mindmap_get(
"mindmapId": mindmap_id,
}),
CliTransportPlan {
kind: "convex_query".into(),
kind: "runtime_query_plan".into(),
function_name: request.function_name,
payload_json: request.payload_json,
args_json: json!({
@@ -1375,7 +1375,7 @@ pub fn plan_mindmap_put(
"data": data,
}),
CliTransportPlan {
kind: "convex_mutation".into(),
kind: "runtime_command_plan".into(),
function_name: request.function_name,
payload_json: request.payload_json,
args_json: json!({
@@ -2118,7 +2118,8 @@ mod tests {
} => {
assert_eq!(name, "documents.save");
assert_eq!(command_id, "cmd_page_save_page_1");
assert_eq!(transport.function_name, "documents:updateContent");
assert_eq!(transport.kind, "runtime_command_plan");
assert_eq!(transport.function_name, "documents.save");
assert_eq!(
transport.args_json,
json!({
@@ -2165,10 +2166,8 @@ mod tests {
name, transport, ..
} => {
assert_eq!(name, "documents.create");
assert_eq!(
transport.function_name,
"documents:createWithParentReference"
);
assert_eq!(transport.kind, "runtime_command_plan");
assert_eq!(transport.function_name, "documents.create");
assert_eq!(transport.args_json["workspaceId"], json!("ws_1"));
assert_eq!(transport.args_json["parentId"], json!("parent_1"));
}
@@ -2194,7 +2193,8 @@ mod tests {
name, transport, ..
} => {
assert_eq!(name, "documents.move");
assert_eq!(transport.function_name, "documents:move");
assert_eq!(transport.kind, "runtime_command_plan");
assert_eq!(transport.function_name, "documents.move");
assert_eq!(transport.args_json["sortOrder"], json!(3));
}
_ => panic!("expected command output"),
@@ -2211,7 +2211,8 @@ mod tests {
name, transport, ..
} => {
assert_eq!(name, "sidebar.dataset.list");
assert_eq!(transport.function_name, "sidebar:datasetList");
assert_eq!(transport.kind, "runtime_query_plan");
assert_eq!(transport.function_name, "sidebar.dataset.list");
assert_eq!(transport.args_json, json!({ "workspaceId": "ws_1" }));
}
_ => panic!("expected query output"),
@@ -2292,7 +2293,7 @@ mod tests {
name, transport, ..
} => {
assert_eq!(name, "mindmaps.get");
assert_eq!(transport.function_name, "mindmaps:get");
assert_eq!(transport.function_name, "mindmaps.get");
assert_eq!(
transport.args_json,
json!({
@@ -2322,7 +2323,7 @@ mod tests {
name, transport, ..
} => {
assert_eq!(name, "mindmaps.put");
assert_eq!(transport.function_name, "mindmaps:put");
assert_eq!(transport.function_name, "mindmaps.put");
assert_eq!(
transport.args_json,
json!({
@@ -842,6 +842,8 @@ import {
setStatus(runtimeDescriptor, 'ready');
}
if (session.pageBodySource) runtimeDescriptor.root.setAttribute('data-mnote-page-body-source', session.pageBodySource);
runtimeDescriptor.root.setAttribute('data-mnote-page-body-local-compat-fallback', session.pageBodyLocalCompatFallback ? 'true' : 'false');
if (session.pageBodyHardGuard) runtimeDescriptor.root.setAttribute('data-mnote-page-body-hard-guard', session.pageBodyHardGuard);
if (session.projectionSource) runtimeDescriptor.root.setAttribute('data-mnote-projection-source', session.projectionSource);
if (session.blockProjectionVersion) runtimeDescriptor.root.setAttribute('data-mnote-block-projection-version', session.blockProjectionVersion);
enhanceEditorAttachmentLinksSoon();
File diff suppressed because it is too large Load Diff
@@ -125,6 +125,8 @@ export const createDocumentSessionRuntime = (dependencies = {}) => {
return `${String(session?.rootUri || '').trim()}#${scope}`;
};
const localFolderEventBusChannelKey = (session) => `${String(session?.rootUri || '').trim()}#event-bus`;
const localMarkdownRelativePathFromDocumentId = (documentId) => {
const value = String(documentId || '').trim();
if (!value.startsWith('local-md:')) return '';
@@ -136,6 +138,25 @@ export const createDocumentSessionRuntime = (dependencies = {}) => {
|| localMarkdownRelativePathFromDocumentId(session?.documentId)
);
const localFolderEventItems = (payload) => {
if (!payload || typeof payload !== 'object') return [];
const changedPaths = Array.isArray(payload.changedPaths)
? payload.changedPaths
: (Array.isArray(payload.changed_paths) ? payload.changed_paths : []);
if (changedPaths.length > 0) {
return changedPaths.map((item) => {
if (typeof item === 'string') return { relativePath: item };
if (!item || typeof item !== 'object') return null;
return {
relativePath: String(item.relativePath || item.relative_path || item.path || '').trim(),
documentId: String(item.documentId || item.document_id || '').trim(),
eventKind: String(item.eventKind || item.event_kind || item.changeType || item.change_type || '').trim(),
};
}).filter(Boolean);
}
return [payload];
};
const sessionBufferStateUrl = (session) => {
if (!session || session.sourceKind !== 'local_folder' || !session.rootUri || !session.documentId) return null;
const url = new URL('/api/documents/buffer-state', window.location.origin);
@@ -206,11 +227,15 @@ export const createDocumentSessionRuntime = (dependencies = {}) => {
if (!channel) return;
channel.sessions.delete(session.key);
if (channel.sessions.size === 0) {
if (typeof channel.unsubscribe === 'function') {
channel.unsubscribe();
} else if (channel.eventSource && typeof channel.eventSource.close === 'function') {
try {
channel.eventSource.close();
} catch (_) {
// noop
}
}
localFolderEventRegistry.delete(channel.key);
}
session.localFolderChannel = null;
@@ -385,7 +410,53 @@ export const createDocumentSessionRuntime = (dependencies = {}) => {
}, source || 'mnote-web-document-session-meta');
};
const pageBodySourceMeta = (sourceKind, pageBody) => {
const pageBodySource = pageBodyTiptapDocumentSource(pageBody || {}, '');
const projectionSource = String(pageBody?.projectionSource || pageBody?.projection_source || '').trim();
const blockProjectionVersion = String(pageBody?.blockProjectionVersion || pageBody?.block_projection_version || '').trim();
const localCompatFallback = sourceKind === 'local_folder' && pageBodySource === 'compat.legacy_content';
return {
pageBodySource,
projectionSource,
blockProjectionVersion,
localCompatFallback,
hardGuard: localCompatFallback
? 'local_compat_fallback'
: (sourceKind === 'local_folder' ? 'local_ok' : 'compat_allowed'),
};
};
const applyPageBodySourceMetaToSession = (session, pageBody) => {
const meta = pageBodySourceMeta(session.sourceKind, pageBody || {});
session.pageBodySource = meta.pageBodySource;
session.projectionSource = meta.projectionSource;
session.blockProjectionVersion = meta.blockProjectionVersion;
session.pageBodyLocalCompatFallback = meta.localCompatFallback;
session.pageBodyHardGuard = meta.hardGuard;
};
const syncPageBodySourceDiagnosticsToViews = (session) => {
sessionViews(session).forEach((view) => {
const root = view?.runtimeDescriptor?.root;
if (!(root instanceof HTMLElement)) return;
root.setAttribute('data-mnote-page-body-source', session.pageBodySource || '');
root.setAttribute('data-mnote-page-body-local-compat-fallback', session.pageBodyLocalCompatFallback ? 'true' : 'false');
root.setAttribute('data-mnote-page-body-hard-guard', session.pageBodyHardGuard || '');
if (session.projectionSource) {
root.setAttribute('data-mnote-projection-source', session.projectionSource);
} else {
root.removeAttribute('data-mnote-projection-source');
}
if (session.blockProjectionVersion) {
root.setAttribute('data-mnote-block-projection-version', session.blockProjectionVersion);
} else {
root.removeAttribute('data-mnote-block-projection-version');
}
});
};
const syncSessionMetaToViews = (session) => {
syncPageBodySourceDiagnosticsToViews(session);
sessionViews(session).forEach((view) => {
if (view.mountId != null) dispatchSessionMetaToView(session, view);
});
@@ -543,6 +614,7 @@ export const createDocumentSessionRuntime = (dependencies = {}) => {
const nextRevision = Number.isInteger(nextBody.revision) ? nextBody.revision : revisionFromConflictKey(nextConflictKey);
session.latestAggregate = nextAggregate;
syncPageAggregateScript(session, nextAggregate);
applyPageBodySourceMetaToSession(session, nextBody);
session.title = nextAggregate?.head?.title || session.title;
session.currentTiptapDocument = nextTiptapDocument;
session.currentSerialized = nextSerialized;
@@ -1168,15 +1240,22 @@ export const createDocumentSessionRuntime = (dependencies = {}) => {
const nextPermissions = nextAggregate?.head?.permissions || {};
const nextConflictKey = conflictDetectionKeyFromBody(nextBody);
const nextTiptapDocument = pageBodyTiptapDocument(nextBody, '', session);
const nextPageBodyMeta = pageBodySourceMeta(session.sourceKind, nextBody);
const nextSerialized = JSON.stringify(nextTiptapDocument);
const contentChanged = nextSerialized !== session.currentSerialized;
session.externalChangePending = false;
session.bufferDirtyState = 'Clean';
if (!nextConflictKey || !session.lastExternalConflictDetectionKey) {
session.lastExternalConflictDetectionKey = nextConflictKey || session.lastExternalConflictDetectionKey;
if (!contentChanged) return;
if (!contentChanged) {
applyPageBodySourceMetaToSession(session, nextBody);
syncPageBodySourceDiagnosticsToViews(session);
return;
}
}
if (nextConflictKey === session.lastExternalConflictDetectionKey) {
applyPageBodySourceMetaToSession(session, nextBody);
syncPageBodySourceDiagnosticsToViews(session);
if (nextConflictKey && session.conflictDetectionKey !== nextConflictKey) {
session.conflictDetectionKey = nextConflictKey;
session.fileVersion = nextConflictKey;
@@ -1187,6 +1266,11 @@ export const createDocumentSessionRuntime = (dependencies = {}) => {
const nextRevision = Number.isInteger(nextBody.revision) ? nextBody.revision : revisionFromConflictKey(nextConflictKey);
session.latestAggregate = nextAggregate;
syncPageAggregateScript(session, nextAggregate);
session.pageBodySource = nextPageBodyMeta.pageBodySource;
session.projectionSource = nextPageBodyMeta.projectionSource;
session.blockProjectionVersion = nextPageBodyMeta.blockProjectionVersion;
session.pageBodyLocalCompatFallback = nextPageBodyMeta.localCompatFallback;
session.pageBodyHardGuard = nextPageBodyMeta.hardGuard;
session.title = nextAggregate?.head?.title || session.title;
session.currentTiptapDocument = nextTiptapDocument;
session.currentSerialized = nextSerialized;
@@ -1284,10 +1368,94 @@ export const createDocumentSessionRuntime = (dependencies = {}) => {
await refreshSessionFromExternalChange(session, 'mnote-web-local-folder-watch');
};
const ensureLocalFolderEventChannel = (session) => {
if (session.sourceKind !== 'local_folder' || !session.rootUri || typeof window.EventSource !== 'function') {
const handleLocalFolderEventPayloadForSessions = (channel, payload) => {
if (!payload) return;
localFolderEventItems(payload).forEach((item) => {
Array.from(channel.sessions.values()).forEach((targetSession) => {
if (!targetSession || targetSession.views.size === 0) return;
const documentId = typeof item.documentId === 'string' ? item.documentId.trim() : '';
const relativePath = typeof item.relativePath === 'string' ? item.relativePath.trim() : '';
if (targetSession.sessionKind === 'resource') {
if (!relativePath || relativePath !== resourceWatchPath(targetSession)) return;
if (targetSession.saving) {
targetSession.externalChangePending = false;
targetSession.lastSelfSaveSignalAt = Date.now();
return;
}
targetSession.lastExternalChangeSignalAt = Date.now();
targetSession.externalChangePending = true;
scheduleSessionExternalRefresh(targetSession, 'mnote-web-local-resource-watch');
return;
}
const eventKind = String(item.eventKind || '');
const relativeTarget = sessionRelativePath(targetSession);
const targetsCurrentDocument = Boolean(
(documentId && documentId === targetSession.documentId)
|| (!documentId && relativePath && relativeTarget && relativePath === relativeTarget)
);
if (!targetsCurrentDocument) return;
if (shouldSuppressLocalFolderSelfChange(targetSession.documentId, eventKind)) {
targetSession.externalChangePending = false;
targetSession.lastSelfSaveSignalAt = Date.now();
return;
}
if (targetSession.saving) {
targetSession.externalChangePending = false;
targetSession.lastSelfSaveSignalAt = Date.now();
return;
}
targetSession.lastExternalChangeSignalAt = Date.now();
targetSession.externalChangePending = true;
scheduleSessionExternalRefresh(targetSession, 'mnote-web-local-folder-watch');
});
});
};
const ensureLocalFolderEventChannel = (session) => {
if (session.sourceKind !== 'local_folder' || !session.rootUri) {
return;
}
const eventBus = window.__mnoteLocalFolderEventBus;
if (eventBus && typeof eventBus.startLocalFolderWatcher === 'function') {
const channelKey = localFolderEventBusChannelKey(session);
let channel = localFolderEventRegistry.get(channelKey);
if (!channel) {
const rootUri = String(session.rootUri || '').trim();
const workspaceId = String(session.workspaceId || '').trim();
const handler = (event) => {
const detail = event?.detail || {};
const eventRootUri = String(detail.rootUri || detail.payload?.rootUri || '').trim();
if (eventRootUri && eventRootUri !== rootUri) return;
handleLocalFolderEventPayloadForSessions(channel, detail.payload || detail);
};
const handle = eventBus.startLocalFolderWatcher({
rootUri,
workspaceId,
bootstrap: {
schema: 'mnote.document_session.local_folder_event_bus.v1',
transport: 'local-folder-events',
workspaceId,
},
});
channel = {
key: channelKey,
rootUri,
documentId: '',
resourcePath: '',
eventSource: handle,
sessions: new Map(),
unsubscribe: () => window.removeEventListener('mnote:local-folder:document-changed', handler),
};
window.addEventListener('mnote:local-folder:document-changed', handler);
localFolderEventRegistry.set(channelKey, channel);
}
channel.sessions.set(session.key, session);
session.localFolderChannel = channel;
return;
}
if (typeof window.EventSource !== 'function') return;
const channelKey = localFolderEventChannelKey(session);
let channel = localFolderEventRegistry.get(channelKey);
if (!channel) {
@@ -1308,42 +1476,7 @@ export const createDocumentSessionRuntime = (dependencies = {}) => {
sessions: new Map(),
};
eventSource.addEventListener('change', (event) => {
const payload = parseLocalFolderEventPayload(event);
if (!payload) return;
Array.from(channel.sessions.values()).forEach((targetSession) => {
if (!targetSession || targetSession.views.size === 0) return;
const documentId = typeof payload.documentId === 'string' ? payload.documentId.trim() : '';
const relativePath = typeof payload.relativePath === 'string' ? payload.relativePath.trim() : '';
if (targetSession.sessionKind === 'resource') {
if (!relativePath || relativePath !== resourceWatchPath(targetSession)) return;
if (targetSession.saving) {
targetSession.externalChangePending = false;
targetSession.lastSelfSaveSignalAt = Date.now();
return;
}
targetSession.lastExternalChangeSignalAt = Date.now();
targetSession.externalChangePending = true;
scheduleSessionExternalRefresh(targetSession, 'mnote-web-local-resource-watch');
return;
}
if (!documentId) return;
const eventKind = String(payload.eventKind || '');
const targetsCurrentDocument = Boolean(documentId && documentId === targetSession.documentId);
if (documentId && !targetsCurrentDocument) return;
if (targetsCurrentDocument && shouldSuppressLocalFolderSelfChange(documentId, eventKind)) {
targetSession.externalChangePending = false;
targetSession.lastSelfSaveSignalAt = Date.now();
return;
}
if (targetsCurrentDocument && targetSession.saving) {
targetSession.externalChangePending = false;
targetSession.lastSelfSaveSignalAt = Date.now();
return;
}
targetSession.lastExternalChangeSignalAt = Date.now();
targetSession.externalChangePending = true;
scheduleSessionExternalRefresh(targetSession, 'mnote-web-local-folder-watch');
});
handleLocalFolderEventPayloadForSessions(channel, parseLocalFolderEventPayload(event));
});
eventSource.onerror = () => {
console.warn('mnote local folder 外部更新事件流中断,将等待浏览器自动重连');
@@ -1609,7 +1742,7 @@ export const createDocumentSessionRuntime = (dependencies = {}) => {
const keyRevision = revisionFromConflictKey(conflictDetectionKey);
const sourceKind = normalizeSessionSourceKind(runtimeDescriptor.bootstrap);
const tiptapDocument = pageBodyTiptapDocument(pageBody, '', runtimeDescriptor.bootstrap);
const pageBodySource = pageBodyTiptapDocumentSource(pageBody, '');
const pageBodyMeta = pageBodySourceMeta(sourceKind, pageBody);
const session = {
key: buildDocumentSessionKey(runtimeDescriptor.bootstrap),
documentId: runtimeDescriptor.bootstrap.documentId,
@@ -1620,9 +1753,11 @@ export const createDocumentSessionRuntime = (dependencies = {}) => {
pageAggregateScriptId: runtimeDescriptor.bootstrap.pageAggregateScriptId || '__MNOTE_PAGE_AGGREGATE__',
latestAggregate: runtimeDescriptor.aggregate,
title: runtimeDescriptor.aggregate.head?.title || '无标题',
pageBodySource,
projectionSource: String(pageBody.projectionSource || pageBody.projection_source || '').trim(),
blockProjectionVersion: String(pageBody.blockProjectionVersion || pageBody.block_projection_version || '').trim(),
pageBodySource: pageBodyMeta.pageBodySource,
projectionSource: pageBodyMeta.projectionSource,
blockProjectionVersion: pageBodyMeta.blockProjectionVersion,
pageBodyLocalCompatFallback: pageBodyMeta.localCompatFallback,
pageBodyHardGuard: pageBodyMeta.hardGuard,
currentTiptapDocument: tiptapDocument,
currentSerialized: JSON.stringify(tiptapDocument),
lastPersistedSerialized: JSON.stringify(tiptapDocument),
@@ -0,0 +1,355 @@
(function(){
'use strict';
if (window.__mnoteLocalFolderEventBus) {
return;
}
var connections = new Map();
var sidebarRefreshQueues = new Map();
var lastSource = '';
var lastReason = '';
function root() {
return document.documentElement;
}
function setDiagnostics(source, reason) {
lastSource = source || lastSource || '';
lastReason = reason || lastReason || '';
root().setAttribute('data-mnote-local-folder-event-bus', 'ready');
root().setAttribute('data-mnote-local-folder-event-bus-connections', String(connections.size));
root().setAttribute('data-mnote-local-folder-event-bus-last-source', lastSource);
root().setAttribute('data-mnote-local-folder-event-bus-last-reason', lastReason);
}
function normalizeRootUri(rootUri) {
return String(rootUri || '').trim();
}
function normalizeWorkspaceId(workspaceId) {
return String(workspaceId || 'default').trim() || 'default';
}
function emit(name, detail) {
window.dispatchEvent(new CustomEvent(name, { detail: detail || {} }));
}
function parseEventPayload(event) {
try {
return JSON.parse((event && event.data) || '{}') || {};
} catch (_) {
return {};
}
}
function revisionOf(payload, event) {
return payload.revision || payload.cursor || (event && event.lastEventId) || null;
}
function arrayOf(value) {
if (!value) return [];
if (Array.isArray(value)) return value.filter(Boolean).map(String);
return [String(value)].filter(Boolean);
}
function pathArrayOf(value) {
if (!value) return [];
var list = Array.isArray(value) ? value : [value];
return list.map(function(item) {
if (typeof item === 'string') return item.trim();
if (!item || typeof item !== 'object') return '';
return String(item.relativePath || item.relative_path || item.path || item.sourcePath || item.source_path || '').trim();
}).filter(Boolean);
}
function collectChangedPaths(payload) {
var paths = []
.concat(pathArrayOf(payload.changedPaths))
.concat(pathArrayOf(payload.changed_paths))
.concat(pathArrayOf(payload.paths));
var events = Array.isArray(payload.events) ? payload.events : [];
events.forEach(function(item) {
if (!item || typeof item !== 'object') return;
paths = paths
.concat(arrayOf(item.path))
.concat(arrayOf(item.relativePath))
.concat(arrayOf(item.relative_path))
.concat(arrayOf(item.sourcePath))
.concat(arrayOf(item.source_path));
});
return Array.from(new Set(paths));
}
function collectChangedPathItems(payload, reason) {
var raw = []
.concat(Array.isArray(payload.changedPaths) ? payload.changedPaths : [])
.concat(Array.isArray(payload.changed_paths) ? payload.changed_paths : [])
.concat(Array.isArray(payload.paths) ? payload.paths : [])
.concat(Array.isArray(payload.events) ? payload.events : []);
var seen = new Set();
var items = [];
raw.forEach(function(item) {
var relativePath = typeof item === 'string'
? item.trim()
: String(item && (item.relativePath || item.relative_path || item.path || item.sourcePath || item.source_path || '') || '').trim();
if (!relativePath || seen.has(relativePath)) return;
seen.add(relativePath);
items.push({
relativePath: relativePath,
reason: reason || 'event-bus',
kind: typeof item === 'object' && item ? String(item.kind || '') : '',
eventKind: typeof item === 'object' && item ? String(item.eventKind || item.event_kind || item.changeType || item.change_type || '') : ''
});
});
if (items.length) return items;
return pathItems(collectChangedPaths(payload), reason);
}
function collectAffectedParents(payload) {
var parents = []
.concat(pathArrayOf(payload.affectedParents))
.concat(pathArrayOf(payload.affected_parents))
.concat(pathArrayOf(payload.parentRelativePaths))
.concat(pathArrayOf(payload.parent_relative_paths));
return Array.from(new Set(parents));
}
function pathItems(paths, reason) {
return (paths || []).map(function(relativePath) {
return {
relativePath: String(relativePath || '').trim(),
reason: reason || 'event-bus'
};
}).filter(function(item) { return item.relativePath || item.relativePath === ''; });
}
function queueSidebarRefresh(detail) {
var key = String(detail.rootUri || '').trim();
if (!key) return;
var queue = sidebarRefreshQueues.get(key);
if (!queue) {
queue = {
rootUri: detail.rootUri,
workspaceId: detail.workspaceId,
revisions: new Set(),
reasons: new Set(),
changedPaths: new Map(),
affectedParents: new Set(),
resyncRequired: false,
timer: 0
};
sidebarRefreshQueues.set(key, queue);
}
if (detail.revision) queue.revisions.add(String(detail.revision));
if (detail.reason) queue.reasons.add(String(detail.reason));
(detail.changedPaths || []).forEach(function(item) {
var path = typeof item === 'string' ? item : String(item && (item.relativePath || item.relative_path || '') || '');
if (!path && path !== '') return;
var existing = queue.changedPaths.get(path) || { relativePath: path };
queue.changedPaths.set(path, {
relativePath: path,
reason: String((item && item.reason) || existing.reason || detail.reason || 'event-bus-orchestrated'),
kind: String((item && item.kind) || existing.kind || ''),
eventKind: String((item && item.eventKind) || (item && item.event_kind) || existing.eventKind || '')
});
});
(detail.affectedParents || []).forEach(function(item) {
var parent = typeof item === 'string' ? item : String(item && (item.relativePath || item.relative_path || '') || '');
if (parent || parent === '') queue.affectedParents.add(parent);
});
queue.resyncRequired = queue.resyncRequired || detail.resyncRequired === true;
if (!queue.timer) {
queue.timer = window.setTimeout(function() {
flushSidebarRefresh(key);
}, 0);
}
root().setAttribute('data-mnote-local-folder-event-bus-sidebar-refresh-pending', String(queue.affectedParents.size));
}
function flushSidebarRefresh(key) {
var queue = sidebarRefreshQueues.get(key);
if (!queue) return;
sidebarRefreshQueues.delete(key);
var reasons = Array.from(queue.reasons);
var detail = {
schema: 'mnote.local_folder.event_bus.sidebar_refresh.v1',
source: 'event_bus_orchestrator',
reason: reasons.join(',') || 'watch_batch',
rootUri: queue.rootUri,
workspaceId: queue.workspaceId,
revision: Array.from(queue.revisions).pop() || null,
reasons: reasons,
changedPaths: Array.from(queue.changedPaths.values()),
affectedParents: pathItems(Array.from(queue.affectedParents), 'event-bus-orchestrated'),
resyncRequired: queue.resyncRequired,
viaEventBus: true
};
root().setAttribute('data-mnote-local-folder-event-bus-sidebar-refresh-applied', detail.reason);
root().setAttribute('data-mnote-local-folder-event-bus-sidebar-refresh-parents', String(detail.affectedParents.length));
emit('mnote:local-folder:sidebar-refresh-requested', detail);
}
function dispatchWatchBatch(entry, event, payload, meta) {
var revision = revisionOf(payload, event);
var changedPaths = collectChangedPaths(payload);
var affectedParents = collectAffectedParents(payload);
var source = String((meta && meta.source) || payload.source || 'watcher_sse').trim() || 'watcher_sse';
var reason = String((meta && meta.reason) || payload.reason || 'watch_batch').trim() || 'watch_batch';
var resyncRequired = payload.fallbackResync === true
|| payload.requiresResync === true
|| payload.resyncRequired === true
|| payload.resync_required === true;
var detail = {
schema: 'mnote.local_folder.event_bus.watch_batch.v1',
source: source,
reason: reason,
rootUri: entry.rootUri,
workspaceId: entry.workspaceId,
revision: revision,
payload: payload,
bootstrap: entry.bootstrap || null,
changedPaths: collectChangedPathItems(payload, reason),
affectedParents: pathItems(affectedParents, reason),
resyncRequired: resyncRequired,
viaEventBus: true
};
setDiagnostics(source, reason);
root().setAttribute('data-mnote-tree-live-revision', String(revision || ''));
emit('mnote:local-folder:watch-batch', detail);
emit('tree:local-folder-watch-batch', detail);
if (affectedParents.length > 0) {
emit('mnote:local-folder:filetree-parent-changed', detail);
}
if (changedPaths.length > 0) {
emit('mnote:local-folder:document-changed', detail);
emit('mnote:local-folder:resource-changed', detail);
emit('mnote:local-folder:knowledge-rag-source-updated', detail);
}
queueSidebarRefresh(detail);
if (resyncRequired) {
emit('mnote:local-folder:resync-required', detail);
}
}
function closeEntry(entry) {
if (!entry || !entry.source || typeof entry.source.close !== 'function') return;
entry.source.close();
}
function startLocalFolderWatcher(options) {
var rootUri = normalizeRootUri(options && options.rootUri);
if (!rootUri || typeof window.EventSource !== 'function') return null;
var workspaceId = normalizeWorkspaceId(options && options.workspaceId);
var key = rootUri;
if (connections.has(key)) {
var existing = connections.get(key);
if ((!existing.workspaceId || existing.workspaceId === 'default') && workspaceId && workspaceId !== 'default') {
existing.workspaceId = workspaceId;
if (existing.handle) existing.handle.workspaceId = workspaceId;
}
setDiagnostics(existing.lastSource || 'watcher_sse', 'reuse_connection');
return existing.handle;
}
var url = new URL('/api/local-folder/events', window.location.origin);
url.searchParams.set('rootUri', rootUri);
url.searchParams.set('treeLive', 'true');
var eventSource = new EventSource(url.toString());
var entry = {
key: key,
rootUri: rootUri,
workspaceId: workspaceId,
bootstrap: (options && options.bootstrap) || null,
source: eventSource,
lastSource: 'watcher_sse',
close: function() {
connections.delete(key);
closeEntry(entry);
setDiagnostics('watcher_sse', 'closed');
}
};
entry.handle = {
key: key,
rootUri: rootUri,
workspaceId: workspaceId,
source: eventSource,
close: entry.close
};
connections.set(key, entry);
setDiagnostics('watcher_sse', 'connect');
emit('mnote:local-folder:event-bus-ready', {
schema: 'mnote.local_folder.event_bus.ready.v1',
source: 'watcher_sse',
reason: 'connect',
rootUri: rootUri,
workspaceId: workspaceId,
connections: connections.size
});
eventSource.addEventListener('open', function() {
setDiagnostics('watcher_sse', 'open');
});
eventSource.addEventListener('watch_batch', function(event) {
dispatchWatchBatch(entry, event, parseEventPayload(event), {
source: 'watcher_sse',
reason: 'watch_batch'
});
});
eventSource.addEventListener('tree_error', function(event) {
var payload = parseEventPayload(event);
setDiagnostics('watcher_sse', 'tree_error');
emit('tree:error', { payload: payload, bootstrap: entry.bootstrap || null, viaEventBus: true });
});
eventSource.onerror = function() {
setDiagnostics('watcher_sse', 'error');
};
return entry.handle;
}
function emitSyntheticWatchBatch(detail) {
var payload = detail && detail.payload ? detail.payload : (detail || {});
var rootUri = normalizeRootUri(detail && detail.rootUri);
var workspaceId = normalizeWorkspaceId(detail && detail.workspaceId);
var entry = {
rootUri: rootUri,
workspaceId: workspaceId,
bootstrap: (detail && detail.bootstrap) || null
};
dispatchWatchBatch(entry, { lastEventId: detail && detail.revision }, payload, {
source: (detail && detail.source) || 'synthetic',
reason: (detail && detail.reason) || payload.source || 'synthetic_watch_batch'
});
}
function closeAll() {
Array.from(connections.values()).forEach(function(entry) {
closeEntry(entry);
});
connections.clear();
setDiagnostics('watcher_sse', 'closed');
}
window.__mnoteLocalFolderEventBus = {
startLocalFolderWatcher: startLocalFolderWatcher,
emitSyntheticWatchBatch: emitSyntheticWatchBatch,
closeAll: closeAll,
flushSidebarRefresh: function(rootUri) {
flushSidebarRefresh(normalizeRootUri(rootUri));
},
connectionCount: function() { return connections.size; },
diagnostics: function() {
return {
connections: connections.size,
lastSource: lastSource,
lastReason: lastReason
};
}
};
setDiagnostics('', 'ready');
})();
@@ -413,23 +413,8 @@ async function uploadLocalFolderAsset(file, plan, context) {
}
async function uploadMediaAsset(file, plan, context) {
var form = new FormData();
form.append('file', file);
form.append('workspaceId', plan && plan.workspaceId || '');
form.append('documentId', plan && plan.targetDocumentId || '');
if (plan && plan.targetMindmapId) form.append('mindmapId', plan.targetMindmapId);
var response = await fetchWithTimeout('/api/media/upload', {
method: 'POST',
credentials: 'include',
body: form
}, Number(context && context.timeoutMs) || 15000, '上传');
var payload = await response.json().catch(function() { return null; });
if (!response.ok || !payload || !payload.asset) {
throw new Error(payload && payload.error ? payload.error : '上传失败');
}
return {
asset: payload.asset
};
document.documentElement.setAttribute('data-mnote-media-upload-retired', 'true');
throw new Error('旧 Convex Files 上传链已退役;local-first 附件请使用本地文件夹上传目标。');
}
async function insertUploadedAssetIntoEditor(asset, targetRoot, deps) {
@@ -749,23 +749,7 @@ export const createSidebarAttachmentOpenRuntime = (dependencies = {}) => {
};
}
}
if (assetId) {
var response = await fetch('/api/media/sign?assetId=' + encodeURIComponent(assetId), {
method: 'GET',
credentials: 'include',
cache: 'no-store'
});
var payload = await response.json().catch(function() { return null; });
if (!response.ok || !payload) {
throw new Error(payload && payload.error ? payload.error : '生成签名链接失败');
}
var signedUrl = String(payload && payload.signedUrl || '').trim();
if (!signedUrl) throw new Error('附件链接不可用');
return {
url: signedUrl,
asset: payload.asset && typeof payload.asset === 'object' ? payload.asset : {}
};
}
if (assetId) document.documentElement.setAttribute('data-mnote-media-sign-retired', 'true');
var url = String(detail && (detail.fileUrl || detail.href) || '').trim();
if (!url) throw new Error('附件链接不可用');
return { url: url, asset: {} };
@@ -855,20 +839,7 @@ export const createSidebarAttachmentOpenRuntime = (dependencies = {}) => {
return;
}
}
if (detail.assetId) {
try {
var response = await fetch('/api/media/sign?assetId=' + encodeURIComponent(detail.assetId), {
method: 'GET',
credentials: 'include'
});
var payload = await response.json().catch(function() { return null; });
var signedUrl = String(payload && payload.signedUrl || '').trim();
if (response.ok && signedUrl) {
window.open(signedUrl, '_blank', 'noopener,noreferrer');
return;
}
} catch (_) {}
}
if (detail.assetId) document.documentElement.setAttribute('data-mnote-media-sign-retired', 'true');
var localRelativePath = String(detail.localRelativePath || '').trim();
if (localRelativePath) {
var localPathDownloadUrl = buildLocalFileOpenUrl(localRelativePath, true);
@@ -380,11 +380,7 @@ export const createSidebarFileTreeCommandRuntime = (dependencies = {}) => {
callback();
}
function isLocalOcrSourceFileName(fileName) {
return /\.(png|jpe?g|webp|gif|bmp|tiff?|pdf)$/i.test(String(fileName || '').trim());
}
function localOcrSourceRelativePath(detail) {
function knowledgeRagSourceRelativePath(detail) {
var workspacePath = detail && detail.workspacePath && typeof detail.workspacePath === 'object' ? detail.workspacePath : null;
var relativePath = String(
detail && detail.localRelativePath
@@ -395,7 +391,7 @@ export const createSidebarFileTreeCommandRuntime = (dependencies = {}) => {
return relativePath.replace(/^\/+/, '');
}
function localOcrRootUri(detail, trigger) {
function knowledgeRagRootUri(detail, trigger) {
var workspacePath = detail && detail.workspacePath && typeof detail.workspacePath === 'object' ? detail.workspacePath : null;
var rootUri = String(
detail && (detail.localRootUri || detail.rootUri)
@@ -409,39 +405,15 @@ export const createSidebarFileTreeCommandRuntime = (dependencies = {}) => {
return rootUri || currentRootUri() || '';
}
function supportsLocalOcr(detail) {
var path = localOcrSourceRelativePath(detail);
var title = String(detail && (detail.title || detail.fileName) || '').trim() || path.split('/').pop() || '';
return Boolean(path && localOcrRootUri(detail, null) && isLocalOcrSourceFileName(title || path));
}
function localOcrProvider() {
var override = String(window.__MNOTE_LOCAL_OCR_PROVIDER || '').trim().toLowerCase();
return override === 'mock' ? 'mock' : 'mineru';
}
async function runLocalOcrForDetail(detail, trigger) {
document.documentElement.setAttribute('data-mnote-local-ocr-menu-status', 'running');
var payload = await ingestKnowledgeRagForDetail(detail, trigger).catch(function(error) {
document.documentElement.setAttribute('data-mnote-local-ocr-menu-status', 'failed');
throw error;
});
var sourceRootRelativePath = localOcrSourceRelativePath(detail)
|| String(detail && (detail.localRelativePath || detail.path || detail.title) || '').trim();
var rootUri = localOcrRootUri(detail, trigger);
var status = payload && payload.retryRequired ? 'retry' : 'done';
document.documentElement.setAttribute('data-mnote-local-ocr-menu-status', status);
document.documentElement.setAttribute('data-mnote-local-ocr-menu-path', sourceRootRelativePath);
window.dispatchEvent(new CustomEvent('mnote:local-ocr-job-updated', {
detail: { status: status, job: payload, rootUri: rootUri, sourceRootRelativePath: sourceRootRelativePath }
}));
return payload;
function supportsKnowledgeRagSource(detail) {
var path = knowledgeRagSourceRelativePath(detail);
return Boolean(path && knowledgeRagRootUri(detail, null));
}
async function ingestKnowledgeRagForDetail(detail, trigger) {
var sourceRootRelativePath = localOcrSourceRelativePath(detail)
var sourceRootRelativePath = knowledgeRagSourceRelativePath(detail)
|| String(detail && (detail.localRelativePath || detail.path || detail.title) || '').trim();
var rootUri = localOcrRootUri(detail, trigger);
var rootUri = knowledgeRagRootUri(detail, trigger);
if (!sourceRootRelativePath || !rootUri) {
throw new Error('缺少资料库来源或 rootUri');
}
@@ -473,17 +445,6 @@ export const createSidebarFileTreeCommandRuntime = (dependencies = {}) => {
function handleTreeContextMenuAction(action, detail, trigger) {
closeTreeContextMenu();
detail = detail || {};
if (action === 'local-ocr') {
recordFileTreeAction('knowledge-rag-index', detail);
recordFileTreeActionStatus('pending', detail);
void runLocalOcrForDetail(detail, trigger).then(function(job) {
recordFileTreeActionStatus(job && job.retryRequired ? 'retry' : 'done', detail);
}).catch(function(error) {
recordFileTreeActionStatus('failed', Object.assign({}, detail, { fallback: 'alert' }));
window.alert(error && error.message ? error.message : '资料库索引失败');
});
return;
}
if (action === 'knowledge-rag-index') {
recordFileTreeAction('knowledge-rag-index', detail);
recordFileTreeActionStatus('pending', detail);
@@ -954,7 +915,7 @@ export const createSidebarFileTreeCommandRuntime = (dependencies = {}) => {
menu.setAttribute('data-command-context-target-kind', String(ctx['tree.targetResourceKind'] || ''));
menu.setAttribute('data-command-context-editor-dirty', String(ctx['editor.dirty'] === true));
menu.setAttribute('data-command-context-ai-can-write', String(ctx['ai.canWrite'] === true));
var localOcrSupported = supportsLocalOcr(detail);
var knowledgeRagSupported = supportsKnowledgeRagSource(detail);
var items = isAttachment ? [
{ action: 'duplicate', icon: 'file_copy', label: '拷贝副本', shortcut: 'Ctrl + D' },
{ action: 'delete-trash', icon: 'delete', label: '删除', shortcut: 'Del', danger: true, destructive: true, requiresApproval: true, when: '!workspace.readonly' },
@@ -1017,7 +978,7 @@ export const createSidebarFileTreeCommandRuntime = (dependencies = {}) => {
{ separator: true },
{ action: 'delete-trash', icon: 'delete', label: '删除', shortcut: 'Del', danger: true, destructive: true, requiresApproval: true, when: '!workspace.readonly && !editor.dirty' }
];
if (currentSourceKind() === 'local_folder' && detail.rowKind !== 'index') {
if (currentSourceKind() === 'local_folder' && detail.rowKind !== 'index' && knowledgeRagSupported) {
var ragItem = { action: 'knowledge-rag-index', icon: 'travel_explore', label: '加入资料库索引', when: '!workspace.readonly' };
var insertAt = isAttachment ? 11 : isAsset ? 3 : 3;
if (insertAt >= 0) items.splice(insertAt, 0, ragItem);
@@ -479,92 +479,8 @@ export const createSidebarFileTreeOpenRuntime = (dependencies = {}) => {
navigateToMindmapObject(documentId, assetId, String(detail.workspaceId || '').trim());
return;
}
try {
var response = await fetch('/api/media/sign?assetId=' + encodeURIComponent(assetId), {
method: 'GET',
credentials: 'include'
});
var payload = await response.json().catch(function() { return null; });
if (!response.ok) {
throw new Error(payload && payload.error ? payload.error : '生成签名链接失败');
}
var asset = payload && payload.asset && typeof payload.asset === 'object' ? payload.asset : {};
var fileUrl = String(payload && payload.signedUrl || asset.file_url || asset.signed_url || '').trim();
if (!fileUrl) throw new Error('附件链接不可用');
var fileName = String(asset.file_name || detail.fileName || '未命名资源').trim() || '未命名资源';
var fileType = inferOnlyOfficeFileType(fileName, asset.mime_type);
if (fileType) {
var userId = await fetchCurrentOnlyOfficeUserId();
var officeUrl = buildOnlyOfficeOpenUrl({
fileUrl: fileUrl,
fileName: fileName,
fileType: fileType,
assetId: assetId,
documentId: String(asset.document_id || detail.documentId || '').trim(),
userId: userId,
mode: forceEditMode ? 'edit' : 'view'
});
if (typeof window.__mnoteDocumentPaneRuntime?.openResourceInActiveTab === 'function') {
var didOpen = await window.__mnoteDocumentPaneRuntime.openResourceInActiveTab({
objectIdentity: resourceObjectIdentityFromWorkspacePath({
workspacePath: detailWorkspacePath,
objectKind: 'only_office',
documentId: String(asset.document_id || detail.documentId || '').trim(),
assetId: assetId
}),
assetId: assetId,
title: fileName,
fileName: fileName,
kind: 'office',
officeUrl: officeUrl,
documentId: String(asset.document_id || detail.documentId || '').trim(),
workspaceId: String(detail.workspaceId || '').trim(),
workspacePath: detailWorkspacePath
});
if (didOpen) return;
}
window.open(officeUrl, '_blank', 'noopener,noreferrer');
return;
}
if (isPdfAttachmentFileName(fileName)) {
var pdfPreviewUrl = buildPdfPreviewOpenUrl(fileUrl, fileName);
if (typeof window.__mnoteDocumentPaneRuntime?.openResourceInActiveTab === 'function') {
var didOpenPdf = await window.__mnoteDocumentPaneRuntime.openResourceInActiveTab({
objectIdentity: resourceObjectIdentityFromWorkspacePath({
workspacePath: detailWorkspacePath,
objectKind: 'pdf',
documentId: String(asset.document_id || detail.documentId || '').trim(),
assetId: assetId
}),
assetId: assetId,
title: fileName,
fileName: fileName,
kind: 'pdf',
href: pdfPreviewUrl,
documentId: String(asset.document_id || detail.documentId || '').trim(),
workspaceId: String(detail.workspaceId || '').trim(),
workspacePath: detailWorkspacePath
});
if (didOpenPdf) return;
}
window.open(pdfPreviewUrl || fileUrl, '_blank', 'noopener,noreferrer');
return;
}
if (isCodeAttachmentFileName(fileName) && String(asset.document_id || detail.documentId || '').trim() === currentDocumentId()) {
await openCodeEditorAttachment({
href: fileUrl,
fileUrl: fileUrl,
fileName: fileName,
assetId: assetId,
documentId: String(asset.document_id || detail.documentId || '').trim(),
fileSize: uploadedFileSize(asset)
});
return;
}
window.open(fileUrl, '_blank', 'noopener,noreferrer');
} catch (error) {
window.alert(error && error.message ? error.message : '打开附件失败');
}
document.documentElement.setAttribute('data-mnote-media-sign-retired', 'true');
window.alert('旧 Convex Files 附件签名链已退役;local-first 附件请通过本地文件夹资源打开。');
}
window.addEventListener('tree.asset.open', function(event) {
@@ -469,7 +469,6 @@ export function createSidebarPageAiRuntime(context) {
const pageAiWorkspacePathForTarget = (...args) => pageAiTargetRuntime.pageAiWorkspacePathForTarget(...args);
const pageAiBuildAllowedRoots = (...args) => pageAiTargetRuntime.pageAiBuildAllowedRoots(...args);
const pageAiBuildContextRefs = (...args) => pageAiTargetRuntime.pageAiBuildContextRefs(...args);
const pageAiEnrichOcrContextRefs = (...args) => pageAiTargetRuntime.pageAiEnrichOcrContextRefs(...args);
const pageAiBuildRunTargetSnapshot = (...args) => pageAiTargetRuntime.pageAiBuildRunTargetSnapshot(...args);
const pageAiPageContextForRefs = (...args) => pageAiTargetRuntime.pageAiPageContextForRefs(...args);
const pageAiSetRunTargetSnapshot = (...args) => pageAiTargetRuntime.pageAiSetRunTargetSnapshot(...args);
@@ -1085,21 +1084,31 @@ export function createSidebarPageAiRuntime(context) {
return index === list.findIndex(function(candidate) { return candidate.relativePath === item.relativePath; });
});
if (changedPaths.length) {
document.documentElement.setAttribute('data-mnote-page-ai-receipt-filetree-refresh', 'true');
window.dispatchEvent(new CustomEvent('tree:local-folder-watch-batch', {
detail: {
payload: {
var rootUri = String((receipt && receipt.rootUri) || (fallbackAudit && fallbackAudit.rootUri) || currentRootUri() || '');
var syntheticPayload = {
schema: 'mnote.local_folder.watch_batch.v1',
source: 'agent_run_receipt',
runId: runId,
rootUri: String((receipt && receipt.rootUri) || (fallbackAudit && fallbackAudit.rootUri) || currentRootUri() || ''),
rootUri: rootUri,
changedPaths: changedPaths,
affectedParents: affectedParents
}
}
};
document.documentElement.setAttribute('data-mnote-page-ai-receipt-filetree-refresh', 'true');
if (window.__mnoteLocalFolderEventBus && typeof window.__mnoteLocalFolderEventBus.emitSyntheticWatchBatch === 'function') {
window.__mnoteLocalFolderEventBus.emitSyntheticWatchBatch({
source: 'synthetic_page_ai_receipt',
reason: 'agent_run_receipt',
rootUri: rootUri,
workspaceId: resolveWorkspaceId(document.body),
payload: syntheticPayload
});
} else {
window.dispatchEvent(new CustomEvent('tree:local-folder-watch-batch', {
detail: { payload: syntheticPayload }
}));
}
}
}
if (refresh.touchesCurrentFile === true) {
var documentId = String(refresh.currentDocumentId || receipt && receipt.documentId || currentDocumentId() || '').trim();
document.documentElement.setAttribute('data-mnote-page-ai-receipt-current-refresh', 'true');
@@ -1975,11 +1984,6 @@ export function createSidebarPageAiRuntime(context) {
var allowedRoots = pageAiBuildAllowedRoots();
var agentTargetPackage = pageAiBuildAgentTargetPackage(scopedContext, runTargetSnapshot);
assertPageAiLocalWritePermission(prompt, agentTargetPackage);
if (typeof pageAiEnrichOcrContextRefs === 'function') {
var ocrContext = await pageAiEnrichOcrContextRefs(contextRefs, agentTargetPackage, scopedContext.editorTarget);
contextRefs = ocrContext.contextRefs || contextRefs;
agentTargetPackage = ocrContext.agentTargetPackage || agentTargetPackage;
}
if (scopedContext.pageContext && scopedContext.pageContext.aiContext) {
scopedContext.pageContext.aiContext.runTargetSnapshot = runTargetSnapshot;
scopedContext.pageContext.aiContext.agentTargetPackage = agentTargetPackage;
@@ -427,48 +427,6 @@ export function createSidebarPageAiTargetRuntime(context) {
});
}
function pageAiOcrEligibleResourceKind(value) {
var normalized = String(value || '').trim().toLowerCase();
return normalized === 'image' || normalized === 'pdf' || normalized === 'attachment' || normalized === 'resource';
}
function pageAiOcrEligiblePath(value) {
var path = String(value || '').trim().toLowerCase();
return /\.(png|jpg|jpeg|webp|bmp|tif|tiff|pdf)$/.test(path);
}
function pageAiOcrBodyPreview(markdown) {
var body = String(markdown || '').replace(/^---\n[\s\S]*?\n---\n?/, '').trim();
return body.slice(0, 1600);
}
async function fetchPageAiOcrSidecarContext(editorTarget) {
return null;
}
async function pageAiEnrichOcrContextRefs(contextRefs, agentTargetPackage, editorTarget) {
var ocrContext = await fetchPageAiOcrSidecarContext(editorTarget);
if (!ocrContext) return { contextRefs, agentTargetPackage };
var nextRefs = pageAiNormalizeArray(contextRefs).map(function(ref) {
if (ref && ref.kind === 'active_editor') return Object.assign({}, ref, { ocrContext: ocrContext });
return ref;
});
var nextPackage = agentTargetPackage && typeof agentTargetPackage === 'object'
? Object.assign({}, agentTargetPackage, { ocrContext: ocrContext })
: agentTargetPackage;
if (nextPackage && nextPackage.currentFile && typeof nextPackage.currentFile === 'object') {
nextPackage.currentFile = Object.assign({}, nextPackage.currentFile, { ocrRootRelativePath: ocrContext.ocrRootRelativePath });
}
if (nextPackage && Array.isArray(nextPackage.targets)) {
nextPackage.targets = nextPackage.targets.map(function(target, index) {
return index === 0 && target && typeof target === 'object'
? Object.assign({}, target, { ocrContext: ocrContext })
: target;
});
}
return { contextRefs: nextRefs, agentTargetPackage: nextPackage };
}
function pageAiBuildAllowedRoots() {
return pageAiNormalizeArray(pageUiState.pageAiAllowedRoots).map(function(root) {
return {
@@ -790,7 +748,6 @@ export function createSidebarPageAiTargetRuntime(context) {
pageAiBuildAgentTargetPackage,
pageAiBuildAllowedRoots,
pageAiBuildContextRefs,
pageAiEnrichOcrContextRefs,
pageAiBuildRunTargetSnapshot,
pageAiCloneJson,
pageAiContextKindsFromRefs,
@@ -67,10 +67,6 @@ export function createSidebarPageSettingsRuntime(context) {
return Object.assign({}, DEFAULT_PAGE_WIDTH_PREFERENCES, pageUiState.pageWidthPreferences || {});
}
function currentLocalOcrPreferences() {
return Object.assign({ 'localOcr.autoEnabled': false }, pageUiState.localOcrPreferences || {});
}
function currentKnowledgeRagSummary() {
return pageUiState.knowledgeRagSummary || {};
}
@@ -340,12 +336,6 @@ export function createSidebarPageSettingsRuntime(context) {
indexTrigger.setAttribute('data-state', indexOpen ? 'open' : 'closed');
indexTrigger.setAttribute('aria-expanded', indexOpen ? 'true' : 'false');
}
var ocrTrigger = document.querySelector('[data-testid="mnote-local-ocr-task-toggle"]');
var ocrOpen = isLocalOcrSettingsOpen();
if (ocrTrigger instanceof HTMLElement) {
ocrTrigger.setAttribute('data-state', ocrOpen ? 'open' : 'closed');
ocrTrigger.setAttribute('aria-expanded', ocrOpen ? 'true' : 'false');
}
var ragTrigger = document.querySelector('[data-testid="mnote-knowledge-rag-settings-toggle"]');
var ragOpen = isKnowledgeRagSettingsOpen();
if (ragTrigger instanceof HTMLElement) {
@@ -391,17 +381,6 @@ export function createSidebarPageSettingsRuntime(context) {
'</label>';
}
function createLocalOcrAutoRow() {
return '' +
'<label class="wolai-page-setting-row" data-page-setting-row="localOcrAutoEnabled">' +
'<span class="wolai-page-setting-copy">' +
'<span class="wolai-page-setting-label">资料库自动索引</span>' +
'<span class="wolai-page-setting-hint">LiteParse/OCR sidecar 已退役;图片、PDF、Office 统一由 LightRAG 资料库处理</span>' +
'</span>' +
'<input type="checkbox" class="wolai-page-setting-checkbox" data-local-ocr-option-checkbox="autoEnabled" />' +
'</label>';
}
function createPageWidthSelectRow(type) {
var options = type === 'default'
? [
@@ -456,11 +435,8 @@ export function createSidebarPageSettingsRuntime(context) {
});
}
function renderLocalOcrOptions(popover) {
var localOcrPreferences = currentLocalOcrPreferences();
popover.querySelectorAll('[data-local-ocr-option-checkbox="autoEnabled"]').forEach(function(input) {
input.checked = localOcrPreferences['localOcr.autoEnabled'] === true;
});
function markRetiredLocalOcrPreferenceSurface() {
document.documentElement.setAttribute('data-mnote-local-ocr-auto-retired', 'true');
}
function createPageFontRow() {
@@ -673,10 +649,6 @@ export function createSidebarPageSettingsRuntime(context) {
return popover;
}
function ensureLocalOcrSettingsPopover() {
return ensureKnowledgeRagSettingsPopover();
}
function createKnowledgeRagSettingsPanelHtml() {
return '' +
'<div class="wolai-page-settings-panel mnote-settings-panel mnote-knowledge-rag-settings-panel" role="dialog" aria-modal="false" aria-label="资料库问答设置">' +
@@ -1809,51 +1781,12 @@ export function createSidebarPageSettingsRuntime(context) {
var payload = await response.json().catch(function(){ return null; });
if (!response.ok || !payload || payload.ok !== true) return;
pageUiState.pageWidthPreferences = normalizePageWidthPreferences(payload.result && payload.result.pageWidthPreferences);
pageUiState.localOcrPreferences = Object.assign({ 'localOcr.autoEnabled': false }, payload.result && payload.result.localOcrPreferences || {});
window.__MNOTE_LOCAL_OCR_PREFERENCES = Object.assign({}, pageUiState.localOcrPreferences);
document.documentElement.setAttribute('data-mnote-local-ocr-auto-enabled', String(pageUiState.localOcrPreferences['localOcr.autoEnabled'] === true));
markRetiredLocalOcrPreferenceSurface();
applyPageOptionsToShell();
if (isPageSettingsOpen()) renderPageSettingsPopover();
if (isLocalOcrSettingsOpen()) renderLocalOcrOptions(ensureLocalOcrSettingsPopover());
} catch (_) {}
}
async function persistLocalOcrAutoPreference(enabled) {
var previous = currentLocalOcrPreferences();
var next = Object.assign({}, previous, { 'localOcr.autoEnabled': Boolean(enabled) });
pageUiState.localOcrPreferences = next;
window.__MNOTE_LOCAL_OCR_PREFERENCES = Object.assign({}, next);
if (isPageSettingsOpen()) renderPageSettingsPopover();
if (isLocalOcrSettingsOpen()) renderLocalOcrOptions(ensureLocalOcrSettingsPopover());
try {
var response = await fetch('/api/ui/preferences', {
method: 'PUT',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
documentId: currentDocumentId(),
workspaceId: resolveWorkspaceId(document.body),
...currentWorkspaceSourcePayload(),
updates: { 'localOcr.autoEnabled': Boolean(enabled) }
})
});
var payload = await response.json().catch(function(){ return null; });
if (!response.ok || !payload || payload.ok !== true) {
throw new Error(payload && payload.error && payload.error.message ? payload.error.message : 'local_ocr_preference_save_failed_' + response.status);
}
pageUiState.localOcrPreferences = Object.assign({ 'localOcr.autoEnabled': false }, payload.result && payload.result.localOcrPreferences || {});
window.__MNOTE_LOCAL_OCR_PREFERENCES = Object.assign({}, pageUiState.localOcrPreferences);
document.documentElement.setAttribute('data-mnote-local-ocr-auto-enabled', String(pageUiState.localOcrPreferences['localOcr.autoEnabled'] === true));
if (isPageSettingsOpen()) renderPageSettingsPopover();
if (isLocalOcrSettingsOpen()) renderLocalOcrOptions(ensureLocalOcrSettingsPopover());
} catch (error) {
pageUiState.localOcrPreferences = previous;
window.__MNOTE_LOCAL_OCR_PREFERENCES = Object.assign({}, previous);
if (isPageSettingsOpen()) renderPageSettingsPopover();
if (isLocalOcrSettingsOpen()) renderLocalOcrOptions(ensureLocalOcrSettingsPopover());
document.documentElement.setAttribute('data-mnote-local-ocr-preference-error', error instanceof Error ? error.message : String(error));
}
}
async function persistPageWidthPreference(type, mode) {
if (PAGE_WIDTH_TYPES.indexOf(type) < 0) return;
var previous = pageUiState.pageWidthPreferences;
@@ -1908,24 +1841,18 @@ export function createSidebarPageSettingsRuntime(context) {
return popover instanceof HTMLElement && !popover.hidden;
}
function isLocalOcrSettingsOpen() {
var popover = document.querySelector('[data-testid="mnote-local-ocr-settings-popover"]');
return popover instanceof HTMLElement && !popover.hidden;
}
function isKnowledgeRagSettingsOpen() {
var popover = document.querySelector('[data-testid="mnote-knowledge-rag-settings-popover"]');
return popover instanceof HTMLElement && !popover.hidden;
}
function isAnySettingsOpen() {
return isPageSettingsOpen() || isLocalIndexSettingsOpen() || isLocalOcrSettingsOpen() || isKnowledgeRagSettingsOpen();
return isPageSettingsOpen() || isLocalIndexSettingsOpen() || isKnowledgeRagSettingsOpen();
}
function openPageSettingsPopover(initialTab) {
if (!currentDocumentId()) return;
closeLocalIndexSettingsPopover();
closeLocalOcrSettingsPopover();
closeKnowledgeRagSettingsPopover();
var popover = ensurePageSettingsPopover();
renderPageSettingsPopover();
@@ -1937,7 +1864,6 @@ export function createSidebarPageSettingsRuntime(context) {
function openPageIndexSettingsPopover() {
closePageSettingsPopover();
closeLocalOcrSettingsPopover();
closeKnowledgeRagSettingsPopover();
var popover = ensureLocalIndexSettingsPopover();
renderPageSettingsLocalIndex(popover);
@@ -1946,14 +1872,9 @@ export function createSidebarPageSettingsRuntime(context) {
updateStandaloneSettingsTriggerState();
}
function openLocalOcrSettingsPopover() {
openKnowledgeRagSettingsPopover();
}
function openKnowledgeRagSettingsPopover() {
closePageSettingsPopover();
closeLocalIndexSettingsPopover();
closeLocalOcrSettingsPopover();
var popover = ensureKnowledgeRagSettingsPopover();
renderKnowledgeRagSettings(popover);
popover.hidden = false;
@@ -1978,12 +1899,6 @@ export function createSidebarPageSettingsRuntime(context) {
updateStandaloneSettingsTriggerState();
}
function closeLocalOcrSettingsPopover() {
var popover = document.querySelector('[data-testid="mnote-local-ocr-settings-popover"]');
if (popover instanceof HTMLElement) popover.hidden = true;
updateStandaloneSettingsTriggerState();
}
function closeKnowledgeRagSettingsPopover() {
var popover = document.querySelector('[data-testid="mnote-knowledge-rag-settings-popover"]');
if (popover instanceof HTMLElement) popover.hidden = true;
@@ -1993,7 +1908,6 @@ export function createSidebarPageSettingsRuntime(context) {
function closeAllSettingsPopovers() {
closePageSettingsPopover();
closeLocalIndexSettingsPopover();
closeLocalOcrSettingsPopover();
closeKnowledgeRagSettingsPopover();
}
@@ -2006,10 +1920,6 @@ export function createSidebarPageSettingsRuntime(context) {
applyPageOptionsToShell();
});
window.addEventListener('mnote:open-local-ocr-settings', function() {
openLocalOcrSettingsPopover();
});
window.addEventListener('mnote:knowledge-rag-source-updated', function() {
if (isKnowledgeRagSettingsOpen()) void loadKnowledgeRagStatus(true);
});
@@ -2021,32 +1931,26 @@ export function createSidebarPageSettingsRuntime(context) {
closeKnowledgeRagSettingsPopover,
closePageHistoryDrawer,
closeLocalIndexSettingsPopover,
closeLocalOcrSettingsPopover,
closePageSettingsPopover,
closePageShareDialog,
currentLocalOcrPreferences,
currentKnowledgeRagSummary,
currentPageOptions,
ensureHistorySnapshotsSeeded,
ensureKnowledgeRagSettingsPopover,
ensureLocalIndexSettingsPopover,
ensureLocalOcrSettingsPopover,
ensurePageHistoryDrawer,
ensurePageSettingsPopover,
ensurePageShareDialog,
isAnySettingsOpen,
isKnowledgeRagSettingsOpen,
isLocalIndexSettingsOpen,
isLocalOcrSettingsOpen,
isPageSettingsOpen,
openKnowledgeRagSettingsPopover,
openPageHistoryDrawer,
openLocalOcrSettingsPopover,
openPageSettingsPopover,
openPageIndexSettingsPopover,
openPageShareDialog,
pageOptionIsSupported,
persistLocalOcrAutoPreference,
persistLocalIndexSettings,
persistPageOptionsPatch,
persistPageWidthPreference,
@@ -2004,7 +2004,17 @@ export const createSidebarTreeLiveApplyRuntime = (dependencies = {}) => {
setTreeLiveApplyError('tree_resync_missing_projection_payload');
});
window.addEventListener('mnote:local-folder:sidebar-refresh-requested', function(event) {
var detail = event.detail || {};
document.documentElement.setAttribute('data-mnote-local-folder-event-bus-sidebar-refresh-received', String(detail.reason || 'event-bus'));
applyLocalFolderWatchBatch(detail);
});
window.addEventListener('tree:local-folder-watch-batch', function(event) {
if (event.detail && event.detail.viaEventBus === true) {
document.documentElement.setAttribute('data-mnote-local-folder-watch-batch-skipped', 'event-bus-orchestrated');
return;
}
var payload = event.detail && event.detail.payload ? event.detail.payload : event.detail;
applyLocalFolderWatchBatch(payload);
});
@@ -209,7 +209,6 @@ import { createSidebarPageSettingsRuntime } from './sidebar-page-settings-runtim
const isPageSettingsOpen = (...args) => sidebarPageSettings.isPageSettingsOpen(...args);
const openPageHistoryDrawer = (...args) => sidebarPageSettings.openPageHistoryDrawer(...args);
const openPageIndexSettingsPopover = (...args) => sidebarPageSettings.openPageIndexSettingsPopover(...args);
const openLocalOcrSettingsPopover = (...args) => sidebarPageSettings.openLocalOcrSettingsPopover(...args);
const openKnowledgeRagSettingsPopover = (...args) => sidebarPageSettings.openKnowledgeRagSettingsPopover(...args);
const openPageShareDialog = (...args) => sidebarPageSettings.openPageShareDialog(...args);
const pageOptionIsSupported = (...args) => sidebarPageSettings.pageOptionIsSupported(...args);
@@ -223,7 +222,6 @@ import { createSidebarPageSettingsRuntime } from './sidebar-page-settings-runtim
const pruneKnowledgeRagRegistry = (...args) => sidebarPageSettings.pruneKnowledgeRagRegistry(...args);
const setKnowledgeRagSourceFilter = (...args) => sidebarPageSettings.setKnowledgeRagSourceFilter(...args);
const useKnowledgeRagFileTreeSelection = (...args) => sidebarPageSettings.useKnowledgeRagFileTreeSelection(...args);
const persistLocalOcrAutoPreference = (...args) => sidebarPageSettings.persistLocalOcrAutoPreference(...args);
const persistLocalIndexSettings = (...args) => sidebarPageSettings.persistLocalIndexSettings(...args);
const persistPageOptionsPatch = (...args) => sidebarPageSettings.persistPageOptionsPatch(...args);
const persistPageWidthPreference = (...args) => sidebarPageSettings.persistPageWidthPreference(...args);
@@ -477,7 +475,7 @@ import { createSidebarPageSettingsRuntime } from './sidebar-page-settings-runtim
return title && title.textContent ? title.textContent.trim() : (fallback || '文件夹');
}
function isLocalOcrMarkdownPath(relativePath) {
function isRetiredOcrSidecarMarkdownPath(relativePath) {
var normalized = String(relativePath || '').trim().replace(/\\/g, '/').replace(/^\/+/, '');
return /(^|\/)[^/]+\.ocr\/[^/]+\.ocr\.md$/i.test(normalized);
}
@@ -1298,24 +1296,8 @@ import { createSidebarPageSettingsRuntime } from './sidebar-page-settings-runtim
applyEditorAttachmentMeta(link, attachmentMetaCache[assetId]);
return;
}
attachmentMetaPending[assetId] = fetch('/api/media/sign?assetId=' + encodeURIComponent(assetId), {
method: 'GET',
credentials: 'include',
cache: 'no-store'
}).then(function(response) {
return response.json().catch(function() { return null; }).then(function(payload) {
if (!response.ok || !payload) return null;
var asset = payload.asset && typeof payload.asset === 'object' ? payload.asset : {};
var meta = {
assetId: assetId,
fileSize: uploadedFileSize(asset)
};
attachmentMetaCache[assetId] = meta;
return meta;
});
}).catch(function() {
return null;
}).finally(function() {
document.documentElement.setAttribute('data-mnote-media-sign-retired', 'true');
attachmentMetaPending[assetId] = Promise.resolve(null).finally(function() {
delete attachmentMetaPending[assetId];
});
try {
@@ -1899,20 +1881,8 @@ import { createSidebarPageSettingsRuntime } from './sidebar-page-settings-runtim
throw new Error('上传失败');
}
} else {
var form = new FormData();
form.append('file', file);
form.append('workspaceId', plan.workspaceId);
form.append('documentId', plan.targetDocumentId);
if (plan.targetMindmapId) form.append('mindmapId', plan.targetMindmapId);
var response = await fetchWithTimeout('/api/media/upload', {
method: 'POST',
credentials: 'include',
body: form
}, 15000, '上传');
payload = await response.json().catch(function() { return null; });
if (!response.ok || !payload || !payload.asset) {
throw new Error(payload && payload.error ? payload.error : '上传失败');
}
document.documentElement.setAttribute('data-mnote-media-upload-retired', 'true');
throw new Error('旧 Convex Files 上传链已退役;local-first 附件请使用本地文件夹上传目标。');
}
appendUploadedAssetRow(payload.asset, plan.targetDocumentId);
if (options && options.insertIntoEditor) {
@@ -2695,20 +2665,6 @@ import { createSidebarPageSettingsRuntime } from './sidebar-page-settings-runtim
return;
}
var ocrTaskTrigger = closestAction(e.target, '[data-mnote-action="toggle-ocr-tasks"]');
if (ocrTaskTrigger) {
e.preventDefault();
openKnowledgeRagSettingsPopover();
return;
}
var ocrSettingsTrigger = closestAction(e.target, '[data-mnote-action="open-ocr-settings"]');
if (ocrSettingsTrigger) {
e.preventDefault();
openKnowledgeRagSettingsPopover();
return;
}
var settingsClose = closestAction(e.target, '[data-settings-action="close"]');
if (settingsClose) {
e.preventDefault();
@@ -2750,17 +2706,6 @@ import { createSidebarPageSettingsRuntime } from './sidebar-page-settings-runtim
}
}
var localOcrSettingsAction = closestAction(e.target, '[data-local-ocr-settings-action]');
if (localOcrSettingsAction) {
e.preventDefault();
var localOcrSettingsActionName = localOcrSettingsAction.getAttribute('data-local-ocr-settings-action') || '';
closeAllSettingsPopovers();
window.dispatchEvent(new CustomEvent('mnote:local-ocr-settings-action', {
detail: { action: localOcrSettingsActionName }
}));
return;
}
var knowledgeRagAction = closestAction(e.target, '[data-knowledge-rag-action]');
if (knowledgeRagAction) {
e.preventDefault();
@@ -3042,14 +2987,14 @@ import { createSidebarPageSettingsRuntime } from './sidebar-page-settings-runtim
if (e.shiftKey || e.ctrlKey || e.metaKey) {
return;
}
if ((rowKind === 'document' || rowKind === 'index' || rowKind === 'markdown') && isLocalOcrMarkdownPath(localRelativePath)) {
document.documentElement.setAttribute('data-mnote-local-ocr-filetree-open', 'resource-tab');
var ocrResourceInput = {
if ((rowKind === 'document' || rowKind === 'index' || rowKind === 'markdown') && isRetiredOcrSidecarMarkdownPath(localRelativePath)) {
document.documentElement.setAttribute('data-mnote-retired-ocr-sidecar-filetree-open', 'resource-tab');
var retiredOcrSidecarResourceInput = {
path: localRelativePath,
title: fileTreeRowTitleForShortcut(fileRow, localRelativePath),
kind: 'markdown',
objectIdentity: 'local-ocr:' + localRelativePath,
assetId: 'local-ocr:' + localRelativePath,
objectIdentity: 'retired-ocr-sidecar:' + localRelativePath,
assetId: 'retired-ocr-sidecar:' + localRelativePath,
documentId: documentId || ownerDocumentId || null,
workspaceId: resolveWorkspaceId(fileRow),
sourceKind: 'local_folder',
@@ -3059,9 +3004,9 @@ import { createSidebarPageSettingsRuntime } from './sidebar-page-settings-runtim
paneRole: 'primary'
};
if (typeof window.__mnoteDocumentPaneRuntime?.openResourceInActiveTab === 'function') {
void window.__mnoteDocumentPaneRuntime.openResourceInActiveTab(ocrResourceInput);
void window.__mnoteDocumentPaneRuntime.openResourceInActiveTab(retiredOcrSidecarResourceInput);
} else {
void openLocalResourceInActiveTab(ocrResourceInput);
void openLocalResourceInActiveTab(retiredOcrSidecarResourceInput);
}
return;
}
@@ -3219,11 +3164,6 @@ import { createSidebarPageSettingsRuntime } from './sidebar-page-settings-runtim
renderPageSettingsPopover();
return;
}
var localOcrCheckbox = closestAction(event.target, '[data-local-ocr-option-checkbox="autoEnabled"]');
if (localOcrCheckbox instanceof HTMLInputElement) {
void persistLocalOcrAutoPreference(localOcrCheckbox.checked);
return;
}
var checkbox = closestAction(event.target, '[data-page-option-checkbox]');
if (checkbox instanceof HTMLInputElement) {
var key = checkbox.getAttribute('data-page-option-checkbox') || '';
@@ -194,6 +194,19 @@
var localRootUri = (params.get('rootUri') || '').trim()
|| (document.body instanceof HTMLElement ? (document.body.getAttribute('data-mnote-root-uri') || '').trim() : '');
if (localRootUri && 'EventSource' in window) {
var localBus = window.__mnoteLocalFolderEventBus;
if (localBus && typeof localBus.startLocalFolderWatcher === 'function') {
var localHandle = localBus.startLocalFolderWatcher({
rootUri: localRootUri,
workspaceId: bootstrap.workspaceId || resolveWorkspaceId(),
bootstrap: bootstrap
});
if (localHandle) {
window.__mnoteTreeLiveEventSource = localHandle;
applyStatus('connected');
return;
}
}
var url = new URL('/api/local-folder/events', window.location.origin);
url.searchParams.set('rootUri', localRootUri);
url.searchParams.set('treeLive', 'true');
+1 -7
View File
@@ -9,10 +9,9 @@ use axum::middleware::Next;
use axum::response::Response;
use axum::Router;
use control_plane::{ControlPlaneStore, SqliteControlPlaneStore};
use std::collections::BTreeMap;
use std::env;
use std::fs;
use std::sync::{Arc, RwLock};
use std::sync::Arc;
use tower_http::trace::TraceLayer;
use tracing::{error, warn};
@@ -140,8 +139,6 @@ pub struct AppState {
pub editor_actor: EditorRuntimeActor,
pub block_delta_tx: broadcast::Sender<serde_json::Value>,
pub stream_delta_tx: broadcast::Sender<serde_json::Value>,
pub local_ocr_job_tx: broadcast::Sender<serde_json::Value>,
pub local_ocr_active_jobs: Arc<RwLock<BTreeMap<String, serde_json::Value>>>,
pub acp_runtime: Arc<AcpRuntimeManager>,
pub buffer_store: BufferStore,
control_plane: Arc<SqliteControlPlaneStore>,
@@ -151,7 +148,6 @@ impl AppState {
pub fn new(config: AppConfig) -> Self {
let (block_delta_tx, _) = broadcast::channel(256);
let (stream_delta_tx, _) = broadcast::channel(256);
let (local_ocr_job_tx, _) = broadcast::channel(256);
let actor = EditorRuntimeActor::new();
actor.set_block_delta_tx(block_delta_tx.clone());
let buffer_store = BufferStore::new();
@@ -165,8 +161,6 @@ impl AppState {
editor_actor: actor,
block_delta_tx,
stream_delta_tx,
local_ocr_job_tx,
local_ocr_active_jobs: Arc::new(RwLock::new(BTreeMap::new())),
acp_runtime: Arc::new(AcpRuntimeManager::from_env()),
buffer_store,
control_plane,
@@ -130,6 +130,8 @@ fn compact_query_result_for_agent(payload: Value) -> Value {
"references": references,
"citations": citations,
"sourceScope": payload.get("sourceScope").cloned().unwrap_or(Value::Array(Vec::new())),
"sourceScopeMode": payload.get("sourceScopeMode").cloned().unwrap_or_else(|| json!("post_filter_mapped_references")),
"rawScopeFiltered": payload.get("rawScopeFiltered").cloned().unwrap_or(Value::Bool(false)),
"rawStatus": payload.pointer("/raw/status").cloned().unwrap_or(Value::Null),
"rawMessage": payload.pointer("/raw/message").cloned().unwrap_or(Value::Null),
"rawMetadata": payload.pointer("/raw/metadata").cloned().unwrap_or(Value::Null),
@@ -155,3 +157,43 @@ fn compact_reference_for_agent(reference: &Value) -> Value {
"citationUrl": reference.get("citationUrl").cloned().unwrap_or(Value::Null),
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn compact_query_result_marks_post_filter_scope_without_raw_chunks() {
let payload = json!({
"ok": true,
"provider": "lightrag",
"sourceScope": ["docs/a.md"],
"sourceScopeMode": "post_filter_mapped_references",
"rawScopeFiltered": false,
"raw": {
"status": "success",
"chunks": [{"content": "raw chunk must not be exposed to agent"}],
"metadata": {"count": 1}
},
"references": [{
"sourceRootRelativePath": "docs/a.md",
"quote": "scoped quote",
"locatorDegraded": true,
"citationMarkdown": "[来源定位降级:docs/a.md](/documents/local-md:docs~2Fa.md)"
}]
});
let compact = compact_query_result_for_agent(payload);
assert_eq!(
compact["sourceScopeMode"].as_str(),
Some("post_filter_mapped_references")
);
assert_eq!(compact["rawScopeFiltered"].as_bool(), Some(false));
assert!(compact.get("raw").is_none());
assert!(compact.get("chunks").is_none());
assert_eq!(
compact["references"][0]["sourceRootRelativePath"].as_str(),
Some("docs/a.md")
);
}
}
@@ -290,6 +290,7 @@ fn doc_find_tool() -> Value {
})
}
// 旧 evidence 工具仅保留为历史对照;当前 manifest() 不注册这些工具,资料库问答走 mnote.knowledge_rag.*。
#[allow(dead_code)]
fn evidence_search_tool() -> Value {
let mut properties = base_identity_properties();
@@ -434,13 +435,13 @@ fn knowledge_rag_query_tool() -> Value {
json!({
"type": "array",
"items": { "type": "string" },
"description": "可选 MNote workspace 相对路径范围;可传文件或目录返回 references 会限制在这些来源内"
"description": "可选 MNote workspace 相对路径范围;可传文件或目录。当前语义是 LightRAG provider 检索后,MNote 只过滤返回 referencesprovider raw 仍可能是全局结果"
}),
);
}
json!({
"name": "mnote.knowledge_rag.query",
"description": "向 LightRAG 资料库提问,返回 provider 原始结果和经 MNote registry 映射的引用。",
"description": "向 LightRAG 资料库提问,返回 provider 原始结果和经 MNote registry 映射、sourcePaths 后过滤的引用。",
"schemaVersion": TOOL_SCHEMA_VERSION,
"capabilityScope": ["knowledge_rag.read", "evidence.read"],
"status": "available",
@@ -477,6 +478,7 @@ fn knowledge_rag_open_reference_tool() -> Value {
})
}
// 旧 local index agent 工具仅保留为历史对照;当前 manifest() 不注册这些工具。
#[allow(dead_code)]
fn index_status_tool() -> Value {
let mut properties = base_identity_properties();
+5 -12
View File
@@ -1087,7 +1087,7 @@ mod tests {
}
#[tokio::test]
async fn evidence_search_route_prefers_sqlite_index() {
async fn evidence_search_route_returns_retired_guard() {
let root = std::env::temp_dir().join(format!(
"mnote-evidence-route-sqlite-{}-{}",
std::process::id(),
@@ -1182,22 +1182,15 @@ mod tests {
.await
.expect("response");
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(response.status(), StatusCode::GONE);
let body = to_bytes(response.into_body(), usize::MAX)
.await
.expect("body");
let payload: Value = serde_json::from_slice(&body).expect("json");
let first = payload["results"]
.as_array()
.and_then(|items| items.first())
.expect("sqlite evidence result");
assert_eq!(payload["ok"], false);
assert_eq!(
first["source"]["ownerDocumentPath"].as_str(),
Some("README.md")
);
assert_eq!(
first["source"]["schema"].as_str(),
Some("mnote.evidence_locator.v1")
payload["code"].as_str(),
Some("mnote_evidence_search_retired")
);
fs::remove_dir_all(&root).ok();
}
+1 -1
View File
@@ -3646,7 +3646,7 @@ mod tests {
assert!(!html.contains(r#"data-testid="mnote-workspace-empty-state""#));
assert!(!html.contains("当前还没有可显示的本地工作区"));
assert!(!html.contains(r#"data-testid="mnote-empty-create-page""#));
assert!(html.contains(r#""transport":"disabled""#));
assert!(html.contains(r#""transport":"tree-live-ws""#));
let _ = std::fs::remove_dir_all(&base);
}
@@ -10608,7 +10608,7 @@ mod tests {
}
#[tokio::test]
async fn page_ai_capabilities_expose_local_index_and_toggle_tools() {
async fn page_ai_capabilities_expose_knowledge_rag_and_toggle_tools() {
let _env_guard = env_lock().lock().expect("env lock");
let hermes_home = std::env::temp_dir().join(format!(
"mnote-web-ai-capability-policy-{}",
@@ -13223,20 +13223,16 @@ mod tests {
)
.await
.expect("response");
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
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["ok"], true);
assert_eq!(payload["sessions"][0]["sessionId"], "sess_1");
assert_eq!(payload["persistence"], "convex_acp_runtime_store");
assert_eq!(payload["ok"], false);
assert_eq!(payload["code"], "convex_retired");
let query_body = captured_body.lock().expect("captured convex body").clone();
assert_eq!(query_body["path"], "aiSessions:listRuntimeRuns");
assert_eq!(query_body["args"]["userId"], "user_1");
assert_eq!(query_body["args"]["workspaceId"], "ws_1");
assert_eq!(query_body["args"]["documentId"], "doc_1");
assert_eq!(query_body, Value::Null);
}
#[tokio::test]
@@ -13317,28 +13313,16 @@ mod tests {
)
.await
.expect("response");
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
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["ok"], true);
assert_eq!(payload["persistence"], "convex_acp_runtime_store");
assert_eq!(payload["session"]["sessionId"], "sess_1");
assert_eq!(payload["session"]["runs"][0]["runId"], "run_1");
assert_eq!(payload["runtime"]["runId"], "run_1");
assert_eq!(payload["events"][0]["eventType"], "message.delta");
assert_eq!(
payload["session"]["messages"].as_array().map(Vec::len),
Some(0)
);
assert_eq!(payload["ok"], false);
assert_eq!(payload["code"], "convex_retired");
let bodies = captured_bodies.lock().expect("captured convex bodies");
assert_eq!(bodies[0]["path"], "aiSessions:listRuntimeRuns");
assert_eq!(bodies[0]["args"]["userId"], "user_1");
assert_eq!(bodies[0]["args"]["sessionId"], "sess_1");
assert_eq!(bodies[1]["path"], "aiSessions:listRuntimeEvents");
assert_eq!(bodies[1]["args"]["runId"], "run_1");
assert!(bodies.is_empty());
}
#[tokio::test]
@@ -13403,15 +13387,13 @@ mod tests {
)
.await
.expect("response");
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
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["ok"], true);
assert_eq!(payload["resumed"], true);
assert_eq!(payload["resumeSource"], "convex_acp_runtime_store");
assert_eq!(payload["session"]["runs"][0]["runId"], "run_1");
assert_eq!(payload["ok"], false);
assert_eq!(payload["code"], "convex_retired");
}
#[tokio::test]
@@ -13486,7 +13468,7 @@ mod tests {
}
#[tokio::test]
async fn hermes_client_acp_session_search_returns_convex_snippets() {
async fn hermes_client_acp_session_search_legacy_convex_returns_retired_guard() {
let captured_body = Arc::new(Mutex::new(Value::Null));
let captured_for_route = Arc::clone(&captured_body);
let mock = axum::Router::new().route(
@@ -13548,21 +13530,16 @@ mod tests {
)
.await
.expect("response");
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
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["ok"], true);
assert_eq!(payload["results"][0]["sessionId"], "sess_1");
assert_eq!(payload["results"][0]["snippet"], "帮我总结化学页面");
assert_eq!(payload["ok"], false);
assert_eq!(payload["code"], "convex_retired");
let query_body = captured_body.lock().expect("captured convex body").clone();
assert_eq!(query_body["path"], "aiSessions:searchRuntimeSessions");
assert_eq!(query_body["args"]["userId"], "user_1");
assert_eq!(query_body["args"]["workspaceId"], "ws_1");
assert_eq!(query_body["args"]["q"], "化学");
assert_eq!(query_body["args"]["limit"], 5);
assert_eq!(query_body, Value::Null);
}
#[tokio::test]
@@ -14520,13 +14497,40 @@ mod tests {
&"/api/hermes/client/runs".parse().expect("uri"),
&headers,
);
let root = std::env::temp_dir().join(format!(
"mnote-local-agent-run-receipt-{}",
std::process::id()
));
let _ = std::fs::remove_dir_all(&root);
std::fs::create_dir_all(&root).expect("create root");
std::fs::write(root.join("README.md"), "# Readme\nold\n").expect("write readme");
std::fs::write(root.join("Other.md"), "# Other\nold\n").expect("write other");
let root_uri = format!("file://{}", root.display());
let payload = json!({
"workspaceId": "local-workspace-1",
"documentId": "local-md:README.md",
"sessionId": "sess_local_1",
"rootUri": "file:///tmp/mnote-agent-run-receipt",
"actorId": "user_1"
"rootUri": root_uri,
"actorId": "user_1",
"contextRefs": [{
"kind": "folder",
"rootUri": root_uri,
"relativePath": ""
}],
"targetPackage": {
"schema": "mnote.agent_target_package.v1",
"allowedFiles": ["README.md"],
"currentFile": {
"relativePath": "README.md"
}
}
});
let before = local_agent_audit_collect_snapshot_for_payload(&payload, None)
.expect("before allowed-files snapshot");
std::fs::write(root.join("README.md"), "# Readme\nnew\n").expect("modify readme");
std::fs::write(root.join("Other.md"), "# Other\nnew\n").expect("modify other");
let after = local_agent_audit_collect_snapshot_for_payload(&payload, Some(&before))
.expect("after allowed-files snapshot");
let changed_files = json!([
{
"path": "README.md",
@@ -14541,8 +14545,8 @@ mod tests {
"reasonix",
"completed",
changed_files,
None,
None,
Some(&before),
Some(&after),
false,
);
let receipt = &event["agentRunReceipt"];
@@ -14553,6 +14557,11 @@ mod tests {
assert_eq!(receipt["status"], "completed");
assert_eq!(receipt["changedFiles"][0]["path"], "README.md");
assert_eq!(receipt["refresh"]["touchesCurrentFile"], true);
assert_eq!(event["auditScope"]["scope"], "allowed_files");
assert_eq!(event["auditScope"]["fileCount"], 1);
assert_eq!(receipt["auditScope"]["scope"], "allowed_files");
assert_eq!(receipt["auditScope"]["fileCount"], 1);
let _ = std::fs::remove_dir_all(root);
}
#[test]
@@ -15066,7 +15075,7 @@ mod tests {
"/api/hermes/client/profile-memory?profile=chemist",
None,
),
("GET", "/api/hermes/client/skills?profile=chemist", None),
("GET", "/api/hermes/client/skills", None),
(
"PUT",
"/api/hermes/client/profiles/active",
@@ -15107,7 +15116,7 @@ mod tests {
);
let request = Request::builder()
.method("GET")
.uri("/api/hermes/client/skills?profile=chemist")
.uri("/api/hermes/client/skills")
.header("x-mnote-actor-id", "user_1")
.body(Body::empty())
.expect("request");
@@ -3099,7 +3099,7 @@ mod tests {
assert!(markdown_edit["description"]
.as_str()
.expect("description")
.contains("兼容"));
.contains("compat"));
assert!(markdown_edit["description"]
.as_str()
.expect("description")
@@ -6432,7 +6432,7 @@ mod tests {
"toolCallId": "call_1",
"traceId": "trace_1",
"idempotencyKey": "idem_markdown_normalized_1",
"dryRun": false,
"dryRun": true,
"args": {
"operations": [{"search": "第二 段", "replace": "测试123"}]
}
@@ -6444,25 +6444,23 @@ mod tests {
.await
.expect("response");
assert_eq!(response.status(), StatusCode::OK);
let status = response.status();
let body = to_bytes(response.into_body(), usize::MAX)
.await
.expect("body");
assert_eq!(status, StatusCode::OK, "{}", String::from_utf8_lossy(&body));
let payload: Value = serde_json::from_slice(&body).expect("json");
assert_eq!(payload["result"]["operationsApplied"], 1);
// 7-27: 新路径 changedBlocks 格式验证
let changed = payload["result"]["applyResult"]["changedBlocks"]
let changed = payload["result"]["applyResult"]["diff"]
.as_array()
.expect("changedBlocks");
assert!(!changed.is_empty(), "changedBlocks should not be empty");
.expect("diff");
assert!(!changed.is_empty(), "diff should not be empty");
assert_eq!(
payload["result"]["applyResult"]["changedBlocks"][0]["blockId"],
payload["result"]["applyResult"]["diff"][0]["blockId"],
"p_2"
);
assert_eq!(
payload["result"]["applyResult"]["changedBlocks"][0]["op"],
"replace"
);
assert_eq!(payload["result"]["applyResult"]["diff"][0]["op"], "replace");
}
#[tokio::test]
+273 -26
View File
@@ -24,6 +24,7 @@ const DEFAULT_LIGHTRAG_ENDPOINT: &str = "http://127.0.0.1:9621";
const DEFAULT_LIGHTRAG_INPUT_DIR: &str = "/mnt/Data1T/Mnote_data/lightrag/inputs";
const DEFAULT_LIGHTRAG_WORKING_DIR: &str = "/mnt/Data1T/Mnote_data/lightrag/rag_storage";
const MAX_INGEST_SOURCES_PER_REQUEST: usize = 200;
const SOURCE_SCOPE_MODE_POST_FILTER: &str = "post_filter_mapped_references";
const KNOWLEDGE_RAG_SOURCE_EXTENSIONS: &[&str] = &[
"md", "markdown", "txt", "pdf", "doc", "docx", "ppt", "pptx", "xls", "xlsx", "csv", "png",
"jpg", "jpeg", "webp", "gif", "bmp", "tif", "tiff",
@@ -154,6 +155,8 @@ pub(crate) fn knowledge_rag_source_statuses(
}
if provider_status == "failed" {
statuses.failed_paths.insert(path.to_string());
} else if provider_status == "delete_retry_required" {
statuses.failed_paths.insert(path.to_string());
} else if provider_status == "delete_submitted"
|| (entry.deleted_at_ms.is_some() && entry.light_rag_doc_id.is_some())
{
@@ -473,6 +476,8 @@ pub async fn query_rag(
"schema": "mnote.knowledge_rag.query_result.v1",
"provider": "lightrag",
"sourceScope": source_scope,
"sourceScopeMode": SOURCE_SCOPE_MODE_POST_FILTER,
"rawScopeFiltered": false,
"raw": raw,
"references": references,
})))
@@ -735,7 +740,13 @@ pub async fn prune_registry(
}
fn knowledge_rag_registry_entry_prunable(entry: &KnowledgeRagSourceRegistryEntry) -> bool {
if entry.light_rag_status.as_deref() == Some("delete_submitted") {
if matches!(
entry.light_rag_status.as_deref(),
Some("delete_submitted" | "delete_retry_required")
) {
return false;
}
if entry.light_rag_doc_id.is_some() && (entry.deleted_at_ms.is_some() || entry.stale) {
return false;
}
entry.deleted_at_ms.is_some()
@@ -746,6 +757,23 @@ fn knowledge_rag_registry_entry_prunable(entry: &KnowledgeRagSourceRegistryEntry
)
}
fn knowledge_rag_provider_delete_confirmed(entry: &KnowledgeRagSourceRegistryEntry) -> bool {
entry.light_rag_doc_id.is_some()
&& (entry.deleted_at_ms.is_some()
|| entry.stale
|| matches!(
entry.light_rag_status.as_deref(),
Some("delete_submitted" | "delete_retry_required")
))
}
fn mark_registry_entry_delete_completed(entry: &mut KnowledgeRagSourceRegistryEntry, now: u128) {
entry.light_rag_doc_id = None;
entry.indexed_at_ms = None;
entry.light_rag_status = Some("delete_completed".into());
entry.updated_at_ms = now;
}
async fn sync_registry_with_documents(
root_path: &Path,
registry: &mut KnowledgeRagSourceRegistry,
@@ -758,22 +786,28 @@ async fn sync_registry_with_documents(
let by_file_path = lightrag_documents_by_file_path(&docs);
let now = now_ms();
let mut changed = false;
let mut retry_doc_ids = Vec::new();
for entry in &mut registry.entries {
if let Some(doc) = document_for_registry_entry(&by_file_path, entry) {
if let Some(id) = doc.get("id").and_then(Value::as_str) {
entry.light_rag_doc_id = Some(id.to_string());
}
if let Some(status) = doc.get("status").and_then(Value::as_str) {
entry.light_rag_status = Some(
if entry.deleted_at_ms.is_some() {
"delete_submitted"
} else {
status
let delete_pending = matches!(
entry.light_rag_status.as_deref(),
Some("delete_submitted" | "delete_retry_required")
) || entry.deleted_at_ms.is_some();
if delete_pending {
if let Some(doc_id) = entry.light_rag_doc_id.clone() {
retry_doc_ids.push(doc_id);
}
.to_string(),
);
if entry.light_rag_status.is_none() {
entry.light_rag_status = Some("delete_submitted".into());
}
if entry.deleted_at_ms.is_none()
} else if let Some(status) = doc.get("status").and_then(Value::as_str) {
entry.light_rag_status = Some(status.to_string());
}
if !entry.stale
&& entry.deleted_at_ms.is_none()
&& doc.get("status").and_then(Value::as_str) == Some("processed")
{
entry.indexed_at_ms.get_or_insert(now);
@@ -781,11 +815,8 @@ async fn sync_registry_with_documents(
}
entry.updated_at_ms = now;
changed = true;
} else if entry.deleted_at_ms.is_some() && entry.light_rag_doc_id.is_some() {
entry.light_rag_doc_id = None;
entry.indexed_at_ms = None;
entry.light_rag_status = Some("delete_completed".into());
entry.updated_at_ms = now;
} else if knowledge_rag_provider_delete_confirmed(entry) {
mark_registry_entry_delete_completed(entry, now);
changed = true;
} else if entry.deleted_at_ms.is_some()
&& entry.light_rag_doc_id.is_none()
@@ -801,9 +832,12 @@ async fn sync_registry_with_documents(
changed = true;
}
}
let stale_doc_ids = sync_registry_source_state(registry, now)?;
let mut stale_doc_ids = sync_registry_source_state(registry, now)?;
stale_doc_ids.extend(retry_doc_ids);
stale_doc_ids.sort();
stale_doc_ids.dedup();
if !stale_doc_ids.is_empty() {
let _ = lightrag_json(
let delete_result = lightrag_json(
reqwest::Method::DELETE,
"/documents/delete_document",
Some(json!({
@@ -815,6 +849,23 @@ async fn sync_registry_with_documents(
context,
)
.await;
for entry in &mut registry.entries {
if entry
.light_rag_doc_id
.as_deref()
.is_some_and(|doc_id| stale_doc_ids.iter().any(|item| item == doc_id))
{
entry.light_rag_status = Some(
if delete_result.is_err() {
"delete_retry_required"
} else {
"delete_submitted"
}
.into(),
);
entry.updated_at_ms = now;
}
}
changed = true;
}
if changed {
@@ -897,8 +948,8 @@ fn sync_registry_source_state(
if !source_path.exists() {
entry.stale = true;
entry.deleted_at_ms = Some(now);
entry.light_rag_doc_id = None;
entry.indexed_at_ms = None;
entry.light_rag_status = Some("delete_submitted".into());
entry.updated_at_ms = now;
stale_doc_ids.push(doc_id);
continue;
@@ -907,8 +958,8 @@ fn sync_registry_source_state(
if current_hash != entry.source_hash {
entry.stale = true;
entry.source_hash = current_hash;
entry.light_rag_doc_id = None;
entry.indexed_at_ms = None;
entry.light_rag_status = Some("delete_submitted".into());
entry.updated_at_ms = now;
stale_doc_ids.push(doc_id);
}
@@ -989,6 +1040,10 @@ fn mapped_references(
.get("deleted")
.and_then(Value::as_bool)
.unwrap_or(false)
&& !reference
.get("unmapped")
.and_then(Value::as_bool)
.unwrap_or(false)
})
.collect()
}
@@ -1127,6 +1182,7 @@ fn map_reference_plan(
"sourceId": entry.map(|entry| entry.source_id.clone()),
"sourcePath": source_path,
"sourceRootRelativePath": source_root_relative_path,
"unmapped": entry.is_none(),
"stale": entry.is_some_and(|entry| entry.stale),
"deleted": entry.is_some_and(|entry| entry.deleted_at_ms.is_some()),
"locatorDegraded": locator_degraded,
@@ -1346,15 +1402,11 @@ fn find_lightrag_sidecar_block(
}
let path = sidecar_blocks_path(entry)?;
let content = fs::read_to_string(path).ok()?;
let mut first_positioned_block = None;
for line in content.lines() {
let block = serde_json::from_str::<Value>(line).ok()?;
if block.get("positions").and_then(Value::as_array).is_none() {
continue;
}
if first_positioned_block.is_none() {
first_positioned_block = Some(block.clone());
}
let block_text = block
.get("content")
.and_then(Value::as_str)
@@ -1369,7 +1421,7 @@ fn find_lightrag_sidecar_block(
return Some(block);
}
}
first_positioned_block
None
}
fn normalize_text_for_match(value: &str) -> String {
@@ -2234,17 +2286,85 @@ mod tests {
let stale_doc_ids = sync_registry_source_state(&mut registry, 42).expect("sync");
assert_eq!(stale_doc_ids, vec!["doc-changed", "doc-missing"]);
assert_eq!(registry.entries[0].deleted_at_ms, Some(42));
assert_eq!(registry.entries[0].light_rag_doc_id, None);
assert_eq!(
registry.entries[0].light_rag_doc_id.as_deref(),
Some("doc-missing")
);
assert_eq!(
registry.entries[0].light_rag_status.as_deref(),
Some("delete_submitted")
);
assert_eq!(registry.entries[0].indexed_at_ms, None);
assert!(registry.entries[0].stale);
assert_eq!(registry.entries[1].deleted_at_ms, None);
assert_eq!(registry.entries[1].light_rag_doc_id, None);
assert_eq!(
registry.entries[1].light_rag_doc_id.as_deref(),
Some("doc-changed")
);
assert_eq!(
registry.entries[1].light_rag_status.as_deref(),
Some("delete_submitted")
);
assert_eq!(registry.entries[1].indexed_at_ms, None);
assert!(registry.entries[1].stale);
assert_ne!(registry.entries[1].source_hash, "mnote-fnv64:old");
let _ = fs::remove_dir_all(root);
}
#[test]
fn provider_delete_confirmation_clears_doc_id_for_deleted_or_stale_entries() {
let root = temp_root("mnote-knowledge-rag-delete-confirmed");
let mut deleted = test_registry_entry(
&root,
"deleted.pdf",
Some("doc-deleted"),
Some("delete_submitted"),
Some(2),
Some(3),
true,
);
let mut changed = test_registry_entry(
&root,
"changed.pdf",
Some("doc-changed"),
Some("delete_submitted"),
Some(2),
None,
true,
);
let active = test_registry_entry(
&root,
"active.pdf",
Some("doc-active"),
Some("processed"),
Some(2),
None,
false,
);
assert!(knowledge_rag_provider_delete_confirmed(&deleted));
mark_registry_entry_delete_completed(&mut deleted, 42);
assert_eq!(deleted.light_rag_doc_id, None);
assert_eq!(deleted.indexed_at_ms, None);
assert_eq!(
deleted.light_rag_status.as_deref(),
Some("delete_completed")
);
assert!(knowledge_rag_provider_delete_confirmed(&changed));
mark_registry_entry_delete_completed(&mut changed, 43);
assert_eq!(changed.light_rag_doc_id, None);
assert_eq!(
changed.light_rag_status.as_deref(),
Some("delete_completed")
);
assert!(!knowledge_rag_provider_delete_confirmed(&active));
assert_eq!(active.light_rag_doc_id.as_deref(), Some("doc-active"));
let _ = fs::remove_dir_all(root);
}
#[test]
fn source_statuses_distinguish_indexed_processing_failed_and_deleted() {
let root = temp_root("mnote-knowledge-rag-source-statuses");
@@ -2372,9 +2492,29 @@ mod tests {
);
let failed =
test_registry_entry(&root, "failed.pdf", None, Some("failed"), None, None, false);
let retry = test_registry_entry(
&root,
"retry.pdf",
Some("doc-retry"),
Some("delete_retry_required"),
Some(2),
Some(3),
true,
);
let stale_with_doc = test_registry_entry(
&root,
"stale.pdf",
Some("doc-stale"),
Some("processed"),
Some(2),
None,
true,
);
assert!(!knowledge_rag_registry_entry_prunable(&active));
assert!(!knowledge_rag_registry_entry_prunable(&deleting));
assert!(!knowledge_rag_registry_entry_prunable(&retry));
assert!(!knowledge_rag_registry_entry_prunable(&stale_with_doc));
assert!(knowledge_rag_registry_entry_prunable(&removed));
assert!(knowledge_rag_registry_entry_prunable(&failed));
@@ -2466,6 +2606,42 @@ mod tests {
);
}
#[test]
fn mapped_references_filters_unmapped_provider_references() {
let registry = KnowledgeRagSourceRegistry {
schema: REGISTRY_SCHEMA.to_string(),
workspace_id: "ws".into(),
root_uri: "file:///tmp/root".into(),
updated_at_ms: 1,
entries: vec![],
};
let raw = json!({
"data": {
"references": [{"reference_id":"1","file_path":"orphan.pdf"}],
"chunks": [{
"reference_id":"1",
"chunk_id":"orphan-chunk",
"file_path":"orphan.pdf",
"content":"orphan provider chunk"
}]
}
});
let mapped = mapped_references(&raw, &registry, "file:///tmp/root", Path::new("/tmp/root"));
assert!(
mapped.is_empty(),
"unmapped provider references must not become MNote citations"
);
let plan = map_reference_plan(
&json!({"file_path":"orphan.pdf","chunk_id":"orphan-chunk"}),
&registry,
"file:///tmp/root",
Path::new("/tmp/root"),
);
assert_eq!(plan["unmapped"], true);
assert_eq!(plan["locatorDegraded"], true);
}
#[test]
fn query_ranking_prefers_exact_source_and_quote_match() {
let mut references = vec![
@@ -2597,6 +2773,77 @@ mod tests {
let _ = fs::remove_dir_all(root);
}
#[test]
fn locator_degrades_when_sidecar_quote_does_not_match() {
let _guard = env_lock().lock().expect("env lock");
let root = temp_root("mnote-knowledge-rag-sidecar-no-match");
fs::create_dir_all(root.join("docs")).expect("docs");
fs::write(root.join("docs").join("Host.md"), "# Host\n").expect("host");
fs::write(root.join("docs").join("scan.pdf"), b"pdf").expect("pdf");
let input_dir = root.join("inputs");
let parsed_dir = input_dir
.join("__parsed__")
.join("mnote-hash-scan.pdf.parsed");
fs::create_dir_all(&parsed_dir).expect("parsed dir");
fs::write(
parsed_dir.join("mnote-hash-scan.blocks.jsonl"),
[
r#"{"type":"meta","blocks":1}"#,
r##"{"type":"content","blockid":"block1","content":"This block is not the returned quote.","positions":[{"type":"bbox","anchor":"9","range":[1.0,2.0,3.0,4.0]}]}"##,
]
.join("\n"),
)
.expect("blocks");
std::env::set_var("MNOTE_LIGHTRAG_INPUT_DIR", &input_dir);
let registry = KnowledgeRagSourceRegistry {
schema: REGISTRY_SCHEMA.to_string(),
workspace_id: "ws".into(),
root_uri: "file:///tmp/root".into(),
updated_at_ms: 1,
entries: vec![KnowledgeRagSourceRegistryEntry {
source_id: "src1".into(),
workspace_id: "ws".into(),
root_uri: "file:///tmp/root".into(),
source_path: root.join("docs").join("scan.pdf").display().to_string(),
source_root_relative_path: "docs/scan.pdf".into(),
source_hash: "mnote-fnv64:1".into(),
light_rag_doc_id: Some("doc1".into()),
light_rag_status: Some("processed".into()),
light_rag_file_path: "mnote-hash-scan.pdf".into(),
symlink_path: input_dir.join("mnote-hash-scan.pdf").display().to_string(),
parser_hint: None,
indexed_at_ms: Some(2),
deleted_at_ms: None,
stale: false,
updated_at_ms: 2,
}],
};
let mapped = map_reference_plan(
&json!({
"file_path": "mnote-hash-scan.pdf",
"chunk_id": "doc1-chunk-000",
"chunks": [{"chunk_id": "doc1-chunk-000", "content": "A different quote should not get page or bbox."}]
}),
&registry,
"file:///tmp/root",
&root,
);
assert!(mapped["locator"].is_null());
assert_eq!(mapped["locatorDegraded"], true);
assert!(mapped["citationUrl"]
.as_str()
.is_some_and(|url| url.contains("resourceTab=")));
assert!(mapped["citationMarkdown"]
.as_str()
.unwrap()
.contains("来源定位降级"));
std::env::remove_var("MNOTE_LIGHTRAG_INPUT_DIR");
let _ = fs::remove_dir_all(root);
}
#[test]
fn lightrag_paths_prefer_source_env_over_legacy_process_env() {
let _guard = env_lock().lock().expect("env lock");
@@ -23,7 +23,7 @@ use std::hash::{Hash, Hasher};
use std::path::PathBuf;
use std::pin::Pin;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use tokio::sync::broadcast::{error::RecvError, Receiver};
use tokio::sync::broadcast::error::RecvError;
use tokio::time::timeout;
type BoxedEventStream =
@@ -85,7 +85,6 @@ async fn build_document_events_stream(
.local_folder_watcher_registry()
.subscribe(&canonical_root)
.map_err(|error| WebError::internal(error).with_context(&context))?;
let local_ocr_job_rx = state.local_ocr_job_tx.subscribe();
let initial = json!({
"sourceKind": "local_folder",
@@ -95,36 +94,16 @@ async fn build_document_events_stream(
"revision": system_time_ms(SystemTime::now()),
});
let stream = stream::unfold(
(
Some(initial),
subscription,
document_relative_path,
query.root_uri.clone(),
local_ocr_job_rx,
),
|(
initial,
mut subscription,
document_relative_path,
root_uri,
mut local_ocr_job_rx,
)| async move {
(Some(initial), subscription, document_relative_path),
|(initial, mut subscription, document_relative_path)| async move {
if let Some(payload) = initial {
return Some((
Ok(stream_event("ready", &payload)),
(
None,
subscription,
document_relative_path,
root_uri,
local_ocr_job_rx,
),
(None, subscription, document_relative_path),
));
}
loop {
tokio::select! {
watcher_result = subscription.receiver.recv() => {
match watcher_result {
match subscription.receiver.recv().await {
Ok(payload) => {
if let Some(expected) = document_relative_path.as_deref() {
if !document_event_targets_relative_path(&payload, expected) {
@@ -133,43 +112,13 @@ async fn build_document_events_stream(
}
return Some((
Ok(stream_event("change", &payload)),
(
None,
subscription,
document_relative_path,
root_uri,
local_ocr_job_rx,
),
(None, subscription, document_relative_path),
));
}
Err(RecvError::Lagged(_)) => continue,
Err(RecvError::Closed) => return None,
}
}
ocr_result = recv_matching_ocr_event(&mut local_ocr_job_rx, &root_uri) => {
match ocr_result {
Some(payload) => {
if let Some(expected) = document_relative_path.as_deref() {
if !document_event_targets_relative_path(&payload, expected) {
continue;
}
}
return Some((
Ok(stream_event("local_ocr.job.updated", &payload)),
(
None,
subscription,
document_relative_path,
root_uri,
local_ocr_job_rx,
),
));
}
None => continue,
}
}
}
}
},
)
.boxed();
@@ -177,20 +126,6 @@ async fn build_document_events_stream(
Ok((HeaderMap::new(), stream))
}
async fn recv_matching_ocr_event(rx: &mut Receiver<Value>, root_uri: &str) -> Option<Value> {
loop {
match rx.recv().await {
Ok(payload) => {
if payload.get("rootUri").and_then(Value::as_str) == Some(root_uri) {
return Some(payload);
}
}
Err(RecvError::Lagged(_)) => continue,
Err(RecvError::Closed) => return None,
}
}
}
/// Build the tree live stream: emits `snapshot` (initial) and `resync` (on watcher change)
/// with full sidebar + file tree projections.
///
File diff suppressed because it is too large Load Diff
@@ -1217,11 +1217,8 @@ pub(crate) fn refresh_local_search_index_for_path_with_settings(
if local_ocr::is_local_ocr_sidecar_relative_path(&relative_path) {
index.built_at = now_ms();
write_local_search_index_json(root_path, &index)?;
if included {
refresh_evidence_sqlite_index_for_path(root_path, &index, &relative_path)?;
} else {
let _ = included;
remove_evidence_sqlite_index_for_path(root_path, &index, &relative_path)?;
}
return Ok(json!({
"version": index.version,
"rootUri": index.root_uri,
@@ -1948,13 +1945,6 @@ fn index_relative_path_is_included(relative_path: &str, include_paths: &[String]
})
}
pub(crate) fn local_index_relative_path_is_included(
relative_path: &str,
settings: &LocalIndexSettings,
) -> bool {
index_relative_path_is_included(relative_path, &settings.include_paths)
}
fn collect_markdown_documents(
root_path: &Path,
current: &Path,
@@ -2613,84 +2603,6 @@ fn parsed_resource_id(resource: &LocalSearchResource) -> String {
format!("{}#parse", resource.resource_id)
}
#[allow(dead_code)]
fn insert_ocr_evidence(
connection: &Connection,
root_path: &Path,
index: &LocalSearchIndex,
entry: &local_ocr::OcrIndexEntry,
) -> Result<(), WebError> {
if entry.status != "done" {
return Ok(());
}
let ocr_path = root_path.join(&entry.ocr_root_relative_path);
let Ok(markdown) = fs::read_to_string(&ocr_path) else {
return Ok(());
};
let body = local_ocr::strip_ocr_frontmatter(&markdown);
let source_map = path_source_map_path(&entry.ocr_root_relative_path).unwrap_or_default();
let resource_id = format!(
"{}#ocr:{}",
entry.owner_document_id, entry.source_root_relative_path
);
let artifact = ocr_parsed_artifact(entry, &source_map);
if !source_map.is_empty() {
let source_map_path = root_path.join(&source_map);
if let Ok(source_map_content) = fs::read_to_string(&source_map_path) {
if let Ok(resource_source_map) =
serde_json::from_str::<ResourceSourceMap>(&source_map_content)
{
return insert_source_map_artifact_evidence(
connection,
index,
&resource_id,
&artifact,
&resource_source_map,
body,
);
}
}
}
insert_evidence_resource_from_artifact(connection, &resource_id, &artifact)?;
let locator = json!({
"schema": "mnote.evidence_locator.v1",
"rootUri": index.root_uri,
"ownerDocumentId": entry.owner_document_id,
"ownerDocumentPath": entry.owner_document_path,
"resourcePath": entry.source_root_relative_path,
"resourceKind": evidence_resource_kind_for_path(&entry.source_root_relative_path),
"sourceMapPath": source_map,
"openAction": {
"actionType": "mnote.open_resource_locator",
"url": format!("/documents/{}?sourceKind=local_folder&rootUri={}", entry.owner_document_id, encode_query_component(&index.root_uri)),
"params": {
"resourcePath": entry.source_root_relative_path,
"sourceMapPath": source_map
}
}
});
insert_evidence_block(connection, &resource_id, &resource_id, body, locator)
}
#[allow(dead_code)]
fn ocr_parsed_artifact(
entry: &local_ocr::OcrIndexEntry,
source_map_root_relative_path: &str,
) -> ParsedResourceArtifact {
ParsedResourceArtifact {
schema: PARSED_RESOURCE_ARTIFACT_SCHEMA.into(),
provider: entry.provider.clone(),
model_version: Some(entry.model_version.clone()),
owner_document_id: entry.owner_document_id.clone(),
owner_document_path: entry.owner_document_path.clone(),
source_root_relative_path: entry.source_root_relative_path.clone(),
source_hash: format!("size:{}:mtime:{}", entry.source_size, entry.source_mtime_ms),
artifact_root_relative_path: entry.ocr_root_relative_path.clone(),
source_map_root_relative_path: source_map_root_relative_path.to_string(),
updated_at_ms: entry.updated_at_ms as u64,
}
}
fn insert_source_map_artifact_evidence(
connection: &Connection,
index: &LocalSearchIndex,
@@ -3479,35 +3391,6 @@ fn local_search_resource_matches(
}
}
#[allow(dead_code)]
fn local_search_ocr_matches(
entry: &local_ocr::OcrIndexEntry,
body: &str,
query: &str,
title_only: bool,
exact: bool,
) -> bool {
if query.is_empty() {
return false;
}
let haystack = if title_only {
normalize_search_text(&format!(
"{}\n{}",
entry.source_root_relative_path, entry.ocr_root_relative_path
))
} else {
normalize_search_text(&format!(
"{}\n{}\n{}",
entry.source_root_relative_path, entry.ocr_root_relative_path, body
))
};
if exact {
haystack.contains(query)
} else {
token_search_match(&haystack, query)
}
}
fn local_search_document_projection(
document: &LocalSearchDocument,
root_uri: &str,
@@ -3557,43 +3440,6 @@ fn local_search_resource_projection(resource: &LocalSearchResource, root_uri: &s
})
}
#[allow(dead_code)]
fn local_search_ocr_projection(
entry: &local_ocr::OcrIndexEntry,
body: &str,
root_uri: &str,
query: &str,
) -> Value {
let title = Path::new(&entry.owner_document_path)
.file_stem()
.and_then(|value| value.to_str())
.unwrap_or("OCR")
.to_string();
json!({
"id": format!("{}#ocr:{}", entry.owner_document_id, entry.source_root_relative_path),
"documentId": entry.owner_document_id,
"title": title,
"path": entry.owner_document_path,
"resourceType": "markdown",
"sourceKind": "local_folder",
"rootUri": root_uri,
"hasOcr": true,
"snippet": ocr_search_snippet(body, query),
"ocrEvidence": {
"sourceRootRelativePath": entry.source_root_relative_path,
"ocrRootRelativePath": entry.ocr_root_relative_path,
"provider": entry.provider,
"status": entry.status,
},
"updatedAt": entry.updated_at_ms,
"publicPath": format!(
"/documents/{}?sourceKind=local_folder&rootUri={}",
entry.owner_document_id,
encode_query_component(root_uri),
)
})
}
fn encode_query_component(value: &str) -> String {
let mut encoded = String::with_capacity(value.len());
for byte in value.as_bytes() {
@@ -5143,6 +4989,10 @@ mod tests {
.documents
.iter()
.any(|document| document.path == "docs/Page.ocr/photo.png-704905.ocr.md"));
let refreshed_evidence = query_evidence_sqlite_results(&root, "OCR-hash-token", None, 10)
.expect("query refreshed evidence")
.unwrap_or_default();
assert!(refreshed_evidence.is_empty());
let _ = fs::remove_dir_all(&root);
}
@@ -5157,6 +5007,8 @@ mod tests {
"---\ntitle: Child\n---\n# Child\nOriginalToken body.\n",
)
.expect("write child");
write_local_index_settings(&root, &[String::from(".")], None, None, None, None)
.expect("settings");
let first_projection = query_local_search_index(
&root,
@@ -5421,7 +5273,7 @@ mod tests {
#[test]
#[cfg(unix)]
fn evidence_index_parses_resource_body_with_liteparse_sidecar() {
fn evidence_index_does_not_parse_resource_body_with_retired_liteparse_sidecar() {
use std::os::unix::fs::PermissionsExt;
let _guard = env_lock().lock().expect("env lock");
@@ -5479,23 +5331,11 @@ JSON
let results = query_evidence_sqlite_results(&root, "ResourceBodyToken", None, 10)
.expect("sqlite query")
.expect("sqlite exists");
let hit = results
assert!(
!results
.iter()
.find(|result| result.quote.contains("ResourceBodyToken"))
.expect("parsed resource body hit");
assert_eq!(
hit.source.owner_document_id, "local-md:docs~2FPage.md",
"资源正文证据应归属引用它的 owner Markdown"
);
assert_eq!(
hit.source.resource_path.as_deref(),
Some("docs/Page.assets/spec.pdf")
);
assert_eq!(hit.source.page, Some(2));
assert!(hit.source.bbox.is_some());
assert_eq!(
hit.source.source_map_path.as_deref(),
Some("docs/Page.ocr/spec.pdf.source-map.json")
.any(|result| result.quote.contains("ResourceBodyToken")),
"LiteParse resource body fallback is retired from active evidence indexing"
);
let resource_scoped = query_evidence_sqlite_results_with_mode(
&root,
@@ -5506,18 +5346,16 @@ JSON
)
.expect("resource scoped sqlite query")
.expect("sqlite exists");
assert_eq!(resource_scoped.len(), 1);
assert_eq!(
resource_scoped[0].source.resource_path.as_deref(),
Some("docs/Page.assets/spec.pdf"),
"页面内搜索打开资源时应只限制到当前资源,而不是它的 owner Markdown"
assert!(
resource_scoped.is_empty(),
"retired LiteParse sidecar must not create resource-scoped evidence hits"
);
assert!(root
assert!(!root
.join("docs")
.join("Page.ocr")
.join("spec.pdf.parse.md")
.exists());
assert!(root
assert!(!root
.join("docs")
.join("Page.ocr")
.join("spec.pdf.source-map.json")
+33 -430
View File
@@ -1,28 +1,11 @@
use crate::app::AppState;
use crate::context::RequestContext;
use crate::error::WebError;
use crate::routes::command_support::{build_runtime_command_plan, runtime_context};
use crate::transport::convex::{
execute_retired_mutation_by_name, execute_retired_query_by_name,
persist_runtime_command_artifacts,
};
use axum::extract::{Multipart, Query, State};
use axum::http::{header, HeaderMap};
use axum::extract::Query;
use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use axum::{Extension, Json};
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
use bridge_runtime::{
build_runtime_command_artifact_plan, RuntimeActorWire, RuntimeCommandEnvelopeWire,
RuntimeSourceWire, RuntimeTargetWire,
};
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{SystemTime, UNIX_EPOCH};
use time::format_description::well_known::Rfc3339;
use time::OffsetDateTime;
static UPLOAD_COUNTER: AtomicU64 = AtomicU64::new(1);
use serde_json::json;
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
@@ -69,242 +52,6 @@ pub struct FileTreeUploadTargetPlan {
target_sub_path: Option<String>,
}
#[derive(Debug)]
struct UploadFile {
name: String,
content_type: String,
bytes: Vec<u8>,
}
async fn current_user_id(state: &AppState, context: &RequestContext) -> String {
let actor_id = context.auth.actor_id.trim();
if !actor_id.is_empty() && actor_id != "anonymous" {
return actor_id.to_string();
}
if let Ok(user) = execute_retired_query_by_name(
state.config(),
context,
"users:currentUser",
json!({}),
context.workspace.workspace_id.as_deref(),
"media_current_user",
)
.await
{
for key in ["_id", "id"] {
if let Some(user_id) = user
.get(key)
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
{
return user_id.to_string();
}
}
}
state.config().dev_user_id.clone()
}
fn now_millis() -> u128 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_millis()
}
fn new_asset_id() -> String {
format!(
"asset_{}_{}",
now_millis(),
UPLOAD_COUNTER.fetch_add(1, Ordering::Relaxed)
)
}
fn now_iso_like() -> String {
OffsetDateTime::now_utc()
.format(&Rfc3339)
.unwrap_or_else(|_| "1970-01-01T00:00:00Z".into())
}
fn asset_type(mime: &str) -> &'static str {
if mime.starts_with("image/") {
"image"
} else if mime.starts_with("video/") {
"video"
} else if mime.starts_with("audio/") {
"audio"
} else {
"file"
}
}
async fn record_upload_artifacts(
state: &AppState,
context: &RequestContext,
user_id: &str,
workspace_id: &str,
document_id: &str,
asset_id: &str,
file: &UploadFile,
asset_kind: &str,
target_sub_path: Option<&str>,
created: &Value,
) -> Result<(), WebError> {
let command = RuntimeCommandEnvelopeWire {
name: "tree.resource.upload".into(),
command_id: format!("resource_upload_{}", context.trace.request_id),
idempotency_key: context.source.idempotency_key.clone(),
actor: RuntimeActorWire {
actor_type: context.auth.actor_type.clone(),
actor_id: user_id.to_string(),
session_id: context.auth.session_id.clone(),
},
source: RuntimeSourceWire {
channel: context.source.channel.clone(),
client: context.source.client.clone(),
source_kind: None,
root_uri: None,
workspace_id: Some(workspace_id.to_string()),
capabilities: Vec::new(),
},
target: Some(RuntimeTargetWire {
workspace_id: Some(workspace_id.to_string()),
page_id: Some(document_id.to_string()),
block_id: Some(asset_id.to_string()),
}),
payload: json!({
"assetId": asset_id,
"workspaceId": workspace_id,
"targetDocumentId": document_id,
"targetSubPath": target_sub_path,
"fileName": file.name,
"fileSize": file.bytes.len(),
"mimeType": file.content_type,
"assetType": asset_kind,
}),
preflight_data: None,
reason: Some("mnote-web media upload tree.resource.upload".into()),
refs: vec!["file-tree-resource-upload".into()],
dry_run: false,
validate_only: false,
};
let runtime_context = runtime_context(context, Some(workspace_id));
let plan = build_runtime_command_plan(context, Some(workspace_id), command.clone())?;
let artifact_result = json!({
"items": [created.clone()],
});
if let Some(artifacts) = build_runtime_command_artifact_plan(
&runtime_context,
&command,
&plan,
&artifact_result,
&now_iso_like(),
) {
persist_runtime_command_artifacts(state.config(), context, &artifacts).await?;
}
Ok(())
}
async fn read_upload_multipart(
mut multipart: Multipart,
) -> Result<(UploadFile, String, String, Option<String>), WebError> {
let mut file: Option<UploadFile> = None;
let mut workspace_id = String::new();
let mut document_id = String::new();
let mut mindmap_id: Option<String> = None;
while let Some(field) = multipart.next_field().await.map_err(|error| {
WebError::bad_request_code(
"media_upload_bad_multipart",
format!("上传表单解析失败: {error}"),
)
})? {
let name = field.name().unwrap_or_default().to_string();
if name == "file" {
let file_name = field
.file_name()
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or("附件")
.to_string();
let content_type = field
.content_type()
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or("application/octet-stream")
.to_string();
let bytes = field
.bytes()
.await
.map_err(|error| {
WebError::bad_request_code(
"media_upload_file_read_failed",
format!("读取上传文件失败: {error}"),
)
})?
.to_vec();
file = Some(UploadFile {
name: file_name,
content_type,
bytes,
});
continue;
}
let value = field.text().await.map_err(|error| {
WebError::bad_request_code(
"media_upload_field_read_failed",
format!("读取上传字段失败: {error}"),
)
})?;
match name.as_str() {
"workspaceId" => workspace_id = value.trim().to_string(),
"documentId" => document_id = value.trim().to_string(),
"mindmapId" => {
let trimmed = value.trim();
if !trimmed.is_empty() {
mindmap_id = Some(trimmed.to_string());
}
}
_ => {}
}
}
let file =
file.ok_or_else(|| WebError::bad_request_code("media_upload_file_missing", "缺少 file"))?;
if file.bytes.is_empty() || workspace_id.is_empty() || document_id.is_empty() {
return Err(WebError::bad_request_code(
"media_upload_required_missing",
"缺少必要参数",
));
}
Ok((file, workspace_id, document_id, mindmap_id))
}
fn absolute_origin(headers: &HeaderMap) -> String {
let proto = headers
.get("x-forwarded-proto")
.and_then(|value| value.to_str().ok())
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or("http");
let host = headers
.get(header::HOST)
.and_then(|value| value.to_str().ok())
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or("127.0.0.1:3000");
format!("{proto}://{host}")
}
fn proxied_file_url(headers: &HeaderMap, raw: &str) -> String {
let encoded = URL_SAFE_NO_PAD.encode(raw.as_bytes());
format!(
"{}/api/onlyoffice/proxy?u={encoded}",
absolute_origin(headers)
)
}
fn trim_string(value: Option<&String>) -> Option<String> {
value
.map(String::as_str)
@@ -353,128 +100,8 @@ fn document_for_target_row(
None
}
pub async fn upload(
State(state): State<AppState>,
Extension(context): Extension<RequestContext>,
multipart: Multipart,
) -> Result<Response, WebError> {
let (file, workspace_id, document_id, mindmap_id) = read_upload_multipart(multipart).await?;
let user_id = current_user_id(&state, &context).await;
let upload_url = execute_retired_mutation_by_name(
state.config(),
&context,
"mediaAssets:generateUploadUrl",
json!({ "userId": user_id }),
Some(&workspace_id),
None,
"media_upload_generate_url",
)
.await?;
let upload_url = upload_url.as_str().ok_or_else(|| {
WebError::bad_gateway_code("media_upload_bad_upload_url", "Convex 未返回上传 URL")
})?;
let client = reqwest::Client::new();
let upload_response = client
.post(upload_url)
.header(header::CONTENT_TYPE, file.content_type.as_str())
.body(file.bytes.clone())
.send()
.await
.map_err(|error| {
WebError::bad_gateway_code(
"media_upload_storage_failed",
format!("上传到 Convex Files 失败: {error}"),
)
})?;
let upload_status = upload_response.status();
let upload_json: Value = upload_response.json().await.map_err(|error| {
WebError::bad_gateway_code(
"media_upload_storage_bad_response",
format!("Convex Files 响应解析失败: {error}"),
)
.with_header("x-upstream-status", upload_status.as_u16().to_string())
})?;
if !upload_status.is_success() {
return Err(WebError::bad_gateway_code(
"media_upload_storage_status",
format!("上传到 Convex Files 失败: {upload_json}"),
)
.with_header("x-upstream-status", upload_status.as_u16().to_string()));
}
let storage_id = upload_json
.get("storageId")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.ok_or_else(|| {
WebError::bad_gateway_code(
"media_upload_storage_id_missing",
"Convex Files 缺少 storageId",
)
})?;
let target_sub_path = mindmap_id
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.map(|value| format!("mindmaps/{value}"));
let id = new_asset_id();
let kind = asset_type(&file.content_type);
let created = execute_retired_mutation_by_name(
state.config(),
&context,
"mediaAssets:createWithStorage",
json!({
"userId": user_id,
"storageId": storage_id,
"targetSubPath": target_sub_path,
"asset": {
"id": id,
"workspace_id": workspace_id,
"document_id": document_id,
"asset_type": kind,
"file_name": file.name,
"file_size": file.bytes.len(),
"mime_type": file.content_type,
}
}),
Some(&workspace_id),
None,
"media_upload_create_asset",
)
.await?;
let asset_id = created
.get("id")
.and_then(Value::as_str)
.unwrap_or_default()
.to_string();
if let Err(error) = record_upload_artifacts(
&state,
&context,
&user_id,
&workspace_id,
&document_id,
&asset_id,
&file,
&kind,
target_sub_path.as_deref(),
&created,
)
.await
{
tracing::warn!(
error = %error.message(),
asset_id = %asset_id,
"media upload tree.resource.upload artifacts 记录失败,主上传结果继续返回"
);
}
Ok(Json(json!({
"asset": created,
"mindmapUrl": format!("asset:{asset_id}"),
}))
.into_response())
pub async fn upload(Extension(context): Extension<RequestContext>) -> Response {
retired_convex_media_response(&context, "upload", None)
}
pub async fn filetree_upload_target_preflight(
@@ -529,63 +156,39 @@ pub async fn filetree_upload_target_preflight(
}
pub async fn sign(
State(state): State<AppState>,
Extension(context): Extension<RequestContext>,
Query(query): Query<MediaSignQuery>,
headers: HeaderMap,
) -> Result<Response, WebError> {
) -> Response {
let asset_id = query
.asset_id
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.ok_or_else(|| WebError::bad_request_code("media_sign_asset_missing", "缺少 assetId"))?;
let user_id = current_user_id(&state, &context).await;
let asset = execute_retired_query_by_name(
state.config(),
&context,
"mediaAssets:getById",
json!({ "userId": user_id, "id": asset_id }),
None,
"media_sign_get_asset",
)
.await?;
if asset.is_null() {
return Err(WebError::new(
axum::http::StatusCode::NOT_FOUND,
"media_asset_not_found",
"资源不存在",
));
}
let refreshed = execute_retired_mutation_by_name(
state.config(),
&context,
"mediaAssets:refreshUrl",
json!({ "userId": user_id, "id": asset_id }),
None,
None,
"media_sign_refresh_url",
.filter(|value| !value.is_empty());
retired_convex_media_response(&context, "sign", asset_id)
}
fn retired_convex_media_response(
context: &RequestContext,
operation: &str,
asset_id: Option<&str>,
) -> Response {
(
StatusCode::GONE,
Json(json!({
"ok": false,
"code": "mnote_media_convex_retired",
"error": "旧 Convex Files media route 已退役",
"message": "旧 /api/media Convex Files 上传与签名链已退役;local-first 附件请使用 /api/local-folder/assets/upload 与 /api/local-folder/files/open。",
"operation": operation,
"assetId": asset_id,
"replacement": {
"upload": "/api/local-folder/assets/upload",
"open": "/api/local-folder/files/open",
"preflight": "/api/tree/filetree/upload-target-preflight"
},
"requestId": context.trace.request_id,
"traceId": context.trace.trace_id,
})),
)
.await?;
let signed_url = refreshed
.get("signedUrl")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.ok_or_else(|| WebError::bad_gateway_code("media_sign_url_missing", "生成签名链接失败"))?;
Ok(Json(json!({
"signedUrl": proxied_file_url(&headers, signed_url),
"asset": {
"id": asset.get("id").cloned().unwrap_or(Value::Null),
"document_id": asset.get("document_id").cloned().unwrap_or(Value::Null),
"workspace_id": asset.get("workspace_id").cloned().unwrap_or(Value::Null),
"file_name": asset.get("file_name").cloned().unwrap_or(Value::Null),
"mime_type": asset.get("mime_type").cloned().unwrap_or(Value::Null),
"file_size": asset.get("file_size").cloned().unwrap_or(Value::Null),
"storage_id": asset.get("storage_id").cloned().unwrap_or(Value::Null),
"updated_at": asset.get("updated_at").cloned().unwrap_or(Value::Null),
}
}))
.into_response())
.into_response()
}
+43 -1
View File
@@ -252,6 +252,10 @@ pub fn build_router(state: AppState) -> Router {
"/api/mnote-browser-runtime/sidebar-tree-runtime.js",
get(web_shell::sidebar_tree_runtime_asset),
)
.route(
"/api/mnote-browser-runtime/local-folder-event-bus-runtime.js",
get(web_shell::local_folder_event_bus_runtime_asset),
)
.route(
"/api/mnote-browser-runtime/tree-live-controller.js",
get(web_shell::tree_live_controller_runtime_asset),
@@ -723,6 +727,7 @@ mod tests {
use crate::context::RequestContext;
use axum::body::{to_bytes, Body};
use axum::http::{Request, StatusCode};
use axum::Extension;
use axum::Router;
use serde_json::{json, Value};
use std::fs;
@@ -848,6 +853,38 @@ mod tests {
}
}
#[tokio::test]
async fn legacy_media_routes_return_retired_guard() {
for (method, path, operation) in [
("POST", "/api/media/upload", "upload"),
("GET", "/api/media/sign?assetId=asset_1", "sign"),
] {
let request = Request::builder()
.method(method)
.uri(path)
.body(Body::empty())
.expect("request");
let context =
RequestContext::from_http_parts(request.method(), request.uri(), request.headers());
let response = app(false)
.layer(Extension(context))
.oneshot(request)
.await
.expect("response");
assert_eq!(response.status(), StatusCode::GONE);
let body = to_bytes(response.into_body(), usize::MAX)
.await
.expect("body bytes");
let payload: Value = serde_json::from_slice(&body).expect("json body");
assert_eq!(payload["code"], "mnote_media_convex_retired");
assert_eq!(payload["operation"], operation);
assert_eq!(
payload["replacement"]["upload"],
"/api/local-folder/assets/upload"
);
}
}
#[tokio::test]
async fn office_preview_page_serves_lightweight_viewer_shell() {
let response = app(false)
@@ -1144,8 +1181,12 @@ mod tests {
);
assert_eq!(
alice_payload["result"]["localOcrPreferences"]["localOcr.autoEnabled"],
true
false
);
assert!(alice_payload["result"]["sources"]
.as_object()
.and_then(|sources| sources.get("localOcr.autoEnabled"))
.is_none());
let mut bob_get = Request::builder()
.uri(format!("/api/ui/preferences/effective?workspaceId=workspace-ai-a&sourceKind=local_folder&rootUri={root_uri}&documentId=local-md:ai.md"))
@@ -1201,6 +1242,7 @@ mod tests {
"/api/mnote-browser-runtime/sidebar-page-ai-target-runtime.js",
"/api/mnote-browser-runtime/sidebar-page-settings-runtime.js",
"/api/mnote-browser-runtime/sidebar-tree-runtime.js",
"/api/mnote-browser-runtime/local-folder-event-bus-runtime.js",
"/api/mnote-browser-runtime/tree-live-controller.js",
"/api/mnote-browser-runtime/tree-shell-runtime.js",
"/api/mnote-browser-runtime/tree-shell-render-runtime.js",
@@ -224,7 +224,7 @@ async fn record_media_empty_trash_artifacts(
let plan = RuntimeCommandExecutionPlan {
command_name: command.name.clone(),
command_id: command.command_id.clone(),
function_name: "mediaAssets:emptyTrashByWorkspace".into(),
function_name: command.name.clone(),
workspace_id: Some(workspace_id.to_string()),
request_id: context.trace.request_id.clone(),
trace_id: context.trace.trace_id.clone(),
+68 -245
View File
@@ -7,16 +7,15 @@ use crate::routes::query_support::{
resolve_effective_workspace_id,
};
use crate::routes::web_shell::load_sidebar_tree_html;
use crate::routes::{evidence, local_folder_source, local_search_index};
use crate::routes::{local_folder_source, local_search_index};
use crate::ssr::pages::search::SearchPage;
use axum::Json;
use axum::extract::{Extension, Query, State};
use axum::http::{HeaderMap, HeaderName, HeaderValue, StatusCode};
use axum::response::{Html, IntoResponse, Response};
use axum::Json;
use bridge_runtime::RuntimeQueryEnvelopeWire;
use core_protocol::{EvidenceSearchMode, EvidenceSearchResult};
use serde::Deserialize;
use serde_json::{Value, json};
use serde_json::{json, Value};
const HEADER_MNOTE_WEB_OWNER: &str = "x-mnote-web-owner";
const HEADER_QUERY_NAME: &str = "x-query-name";
@@ -183,8 +182,7 @@ pub async fn documents(
None
};
let (result, evidence_results) =
if body.source_kind.as_deref().map(str::trim) == Some("local_folder") {
let result = if body.source_kind.as_deref().map(str::trim) == Some("local_folder") {
let root_uri = body
.root_uri
.as_deref()
@@ -209,7 +207,7 @@ pub async fn documents(
&effective_workspace_id,
&root_path,
)?;
let result = local_search_index::query_local_search_index_with_settings(
local_search_index::query_local_search_index_with_settings(
&root_path,
root_uri,
&effective_workspace_id,
@@ -221,46 +219,9 @@ pub async fn documents(
filters.title_only.unwrap_or(false),
filters.exact.unwrap_or(false),
filters.include_ocr.unwrap_or(false),
)?;
let evidence_results = evidence::evidence_results_from_local_search(
&result,
&root_path,
root_uri,
EvidenceSearchMode::Hybrid,
&normalized_query,
);
let limit = body.limit.unwrap_or(30).max(1) as usize;
let direct_evidence_results = if !filters.title_only.unwrap_or(false) {
local_search_index::query_evidence_sqlite_results_with_mode(
&root_path,
&normalized_query,
page_id.as_deref(),
body.limit.unwrap_or(30),
filters.exact.unwrap_or(false),
)?
.unwrap_or_default()
.into_iter()
.filter(|evidence| {
let path = evidence
.source
.resource_path
.as_deref()
.unwrap_or(evidence.source.owner_document_path.as_str());
local_search_index::local_index_relative_path_is_included(path, &user_settings)
})
.collect::<Vec<_>>()
} else {
Vec::new()
};
let (result, evidence_results) = merge_local_search_with_evidence_results(
result,
evidence_results,
direct_evidence_results,
limit,
);
(result, evidence_results)
} else {
let result = load_search_results_with_filters(
load_search_results_with_filters(
state.config(),
&context,
&effective_workspace_id,
@@ -269,14 +230,12 @@ pub async fn documents(
body.limit.unwrap_or(30),
filters,
)
.await?;
(result, Vec::new())
.await?
};
let results = result
.get("results")
.cloned()
.unwrap_or(Value::Array(vec![]));
let results = attach_evidence_to_search_results(results, &evidence_results);
let mut headers = HeaderMap::new();
stamp_search_headers(&mut headers);
@@ -285,13 +244,20 @@ pub async fn documents(
headers,
Json(json!({
"results": results,
"evidence": evidence_results,
"evidence": [],
"projectionOwner": result.get("projectionOwner").cloned().unwrap_or_else(|| json!("rust-kernel")),
"recent": result.get("recentChanges").cloned().unwrap_or_else(|| Value::Array(vec![])),
"meta": {
"owner": "mnote-web",
"projectionOwner": result.get("projectionOwner").cloned().unwrap_or_else(|| json!("rust-kernel")),
"queryName": "search.documents.query",
"boundary": {
"kind": "ordinary_local_search",
"knowledgeRag": false,
"evidenceSqliteFallback": false,
"liteParseFallback": false,
"ocrSidecarFallback": false
},
"degraded": result.get("degraded").cloned().unwrap_or_else(|| json!(false)),
"degradedReason": result.get("degradedReason").cloned().unwrap_or(Value::Null),
"requestId": context.trace.request_id,
@@ -475,158 +441,6 @@ pub async fn update_local_index_settings(
))
}
fn merge_local_search_with_evidence_results(
mut result: Value,
mut evidence_results: Vec<EvidenceSearchResult>,
direct_evidence_results: Vec<EvidenceSearchResult>,
limit: usize,
) -> (Value, Vec<EvidenceSearchResult>) {
if direct_evidence_results.is_empty() {
return (result, evidence_results);
}
let original_items = result
.get("results")
.and_then(Value::as_array)
.cloned()
.unwrap_or_default();
let original_evidence_results = std::mem::take(&mut evidence_results);
let mut result_items = Vec::new();
let mut merged_evidence_results = Vec::new();
let mut seen_result_ids = std::collections::HashSet::new();
let mut seen_evidence_ids = std::collections::HashSet::new();
let mut seen_paths = std::collections::HashSet::new();
for evidence in direct_evidence_results {
if result_items.len() >= limit || !seen_evidence_ids.insert(evidence.evidence_id.clone()) {
continue;
}
let item = search_result_from_evidence(&evidence);
if let Some(id) = item.get("id").and_then(Value::as_str) {
seen_result_ids.insert(id.to_string());
}
if let Some(path) = search_result_dedupe_path(&item) {
seen_paths.insert(path);
}
result_items.push(item);
merged_evidence_results.push(evidence);
}
for (index, item) in original_items.into_iter().enumerate() {
if result_items.len() >= limit {
break;
}
let item_id = item
.get("id")
.and_then(Value::as_str)
.map(ToOwned::to_owned)
.unwrap_or_default();
if !item_id.is_empty() && !seen_result_ids.insert(item_id) {
continue;
}
if let Some(path) = search_result_dedupe_path(&item) {
if !seen_paths.insert(path) {
continue;
}
}
if let Some(evidence) = original_evidence_results.get(index).cloned() {
merged_evidence_results.push(evidence);
}
result_items.push(item);
}
if let Some(map) = result.as_object_mut() {
map.insert("results".into(), Value::Array(result_items));
}
(result, merged_evidence_results)
}
fn search_result_dedupe_path(item: &Value) -> Option<String> {
let source_kind = item
.get("sourceKind")
.and_then(Value::as_str)
.unwrap_or_default();
if source_kind != "local_folder" {
return None;
}
item.get("path")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
}
fn search_result_from_evidence(evidence: &EvidenceSearchResult) -> Value {
let source = &evidence.source;
let evidence_value = serde_json::to_value(evidence).unwrap_or(Value::Null);
let resource_path = source
.resource_path
.as_deref()
.unwrap_or(source.owner_document_path.as_str());
let title = std::path::Path::new(resource_path)
.file_name()
.and_then(|value| value.to_str())
.unwrap_or(resource_path)
.to_string();
let resource_type = match source.resource_kind {
core_protocol::EvidenceResourceKind::Markdown => "markdown",
core_protocol::EvidenceResourceKind::Pdf => "pdf",
core_protocol::EvidenceResourceKind::Image => "image",
core_protocol::EvidenceResourceKind::Office => "office",
core_protocol::EvidenceResourceKind::Mindmap => "mindmap",
core_protocol::EvidenceResourceKind::RawFile => "resource",
};
json!({
"id": format!("evidence:{}", evidence.evidence_id),
"documentId": source.owner_document_id,
"title": title,
"path": source.owner_document_path,
"resourceType": resource_type,
"sourceKind": "local_folder",
"rootUri": source.root_uri,
"snippet": evidence.quote,
"score": evidence.score,
"matchInfo": evidence.match_info,
"publicPath": source.open_action.url,
"evidence": evidence_value,
"source": {
"locator": source
},
})
}
fn attach_evidence_to_search_results(
results: Value,
evidence_results: &[EvidenceSearchResult],
) -> Value {
let Value::Array(items) = results else {
return results;
};
Value::Array(
items
.into_iter()
.enumerate()
.map(|(index, item)| {
let Some(evidence) = evidence_results.get(index) else {
return item;
};
let mut item = item;
if let Some(map) = item.as_object_mut() {
if map.get("evidence").is_some() {
return item;
}
let evidence_value = serde_json::to_value(evidence).unwrap_or(Value::Null);
map.insert("evidence".into(), evidence_value);
let source = map.entry("source").or_insert_with(|| json!({}));
if let Some(source_map) = source.as_object_mut() {
source_map.insert(
"locator".into(),
serde_json::to_value(&evidence.source).unwrap_or(Value::Null),
);
}
}
item
})
.collect(),
)
}
fn resolve_local_index_user_settings(
state: &AppState,
context: &RequestContext,
@@ -940,12 +754,12 @@ fn stamp_search_headers(headers: &mut HeaderMap) {
#[cfg(test)]
mod tests {
use crate::app::{AppConfig, AppState, build_app};
use crate::app::{build_app, AppConfig, AppState};
use crate::routes::local_search_index;
use axum::body::{Body, to_bytes};
use axum::body::{to_bytes, Body};
use axum::http::{Request, StatusCode};
use control_plane::{DirectoryGrantInput, UpsertUserInput};
use serde_json::{Value, json};
use serde_json::{json, Value};
use std::fs;
use tower::util::ServiceExt;
@@ -1194,41 +1008,38 @@ mod tests {
assert_eq!(home["sourceKind"].as_str(), Some("local_folder"));
assert_eq!(home["resourceType"].as_str(), Some("markdown"));
assert_eq!(
home["source"]["locator"]["schema"].as_str(),
Some("mnote.evidence_locator.v1")
payload["meta"]["boundary"]["kind"].as_str(),
Some("ordinary_local_search")
);
assert_eq!(
home["evidence"]["source"]["ownerDocumentPath"].as_str(),
Some("README.md")
payload["meta"]["boundary"]["knowledgeRag"].as_bool(),
Some(false)
);
assert!(!payload["evidence"].as_array().expect("evidence").is_empty());
assert!(
home["tags"]
assert_eq!(
payload["meta"]["boundary"]["evidenceSqliteFallback"].as_bool(),
Some(false)
);
assert!(payload["evidence"].as_array().expect("evidence").is_empty());
assert!(home["tags"]
.as_array()
.unwrap()
.iter()
.any(|tag| tag.as_str() == Some("alpha"))
);
assert!(
home["backlinks"]
.any(|tag| tag.as_str() == Some("alpha")));
assert!(home["backlinks"]
.as_array()
.unwrap()
.iter()
.any(|link| link.as_str() == Some("Daily"))
);
assert!(
home["resourceRefs"]
.any(|link| link.as_str() == Some("Daily")));
assert!(home["resourceRefs"]
.as_array()
.unwrap()
.iter()
.any(|reference| reference.as_str() == Some("assets/spec.pdf"))
);
assert!(
root.join(".mnote")
.any(|reference| reference.as_str() == Some("assets/spec.pdf")));
assert!(root
.join(".mnote")
.join("index")
.join("search-index.json")
.exists()
);
.exists());
let evidence_db = root.join(".mnote").join("index").join("evidence.sqlite");
assert!(evidence_db.exists(), "evidence sqlite should be built");
let connection = rusqlite::Connection::open(&evidence_db).expect("open evidence sqlite");
@@ -1258,19 +1069,17 @@ mod tests {
locator["schema"].as_str(),
Some("mnote.evidence_locator.v1")
);
assert!(
payload["recent"]
assert!(payload["recent"]
.as_array()
.unwrap()
.iter()
.any(|item| item["documentId"].as_str() == Some("local-md:README.md"))
);
.any(|item| item["documentId"].as_str() == Some("local-md:README.md")));
let _ = fs::remove_dir_all(&root);
}
#[tokio::test]
async fn search_documents_local_folder_includes_evidence_sqlite_body_hits() {
async fn search_documents_local_folder_does_not_promote_evidence_sqlite_body_hits() {
let root = std::env::temp_dir().join(format!(
"mnote-local-search-evidence-route-{}",
std::process::id()
@@ -1350,20 +1159,18 @@ mod tests {
.expect("body");
let payload: Value = serde_json::from_slice(&body).expect("json");
let results = payload["results"].as_array().expect("results");
let hit = results
.iter()
.find(|item| {
item["snippet"]
assert!(!results.iter().any(|item| item["id"]
.as_str()
.unwrap_or("")
.contains("BodyOnlyEvidenceToken")
})
.expect("evidence sqlite body hit should be promoted to search result");
assert_eq!(hit["resourceType"].as_str(), Some("markdown"));
.unwrap_or_default()
.starts_with("evidence:")));
assert!(results
.iter()
.any(|item| item["resourceType"].as_str() == Some("markdown")));
assert_eq!(
hit["evidence"]["source"]["schema"].as_str(),
Some("mnote.evidence_locator.v1")
payload["meta"]["boundary"]["evidenceSqliteFallback"].as_bool(),
Some(false)
);
assert!(payload["evidence"].as_array().expect("evidence").is_empty());
let _ = fs::remove_dir_all(&root);
}
@@ -1382,6 +1189,15 @@ mod tests {
.expect("manifest");
fs::write(root.join("README.md"), "# Refresh\nrefresh-token\n").expect("readme");
let root_uri = format!("file://{}", root.display());
local_search_index::write_local_index_settings(
&root,
&[String::from(".")],
None,
None,
None,
None,
)
.expect("settings");
let response = app()
.oneshot(
@@ -1582,6 +1398,15 @@ mod tests {
)
.expect("child");
let root_uri = format!("file://{}", root.display());
local_search_index::write_local_index_settings(
&root,
&[String::from(".")],
None,
None,
None,
None,
)
.expect("settings");
let encoded_root = query_escape(&root_uri);
let backlinks_response = app()
@@ -1607,13 +1432,11 @@ mod tests {
backlinks_payload["meta"]["queryName"].as_str(),
Some("search.local_index.backlinks")
);
assert!(
backlinks_payload["result"]["backlinks"]
assert!(backlinks_payload["result"]["backlinks"]
.as_array()
.unwrap()
.iter()
.any(|item| item["documentId"].as_str() == Some("local-md:README.md"))
);
.any(|item| item["documentId"].as_str() == Some("local-md:README.md")));
let tags_response = app()
.oneshot(
+7 -16
View File
@@ -4323,7 +4323,7 @@ mod tests {
}
#[test]
fn tree_commands_keep_documents_alias_mapping_for_runtime_plan() {
fn tree_commands_use_protocol_names_in_runtime_plan() {
let context = RequestContext::from_http_parts(
&Method::POST,
&"/api/tree/commands".parse::<Uri>().expect("uri"),
@@ -4354,14 +4354,8 @@ mod tests {
let compat_create_plan =
build_runtime_command_plan(&context, Some("ws_demo"), compat_create_wire)
.expect("compat create plan");
assert_eq!(
tree_create_plan.function_name,
"documents:createWithParentReference"
);
assert_eq!(
tree_create_plan.function_name,
compat_create_plan.function_name
);
assert_eq!(tree_create_plan.function_name, "tree.node.create");
assert_eq!(compat_create_plan.function_name, "documents.create");
let tree_rename_wire = create_command_wire(
&context,
@@ -4384,11 +4378,8 @@ mod tests {
let compat_rename_plan =
build_runtime_command_plan(&context, Some("ws_demo"), compat_rename_wire)
.expect("compat rename plan");
assert_eq!(tree_rename_plan.function_name, "documents:updateTitle");
assert_eq!(
tree_rename_plan.function_name,
compat_rename_plan.function_name
);
assert_eq!(tree_rename_plan.function_name, "tree.node.rename");
assert_eq!(compat_rename_plan.function_name, "documents.title.update");
let tree_move_wire = create_command_wire(
&context,
@@ -4411,8 +4402,8 @@ mod tests {
let compat_move_plan =
build_runtime_command_plan(&context, Some("ws_demo"), compat_move_wire)
.expect("compat move plan");
assert_eq!(tree_move_plan.function_name, "documents:move");
assert_eq!(tree_move_plan.function_name, compat_move_plan.function_name);
assert_eq!(tree_move_plan.function_name, "tree.subtree.move");
assert_eq!(compat_move_plan.function_name, "documents.move");
}
#[test]
@@ -282,7 +282,6 @@ fn apply_preference_records(
"source_family".to_string(),
"workspace".to_string(),
"document".to_string(),
"localOcr".to_string(),
];
for preference in preferences {
if preference.scope_kind.starts_with("ai.") && !scope_kinds.contains(&preference.scope_kind)
@@ -301,7 +300,6 @@ fn apply_preference_records(
"workspace" => preference.scope_id.trim() == scope.workspace_id,
"document" => preference.scope_id.trim() == scope.document_id,
kind if kind.starts_with("ai.") => preference.scope_id.trim() == scope.workspace_id,
"localOcr" => preference.scope_id.trim() == scope.workspace_id,
_ => false,
};
if !scope_matches {
@@ -319,8 +317,8 @@ fn apply_preference_records(
continue;
}
if preference.key.starts_with("localOcr.") {
local_ocr_preferences.insert(preference.key.clone(), value);
sources.insert(preference.key.clone(), scope_kind.to_string());
local_ocr_preferences.insert(preference.key.clone(), Value::Bool(false));
sources.insert(preference.key.clone(), "retired".to_string());
continue;
}
if let Some(content_type) = page_width_content_type_for_key(&preference.key) {
@@ -381,7 +379,7 @@ fn preference_scope_for_key(key: &str, scope: &PagePreferenceScope) -> Option<(S
}
}
if trimmed.starts_with("localOcr.") {
return Some(("localOcr".to_string(), scope.workspace_id.clone()));
return None;
}
if page_width_content_type_for_key(key).is_some() {
return Some(("global".to_string(), "default".to_string()));
@@ -416,11 +414,7 @@ fn preference_scope_for_key(key: &str, scope: &PagePreferenceScope) -> Option<(S
}
fn preference_workspace_for_scope(workspace_id: &str, scope_kind: &str) -> Option<String> {
if scope_kind == "workspace"
|| scope_kind == "document"
|| scope_kind.starts_with("ai.")
|| scope_kind == "localOcr"
{
if scope_kind == "workspace" || scope_kind == "document" || scope_kind.starts_with("ai.") {
Some(workspace_id.to_string())
} else {
None
@@ -428,11 +422,7 @@ fn preference_workspace_for_scope(workspace_id: &str, scope_kind: &str) -> Optio
}
fn preference_source_kind_for_scope(source_kind: &str, scope_kind: &str) -> Option<String> {
if scope_kind == "workspace"
|| scope_kind == "document"
|| scope_kind.starts_with("ai.")
|| scope_kind == "localOcr"
{
if scope_kind == "workspace" || scope_kind == "document" || scope_kind.starts_with("ai.") {
Some(source_kind.to_string())
} else {
None
+44 -11
View File
@@ -2328,6 +2328,20 @@ pub async fn filetree_selection_runtime_asset() -> Response {
.unwrap_or_else(|_| Response::new(Body::empty()))
}
pub async fn local_folder_event_bus_runtime_asset() -> Response {
const JS: &str = include_str!("../../browser/local-folder-event-bus-runtime.js");
Response::builder()
.status(StatusCode::OK)
.header(
header::CONTENT_TYPE,
"application/javascript; charset=utf-8",
)
.header(header::CACHE_CONTROL, runtime_asset_cache_control())
.header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
.body(browser_runtime_js_body(JS))
.unwrap_or_else(|_| Response::new(Body::empty()))
}
pub async fn tree_live_controller_runtime_asset() -> Response {
const JS: &str = include_str!("../../browser/tree-live-controller.js");
Response::builder()
@@ -3632,11 +3646,11 @@ mod tests {
assert!(runtime.contains("document-resource-tab-runtime.js"));
assert!(resource_runtime.contains("resourceTabBadgeKind(input, kind)"));
assert!(resource_runtime.contains("后台任务"));
assert!(resource_runtime.contains("data-mnote-local-ocr-task-tab"));
assert!(resource_runtime.contains("data-mnote-local-ocr-task-clear-completed"));
assert!(resource_runtime.contains("data-mnote-knowledge-rag-task-tab"));
assert!(resource_runtime.contains("data-mnote-knowledge-rag-task-clear-completed"));
assert!(resource_runtime.contains("role=\"progressbar\""));
assert!(resource_runtime.contains("localOcrTaskCategory(job)"));
assert!(resource_runtime.contains("localOcrTaskProgress(job)"));
assert!(resource_runtime.contains("knowledgeRagTaskCategory(job)"));
assert!(resource_runtime.contains("knowledgeRagTaskProgress(job)"));
assert!(resource_runtime.contains("mnote.open_editors_snapshot.v1"));
assert!(runtime.contains("getOpenEditorsSnapshot"));
assert!(resource_runtime.contains("bindMainEditorTabStrip"));
@@ -3734,6 +3748,11 @@ mod tests {
assert!(session_runtime.contains("mnote.localFolder.selfChangeSuppressions.v1"));
assert!(session_runtime.contains("ensureLocalFolderSelfChangeSuppressions()"));
assert!(session_runtime.contains("markLocalFolderSelfChangeSuppression(session);"));
assert!(session_runtime.contains("data-mnote-page-body-local-compat-fallback"));
assert!(session_runtime.contains("data-mnote-page-body-hard-guard"));
assert!(session_runtime.contains("local_compat_fallback"));
assert!(runtime.contains("data-mnote-page-body-local-compat-fallback"));
assert!(runtime.contains("data-mnote-page-body-hard-guard"));
assert!(session_runtime.contains("const suppressibleSelfWrite = kind.includes('Create')"));
assert!(session_runtime.contains("|| kind.includes('Modify(Data')"));
assert!(session_runtime.contains("|| kind.includes('Modify(Any')"));
@@ -3765,8 +3784,9 @@ mod tests {
resource_runtime.contains("const refreshExistingOfficeResourceTab = (entry, input) =>")
);
assert!(resource_runtime.contains("if (!entry || entry.kind !== 'office') return false;"));
assert!(resource_runtime
.contains("if (currentHref !== nextHref) void openPassiveResourceTab(entry, input);"));
assert!(resource_runtime.contains(
"if (officePreviewBaseHref(currentHref) !== officePreviewBaseHref(nextHref)) void openPassiveResourceTab(entry, input);"
));
assert!(resource_runtime.contains("refreshExistingOfficeResourceTab(existing, input);"));
assert!(resource_runtime.contains("syncActiveResourceWidthType(activeEntry, role);"));
assert!(resource_runtime.contains("data-mnote-active-resource-width-type"));
@@ -4076,14 +4096,14 @@ mod tests {
.headers()
.get("x-error-phase")
.and_then(|value| value.to_str().ok()),
Some("query_send")
Some("convex_query_retired")
);
assert_eq!(
response
.headers()
.get("x-upstream-service")
.and_then(|value| value.to_str().ok()),
Some("convex")
Some("convex-retired")
);
let body = to_bytes(response.into_body(), usize::MAX)
.await
@@ -4733,7 +4753,9 @@ mod tests {
assert!(session_runtime.contains("/api/local-folder/events"));
assert!(session_runtime.contains("localFolderEventChannelKey"));
assert!(session_runtime.contains("url.searchParams.set('documentId', session.documentId);"));
assert!(session_runtime.contains("if (!documentId) return;"));
assert!(session_runtime.contains(
"if (!session || session.sourceKind !== 'local_folder' || !session.rootUri || !session.documentId) return null;"
));
assert!(session_runtime.contains("new EventSource(url.toString())"));
assert!(session_runtime.contains("localFolderEventRegistry"));
assert!(session_runtime
@@ -4939,6 +4961,18 @@ mod tests {
assert!(DOCUMENT_SESSION_RUNTIME_JS.contains("createDocumentSessionRuntime"));
assert!(DOCUMENT_SESSION_RUNTIME_JS.contains("getOrCreateDocumentSession"));
assert!(DOCUMENT_SESSION_RUNTIME_JS.contains("persistSession"));
assert!(DOCUMENT_SESSION_RUNTIME_JS.contains("__mnoteLocalFolderEventBus"));
assert!(DOCUMENT_SESSION_RUNTIME_JS.contains("mnote:local-folder:document-changed"));
assert!(DOCUMENT_RESOURCE_TAB_RUNTIME_JS.contains("__mnoteLocalFolderEventBus"));
assert!(DOCUMENT_RESOURCE_TAB_RUNTIME_JS.contains("mnote:local-folder:resource-changed"));
assert!(
DOCUMENT_RESOURCE_TAB_RUNTIME_JS.contains("data-mnote-local-ocr-event-stream-retired")
);
assert!(!DOCUMENT_RESOURCE_TAB_RUNTIME_JS.contains("local_ocr.job.updated"));
assert!(DOCUMENT_RESOURCE_TAB_RUNTIME_JS.contains("mnote:knowledge-rag-job-updated"));
assert!(!DOCUMENT_RESOURCE_TAB_RUNTIME_JS.contains("mnote:local-ocr-job-updated"));
assert!(DOCUMENT_RESOURCE_TAB_RUNTIME_JS
.contains("data-mnote-resource-watch-ready', 'event-bus'"));
assert!(DOCUMENT_EDITOR_ADAPTER_RUNTIME_JS.contains("document.createElement('script')"));
assert!(DOCUMENT_EDITOR_ADAPTER_RUNTIME_JS
.contains("syncPageAggregateScript({ pageAggregateScriptId"));
@@ -5120,8 +5154,7 @@ mod tests {
assert!(runtime.contains("typeof payload.text === 'string'"));
assert!(runtime.contains("payload.type === 'hard_break'"));
assert!(runtime.contains("typeof body?.fileVersion === 'string'"));
assert!(DOCUMENT_SESSION_RUNTIME_JS
.contains("expectedFileVersion: session.conflictDetectionKey"));
assert!(DOCUMENT_SESSION_RUNTIME_JS.contains("expectedFileVersion: expectedFileVersion"));
assert!(runtime.contains("node?.attrs?.mnoteBlockType === 'mindmap'"));
assert!(runtime.contains("blockType: 'mindmap'"));
assert!(runtime.contains("...mindmapPropsFromAttrs(node?.attrs, blockId)"));
+83 -15
View File
@@ -171,6 +171,7 @@ pub fn PageLayout(
<script type="module" src={browser_runtime_src("sidebar-shell-runtime.js")}></script>
<script type="module" src={browser_runtime_src("sidebar-tree-runtime.js")}></script>
<script inner_html={SIDEBAR_TREE_JS.to_string()}></script>
<script type="module" src={browser_runtime_src("local-folder-event-bus-runtime.js")}></script>
<script type="module" src={browser_runtime_src("tree-live-controller.js")}></script>
</aside>
<div class="mnote-sidebar-resizer" data-mnote-sidebar-resizer="true" role="separator" aria-orientation="vertical" aria-label="调整侧栏宽度"></div>
@@ -254,6 +255,8 @@ mod tests {
const FILETREE_RUNTIME_JS: &str = include_str!("../../../browser/filetree-runtime.js");
const FILETREE_SELECTION_RUNTIME_JS: &str =
include_str!("../../../browser/filetree-selection-runtime.js");
const LOCAL_FOLDER_EVENT_BUS_RUNTIME_JS: &str =
include_str!("../../../browser/local-folder-event-bus-runtime.js");
const TREE_LIVE_CONTROLLER_JS: &str = include_str!("../../../browser/tree-live-controller.js");
fn js_function_body(source: &str, name: &str) -> String {
@@ -337,7 +340,12 @@ mod tests {
assert!(SIDEBAR_TREE_RUNTIME_JS.contains("readFileTreeObjectIdentity"));
assert!(SIDEBAR_TREE_RUNTIME_JS.contains("objectIdentity: objectIdentity"));
assert!(SIDEBAR_TREE_RUNTIME_JS.contains("workspaceId: resolveWorkspaceId(fileRow)"));
assert!(SIDEBAR_FILETREE_OPEN_RUNTIME_JS.contains("/api/media/sign?assetId="));
assert!(!SIDEBAR_FILETREE_OPEN_RUNTIME_JS.contains("/api/media/sign?assetId="));
assert!(SIDEBAR_FILETREE_OPEN_RUNTIME_JS.contains("data-mnote-media-sign-retired"));
assert!(!SIDEBAR_TREE_RUNTIME_JS.contains("/api/media/sign?assetId="));
assert!(SIDEBAR_TREE_RUNTIME_JS.contains("data-mnote-media-sign-retired"));
assert!(!SIDEBAR_TREE_RUNTIME_JS.contains("/api/media/upload"));
assert!(SIDEBAR_TREE_RUNTIME_JS.contains("data-mnote-media-upload-retired"));
assert!(SIDEBAR_FILETREE_OPEN_RUNTIME_JS.contains("function localFilePathFromAssetId"));
assert!(SIDEBAR_FILETREE_OPEN_RUNTIME_JS.contains("/api/local-folder/files/open"));
assert!(SIDEBAR_FILETREE_OPEN_RUNTIME_JS.contains("local-file:"));
@@ -347,7 +355,7 @@ mod tests {
assert!(SIDEBAR_FILETREE_OPEN_RUNTIME_JS.contains("/api/auth/whoami"));
assert!(SIDEBAR_TREE_RUNTIME_JS.contains("buildOnlyOfficeOpenUrl"));
assert!(SIDEBAR_FILETREE_OPEN_RUNTIME_JS.contains("target.searchParams.set('userId'"));
assert!(SIDEBAR_FILETREE_OPEN_RUNTIME_JS.contains("window.open(officeUrl,"));
assert!(!SIDEBAR_FILETREE_OPEN_RUNTIME_JS.contains("window.open(officeUrl,"));
assert!(SIDEBAR_TREE_RUNTIME_JS.contains("tree.filetree.internal-drop"));
assert!(SIDEBAR_TREE_RUNTIME_JS.contains("tree.filetree.external-drop"));
assert!(SIDEBAR_TREE_RUNTIME_JS.contains("beginFileTreeInlineRename"));
@@ -429,6 +437,29 @@ mod tests {
html.contains("/api/mnote-browser-runtime/sidebar-tree-runtime.js?devHot="),
"dev:hot 下 sidebar runtime URL 也必须带 cache buster"
);
assert!(
html.contains("/api/mnote-browser-runtime/local-folder-event-bus-runtime.js?devHot="),
"dev:hot 下 local-folder event bus URL 必须带 cache buster,避免复用旧连接编排逻辑"
);
}
#[test]
fn page_layout_loads_local_folder_event_bus_before_tree_live_controller() {
let html = crate::ssr::render_view(leptos::view! {
<super::PageLayout current_nav="documents" topbar_title={"个人".to_string()}>
<main>"正文"</main>
</super::PageLayout>
});
let bus_index = html
.find("/api/mnote-browser-runtime/local-folder-event-bus-runtime.js")
.expect("local-folder event bus runtime should be loaded");
let controller_index = html
.find("/api/mnote-browser-runtime/tree-live-controller.js")
.expect("tree live controller runtime should be loaded");
assert!(
bus_index < controller_index,
"local-folder event bus 必须先于 tree-live-controller 加载,确保 local-folder watcher 连接由 bus 接管"
);
}
#[test]
@@ -524,6 +555,8 @@ mod tests {
assert!(SIDEBAR_PAGE_SETTINGS_RUNTIME_JS.contains("publicKnowledgeRagDashboardUrl"));
assert!(SIDEBAR_PAGE_SETTINGS_RUNTIME_JS.contains("label.hidden = status === 'idle'"));
assert!(SIDEBAR_PAGE_SETTINGS_RUNTIME_JS.contains("data-knowledge-rag-input-status-label"));
assert!(!SIDEBAR_PAGE_SETTINGS_RUNTIME_JS.contains("localOcrAutoEnabled"));
assert!(SIDEBAR_PAGE_SETTINGS_RUNTIME_JS.contains("data-mnote-local-ocr-auto-retired"));
assert!(
SIDEBAR_TREE_RUNTIME_JS.contains("[data-mnote-action=\"open-knowledge-rag-settings\"]")
);
@@ -534,6 +567,11 @@ mod tests {
assert!(SIDEBAR_TREE_RUNTIME_JS.contains("pruneKnowledgeRagRegistry"));
assert!(SIDEBAR_TREE_RUNTIME_JS.contains("setKnowledgeRagSourceFilter"));
assert!(SIDEBAR_TREE_RUNTIME_JS.contains("mnote:knowledge-rag-source-updated"));
assert!(SIDEBAR_FILETREE_COMMAND_RUNTIME_JS.contains("knowledgeRagSourceRelativePath"));
assert!(SIDEBAR_FILETREE_COMMAND_RUNTIME_JS.contains("supportsKnowledgeRagSource"));
assert!(!SIDEBAR_FILETREE_COMMAND_RUNTIME_JS.contains("runLocalOcr"));
assert!(!SIDEBAR_FILETREE_COMMAND_RUNTIME_JS.contains("mnote:local-ocr-job-updated"));
assert!(!SIDEBAR_FILETREE_COMMAND_RUNTIME_JS.contains("data-mnote-local-ocr-menu-status"));
assert!(!SIDEBAR_TREE_RUNTIME_JS.contains("openPageIndexSettingsPopover();"));
assert!(!SIDEBAR_TREE_RUNTIME_JS.contains("openLocalOcrSettingsPopover();"));
assert!(!SIDEBAR_PAGE_SETTINGS_RUNTIME_JS.contains("data-page-settings-tab=\"index\""));
@@ -594,6 +632,14 @@ mod tests {
.contains("if (currentSourceKind() === 'local_folder') return false;"),
"local source 不应进入 page-ai fast-path,后端会把 run 收敛为文件引用 scope"
);
assert!(
!SIDEBAR_PAGE_AI_RUNTIME_JS.contains("pageAiEnrichOcrContextRefs"),
"Page AI 目标包不应保留已退役 OCR sidecar context enrichment"
);
assert!(
!SIDEBAR_PAGE_AI_TARGET_RUNTIME_JS.contains("ocrRootRelativePath"),
"Page AI target runtime 不应再注入旧 OCR sidecar 路径"
);
}
#[test]
@@ -786,7 +832,7 @@ mod tests {
.contains("var resolvedSidebarPayload = sidebarPayload ? (sidebarPayload.result || sidebarPayload) : null"));
assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS.contains("var renderedPage = false;"));
assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS
.contains("void renderPageProjection(resolvedSidebarPayload);"));
.contains("renderedPage = await renderPageProjection(resolvedSidebarPayload);"));
assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS.contains("refreshFileTreeParent(fileTreeScope)"));
assert!(
SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS.contains("data-mnote-local-folder-watch-applied")
@@ -802,6 +848,25 @@ mod tests {
TREE_LIVE_CONTROLLER_JS.contains("bootstrap.transport === 'local-folder-events'"),
"本地文件夹 bootstrap transport 应直接选择 /api/local-folder/events"
);
assert!(LOCAL_FOLDER_EVENT_BUS_RUNTIME_JS.contains("__mnoteLocalFolderEventBus"));
assert!(LOCAL_FOLDER_EVENT_BUS_RUNTIME_JS.contains("startLocalFolderWatcher"));
assert!(LOCAL_FOLDER_EVENT_BUS_RUNTIME_JS.contains("connections.has(key)"));
assert!(LOCAL_FOLDER_EVENT_BUS_RUNTIME_JS.contains("function pathArrayOf"));
assert!(LOCAL_FOLDER_EVENT_BUS_RUNTIME_JS.contains("function queueSidebarRefresh"));
assert!(LOCAL_FOLDER_EVENT_BUS_RUNTIME_JS
.contains("mnote:local-folder:sidebar-refresh-requested"));
assert!(LOCAL_FOLDER_EVENT_BUS_RUNTIME_JS
.contains("data-mnote-local-folder-event-bus-connections"));
assert!(LOCAL_FOLDER_EVENT_BUS_RUNTIME_JS.contains("tree:local-folder-watch-batch"));
assert!(LOCAL_FOLDER_EVENT_BUS_RUNTIME_JS.contains("viaEventBus: true"));
assert!(LOCAL_FOLDER_EVENT_BUS_RUNTIME_JS.contains("emitSyntheticWatchBatch"));
assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("__mnoteLocalFolderEventBus"));
assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("synthetic_page_ai_receipt"));
assert!(TREE_LIVE_CONTROLLER_JS.contains("__mnoteLocalFolderEventBus"));
assert!(TREE_LIVE_CONTROLLER_JS.contains("startLocalFolderWatcher"));
assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS
.contains("mnote:local-folder:sidebar-refresh-requested"));
assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS.contains("event.detail.viaEventBus === true"));
assert!(!SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS.contains("/api/tree/local-folder-watch"));
assert!(!SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS
.contains("fetch(window.location.href, { headers: { accept: 'text/html' } })"));
@@ -896,6 +961,8 @@ mod tests {
assert!(LOCAL_UPLOAD_RUNTIME_JS.contains("function fetchWithTimeout"));
assert!(LOCAL_UPLOAD_RUNTIME_JS.contains("function uploadLocalFolderAsset"));
assert!(LOCAL_UPLOAD_RUNTIME_JS.contains("function uploadMediaAsset"));
assert!(!LOCAL_UPLOAD_RUNTIME_JS.contains("/api/media/upload"));
assert!(LOCAL_UPLOAD_RUNTIME_JS.contains("data-mnote-media-upload-retired"));
assert!(LOCAL_UPLOAD_RUNTIME_JS.contains("function insertUploadedAssetIntoEditor"));
assert!(LOCAL_UPLOAD_RUNTIME_JS.contains("function dispatchUploadedEditorChange"));
assert!(LOCAL_UPLOAD_RUNTIME_JS.contains("function persistUploadedEditorChange"));
@@ -1361,7 +1428,7 @@ mod tests {
.contains("return patchFileTreeParentChildren(parentRelativePath, rows);"));
assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS.contains("var renderedPage = false;"));
assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS
.contains("void renderPageProjection(resolvedSidebarPayload);"));
.contains("renderedPage = await renderPageProjection(resolvedSidebarPayload);"));
let render_start = SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS
.find("function renderFileProjection(projection)")
.expect("renderFileProjection");
@@ -1656,9 +1723,8 @@ mod tests {
SIDEBAR_FILETREE_COMMAND_RUNTIME_JS.contains("recordFileTreeActionStatus('blocked'")
);
assert!(SIDEBAR_FILETREE_UPLOAD_RUNTIME_JS.contains("/api/tree/filetree/drop-preflight"));
assert!(
SIDEBAR_FILETREE_COMMAND_RUNTIME_JS.contains("ensureFileTreeWritableTarget('paste'")
);
assert!(SIDEBAR_FILETREE_COMMAND_RUNTIME_JS
.contains("moveSidebarFileTreeRows(runtimeState.sidebarFileTreeClipboard.rowIds"));
assert!(SIDEBAR_TREE_RUNTIME_JS.contains("ensureFileTreeWritableTarget('drop'"));
}
@@ -1851,10 +1917,13 @@ mod tests {
);
assert!(SIDEBAR_ATTACHMENT_OPEN_RUNTIME_JS.contains("function openCodeEditorAttachment"));
assert!(SIDEBAR_ATTACHMENT_OPEN_RUNTIME_JS.contains("function resolveEditorAttachmentUrl"));
assert!(!SIDEBAR_ATTACHMENT_OPEN_RUNTIME_JS.contains("/api/media/sign?assetId="));
assert!(SIDEBAR_ATTACHMENT_OPEN_RUNTIME_JS.contains("data-mnote-media-sign-retired"));
assert!(SIDEBAR_ATTACHMENT_OPEN_RUNTIME_JS
.contains("var localFilePath = localFilePathFromAssetId(assetId)"));
assert!(SIDEBAR_ATTACHMENT_OPEN_RUNTIME_JS
.contains("var localDownloadUrl = buildLocalFileOpenUrl(localFilePath, true)"));
assert!(SIDEBAR_ATTACHMENT_OPEN_RUNTIME_JS.contains(
"var localDownloadUrl = buildLocalFileOpenUrlForRoot(localFilePath, detail.localRootUri || currentRootUri(), true)"
));
assert!(SIDEBAR_ATTACHMENT_OPEN_RUNTIME_JS.contains("type: 'codeBlock'"));
assert!(SIDEBAR_ATTACHMENT_OPEN_RUNTIME_JS.contains("attrs: { language: language }"));
assert!(SIDEBAR_TREE_RUNTIME_JS.contains("inferCodeAttachmentLanguage"));
@@ -1862,9 +1931,10 @@ mod tests {
.contains("if (isPdfAttachmentFileName(detail.fileName))"));
assert!(SIDEBAR_ATTACHMENT_OPEN_RUNTIME_JS
.contains("if (isCodeAttachmentFileName(detail.fileName))"));
assert!(SIDEBAR_FILETREE_OPEN_RUNTIME_JS
assert!(!SIDEBAR_FILETREE_OPEN_RUNTIME_JS
.contains("if (isCodeAttachmentFileName(fileName) && String(asset.document_id"));
assert!(SIDEBAR_FILETREE_OPEN_RUNTIME_JS.contains("await openCodeEditorAttachment({"));
assert!(!SIDEBAR_FILETREE_OPEN_RUNTIME_JS.contains("await openCodeEditorAttachment({"));
assert!(SIDEBAR_FILETREE_OPEN_RUNTIME_JS.contains("data-mnote-media-sign-retired"));
let attachment_class_index = SIDEBAR_ATTACHMENT_OPEN_RUNTIME_JS
.find("attachmentClassForFileName(fileName).split")
.expect("editor attachment links apply type-specific classes");
@@ -1874,10 +1944,7 @@ mod tests {
assert!(attachment_class_index < local_refresh_index);
assert!(SIDEBAR_ATTACHMENT_OPEN_RUNTIME_JS.contains("'mnote:leptos-tiptap-spike:ready'"));
assert!(SIDEBAR_ATTACHMENT_OPEN_RUNTIME_JS.contains("window.setTimeout(function()"));
assert!(SIDEBAR_ATTACHMENT_OPEN_RUNTIME_JS.contains("[120, 500, 1200, 2500]"));
assert!(
SIDEBAR_ATTACHMENT_OPEN_RUNTIME_JS.contains("attachmentInitialEnhanceAttempts >= 120")
);
assert!(SIDEBAR_ATTACHMENT_OPEN_RUNTIME_JS.contains("enhanceEditorAttachmentLinks();"));
}
#[test]
@@ -1889,6 +1956,7 @@ mod tests {
assert!(TREE_LIVE_CONTROLLER_JS.contains("data-mnote-tree-live-transport"));
assert!(TREE_LIVE_CONTROLLER_JS.contains("pagehide"));
assert!(TREE_LIVE_CONTROLLER_JS.contains("__mnoteTreeLiveEventSource"));
assert!(TREE_LIVE_CONTROLLER_JS.contains("__mnoteLocalFolderEventBus"));
assert!(TREE_LIVE_CONTROLLER_JS.contains("tree:snapshot"));
assert!(TREE_LIVE_CONTROLLER_JS.contains("tree:delta"));
assert!(TREE_LIVE_CONTROLLER_JS.contains("tree:resync"));
+46 -46
View File
@@ -2771,7 +2771,7 @@ body {
background: #FFF;
}
.mnote-local-ocr-toolbar {
.mnote-knowledge-rag-toolbar {
display: flex;
align-items: center;
gap: 8px;
@@ -2784,7 +2784,7 @@ body {
box-sizing: border-box;
}
.mnote-local-ocr-status {
.mnote-knowledge-rag-status {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
@@ -2793,7 +2793,7 @@ body {
color: #787774;
}
.mnote-local-ocr-toolbar button {
.mnote-knowledge-rag-toolbar button {
flex: 0 0 auto;
height: 28px;
border: 1px solid rgba(55, 53, 47, 0.16);
@@ -2805,17 +2805,17 @@ body {
cursor: pointer;
}
.mnote-local-ocr-toolbar button:hover:not(:disabled) {
.mnote-knowledge-rag-toolbar button:hover:not(:disabled) {
background: rgba(55, 53, 47, 0.06);
}
.mnote-local-ocr-toolbar button:disabled {
.mnote-knowledge-rag-toolbar button:disabled {
cursor: default;
color: #a8a29e;
background: #f7f6f3;
}
.mnote-local-ocr-task-dock {
.mnote-knowledge-rag-task-dock {
position: fixed;
top: 12px;
right: 12px;
@@ -2826,11 +2826,11 @@ body {
pointer-events: none;
}
.mnote-local-ocr-task-toggle {
.mnote-knowledge-rag-task-toggle {
position: relative;
}
.mnote-local-ocr-task-badge {
.mnote-knowledge-rag-task-badge {
position: absolute;
top: 2px;
right: 2px;
@@ -2847,17 +2847,17 @@ body {
pointer-events: none;
}
.mnote-local-ocr-task-drawer {
.mnote-knowledge-rag-task-drawer {
width: min(440px, calc(100vw - 24px));
height: 100%;
pointer-events: auto;
}
.mnote-local-ocr-task-drawer[hidden] {
.mnote-knowledge-rag-task-drawer[hidden] {
display: none !important;
}
.mnote-local-ocr-task-panel {
.mnote-knowledge-rag-task-panel {
height: 100%;
display: flex;
flex-direction: column;
@@ -2870,18 +2870,18 @@ body {
box-sizing: border-box;
}
.mnote-local-ocr-task-head {
.mnote-knowledge-rag-task-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
}
.mnote-local-ocr-task-head > div {
.mnote-knowledge-rag-task-head > div {
min-width: 0;
}
.mnote-local-ocr-task-head strong {
.mnote-knowledge-rag-task-head strong {
display: block;
color: #1B1C1C;
font-size: 16px;
@@ -2889,7 +2889,7 @@ body {
line-height: 22px;
}
.mnote-local-ocr-task-head span {
.mnote-knowledge-rag-task-head span {
display: block;
margin-top: 2px;
color: #8B8782;
@@ -2897,7 +2897,7 @@ body {
line-height: 18px;
}
.mnote-local-ocr-task-close {
.mnote-knowledge-rag-task-close {
flex: 0 0 auto;
width: 28px;
height: 28px;
@@ -2908,12 +2908,12 @@ body {
cursor: pointer;
}
.mnote-local-ocr-task-close:hover {
.mnote-knowledge-rag-task-close:hover {
background: rgba(55, 53, 47, 0.08);
color: #37352f;
}
.mnote-local-ocr-task-tabs {
.mnote-knowledge-rag-task-tabs {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 4px;
@@ -2922,7 +2922,7 @@ body {
background: #F4F3F2;
}
.mnote-local-ocr-task-tabs button {
.mnote-knowledge-rag-task-tabs button {
min-width: 0;
height: 28px;
border: 0;
@@ -2936,26 +2936,26 @@ body {
white-space: nowrap;
}
.mnote-local-ocr-task-tabs button[aria-selected="true"] {
.mnote-knowledge-rag-task-tabs button[aria-selected="true"] {
background: #FFF;
color: #1B1C1C;
box-shadow: 0 1px 4px rgba(27, 28, 28, 0.08);
}
.mnote-local-ocr-task-toolbar {
.mnote-knowledge-rag-task-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.mnote-local-ocr-task-toolbar span {
.mnote-knowledge-rag-task-toolbar span {
color: #8B8782;
font-size: 12px;
}
.mnote-local-ocr-task-toolbar button,
.mnote-local-ocr-task-actions button {
.mnote-knowledge-rag-task-toolbar button,
.mnote-knowledge-rag-task-actions button {
min-height: 28px;
border: 1px solid rgba(27, 28, 28, 0.08);
border-radius: 6px;
@@ -2965,12 +2965,12 @@ body {
cursor: pointer;
}
.mnote-local-ocr-task-toolbar button:disabled {
.mnote-knowledge-rag-task-toolbar button:disabled {
color: #AAA6A0;
cursor: default;
}
.mnote-local-ocr-task-list {
.mnote-knowledge-rag-task-list {
min-height: 0;
overflow: auto;
display: flex;
@@ -2978,7 +2978,7 @@ body {
gap: 8px;
}
.mnote-local-ocr-task-row {
.mnote-knowledge-rag-task-row {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: start;
@@ -2989,20 +2989,20 @@ body {
background: #FFF;
}
.mnote-local-ocr-task-main {
.mnote-knowledge-rag-task-main {
min-width: 0;
display: grid;
gap: 5px;
}
.mnote-local-ocr-task-title-line {
.mnote-knowledge-rag-task-title-line {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.mnote-local-ocr-task-main strong {
.mnote-knowledge-rag-task-main strong {
display: block;
min-width: 0;
overflow: hidden;
@@ -3014,7 +3014,7 @@ body {
line-height: 18px;
}
.mnote-local-ocr-task-main em {
.mnote-knowledge-rag-task-main em {
flex: 0 0 auto;
padding: 1px 6px;
border-radius: 999px;
@@ -3025,17 +3025,17 @@ body {
line-height: 15px;
}
.mnote-local-ocr-task-row[data-mnote-local-ocr-task-category="attention"] .mnote-local-ocr-task-main em {
.mnote-knowledge-rag-task-row[data-mnote-knowledge-rag-task-category="attention"] .mnote-knowledge-rag-task-main em {
background: #FEE2E2;
color: #B3261E;
}
.mnote-local-ocr-task-row[data-mnote-local-ocr-task-category="active"] .mnote-local-ocr-task-main em {
.mnote-knowledge-rag-task-row[data-mnote-knowledge-rag-task-category="active"] .mnote-knowledge-rag-task-main em {
background: #DBEAFE;
color: #1D4ED8;
}
.mnote-local-ocr-task-main span {
.mnote-knowledge-rag-task-main span {
display: block;
min-width: 0;
overflow: hidden;
@@ -3046,7 +3046,7 @@ body {
white-space: nowrap;
}
.mnote-local-ocr-task-progress {
.mnote-knowledge-rag-task-progress {
position: relative;
height: 4px;
overflow: hidden;
@@ -3054,14 +3054,14 @@ body {
background: #ECE9E4;
}
.mnote-local-ocr-task-progress i {
.mnote-knowledge-rag-task-progress i {
display: block;
height: 100%;
border-radius: inherit;
background: #5B8DEF;
}
.mnote-local-ocr-task-progress[data-progress-mode="indeterminate"] i {
.mnote-knowledge-rag-task-progress[data-progress-mode="indeterminate"] i {
width: 40%;
animation: mnote-task-progress-slide 1.15s ease-in-out infinite;
}
@@ -3075,7 +3075,7 @@ body {
}
}
.mnote-local-ocr-task-empty {
.mnote-knowledge-rag-task-empty {
padding: 24px 12px;
border: 1px dashed rgba(27, 28, 28, 0.12);
border-radius: 8px;
@@ -3083,34 +3083,34 @@ body {
text-align: center;
}
.mnote-local-ocr-task-actions {
.mnote-knowledge-rag-task-actions {
display: grid;
flex: 0 0 auto;
gap: 6px;
}
.mnote-local-ocr-task-actions button {
.mnote-knowledge-rag-task-actions button {
padding: 0 8px;
}
.mnote-local-ocr-task-actions button[data-mnote-local-ocr-task-delete] {
.mnote-knowledge-rag-task-actions button[data-mnote-knowledge-rag-task-delete] {
color: #b3261e;
}
@media (max-width: 720px) {
.mnote-local-ocr-task-dock {
.mnote-knowledge-rag-task-dock {
left: 12px;
}
.mnote-local-ocr-task-drawer {
.mnote-knowledge-rag-task-drawer {
width: 100%;
}
.mnote-local-ocr-task-row {
.mnote-knowledge-rag-task-row {
grid-template-columns: 1fr;
}
.mnote-local-ocr-task-actions {
.mnote-knowledge-rag-task-actions {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
}
@@ -5870,6 +5870,6 @@ mod tests {
// 至少 2000 字符才能包含完整样式
assert!(MNOTE_CSS.len() > 2000);
// 当前整合了工作区壳、编辑器样式、树菜单、文件树图标、页面 AI、账号弹窗与授权管理控制面,仍保持在单文件可审阅范围内。
assert!(MNOTE_CSS.len() < 114000);
assert!(MNOTE_CSS.len() < 145000);
}
}
+56 -5
View File
@@ -2,7 +2,8 @@ use crate::app::AppConfig;
use crate::context::RequestContext;
use crate::error::WebError;
use bridge_runtime::{
build_runtime_command_artifact_plan, RuntimeCommandArtifactPlan, RuntimeCommandExecutionPlan,
build_runtime_command_artifact_plan, retired_command_transport_function_name,
retired_query_transport_function_name, RuntimeCommandArtifactPlan, RuntimeCommandExecutionPlan,
RuntimeQueryExecutionPlan,
};
use serde_json::Value;
@@ -48,9 +49,15 @@ fn load_query_fixture(
.with_header("x-error-phase", "fixture_parse")
})?;
Ok(fixtures
.as_object()
.and_then(|map| map.get(plan.function_name.as_str()))
let Some(map) = fixtures.as_object() else {
return Ok(None);
};
if let Some(fixture) = map.get(plan.function_name.as_str()) {
return Ok(Some(fixture.clone()));
}
Ok(retired_query_transport_function_name(&plan.query_name)
.ok()
.and_then(|legacy_name| map.get(legacy_name))
.cloned())
}
@@ -88,7 +95,51 @@ fn load_mutation_fixture(
context: &RequestContext,
plan: &RuntimeCommandExecutionPlan,
) -> Result<Option<Value>, WebError> {
load_mutation_fixture_by_name(config, context, plan.function_name.as_str())
if let Some(fixture) =
load_mutation_fixture_by_name(config, context, plan.function_name.as_str())?
{
return Ok(Some(fixture));
}
if let Some(legacy_name) = resource_lifecycle_transport_function_name(plan) {
if let Some(fixture) = load_mutation_fixture_by_name(config, context, legacy_name)? {
return Ok(Some(fixture));
}
}
retired_command_transport_function_name(&plan.command_name)
.ok()
.map(|legacy_name| load_mutation_fixture_by_name(config, context, legacy_name))
.transpose()
.map(|fixture| fixture.flatten())
}
fn resource_lifecycle_transport_function_name(
plan: &RuntimeCommandExecutionPlan,
) -> Option<&'static str> {
let action = match plan.command_name.as_str() {
"tree.resource.archive" => "archive",
"tree.resource.restore" => "restore",
"tree.resource.purge" => "purge",
"tree.resource.rename" => "rename",
_ => return None,
};
let resource_kind = plan
.args_json
.get("resourceLifecyclePlan")
.and_then(|value| value.get("resourceKind"))
.or_else(|| plan.args_json.get("resourceKind"))
.and_then(Value::as_str)?;
match (action, resource_kind) {
("archive" | "restore" | "rename", "file" | "media") => Some("mediaAssets:patchById"),
("purge", "file" | "media") => Some("mediaAssets:purgeById"),
("archive", "mindmap") => Some("mindmaps:softDelete"),
("restore", "mindmap") => Some("mindmaps:restore"),
("purge", "mindmap") => Some("mindmaps:purge"),
("archive", "table") => Some("tables:remove"),
("restore", "table") => Some("tables:restore"),
("purge", "table") => Some("tables:purge"),
("rename", "table") => Some("tables:update"),
_ => None,
}
}
pub async fn execute_retired_query_plan(
+2 -2
View File
@@ -35,8 +35,8 @@ node scripts/task490-runtime-surfaces-smoke.js
- 入口与认证:`task114-rust-web-gateway-entry-smoke.js``task159-auth-entry-smoke.js``task097-homepage-entry-smoke.js`
- Rust SSR 文档页 / Page Aggregatelocal-first 默认优先 `task167-local-markdown-title-body-options-no-convex-smoke.js`Page Aggregate browser conversion / compat fallback 改动补跑 `task522-page-aggregate-compat-fallback-contract.js`cloud/control-plane 文档可补跑 `task110-page-title-single-truth-smoke.js``task-page-aggregate-body-sync-smoke.js``task-page-aggregate-options-sync-smoke.js``task-page-aggregate-refresh-persistence-smoke.js`
- leptos-tiptap runtime surface`task490-runtime-surfaces-smoke.js`;需要验证保存回读可补跑 `task121-rust-web-editor-island-hydration-smoke.js`,但它仍使用 `/api/tree/commands create` 准备文档,不作为无 Convex 默认基线;需要验证浮层互斥和更多菜单状态时再跑 `task158-e30-menu-state-smoke.js`
- local-first 本地工作区:`task164-desktop-hot-local-folder-main-entry-smoke.js``task166-local-first-managed-workspace-no-convex-smoke.js``task167-local-markdown-title-body-options-no-convex-smoke.js``task436-local-markdown-open-document-external-change-smoke.js``task443-local-markdown-asset-upload-smoke.js``task451-local-markdown-conflict-resolution-ui-smoke.js``task452-local-search-index-browser-smoke.js``task453-local-folder-page-ai-changed-files-smoke.js`WorkspacePath / ObjectIdentity runtime 消费统一改动补跑 `task524-workspace-object-identity-matrix-smoke.js`
- LightRAG 资料库:`task534-knowledge-rag-source-management-scope-smoke.js` 覆盖本地文件夹 source 加入资料库、registry 状态与范围管理;`task529-knowledge-rag-citation-resource-tab-smoke.js``task530-knowledge-rag-page-ai-final-answer-smoke.js` 覆盖资料库引用回到资源页与 Page AI 最终回答。旧 `task526-local-folder-ocr-api-smoke.js` / OCR sidecar 链路已退役并软归档。
- local-first 本地工作区:`task164-desktop-hot-local-folder-main-entry-smoke.js``task166-local-first-managed-workspace-no-convex-smoke.js``task167-local-markdown-title-body-options-no-convex-smoke.js``task436-local-markdown-open-document-external-change-smoke.js``task443-local-markdown-asset-upload-smoke.js``task451-local-markdown-conflict-resolution-ui-smoke.js``task452-local-search-index-browser-smoke.js``task453-local-folder-page-ai-changed-files-smoke.js`WorkspacePath / ObjectIdentity runtime 消费统一改动补跑 `task524-workspace-object-identity-matrix-smoke.js``task452` 只覆盖普通本地搜索、settings API、tag/backlink API 和旧 local-index UI 不复活;资料库问答、PDF/Office/image source ingestion 与引用回跳改跑 LightRAG 脚本。
- LightRAG 资料库:`task534-knowledge-rag-source-management-scope-smoke.js` 覆盖本地文件夹 source 加入资料库、registry 状态与范围管理;`task529-knowledge-rag-citation-resource-tab-smoke.js``task530-knowledge-rag-page-ai-final-answer-smoke.js` 覆盖资料库引用回到资源页与 Page AI 最终回答。旧 `task526-local-folder-ocr-api-smoke.js` / OCR sidecar 链路已退役并软归档`/api/search/documents` 不作为 LightRAG、LiteParse、OCR sidecar 或 evidence.sqlite 的资料库问答 fallback
- local Markdown conflict regression`task510-local-markdown-conflict-regression-group.js` 串联真实冲突、连续上传、新建页面、外部恢复、多 tab CAS 与上传 409 孤儿策略;可用 `MNOTE_CONFLICT_REGRESSION_TASKS=a.js,b.js` 做定点子集。
- tree realtime / live cache`task446-tree-rename-dual-browser-live-smoke.js``task447-tree-move-order-dual-browser-live-smoke.js``task448-tree-resync-recovery-dual-browser-smoke.js``task449-tree-sse-reconnect-snapshot-recovery-smoke.js`
- 资源对象与 mindmap`task455-local-folder-mindmap-clean-smoke.js``task456-resource-object-shell-sync-smoke.js``task166-mindmap-phase6-block-smoke.js``task167-mindmap-kmind-parity-smoke.js``task168-mindmap-put-validator-smoke.js`Page AI mindmap skill / 资源生成改动补跑 `task503-mindmap-skill-capability-smoke.js`,真实 mindmap resource tab / Page AI target 改动补跑 `task525-page-ai-mindmap-resource-target-smoke.js`
+141 -65
View File
@@ -25,8 +25,8 @@ import { randomUUID } from 'node:crypto';
import { AsyncLocalStorage } from 'node:async_hooks';
import { readFileSync, existsSync } from 'node:fs';
import { homedir } from 'node:os';
import { join } from 'node:path';
import { pathToFileURL } from 'node:url';
import { dirname, join } from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { execFileSync } from 'node:child_process';
const MNOTE_WEB_URL = process.env.MNOTE_WEB_URL || 'http://127.0.0.1:3000';
@@ -38,6 +38,41 @@ function debugLog(message) {
const toolContextStorage = new AsyncLocalStorage();
const MNOTE_TOOL_NAMES = [
'mnote.skill.read',
'mnote.context.snapshot',
'mnote.context.resolve_target',
'mnote.context.read_current_page',
'mnote.knowledge_rag.status',
'mnote.knowledge_rag.query',
'mnote.knowledge_rag.open_reference',
];
const REASONIX_TOOL_TO_MNOTE_TOOL = {
mnote_skill_read: 'mnote.skill.read',
mnote_context_snapshot: 'mnote.context.snapshot',
mnote_context_resolve_target: 'mnote.context.resolve_target',
mnote_context_read_current_page: 'mnote.context.read_current_page',
mnote_knowledge_rag_status: 'mnote.knowledge_rag.status',
mnote_knowledge_rag_query: 'mnote.knowledge_rag.query',
mnote_knowledge_rag_open_reference: 'mnote.knowledge_rag.open_reference',
};
function readRustManifestToolNames() {
const scriptDir = dirname(fileURLToPath(import.meta.url));
const manifestPath = join(scriptDir, '..', 'rust', 'crates', 'mnote-web', 'src', 'hermes_tools', 'manifest.rs');
const source = readFileSync(manifestPath, 'utf8');
return Array.from(source.matchAll(/"name":\s*"([^"]+)"/g)).map((match) => match[1]);
}
function assertSameSortedSet(actual, expected, label) {
const actualSorted = [...actual].sort();
const expectedSorted = [...expected].sort();
if (JSON.stringify(actualSorted) !== JSON.stringify(expectedSorted)) {
throw new Error(`${label} mismatch: actual=${JSON.stringify(actualSorted)} expected=${JSON.stringify(expectedSorted)}`);
}
}
function isWriteMnoteTool(toolName) {
return ![
'mnote.skill.read',
@@ -278,6 +313,23 @@ if (process.env.MNOTE_REASONIX_ACP_SELFTEST === '1') {
if (toolResultStatusFromContent(failedEvidenceToolResult) !== 'failed') {
throw new Error('selftest expected ok:false evidence result to be failed');
}
const rustKnowledgeTools = readRustManifestToolNames()
.filter((toolName) => toolName.startsWith('mnote.knowledge_rag.'));
const wrapperKnowledgeTools = MNOTE_TOOL_NAMES
.filter((toolName) => toolName.startsWith('mnote.knowledge_rag.'));
assertSameSortedSet(
wrapperKnowledgeTools,
rustKnowledgeTools,
'selftest expected Reasonix MNOTE_TOOL_NAMES to match Rust knowledge RAG manifest tools',
);
const wrapperMappedKnowledgeTools = Object.entries(REASONIX_TOOL_TO_MNOTE_TOOL)
.filter(([reasonixName]) => reasonixName.startsWith('mnote_knowledge_rag_'))
.map(([, toolName]) => toolName);
assertSameSortedSet(
wrapperMappedKnowledgeTools,
rustKnowledgeTools,
'selftest expected Reasonix wrapper tool mapping to match Rust knowledge RAG manifest tools',
);
process.stderr.write('[reasonix-acp-mnote] selftest ok\n');
process.exit(0);
}
@@ -498,26 +550,6 @@ function emitUsage(sessionId, used, size) {
// Calls mnote-web's Rust tool endpoints via HTTP.
// Reference: rust/crates/mnote-web/src/routes/hermes_tools.rs
const MNOTE_TOOL_NAMES = [
'mnote.skill.read',
'mnote.context.snapshot',
'mnote.context.resolve_target',
'mnote.context.read_current_page',
'mnote.knowledge_rag.status',
'mnote.knowledge_rag.query',
'mnote.knowledge_rag.open_reference',
];
const REASONIX_TOOL_TO_MNOTE_TOOL = {
mnote_skill_read: 'mnote.skill.read',
mnote_context_snapshot: 'mnote.context.snapshot',
mnote_context_resolve_target: 'mnote.context.resolve_target',
mnote_context_read_current_page: 'mnote.context.read_current_page',
mnote_knowledge_rag_status: 'mnote.knowledge_rag.status',
mnote_knowledge_rag_query: 'mnote.knowledge_rag.query',
mnote_knowledge_rag_open_reference: 'mnote.knowledge_rag.open_reference',
};
async function callMnoteTool(toolName, args) {
const url = `${MNOTE_WEB_URL}/api/hermes/tools/mnote/call`;
const payload = buildMnoteToolPayload(toolName, args, toolContextStorage.getStore() || {});
@@ -542,7 +574,14 @@ async function callMnoteTool(toolName, args) {
const tools = new ToolRegistry();
const chatOnlyTools = new ToolRegistry();
tools.register({
function reasonixToolNameForMnoteTool(toolName) {
return String(toolName || '').trim().replace(/\./g, '_');
}
function fallbackMnoteToolSpecs() {
return [
{
mnoteToolName: 'mnote.skill.read',
name: 'mnote_skill_read',
description: '按需读取 MNote skill 正文。只有任务需要 MNote 能力时才调用。',
parameters: {
@@ -553,35 +592,24 @@ tools.register({
},
required: ['skillId'],
},
readOnly: true,
fn: async (args) => callMnoteTool(REASONIX_TOOL_TO_MNOTE_TOOL.mnote_skill_read, args),
parallelSafe: true,
});
tools.register({
},
{
mnoteToolName: 'mnote.context.snapshot',
name: 'mnote_context_snapshot',
description: '读取本次 Page AI run 的 MNote 上下文摘要,不返回页面正文全文。',
parameters: {
type: 'object',
properties: {
contextRefs: { type: 'array', items: {} },
},
},
readOnly: true,
fn: async (args) => callMnoteTool(REASONIX_TOOL_TO_MNOTE_TOOL.mnote_context_snapshot, args),
parameters: { type: 'object', properties: { contextRefs: { type: 'array', items: {} } } },
parallelSafe: true,
});
tools.register({
},
{
mnoteToolName: 'mnote.context.resolve_target',
name: 'mnote_context_resolve_target',
description: '解析当前 MNote 工作区、文档、rootUri、relativePath 与 file version。',
parameters: { type: 'object', properties: {} },
readOnly: true,
fn: async (args) => callMnoteTool(REASONIX_TOOL_TO_MNOTE_TOOL.mnote_context_resolve_target, args),
parallelSafe: true,
});
tools.register({
},
{
mnoteToolName: 'mnote.context.read_current_page',
name: 'mnote_context_read_current_page',
description: '在用户任务明确需要当前页内容时读取当前 Markdown 页面。',
parameters: {
@@ -591,12 +619,10 @@ tools.register({
format: { type: 'string', enum: ['markdown', 'blocks'], description: '返回格式' },
},
},
readOnly: true,
fn: async (args) => callMnoteTool(REASONIX_TOOL_TO_MNOTE_TOOL.mnote_context_read_current_page, args),
parallelSafe: false,
});
tools.register({
},
{
mnoteToolName: 'mnote.knowledge_rag.status',
name: 'mnote_knowledge_rag_status',
description: '查看 LightRAG 资料库 provider 状态、dashboard 地址和 MNote source registry。',
parameters: {
@@ -606,14 +632,12 @@ tools.register({
rootUri: { type: 'string', description: 'local folder rootUri,可省略并使用当前上下文' },
},
},
readOnly: true,
fn: async (args) => callMnoteTool(REASONIX_TOOL_TO_MNOTE_TOOL.mnote_knowledge_rag_status, args),
parallelSafe: true,
});
tools.register({
},
{
mnoteToolName: 'mnote.knowledge_rag.query',
name: 'mnote_knowledge_rag_query',
description: '向 LightRAG 资料库提问,返回 answer、provider 原始结果和经 MNote registry 映射的 references。回答必须引用返回来源。',
description: '向 LightRAG 资料库提问,返回 provider 原始结果和经 MNote registry 映射、sourcePaths 后过滤的 references。回答必须引用返回来源,不要引用 raw chunks。',
parameters: {
type: 'object',
properties: {
@@ -628,17 +652,15 @@ tools.register({
sourcePaths: {
type: 'array',
items: { type: 'string' },
description: '可选 MNote workspace 相对路径范围;可传文件或目录,返回 references 会限制在这些来源内。',
description: '可选 MNote workspace 相对路径范围;可传文件或目录。当前语义是 provider 检索后过滤 referencesraw 仍可能是全局结果。',
},
},
required: ['query'],
},
readOnly: true,
fn: async (args) => callMnoteTool(REASONIX_TOOL_TO_MNOTE_TOOL.mnote_knowledge_rag_query, args),
parallelSafe: false,
});
tools.register({
},
{
mnoteToolName: 'mnote.knowledge_rag.open_reference',
name: 'mnote_knowledge_rag_open_reference',
description: '把 LightRAG reference 映射为 MNote 可打开的本地 resource action;没有 registry 命中时返回定位降级。',
parameters: {
@@ -653,10 +675,64 @@ tools.register({
},
required: ['filePath'],
},
readOnly: true,
fn: async (args) => callMnoteTool(REASONIX_TOOL_TO_MNOTE_TOOL.mnote_knowledge_rag_open_reference, args),
parallelSafe: true,
});
},
];
}
async function loadMnoteManifestToolSpecs() {
const response = await fetch(`${MNOTE_WEB_URL}/api/hermes/tools/mnote/manifest`, {
headers: {
accept: 'application/json',
'x-mnote-actor-id': process.env.MNOTE_ACTOR_ID || 'reasonix-acp',
'x-mnote-actor-type': 'agent',
},
});
if (!response.ok) throw new Error(`manifest_http_${response.status}`);
const payload = await response.json();
const manifest = payload?.manifest && typeof payload.manifest === 'object' ? payload.manifest : payload;
const toolsByName = new Map((Array.isArray(manifest?.tools) ? manifest.tools : [])
.filter((tool) => tool && typeof tool.name === 'string')
.map((tool) => [tool.name, tool]));
const specs = MNOTE_TOOL_NAMES.map((mnoteToolName) => {
const tool = toolsByName.get(mnoteToolName);
if (!tool) return null;
return {
mnoteToolName,
name: reasonixToolNameForMnoteTool(mnoteToolName),
description: String(tool.description || mnoteToolName),
parameters: tool.inputSchema || { type: 'object', properties: {} },
parallelSafe: mnoteToolName !== 'mnote.knowledge_rag.query' && mnoteToolName !== 'mnote.context.read_current_page',
};
}).filter(Boolean);
return specs.length === MNOTE_TOOL_NAMES.length ? specs : [];
}
function registerMnoteToolSpecs(registry, specs) {
for (const spec of specs) {
REASONIX_TOOL_TO_MNOTE_TOOL[spec.name] = spec.mnoteToolName;
registry.register({
name: spec.name,
description: spec.description,
parameters: spec.parameters,
readOnly: !isWriteMnoteTool(spec.mnoteToolName),
fn: async (args) => callMnoteTool(spec.mnoteToolName, args),
parallelSafe: Boolean(spec.parallelSafe),
});
}
}
let mnoteToolSpecsSource = 'static_fallback';
let mnoteToolSpecs = [];
try {
mnoteToolSpecs = await loadMnoteManifestToolSpecs();
if (mnoteToolSpecs.length) mnoteToolSpecsSource = 'runtime_manifest';
} catch (error) {
debugLog(`[reasonix-acp-mnote] manifest dynamic tool registration unavailable: ${error instanceof Error ? error.message : String(error)}`);
}
if (!mnoteToolSpecs.length) mnoteToolSpecs = fallbackMnoteToolSpecs();
registerMnoteToolSpecs(tools, mnoteToolSpecs);
debugLog(`[reasonix-acp-mnote] registered ${mnoteToolSpecs.length} mnote tools from ${mnoteToolSpecsSource}`);
// ── Session Store ────────────────────────────────────
@@ -699,7 +775,7 @@ onRequest('session/new', async (params) => {
'<available-skills>',
'Use mnote_skill_read to load the full content of any skill listed by the current prompt capabilities.',
'- mnote-current-page — Read the current MNote Markdown page when the task needs page content.',
'- mnote-knowledge-rag — Ask the LightRAG-backed knowledge library across books, papers, PDFs, Office files, images, and attachments. Use mnote_knowledge_rag_query for knowledge-library questions and cite only returned references; if locatorDegraded is true, say the source location is degraded rather than inventing page/bbox.',
'- mnote-knowledge-rag — Ask the LightRAG-backed knowledge library across books, papers, PDFs, Office files, images, and attachments. Use mnote_knowledge_rag_query for knowledge-library questions and cite only returned references; sourcePaths filters returned references after provider retrieval, so do not cite raw chunks. If locatorDegraded is true, say the source location is degraded rather than inventing page/bbox.',
'- mnote-local-file — Resolve MNote targets and then use native file tools inside allowed roots.',
'- mnote-chat-only — Reply conversationally without MNote file/page tools.',
'</available-skills>',
@@ -92,6 +92,58 @@ async function browserRefreshLocalIndex(page, root) {
}, { rootUri: fileUrl(root) });
}
async function browserUpdateLocalIndexSettings(page, root) {
return page.evaluate(async ({ rootUri }) => {
const response = await fetch("/api/search/local-index/settings", {
method: "PUT",
headers: { "content-type": "application/json", accept: "application/json" },
body: JSON.stringify({
workspaceId: "local-ws:user_real:task452",
rootUri,
includePaths: ["."],
scheduleMode: "manual",
scheduleTime: "02:00",
runOnChange: false,
}),
});
const payload = await response.json().catch(() => null);
return {
status: response.status,
payload,
};
}, { rootUri: fileUrl(root) });
}
async function browserFetchLocalIndexBacklinksAndTags(page, root, documentId) {
return page.evaluate(async ({ rootUri, documentIdValue }) => {
const backlinksParams = new URLSearchParams({
workspaceId: "local-ws:user_real:task452",
rootUri,
documentId: documentIdValue,
});
const tagsParams = new URLSearchParams({
workspaceId: "local-ws:user_real:task452",
rootUri,
});
const backlinksResponse = await fetch(`/api/search/local-index/backlinks?${backlinksParams.toString()}`, {
headers: { accept: "application/json" },
});
const tagsResponse = await fetch(`/api/search/local-index/tags?${tagsParams.toString()}`, {
headers: { accept: "application/json" },
});
return {
backlinks: {
status: backlinksResponse.status,
payload: await backlinksResponse.json().catch(() => null),
},
tags: {
status: tagsResponse.status,
payload: await tagsResponse.json().catch(() => null),
},
};
}, { rootUri: fileUrl(root), documentIdValue: documentId });
}
async function capturePanelDiagnostics(page) {
try {
return await page.evaluate(function() {
@@ -99,12 +151,16 @@ async function capturePanelDiagnostics(page) {
var tg = document.querySelector('[data-testid="wolai-page-settings-local-index-tags"]');
var st = document.querySelector('[data-testid="wolai-page-settings-local-index-status"]');
var popover = document.querySelector('[data-testid="mnote-local-index-settings-popover"]');
var ranges = document.querySelector('[data-testid="wolai-page-settings-local-index-ranges"]');
var pageSettingsIndexTab = document.querySelector('[data-page-settings-tab="index"]');
return {
backlinksHtml: bl ? bl.innerHTML : '(missing)',
tagsHtml: tg ? tg.innerHTML : '(missing)',
statusText: st ? st.textContent : '(missing)',
popoverExists: Boolean(popover),
popoverHidden: popover ? popover.hidden : null,
rangeInputs: ranges ? ranges.querySelectorAll('[data-local-index-range-input]').length : 0,
hasPageSettingsIndexTab: Boolean(pageSettingsIndexTab),
locationHref: window.location.href,
};
});
@@ -157,6 +213,9 @@ async function run() {
"utf8",
);
const settingsBeforeSearch = await browserUpdateLocalIndexSettings(page, root);
assert.equal(settingsBeforeSearch.status, 200, `更新本地索引设置应成功: ${JSON.stringify(settingsBeforeSearch)}`);
debug.settingsBeforeSearch = settingsBeforeSearch.payload;
const refreshedBeforeSearch = await browserRefreshLocalIndex(page, root);
assert.equal(refreshedBeforeSearch.status, 200, `刷新本地索引应成功: ${JSON.stringify(refreshedBeforeSearch)}`);
debug.refreshedBeforeSearch = refreshedBeforeSearch.payload;
@@ -173,23 +232,19 @@ async function run() {
waitUntil: "domcontentloaded",
timeout: UI_TIMEOUT_MS,
});
await page.locator('[data-testid="mnote-local-index-settings-toggle"]').click({ timeout: UI_TIMEOUT_MS });
await page.locator('[data-testid="mnote-local-index-settings-popover"]').waitFor({
state: "visible",
timeout: UI_TIMEOUT_MS,
});
debug.navigationIndexSettings = await page.evaluate(function() {
var popover = document.querySelector('[data-testid="mnote-local-index-settings-popover"]');
var ranges = document.querySelector('[data-testid="wolai-page-settings-local-index-ranges"]');
var indexToggle = document.querySelector('[data-testid="mnote-local-index-settings-toggle"]');
var ragToggle = document.querySelector('[data-testid="mnote-knowledge-rag-settings-toggle"]');
var pageSettingsIndexTab = document.querySelector('[data-page-settings-tab="index"]');
return {
path: window.location.pathname,
visible: Boolean(popover && !popover.hidden),
rangeInputs: ranges ? ranges.querySelectorAll('[data-local-index-range-input]').length : 0,
hasLocalIndexToggle: Boolean(indexToggle),
hasKnowledgeRagToggle: Boolean(ragToggle),
hasPageSettingsIndexTab: Boolean(pageSettingsIndexTab),
};
});
assert.equal(debug.navigationIndexSettings.visible, true, `导航页应能打开索引设置: ${JSON.stringify(debug.navigationIndexSettings)}`);
assert.equal(debug.navigationIndexSettings.hasLocalIndexToggle, false, `导航页不应恢复旧本地索引设置入口: ${JSON.stringify(debug.navigationIndexSettings)}`);
assert.equal(debug.navigationIndexSettings.hasKnowledgeRagToggle, true, `导航页应保留资料库问答入口: ${JSON.stringify(debug.navigationIndexSettings)}`);
assert.equal(debug.navigationIndexSettings.hasPageSettingsIndexTab, false, `索引设置不应留在页面设置页签: ${JSON.stringify(debug.navigationIndexSettings)}`);
await page.goto(documentUrl(root, firstRelativePath), {
@@ -201,49 +256,46 @@ async function run() {
timeout: UI_TIMEOUT_MS,
});
// 拦截 API 请求与页面错误做诊断
// 拦截页面错误做诊断backlink/tag 当前不再由索引面板渲染,下面走 API 直验。
var diagApiResponses = {};
page.on('response', function onDiag(resp) {
var url = resp.url();
if (url.includes('/api/search/local-index/backlinks') || url.includes('/api/search/local-index/tags')) {
resp.json().then(function(body) { diagApiResponses[url] = body; }).catch(function() {});
}
});
page.on('pageerror', function onPageError(err) { diagApiResponses._pageError = String(err); });
page.on('console', function onConsole(msg) {
if (msg.type() === 'error') { diagApiResponses._consoleErrors = (diagApiResponses._consoleErrors || []).concat([msg.text()]); }
});
await page.locator('[data-testid="mnote-local-index-settings-toggle"]').click({ timeout: UI_TIMEOUT_MS });
// 等待本地索引面板渲染;超时时捕获 DOM 诊断再抛
try {
await page.waitForFunction(() => {
var bl = document.querySelector('[data-testid="wolai-page-settings-local-index-backlinks"]');
var tg = document.querySelector('[data-testid="wolai-page-settings-local-index-tags"]');
return Boolean(
bl && bl.textContent && bl.textContent.includes("README.md")
&& bl.textContent.includes("[Target](docs/search-target.md)")
&& tg && tg.textContent && tg.textContent.includes("#alpha"),
);
}, null, { timeout: UI_TIMEOUT_MS });
} catch (waitError) {
debug.diagApiResponses = diagApiResponses;
debug.failedPanelState = await capturePanelDiagnostics(page);
throw waitError;
}
debug.localIndexPanel = await page.evaluate(function() {
var bl = document.querySelector('[data-testid="wolai-page-settings-local-index-backlinks"]');
var tg = document.querySelector('[data-testid="wolai-page-settings-local-index-tags"]');
var st = document.querySelector('[data-testid="wolai-page-settings-local-index-status"]');
var indexToggle = document.querySelector('[data-testid="mnote-local-index-settings-toggle"]');
var ragToggle = document.querySelector('[data-testid="mnote-knowledge-rag-settings-toggle"]');
var pageSettingsIndexTab = document.querySelector('[data-page-settings-tab="index"]');
return {
backlinks: bl ? bl.innerHTML : '(missing)',
tags: tg ? tg.innerHTML : '(missing)',
status: st ? st.textContent : '(missing)',
hasLocalIndexToggle: Boolean(indexToggle),
hasKnowledgeRagToggle: Boolean(ragToggle),
hasPageSettingsIndexTab: Boolean(pageSettingsIndexTab),
hasLegacyBacklinksDom: Boolean(document.querySelector('[data-testid="wolai-page-settings-local-index-backlinks"]')),
hasLegacyTagsDom: Boolean(document.querySelector('[data-testid="wolai-page-settings-local-index-tags"]')),
};
});
debug.diagApiResponses = diagApiResponses;
assert.equal(debug.localIndexPanel.hasLocalIndexToggle, false, `文档页不应恢复旧本地索引设置入口: ${JSON.stringify(debug.localIndexPanel)}`);
assert.equal(debug.localIndexPanel.hasKnowledgeRagToggle, true, `文档页应保留资料库问答入口: ${JSON.stringify(debug.localIndexPanel)}`);
assert.equal(debug.localIndexPanel.hasPageSettingsIndexTab, false, `文档页索引设置不应回落到页面设置页签: ${JSON.stringify(debug.localIndexPanel)}`);
assert.equal(debug.localIndexPanel.hasLegacyBacklinksDom, false, `独立索引面板不应恢复旧 backlink DOM: ${JSON.stringify(debug.localIndexPanel)}`);
assert.equal(debug.localIndexPanel.hasLegacyTagsDom, false, `独立索引面板不应恢复旧 tags DOM: ${JSON.stringify(debug.localIndexPanel)}`);
const backlinksAndTags = await browserFetchLocalIndexBacklinksAndTags(page, root, localMdDocumentId(firstRelativePath));
assert.equal(backlinksAndTags.backlinks.status, 200, `反链 API 应继续可用: ${JSON.stringify(backlinksAndTags)}`);
assert.equal(backlinksAndTags.tags.status, 200, `标签 API 应继续可用: ${JSON.stringify(backlinksAndTags)}`);
debug.backlinksAndTags = backlinksAndTags;
const backlinks = backlinksAndTags.backlinks.payload?.result?.backlinks || [];
assert(
backlinks.some((item) => item.documentId === localMdDocumentId("README.md")),
`本地索引反链应能找到 README.md: ${JSON.stringify(backlinksAndTags.backlinks.payload)}`,
);
const tags = backlinksAndTags.tags.payload?.result?.tags || [];
assert(
tags.some((item) => item.tag === "alpha"),
`本地索引标签 API 应能返回 alpha: ${JSON.stringify(backlinksAndTags.tags.payload)}`,
);
fs.renameSync(path.join(root, firstRelativePath), path.join(root, renamedRelativePath));
const refreshedAfterRename = await browserRefreshLocalIndex(page, root);
@@ -9,8 +9,8 @@ const { chromium } = require("playwright");
const BASE_URL = (process.env.MNOTE_WEB_SMOKE_BASE_URL || "http://127.0.0.1:3000").replace(/\/+$/, "");
const ROOT_URI = process.env.MNOTE_KNOWLEDGE_RAG_ROOT_URI || "file:///mnt/Data1T/Mnote_data/users/mnote-e2e/workspaces/my-space";
const WORKSPACE_ID = process.env.MNOTE_KNOWLEDGE_RAG_WORKSPACE_ID || "local-ws:mnote-e2e:my-space";
const QUERY = process.env.MNOTE_KNOWLEDGE_RAG_QUERY || "scan image start";
const EXPECTED_RESOURCE = process.env.MNOTE_KNOWLEDGE_RAG_EXPECTED_RESOURCE || "knowledge-rag-fixtures-7-50/scan-image-start.pdf";
const QUERY = process.env.MNOTE_KNOWLEDGE_RAG_QUERY || "image copy 6.png";
const EXPECTED_RESOURCE = process.env.MNOTE_KNOWLEDGE_RAG_EXPECTED_RESOURCE || "新页面233155/image copy 6.png";
const OUTPUT_DIR = path.join(process.cwd(), "tmp", "task529-knowledge-rag-citation-resource-tab-smoke");
const RESULT_PATH = path.join(OUTPUT_DIR, "result.json");
const SCREENSHOT_PATH = path.join(OUTPUT_DIR, "citation-resource-tab.png");
@@ -48,17 +48,18 @@ async function main() {
topK: 8,
chunkTopK: 8,
includeChunkContent: true,
sourcePaths: [EXPECTED_RESOURCE],
},
});
assert(queryResponse.ok(), `knowledge-rag query 失败: ${queryResponse.status()} ${await queryResponse.text()}`);
const queryPayload = await queryResponse.json();
const reference = (queryPayload.references || []).find((item) => item.sourceRootRelativePath === EXPECTED_RESOURCE && item.citationUrl);
assert(reference, `缺少目标引用 ${EXPECTED_RESOURCE}: ${JSON.stringify(queryPayload.references || [], null, 2).slice(0, 3000)}`);
assert.equal(reference.locatorDegraded, false, `目标引用不应降级: ${JSON.stringify(reference, null, 2)}`);
assert(reference.locator?.page, "目标引用缺少 page");
assert(reference.locator?.bbox, "目标引用缺少 bbox");
assert.equal(reference.locatorDegraded, true, `当前 image wrapper 引用应明确降级: ${JSON.stringify(reference, null, 2)}`);
assert(!reference.locator?.page, `降级引用不应伪造 page: ${JSON.stringify(reference, null, 2)}`);
assert(!reference.locator?.bbox, `降级引用不应伪造 bbox: ${JSON.stringify(reference, null, 2)}`);
assert(String(reference.citationUrl || "").includes("resourceTab="), "query citationUrl 缺少 resourceTab");
assert(String(reference.citationMarkdown || "").includes("p."), "query citationMarkdown 缺少页码");
assert(String(reference.citationMarkdown || "").includes("来源定位降级"), "query citationMarkdown 缺少降级口径");
const openResponse = await context.request.post(`${BASE_URL}/api/knowledge-rag/open-reference`, {
data: {
@@ -72,9 +73,9 @@ async function main() {
assert(openResponse.ok(), `knowledge-rag open_reference 失败: ${openResponse.status()} ${await openResponse.text()}`);
const openPayload = await openResponse.json();
assert.equal(openPayload.ok, true, `open_reference ok=false: ${JSON.stringify(openPayload, null, 2)}`);
assert.equal(openPayload.reference?.locatorDegraded, false, `open_reference locator 降级: ${JSON.stringify(openPayload.reference, null, 2)}`);
assert.equal(openPayload.reference?.locatorDegraded, true, `open_reference 应保持 locator 降级: ${JSON.stringify(openPayload.reference, null, 2)}`);
assert(String(openPayload.reference?.citationUrl || "").includes("resourceTab="), "open_reference citationUrl 缺少 resourceTab");
assert(String(openPayload.reference?.citationMarkdown || "").includes("p."), "open_reference citationMarkdown 缺少页码");
assert(String(openPayload.reference?.citationMarkdown || "").includes("来源定位降级"), "open_reference citationMarkdown 缺少降级口径");
const page = await context.newPage();
const pageErrors = [];
@@ -95,6 +96,7 @@ async function main() {
const panel = document.querySelector(`[data-mnote-resource-tab-panel][data-resource-path="${expectedResource}"]`);
const inlineViewer = document.querySelector("[data-mnote-inline-pdf-viewer]");
const evidencePage = document.querySelector("[data-mnote-evidence-page='true']");
const image = panel ? panel.querySelector("img, [data-mnote-image-viewer], [data-mnote-resource-image]") : null;
return {
url: location.href,
activeTabText: activeTab ? activeTab.textContent.trim().slice(0, 120) : "",
@@ -103,6 +105,7 @@ async function main() {
panelResourcePath: panel ? panel.getAttribute("data-resource-path") : "",
panelLocator: panel ? panel.getAttribute("data-mnote-evidence-locator") : "",
inlinePdfVisible: !!inlineViewer,
imageVisible: !!image,
evidencePageRendered: !!evidencePage,
evidencePageNumber: evidencePage ? evidencePage.getAttribute("data-page-number") : "",
};
@@ -110,9 +113,7 @@ async function main() {
await page.screenshot({ path: SCREENSHOT_PATH, fullPage: true });
assert.equal(state.panelVisible, true, `citationUrl 未打开资源标签页: ${JSON.stringify(state, null, 2)}`);
assert.equal(state.panelResourcePath, EXPECTED_RESOURCE, `资源标签页路径不匹配: ${JSON.stringify(state, null, 2)}`);
assert(String(state.panelLocator || "").includes('"page"'), `资源标签页缺少 evidence locator: ${JSON.stringify(state, null, 2)}`);
assert.equal(state.inlinePdfVisible, true, `资源标签页 PDF 内容未渲染: ${JSON.stringify(state, null, 2)}`);
assert.equal(state.evidencePageRendered, true, `资源标签页未渲染 evidence page 高亮: ${JSON.stringify(state, null, 2)}`);
assert(!String(state.panelLocator || "").includes('"page"'), `降级资源标签页不应携带伪造 page locator: ${JSON.stringify(state, null, 2)}`);
const result = {
ok: true,
@@ -123,8 +124,7 @@ async function main() {
expectedResource: EXPECTED_RESOURCE,
citationUrl: reference.citationUrl,
citationMarkdown: reference.citationMarkdown,
page: reference.locator.page,
bbox: reference.locator.bbox,
locatorDegraded: reference.locatorDegraded,
openReferenceCitationUrl: openPayload.reference.citationUrl,
state,
pageErrors,
@@ -177,8 +177,8 @@ async function main() {
.count();
const prompt = [
"请调用 mnote_knowledge_rag_query 检索资料库。",
"问题:scan image start 这份扫描 PDF 里出现了什么关键短语?",
"最终只输出一句中文结论,必须包含返回的 citationMarkdown 链接,不要描述检索过程,不要输出 raw JSON,不要编造页码。",
"问题:这个图片资料在资料库里是什么内容?调用工具时请把 sourcePaths 参数设为 [\"新页面233155/image copy 6.png\"]。",
"最终只输出一句中文结论,必须包含返回的 citationMarkdown 链接;如果 locatorDegraded=true,必须说明来源定位降级,不要描述检索过程,不要输出 raw JSON,不要编造页码或 bbox。",
].join("\n");
await page.locator("[data-page-ai-input]").fill(prompt, { timeout: UI_TIMEOUT_MS });
await page.locator('[data-page-ai-action="send"]').click({ timeout: UI_TIMEOUT_MS });
@@ -194,7 +194,7 @@ async function main() {
);
const node = nodes.slice(countBefore).at(-1) || nodes.at(-1);
const text = node?.querySelector(".wolai-page-ai-message-text")?.textContent || "";
return text.includes("first image in PDF") && text.includes("scan-image-start.pdf") && text.includes("p.1");
return text.includes("来源定位降级") && text.includes("image copy 6.png");
},
assistantCount,
{ timeout: Math.max(UI_TIMEOUT_MS, 180_000) },
@@ -202,11 +202,12 @@ async function main() {
const assistantText = await newestAssistantText(page, assistantCount);
const assistantLinks = await newestAssistantLinks(page, assistantCount);
assert(assistantText.includes("first image in PDF"), `可见回答缺少关键短语: ${assistantText}`);
assert(assistantText.includes("scan-image-start.pdf"), `可见回答缺少来源文件名: ${assistantText}`);
assert(assistantText.includes("p.1"), `可见回答缺少页码 citation 文本: ${assistantText}`);
assert(assistantText.includes("image copy 6.png"), `可见回答缺少当前资料来源文件名: ${assistantText}`);
assert(assistantText.includes("来源定位降级"), `可见回答缺少 degraded citation 口径: ${assistantText}`);
assert(!/\bp\.\d+\b/i.test(assistantText), `degraded citation 不应编造页码: ${assistantText}`);
assert(!/bbox/i.test(assistantText), `degraded citation 不应编造 bbox: ${assistantText}`);
assert(
assistantLinks.some((link) => link.text.includes("scan-image-start.pdf") && link.href.includes("resourceTab=")),
assistantLinks.some((link) => link.text.includes("来源定位降级") && link.href.includes("resourceTab=")),
`可见回答缺少可点击 resourceTab citation: ${JSON.stringify(assistantLinks)}`,
);
assert(!assistantText.includes("citationMarkdown"), `可见回答泄漏工具字段名: ${assistantText}`);
@@ -0,0 +1,170 @@
#!/usr/bin/env node
"use strict";
const assert = require("node:assert");
const fs = require("node:fs");
const path = require("node:path");
const { chromium } = require("playwright");
const ROOT = process.env.MNOTE_REPO_ROOT || "/mnt/Data1T/mnote";
const { BASE_URL, UI_TIMEOUT_MS } = require(path.join(ROOT, "scripts", "tree-shell-smoke-helpers"));
const OUTPUT_DIR = path.join(ROOT, "tmp", "task531-lightrag-dashboard-ui-smoke");
const RESULT_PATH = path.join(OUTPUT_DIR, "result.json");
const CHROME = process.env.MNOTE_PAGE_AI_CHROME || "/usr/bin/google-chrome";
async function fetchDashboardStatus(context) {
const response = await context.request.get(`${BASE_URL}/api/knowledge-rag/status`, {
headers: {
"x-mnote-actor-id": "mnote-e2e",
"x-mnote-actor-type": "user",
"x-mnote-workspace-id": "local-ws:mnote-e2e:my-space",
},
timeout: UI_TIMEOUT_MS,
});
const text = await response.text();
assert(response.ok(), `/api/knowledge-rag/status 失败: ${response.status()} ${text.slice(0, 500)}`);
const payload = JSON.parse(text);
assert(payload.dashboardUrl, `status 缺少 dashboardUrl: ${text.slice(0, 500)}`);
assert(payload.health?.healthy !== false, `LightRAG health 非 healthy: ${text.slice(0, 500)}`);
return payload;
}
async function bodyText(page) {
return await page.evaluate(() => document.body.innerText || "");
}
async function screenshot(page, name) {
const screenshotPath = path.join(OUTPUT_DIR, name);
await page.screenshot({ path: screenshotPath, fullPage: true });
return screenshotPath;
}
async function main() {
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
const browser = await chromium.launch({
headless: process.env.MNOTE_DASHBOARD_SMOKE_HEADED !== "1",
executablePath: fs.existsSync(CHROME) ? CHROME : undefined,
});
const context = await browser.newContext({ viewport: { width: 1440, height: 960 } });
const page = await context.newPage();
const pageErrors = [];
const consoleErrors = [];
page.on("pageerror", (error) => pageErrors.push(String(error?.message || error)));
page.on("console", (message) => {
if (message.type() === "error") consoleErrors.push(message.text());
});
try {
const statusPayload = await fetchDashboardStatus(context);
const dashboardUrl = statusPayload.dashboardUrl;
const documentSummaries = Array.isArray(statusPayload.documents?.documents)
? statusPayload.documents.documents
: [];
const rawStatusGroups = statusPayload.documents?.rawStatusGroups || {};
const processedCount = Number(rawStatusGroups.processed || 0);
const webuiUrl = dashboardUrl.endsWith("/webui/") ? dashboardUrl : `${dashboardUrl.replace(/\/+$/, "")}/webui/`;
await page.goto(webuiUrl, { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
await page.waitForLoadState("networkidle", { timeout: UI_TIMEOUT_MS }).catch(() => undefined);
await page.waitForFunction(
() => (document.body.innerText || "").includes("Document Management"),
null,
{ timeout: UI_TIMEOUT_MS },
);
const documentsText = await bodyText(page);
assert(documentsText.includes("Uploaded Documents"), "Documents 页缺少 Uploaded Documents");
if (processedCount > 0) {
assert(
documentsText.includes(`Completed (${processedCount})`),
`Documents 页未显示当前 completed 计数 ${processedCount}: ${documentsText.slice(0, 1000)}`,
);
}
const visibleDocument = documentSummaries.find((doc) => {
const id = String(doc.id || "");
const filePath = String(doc.filePath || "");
return (id && documentsText.includes(id)) || (filePath && documentsText.includes(filePath));
});
assert(
visibleDocument,
`Documents 页没有显示 status API 返回的任一当前文档: ${JSON.stringify(documentSummaries.slice(0, 5), null, 2)}\n${documentsText.slice(0, 1000)}`,
);
const documentsScreenshot = await screenshot(page, "documents.png");
await page.getByRole("tab", { name: /Knowledge Graph/i }).click({ timeout: UI_TIMEOUT_MS });
await page.waitForFunction(
() => {
const text = document.body.innerText || "";
return text.includes("Connected") && text.includes("D:");
},
null,
{ timeout: UI_TIMEOUT_MS },
);
const graphText = await bodyText(page);
assert(graphText.includes("Connected"), `Graph 页缺少连接状态: ${graphText.slice(0, 1000)}`);
const graphScreenshot = await screenshot(page, "graph.png");
await page.getByRole("tab", { name: /Retrieval/i }).click({ timeout: UI_TIMEOUT_MS });
await page.waitForFunction(
() => (document.body.innerText || "").includes("Start a retrieval by typing your query below"),
null,
{ timeout: UI_TIMEOUT_MS },
);
const retrievalText = await bodyText(page);
assert(retrievalText.includes("Query Mode"), "Retrieval 页缺少 Query Mode 参数");
assert(retrievalText.includes("KG Top K"), "Retrieval 页缺少 KG Top K 参数");
assert(retrievalText.includes("Connected"), "Retrieval 页缺少连接状态");
const retrievalScreenshot = await screenshot(page, "retrieval.png");
const result = {
ok: true,
baseUrl: BASE_URL,
dashboardUrl,
webuiUrl: page.url(),
documents: {
processedCount,
completedVisible: processedCount > 0 ? documentsText.includes(`Completed (${processedCount})`) : null,
visibleDocumentId: visibleDocument.id || null,
visibleDocumentFilePath: visibleDocument.filePath || null,
screenshot: documentsScreenshot,
},
graph: {
connectedVisible: graphText.includes("Connected"),
screenshot: graphScreenshot,
},
retrieval: {
queryModeVisible: retrievalText.includes("Query Mode"),
connectedVisible: retrievalText.includes("Connected"),
screenshot: retrievalScreenshot,
},
pageErrors,
consoleErrors,
};
fs.writeFileSync(RESULT_PATH, `${JSON.stringify(result, null, 2)}\n`, "utf8");
console.log(JSON.stringify(result, null, 2));
} catch (error) {
await page.screenshot({ path: path.join(OUTPUT_DIR, "failure.png"), fullPage: true }).catch(() => undefined);
fs.writeFileSync(
path.join(OUTPUT_DIR, "failure.json"),
`${JSON.stringify(
{
ok: false,
error: error instanceof Error ? error.stack || error.message : String(error),
pageErrors,
consoleErrors,
},
null,
2,
)}\n`,
"utf8",
);
throw error;
} finally {
await browser.close().catch(() => undefined);
}
}
main().catch((error) => {
console.error(error.stack || error.message || String(error));
process.exit(1);
});
@@ -125,6 +125,7 @@ async function openKnowledgePanel(page) {
state: "visible",
timeout: UI_TIMEOUT_MS,
});
await page.locator('[data-knowledge-rag-action="filter-sources"][data-knowledge-rag-filter="all"]').click({ timeout: UI_TIMEOUT_MS });
}
async function waitForSourceRow(page, sourcePath) {
@@ -155,6 +156,8 @@ async function main() {
const scoped = await query(context, marker, [sourceA]);
const scopedReferences = Array.isArray(scoped.references) ? scoped.references : [];
assert.equal(scoped.sourceScopeMode, "post_filter_mapped_references", `sourcePaths scope mode 应明确为 post-filter: ${JSON.stringify(scoped, null, 2).slice(0, 3000)}`);
assert.equal(scoped.rawScopeFiltered, false, `rawScopeFiltered 应明确提示 provider raw 未被 sourcePaths 预过滤: ${JSON.stringify(scoped, null, 2).slice(0, 3000)}`);
assert(scopedReferences.length > 0, `sourcePaths scope 应至少返回 alpha: ${JSON.stringify(scoped, null, 2).slice(0, 3000)}`);
assert(
scopedReferences.every((reference) => reference.sourceRootRelativePath === sourceA),
@@ -170,7 +173,8 @@ async function main() {
await page.waitForFunction(
(expectedPath) => {
const row = document.querySelector(`[data-knowledge-rag-source-row="true"][data-knowledge-rag-source-path="${expectedPath}"]`);
return row && (row.textContent || "").includes("已删除");
const text = row ? row.textContent || "" : "";
return text.includes("已删除") || text.includes("删除已提交") || text.includes("LightRAG 已移除");
},
sourceA,
{ timeout: UI_TIMEOUT_MS },
@@ -191,6 +195,8 @@ async function main() {
sourceA,
sourceB,
sourceScope: scoped.sourceScope,
sourceScopeMode: scoped.sourceScopeMode,
rawScopeFiltered: scoped.rawScopeFiltered,
scopedReferenceCount: scopedReferences.length,
alphaDocId: entryFor(indexedStatus, sourceA)?.lightRagDocId || null,
betaDocId: entryFor(indexedStatus, sourceB)?.lightRagDocId || null,
@@ -0,0 +1,188 @@
#!/usr/bin/env node
"use strict";
const assert = require("node:assert");
const fs = require("node:fs");
const path = require("node:path");
const { request } = require("playwright");
const BASE_URL = (process.env.MNOTE_WEB_SMOKE_BASE_URL || "http://127.0.0.1:3000").replace(/\/+$/, "");
const ROOT_PATH = process.env.MNOTE_KNOWLEDGE_RAG_ROOT_PATH || "/mnt/Data1T/Mnote_data/users/mnote-e2e/workspaces/my-space";
const ROOT_URI = process.env.MNOTE_KNOWLEDGE_RAG_ROOT_URI || `file://${ROOT_PATH}`;
const WORKSPACE_ID = process.env.MNOTE_KNOWLEDGE_RAG_WORKSPACE_ID || "local-ws:mnote-e2e:my-space";
const FIXTURE_DIR = "knowledge-rag-fixtures-7-50";
const OUTPUT_DIR = path.join(process.cwd(), "tmp", "task538-knowledge-rag-source-scope-api-smoke");
const RESULT_PATH = path.join(OUTPUT_DIR, "result.json");
const UI_TIMEOUT_MS = Number(process.env.MNOTE_SMOKE_UI_TIMEOUT_MS || 30_000);
const POLL_TIMEOUT_MS = Number(process.env.MNOTE_KNOWLEDGE_RAG_SCOPE_TIMEOUT_MS || 240_000);
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
async function apiJson(context, method, url, data) {
const response = await context.fetch(url, {
method,
data,
headers: {
"x-mnote-actor-id": "mnote-e2e",
"x-mnote-actor-type": "user",
"x-mnote-workspace-id": WORKSPACE_ID,
accept: "application/json",
},
timeout: UI_TIMEOUT_MS,
});
const text = await response.text();
let payload = null;
try {
payload = text ? JSON.parse(text) : null;
} catch (_) {
payload = { rawText: text };
}
return { ok: response.ok(), status: response.status(), payload, text };
}
async function signIn(context) {
const response = await context.post(`${BASE_URL}/api/auth`, {
data: {
action: "auth:signIn",
args: {
provider: "password",
params: {
email: "mnote.e2e@example.com",
password: process.env.MNOTE_E2E_PASSWORD || "MnoteE2E123!",
flow: "signIn",
},
},
},
timeout: UI_TIMEOUT_MS,
});
assert(response.ok(), `登录失败: ${response.status()} ${await response.text()}`);
}
async function status(context) {
const params = new URLSearchParams({ rootUri: ROOT_URI, workspaceId: WORKSPACE_ID });
const result = await apiJson(context, "GET", `${BASE_URL}/api/knowledge-rag/status?${params.toString()}`);
assert(result.ok, `knowledge-rag status 失败: ${result.status} ${result.text.slice(0, 1000)}`);
return result.payload;
}
function entryFor(statusPayload, sourcePath) {
const entries = statusPayload?.registry?.entries;
return Array.isArray(entries) ? entries.find((entry) => entry.sourceRootRelativePath === sourcePath) : null;
}
async function waitForIndexed(context, sourcePaths) {
const startedAt = Date.now();
let lastStatus = null;
while (Date.now() - startedAt < POLL_TIMEOUT_MS) {
lastStatus = await status(context);
const allIndexed = sourcePaths.every((sourcePath) => {
const entry = entryFor(lastStatus, sourcePath);
return entry && entry.indexedAtMs && entry.lightRagDocId && !entry.stale && !entry.deletedAtMs;
});
if (allIndexed) return lastStatus;
await sleep(5_000);
}
throw new Error(`等待 source scope fixture 入库超时: ${JSON.stringify({ sourcePaths, lastStatus }, null, 2).slice(0, 4000)}`);
}
async function ingest(context, sourcePaths) {
const result = await apiJson(context, "POST", `${BASE_URL}/api/knowledge-rag/ingest`, {
rootUri: ROOT_URI,
workspaceId: WORKSPACE_ID,
sources: sourcePaths.map((sourcePath) => ({ sourcePath })),
});
assert(result.ok, `knowledge-rag ingest 失败: ${result.status} ${result.text.slice(0, 1000)}`);
return result.payload;
}
async function query(context, queryText, sourcePaths) {
const result = await apiJson(context, "POST", `${BASE_URL}/api/knowledge-rag/query`, {
rootUri: ROOT_URI,
workspaceId: WORKSPACE_ID,
query: queryText,
mode: "mix",
topK: 12,
chunkTopK: 12,
includeChunkContent: true,
sourcePaths,
});
assert(result.ok, `knowledge-rag query 失败: ${result.status} ${result.text.slice(0, 1000)}`);
return result.payload;
}
async function deleteSource(context, sourcePath) {
const result = await apiJson(context, "POST", `${BASE_URL}/api/knowledge-rag/delete-source`, {
rootUri: ROOT_URI,
workspaceId: WORKSPACE_ID,
sourcePath,
});
assert(result.ok, `knowledge-rag delete-source 失败: ${result.status} ${result.text.slice(0, 1000)}`);
return result.payload;
}
async function main() {
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
fs.mkdirSync(path.join(ROOT_PATH, FIXTURE_DIR), { recursive: true });
const marker = `SOURCE SCOPE API RAG ${Date.now()}`;
const sourceA = `${FIXTURE_DIR}/scope-api-alpha-${Date.now()}.md`;
const sourceB = `${FIXTURE_DIR}/scope-api-beta-${Date.now()}.md`;
fs.writeFileSync(path.join(ROOT_PATH, sourceA), `# Scope API Alpha\n${marker}\nOnly alpha source should remain after API scope filter.\n`, "utf8");
fs.writeFileSync(path.join(ROOT_PATH, sourceB), `# Scope API Beta\n${marker}\nBeta source must be filtered when sourcePaths targets alpha.\n`, "utf8");
const context = await request.newContext({ baseURL: BASE_URL });
try {
await signIn(context);
await ingest(context, [sourceA, sourceB]);
const indexedStatus = await waitForIndexed(context, [sourceA, sourceB]);
const scoped = await query(context, marker, [sourceA]);
const references = Array.isArray(scoped.references) ? scoped.references : [];
assert.equal(scoped.sourceScopeMode, "post_filter_mapped_references", `sourceScopeMode 不正确: ${JSON.stringify(scoped, null, 2).slice(0, 3000)}`);
assert.equal(scoped.rawScopeFiltered, false, `rawScopeFiltered 应为 false: ${JSON.stringify(scoped, null, 2).slice(0, 3000)}`);
assert(references.length > 0, `sourcePaths scope 应至少返回 alpha: ${JSON.stringify(scoped, null, 2).slice(0, 3000)}`);
assert(
references.every((reference) => reference.sourceRootRelativePath === sourceA),
`sourcePaths scope 不应返回非 alpha 来源: ${JSON.stringify(references, null, 2).slice(0, 3000)}`,
);
assert(scoped.raw, "HTTP API 仍应保留 raw 供调试调用方使用");
await deleteSource(context, sourceA);
assert(fs.existsSync(path.join(ROOT_PATH, sourceA)), "delete-source 不应删除用户原始 source 文件");
const afterDelete = await query(context, marker, [sourceA]);
const afterReferences = Array.isArray(afterDelete.references) ? afterDelete.references : [];
assert.equal(afterReferences.length, 0, `删除索引后 source-scoped query 不应继续返回 alpha: ${JSON.stringify(afterReferences, null, 2)}`);
const result = {
ok: true,
baseUrl: BASE_URL,
rootUri: ROOT_URI,
workspaceId: WORKSPACE_ID,
marker,
sourceA,
sourceB,
sourceScopeMode: scoped.sourceScopeMode,
rawScopeFiltered: scoped.rawScopeFiltered,
scopedReferenceCount: references.length,
alphaDocId: entryFor(indexedStatus, sourceA)?.lightRagDocId || null,
betaDocId: entryFor(indexedStatus, sourceB)?.lightRagDocId || null,
sourceAExistsAfterDelete: fs.existsSync(path.join(ROOT_PATH, sourceA)),
afterDeleteReferenceCount: afterReferences.length,
};
fs.writeFileSync(RESULT_PATH, `${JSON.stringify(result, null, 2)}\n`, "utf8");
console.log(JSON.stringify(result, null, 2));
} finally {
await context.dispose().catch(() => undefined);
}
}
main().catch((error) => {
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
fs.writeFileSync(
path.join(OUTPUT_DIR, "failure.json"),
`${JSON.stringify({ ok: false, error: error.stack || error.message || String(error) }, null, 2)}\n`,
"utf8",
);
console.error(error.stack || error.message || String(error));
process.exit(1);
});
@@ -0,0 +1,94 @@
#!/usr/bin/env node
"use strict";
const assert = require("node:assert");
const fs = require("node:fs");
const path = require("node:path");
const { execFileSync } = require("node:child_process");
const ROOT = process.env.MNOTE_REPO_ROOT || "/mnt/Data1T/mnote";
const TASK535 = path.join(ROOT, "scripts", "task535-page-ai-local-agent-clean-edit-smoke.js");
const OUTPUT_DIR = path.join(ROOT, "tmp", "task539-local-agent-audit-scope-contract");
const RESULT_PATH = path.join(OUTPUT_DIR, "result.json");
function run(command, args, options = {}) {
return execFileSync(command, args, {
cwd: ROOT,
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
...options,
});
}
function parseLastJsonObject(output) {
const trimmed = String(output || "").trim();
const start = trimmed.lastIndexOf("\n{");
const jsonText = start >= 0 ? trimmed.slice(start + 1) : trimmed;
return JSON.parse(jsonText);
}
function parseRunBody(task535Result) {
const runRecord = (task535Result.captured || []).find((item) => item.kind === "run");
assert(runRecord, "task535 result 缺少 run payload");
return JSON.parse(runRecord.body || "{}");
}
function main() {
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
const task535Output = run("node", [TASK535]);
const task535Result = parseLastJsonObject(task535Output);
assert.equal(task535Result.ok, true, "task535 clean edit smoke 必须通过");
const runBody = parseRunBody(task535Result);
const allowedFiles = runBody.targetPackage?.allowedFiles || [];
assert.deepEqual(allowedFiles, [task535Result.relativePath], "targetPackage.allowedFiles 应只包含当前目标文件");
assert.equal(runBody.targetPackage?.currentFile?.relativePath, task535Result.relativePath, "currentFile 应冻结为当前目标文件");
assert.equal(runBody.runTargetSnapshot?.schema, "mnote.page_ai_run_target_snapshot.v1", "run payload 应携带 frozen target snapshot");
assert(runBody.runTargetSnapshot?.frozenAt, "frozen target snapshot 应有 frozenAt");
if (runBody.agentTargetPackage) {
assert.deepEqual(runBody.agentTargetPackage.allowedFiles || [], [task535Result.relativePath], "agentTargetPackage.allowedFiles 应只包含当前目标文件");
}
assert.equal(task535Result.usedDocumentsSave, false, "clean edit 不应调用 /api/documents/save");
assert.equal(task535Result.usedMarkdownEdit, false, "clean edit 不应调用 mnote.doc.markdown_edit");
assert.equal(task535Result.usedPageSave, false, "clean edit 不应调用 mnote.page.save");
const cargoOutput = run("cargo", ["test", "-p", "mnote-web", "local_agent_audit", "--", "--test-threads=1"], {
cwd: path.join(ROOT, "rust"),
});
assert(cargoOutput.includes("local_agent_audit_allowed_files_override_folder_context"), "cargo test 应覆盖 allowedFiles 优先于 folder context");
assert(cargoOutput.includes("local_agent_audit_reads_allowed_files_from_agent_run_envelope"), "cargo test 应覆盖 agentRunEnvelope.allowedFiles");
assert(cargoOutput.includes("local_agent_audit_event_carries_agent_run_receipt"), "cargo test 应覆盖 receipt auditScope");
const result = {
ok: true,
task: "task539-local-agent-audit-scope-contract",
task535: {
resultPath: path.join(ROOT, "tmp", "task535-page-ai-local-agent-clean-edit-smoke", "result.json"),
relativePath: task535Result.relativePath,
targetAllowedFiles: allowedFiles,
frozenAt: runBody.runTargetSnapshot.frozenAt,
usedDocumentsSave: task535Result.usedDocumentsSave,
usedMarkdownEdit: task535Result.usedMarkdownEdit,
usedPageSave: task535Result.usedPageSave,
},
backendAudit: {
command: "cargo test -p mnote-web local_agent_audit -- --test-threads=1",
expectedScope: "allowed_files",
expectedFileCount: 1,
},
};
fs.writeFileSync(RESULT_PATH, `${JSON.stringify(result, null, 2)}\n`, "utf8");
console.log(JSON.stringify(result, null, 2));
}
try {
main();
} catch (error) {
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
fs.writeFileSync(
path.join(OUTPUT_DIR, "failure.json"),
`${JSON.stringify({ ok: false, error: error.stack || error.message || String(error) }, null, 2)}\n`,
"utf8",
);
console.error(error.stack || error.message || String(error));
process.exit(1);
}
@@ -0,0 +1,291 @@
#!/usr/bin/env node
"use strict";
const assert = require("node:assert");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const { chromium } = require("playwright");
const {
BASE_URL,
UI_TIMEOUT_MS,
ensureAuthenticated,
} = require("./tree-shell-smoke-helpers");
const TASK = "task540-local-folder-event-bus-single-connection-smoke";
const OUTPUT_DIR = path.join(process.cwd(), "tmp", TASK);
const RESULT_PATH = path.join(OUTPUT_DIR, "result.json");
const CHROMIUM_EXECUTABLE_PATH = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH
|| ["/usr/bin/google-chrome-stable", "/usr/bin/google-chrome", "/snap/bin/chromium", "/usr/bin/chromium"]
.find((candidate) => fs.existsSync(candidate));
function fileUrl(localPath) {
return `file://${localPath}`;
}
function localMdDocumentId(relativePath) {
return `local-md:${relativePath.replaceAll("/", "~2F")}`;
}
function documentUrl(root, relativePath) {
const url = new URL(`${BASE_URL}/documents/${encodeURIComponent(localMdDocumentId(relativePath))}`);
url.searchParams.set("sourceKind", "local_folder");
url.searchParams.set("rootUri", fileUrl(root));
url.searchParams.set("treeView", "filetree");
return url.toString();
}
function writeWorkspaceManifest(root, ownerId, workspaceId) {
fs.mkdirSync(path.join(root, ".mnote"), { recursive: true });
fs.writeFileSync(
path.join(root, ".mnote", "workspace.json"),
`${JSON.stringify({
workspaceId,
ownerId,
createdAt: new Date().toISOString(),
capabilities: ["local_files", "markdown_edit"],
}, null, 2)}\n`,
"utf8",
);
}
async function main() {
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
const suffix = Date.now().toString(36);
const actorId = "mnote-e2e";
const workspaceId = `local-ws:${actorId}:task540`;
const root = fs.mkdtempSync(path.join(os.tmpdir(), "mnote-task540-event-bus-"));
const rootUri = fileUrl(root);
const relativePath = "EventBus.md";
const resourcePath = "Attachment.bin";
const filePath = path.join(root, relativePath);
const resourceFilePath = path.join(root, resourcePath);
writeWorkspaceManifest(root, actorId, workspaceId);
fs.writeFileSync(filePath, `# Event Bus\n\ntask540-${suffix}\n`, "utf8");
fs.writeFileSync(resourceFilePath, `task540-resource-${suffix}\n`, "utf8");
const browser = await chromium.launch({
headless: process.env.HEADFUL !== "1",
...(CHROMIUM_EXECUTABLE_PATH ? { executablePath: CHROMIUM_EXECUTABLE_PATH } : {}),
});
const context = await browser.newContext({
viewport: { width: 1440, height: 960 },
locale: "zh-CN",
extraHTTPHeaders: {
"x-mnote-actor-id": actorId,
"x-mnote-actor-type": "user",
},
});
await context.addInitScript(() => {
const sources = [];
const originalFetch = window.fetch.bind(window);
window.__mnoteTask540StatRequests = [];
window.__mnoteTask540FileProjectionRequests = [];
window.__mnoteTask540SidebarProjectionRequests = [];
window.fetch = function patchedTask540Fetch(input, init) {
const url = typeof input === "string" ? input : String(input?.url || "");
if (url.includes("/api/local-folder/files/stat")) {
window.__mnoteTask540StatRequests.push(url);
}
if (url.includes("/api/tree/projections/file")) {
window.__mnoteTask540FileProjectionRequests.push(url);
}
if (url.includes("/api/tree/projections/sidebar")) {
window.__mnoteTask540SidebarProjectionRequests.push(url);
}
return originalFetch(input, init);
};
class FakeEventSource {
constructor(input) {
this.url = String(input || "");
this.readyState = 0;
this.listeners = new Map();
sources.push(this);
setTimeout(() => this.dispatch("open", {}), 0);
}
addEventListener(name, handler) {
if (!this.listeners.has(name)) this.listeners.set(name, []);
this.listeners.get(name).push(handler);
}
removeEventListener(name, handler) {
const list = this.listeners.get(name) || [];
this.listeners.set(name, list.filter((candidate) => candidate !== handler));
}
dispatch(name, payload) {
const event = { data: JSON.stringify(payload || {}), lastEventId: String(payload?.revision || "") };
(this.listeners.get(name) || []).forEach((handler) => handler(event));
}
close() {
this.readyState = 2;
}
}
window.EventSource = FakeEventSource;
window.__mnoteTask540EventSources = sources;
window.__mnoteTask540CompatEvents = [];
window.addEventListener("tree:local-folder-watch-batch", (event) => {
window.__mnoteTask540CompatEvents.push(event.detail || {});
});
});
const page = await context.newPage();
const statRequests = [];
page.on("request", (request) => {
const url = request.url();
if (url.includes("/api/local-folder/files/stat")) {
statRequests.push(url);
}
});
await page.route("**/api/user/access-policy**", async (route) => {
await route.fulfill({
status: 200,
headers: { "content-type": "application/json" },
body: JSON.stringify({
ok: true,
controlPlane: "sqlite",
grants: [{
id: `grant_task540_${suffix}`,
userId: actorId,
workspaceId,
rootUri,
rootPath: root,
permission: "write",
recursive: true,
capabilities: ["markdown_edit"],
source: "user",
status: "active",
}],
}),
});
});
await page.route("**/api/ui/preferences**", async (route) => {
await route.fulfill({
status: 200,
headers: { "content-type": "application/json" },
body: JSON.stringify({ ok: true, owner: "mnote-web", result: {} }),
});
});
try {
await ensureAuthenticated(page, context.request);
await page.goto(documentUrl(root, relativePath), { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
await page.waitForFunction(() => window.__mnoteLocalFolderEventBus, null, { timeout: UI_TIMEOUT_MS });
await page.waitForFunction(() => {
const sources = window.__mnoteTask540EventSources || [];
return sources.filter((source) => String(source.url || "").includes("/api/local-folder/events")).length === 1;
}, null, { timeout: UI_TIMEOUT_MS });
await page.waitForFunction(() => window.__mnoteDocumentPaneRuntime?.openResourceInActiveTab, null, { timeout: UI_TIMEOUT_MS });
const openedResource = await page.evaluate(async ({ rootUriValue, pathValue, workspaceIdValue }) => {
const href = new URL("/api/local-folder/resource/read", window.location.origin);
href.searchParams.set("rootUri", rootUriValue);
href.searchParams.set("path", pathValue);
return await window.__mnoteDocumentPaneRuntime.openResourceInActiveTab({
objectIdentity: `local-resource:${pathValue}`,
assetId: `task540:${pathValue}`,
title: pathValue,
fileName: pathValue,
kind: "file",
sourceKind: "local_folder",
rootUri: rootUriValue,
workspaceId: workspaceIdValue,
path: pathValue,
href: href.toString(),
});
}, { rootUriValue: rootUri, pathValue: resourcePath, workspaceIdValue: workspaceId });
assert.equal(openedResource, true, "resource tab 应能打开");
await page.waitForFunction(() => {
const panel = document.querySelector('.mnote-resource-tab-panel[data-resource-path="Attachment.bin"]');
return panel?.getAttribute("data-mnote-resource-watch-ready") === "event-bus";
}, null, { timeout: UI_TIMEOUT_MS });
await page.evaluate(() => {
window.__mnoteTask540StatRequests = [];
window.__mnoteTask540FileProjectionRequests = [];
window.__mnoteTask540SidebarProjectionRequests = [];
});
await page.evaluate(({ rootUriValue, pagePath, assetPath }) => {
const detail = {
source: "synthetic_page_ai_receipt",
reason: "agent_run_receipt",
rootUri: rootUriValue,
payload: {
schema: "mnote.local_folder.watch_batch.v1",
source: "agent_run_receipt",
rootUri: rootUriValue,
revision: "task540-revision",
changedPaths: [
{ relativePath: pagePath, changeType: "modified" },
{ relativePath: assetPath, changeType: "modified" },
],
affectedParents: [{ relativePath: "", reason: "task540" }],
},
};
window.__mnoteLocalFolderEventBus.emitSyntheticWatchBatch(detail);
window.__mnoteLocalFolderEventBus.emitSyntheticWatchBatch(detail);
}, { rootUriValue: rootUri, pagePath: relativePath, assetPath: resourcePath });
await page.waitForFunction(() => {
const events = window.__mnoteTask540CompatEvents || [];
return events.some((event) => event?.source === "synthetic_page_ai_receipt" && event?.viaEventBus === true);
}, null, { timeout: UI_TIMEOUT_MS });
await page.waitForFunction((pathValue) => {
const urls = window.__mnoteTask540StatRequests || [];
return urls.some((url) => String(url).includes(encodeURIComponent(pathValue)) || String(url).includes(pathValue));
}, resourcePath, { timeout: 3000 }).catch(() => undefined);
await page.waitForFunction(() => {
const urls = window.__mnoteTask540FileProjectionRequests || [];
return urls.length >= 1;
}, null, { timeout: UI_TIMEOUT_MS });
const result = await page.evaluate(() => {
const sources = (window.__mnoteTask540EventSources || [])
.filter((source) => String(source.url || "").includes("/api/local-folder/events"))
.map((source) => source.url);
const compatEvents = window.__mnoteTask540CompatEvents || [];
const resourcePanel = document.querySelector('.mnote-resource-tab-panel[data-resource-path="Attachment.bin"]');
return {
sources,
busState: document.documentElement.getAttribute("data-mnote-local-folder-event-bus") || "",
connectionCount: document.documentElement.getAttribute("data-mnote-local-folder-event-bus-connections") || "",
lastSource: document.documentElement.getAttribute("data-mnote-local-folder-event-bus-last-source") || "",
lastReason: document.documentElement.getAttribute("data-mnote-local-folder-event-bus-last-reason") || "",
compatEventCount: compatEvents.length,
lastCompatViaEventBus: Boolean(compatEvents.at(-1)?.viaEventBus),
hasPageAiSyntheticCompatEvent: compatEvents.some((event) => (
event?.source === "synthetic_page_ai_receipt"
&& event?.reason === "agent_run_receipt"
&& event?.viaEventBus === true
)),
resourceWatchReady: resourcePanel?.getAttribute("data-mnote-resource-watch-ready") || "",
browserStatRequests: window.__mnoteTask540StatRequests || [],
fileProjectionRequests: window.__mnoteTask540FileProjectionRequests || [],
sidebarProjectionRequests: window.__mnoteTask540SidebarProjectionRequests || [],
};
});
assert.equal(result.sources.length, 1, `同一页面应只有一个 local-folder EventSource,实际 ${result.sources.length}`);
assert.equal(result.busState, "ready", "event bus diagnostics 未 ready");
assert.equal(result.connectionCount, "1", "event bus diagnostics connectionCount 应为 1");
assert.equal(result.hasPageAiSyntheticCompatEvent, true, "Page AI synthetic 事件应保留 source/reason 并通过 bus 派发兼容事件");
assert.equal(result.lastCompatViaEventBus, true, "兼容 tree:local-folder-watch-batch 必须标记 viaEventBus");
assert.equal(result.resourceWatchReady, "event-bus", "resource tab watch 应通过 event bus 准备好");
assert(
result.browserStatRequests.some((url) => String(url).includes(encodeURIComponent(resourcePath)) || String(url).includes(resourcePath)),
"resource-changed 应触发当前资源 stat 刷新",
);
assert.equal(result.fileProjectionRequests.length, 1, "同 tick 两个 receipt 应只触发一次 filetree parent projection 刷新");
assert.equal(result.sidebarProjectionRequests.length, 0, "content-only receipt 不应触发 sidebar projection 刷新");
fs.writeFileSync(RESULT_PATH, `${JSON.stringify({ ok: true, root, result, statRequests }, null, 2)}\n`, "utf8");
console.log(`[${TASK}] ok`, RESULT_PATH);
} finally {
await browser.close();
}
}
main().catch((error) => {
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
fs.writeFileSync(RESULT_PATH, `${JSON.stringify({ ok: false, error: error.stack || String(error) }, null, 2)}\n`, "utf8");
console.error(`[${TASK}] failed`, error);
process.exit(1);
});
@@ -0,0 +1,197 @@
#!/usr/bin/env node
"use strict";
const assert = require("node:assert/strict");
const fs = require("node:fs");
const http = require("node:http");
const net = require("node:net");
const os = require("node:os");
const path = require("node:path");
const { spawn } = require("node:child_process");
const { chromium } = require("playwright");
const TIMEOUT_MS = Number(process.env.MNOTE_SMOKE_UI_TIMEOUT_MS || 30_000);
const OUTPUT_DIR = path.join(process.cwd(), "tmp", "task541-page-aggregate-local-first-hard-guard-smoke");
const RESULT_PATH = path.join(OUTPUT_DIR, "result.json");
const CHROMIUM_EXECUTABLE_PATH = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH
|| ["/usr/bin/google-chrome-stable", "/usr/bin/google-chrome", "/snap/bin/chromium"]
.find((candidate) => fs.existsSync(candidate));
function pickPort() {
return new Promise((resolve, reject) => {
const server = net.createServer();
server.listen(0, "127.0.0.1", () => {
const address = server.address();
const port = address && typeof address === "object" ? address.port : 0;
server.close(() => resolve(port));
});
server.on("error", reject);
});
}
function waitForHttpOk(url, timeoutMs) {
const deadline = Date.now() + timeoutMs;
return new Promise((resolve, reject) => {
const tick = () => {
const request = http.get(url, (response) => {
response.resume();
if (response.statusCode >= 200 && response.statusCode < 500) {
resolve();
return;
}
retry();
});
request.on("error", retry);
request.setTimeout(1000, () => {
request.destroy();
retry();
});
};
const retry = () => {
if (Date.now() > deadline) {
reject(new Error(`server_not_ready: ${url}`));
return;
}
setTimeout(tick, 250);
};
tick();
});
}
function actorHeaders(actorId) {
return {
"content-type": "application/json",
"x-mnote-actor-id": actorId,
"x-mnote-actor-type": "user",
};
}
async function requestJson(baseUrl, actorId, pathname, options = {}) {
const response = await fetch(`${baseUrl}${pathname}`, {
method: options.method || "GET",
headers: actorHeaders(actorId),
body: options.body == null ? undefined : JSON.stringify(options.body),
});
const payload = await response.json().catch(() => null);
assert(response.ok, `${options.method || "GET"} ${pathname} failed ${response.status}: ${JSON.stringify(payload)}`);
return payload;
}
function localMdDocumentId(relativePath) {
return `local-md:${relativePath.replaceAll("/", "~2F")}`;
}
function documentUrl(baseUrl, rootUri, relativePath) {
const url = new URL(`${baseUrl}/documents/${encodeURIComponent(localMdDocumentId(relativePath))}`);
url.searchParams.set("sourceKind", "local_folder");
url.searchParams.set("rootUri", rootUri);
return url.toString();
}
async function quickLogin(page, baseUrl) {
await page.goto(`${baseUrl}/auth`, { waitUntil: "domcontentloaded", timeout: TIMEOUT_MS });
const quickLoginButton = page.getByRole("button", { name: "测试账号快速登录" });
if (await quickLoginButton.count()) {
await quickLoginButton.click({ timeout: TIMEOUT_MS });
await page.waitForURL((url) => url.pathname === "/", { timeout: TIMEOUT_MS });
}
}
async function main() {
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
const port = await pickPort();
const baseUrl = `http://127.0.0.1:${port}`;
const dataRoot = fs.mkdtempSync(path.join(os.tmpdir(), "mnote-page-aggregate-hard-guard-"));
const actorId = `task541-${process.pid}-${Date.now()}`;
const relativePath = "README.md";
const managedRoot = path.join(dataRoot, "users", actorId, "workspaces", "my-space");
const markdownPath = path.join(managedRoot, relativePath);
const server = spawn("cargo", ["run", "-q", "-p", "mnote-web", "--bin", "mnote-web"], {
cwd: path.join(__dirname, "..", "rust"),
env: {
...process.env,
MNOTE_WEB_BIND: `127.0.0.1:${port}`,
MNOTE_WEB_PUBLIC_BIND: `127.0.0.1:${port}`,
MNOTE_WEB_ENABLE_LEGACY_NEXT_COMPAT: "0",
MNOTE_LOCAL_WORKSPACE_BASE_DIR: dataRoot,
},
stdio: ["ignore", "pipe", "pipe"],
});
let stderr = "";
server.stderr.on("data", (chunk) => {
stderr += chunk.toString();
});
let browser = null;
try {
await waitForHttpOk(`${baseUrl}/health`, 90_000);
const created = await requestJson(baseUrl, actorId, "/api/local-folder/workspaces/default", {
method: "POST",
body: {},
});
const rootUri = created.workspace.rootUri;
fs.mkdirSync(path.dirname(markdownPath), { recursive: true });
fs.writeFileSync(markdownPath, "# Local Guard\n\nlocal-first browser hard guard\n", "utf8");
browser = await chromium.launch({
headless: true,
...(CHROMIUM_EXECUTABLE_PATH ? { executablePath: CHROMIUM_EXECUTABLE_PATH } : {}),
});
const context = await browser.newContext({
viewport: { width: 1280, height: 860 },
extraHTTPHeaders: actorHeaders(actorId),
});
const page = await context.newPage();
await quickLogin(page, baseUrl);
await page.goto(documentUrl(baseUrl, rootUri, relativePath), {
waitUntil: "domcontentloaded",
timeout: TIMEOUT_MS,
});
const editorRoot = page.locator('[data-testid="mnote-leptos-tiptap-island-editor-root"]').first();
await editorRoot.waitFor({ state: "visible", timeout: TIMEOUT_MS });
await page.waitForFunction(() => {
const root = document.querySelector('[data-testid="mnote-leptos-tiptap-island-editor-root"]');
return root?.getAttribute("data-runtime-editor-status") === "ready"
&& root?.getAttribute("data-mnote-page-body-source") === "page_aggregate.block_document";
}, null, { timeout: TIMEOUT_MS });
const diagnostic = await page.evaluate(() => {
const root = document.querySelector('[data-testid="mnote-leptos-tiptap-island-editor-root"]');
const editor = document.querySelector(".document-pane[data-pane-role=\"primary\"] .editor-surface .ProseMirror");
return {
status: root?.getAttribute("data-runtime-editor-status") || "",
pageBodySource: root?.getAttribute("data-mnote-page-body-source") || "",
localCompatFallback: root?.getAttribute("data-mnote-page-body-local-compat-fallback") || "",
hardGuard: root?.getAttribute("data-mnote-page-body-hard-guard") || "",
projectionSource: root?.getAttribute("data-mnote-projection-source") || "",
blockProjectionVersion: root?.getAttribute("data-mnote-block-projection-version") || "",
text: editor?.textContent || "",
};
});
assert.equal(diagnostic.pageBodySource, "page_aggregate.block_document");
assert.equal(diagnostic.localCompatFallback, "false");
assert.equal(diagnostic.hardGuard, "local_ok");
assert.equal(diagnostic.projectionSource, "local_markdown.content");
assert(diagnostic.blockProjectionVersion, "local-first blockProjectionVersion 应有诊断值");
assert.match(diagnostic.text, /local-first browser hard guard/);
fs.writeFileSync(RESULT_PATH, `${JSON.stringify({ baseUrl, rootUri, relativePath, diagnostic }, null, 2)}\n`, "utf8");
console.log(`task541 page aggregate local-first hard guard smoke passed ${RESULT_PATH}`);
} finally {
if (browser) await browser.close().catch(() => {});
server.kill("SIGINT");
fs.rmSync(dataRoot, { recursive: true, force: true });
if (server.exitCode == null) {
await new Promise((resolve) => server.once("exit", resolve));
}
if (server.exitCode && server.exitCode !== 130 && server.exitCode !== null) {
process.stderr.write(stderr);
}
}
}
main().catch((error) => {
console.error(error);
process.exit(1);
});
+2 -2
View File
@@ -5,7 +5,7 @@
工具使用:
- 先用 `mnote.knowledge_rag.status` 检查 LightRAG provider 是否可用、当前 root 是否有 source registry。
- 用 `mnote.knowledge_rag.query` 提问。默认 `mode=mix`,需要跨资料总结时可用 `global`指定资料细节时优先`sourcePaths` 限制到对应文件或目录
- 用 `mnote.knowledge_rag.query` 提问。默认 `mode=mix`,需要跨资料总结时可用 `global`指定资料细节时`sourcePaths`,但当前语义是 LightRAG provider 检索后,MNote 只过滤返回的 `references`;不要把 `raw` 当作已被 scope 限制的来源
- 回答必须引用 `references` 中的来源;优先使用返回的 `citationMarkdown`
- 若需要打开来源,调用 `mnote.knowledge_rag.open_reference`,不要手拼 LightRAG dashboard、`file://` 或 provider 内部路径。
@@ -13,5 +13,5 @@
- LightRAG 是派生知识库 provider,不是 MNote Markdown 正文真相。
- `filePath` 是 LightRAG provider-local 名称;只有 registry 命中的 reference 才能映射回 MNote source。
- 如果返回 `locatorDegraded: true`,必须明说“来源定位降级”,不要伪造页码、bbox 或资源 tab 链接
- 如果返回 `locatorDegraded: true`可以使用返回的 `citationMarkdown`,但必须明说“来源定位降级”,不要伪造页码、bbox 或 provider 内部路径
- LightRAG 不可用时,不要声称资料库已查到;提示用户启动或检查 LightRAG provider。旧 LiteParse / evidence 检索已退役,不作为 fallback。