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:
@@ -0,0 +1,165 @@
|
||||
# 3-24 Local-folder browser event bus v1
|
||||
|
||||
> 创建时间:2026-06-07
|
||||
>
|
||||
> 状态:`done`
|
||||
>
|
||||
> Owner:03-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 controller;document 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 D:smoke。
|
||||
|
||||
- [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`
|
||||
>
|
||||
> Owner:05-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。
|
||||
+7
-3
@@ -2,7 +2,7 @@
|
||||
|
||||
> 创建时间:2026-06-06
|
||||
>
|
||||
> 状态:`process`
|
||||
> 状态:`done`
|
||||
>
|
||||
> Owner:07-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`
|
||||
>
|
||||
> Owner:07-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 A:Delete retry / orphan doc guard
|
||||
|
||||
目标:source 删除或 hash 变化后,即使 LightRAG 删除失败,也不能丢失重试 provider doc id;prune 不能隐藏仍可能参与回答的 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 id;LightRAG delete 失败时写入 `delete_retry_required`,成功提交后保持 `delete_submitted`,等 `/documents` 消失后才标记 `delete_completed` 并清空 doc id。
|
||||
- 2026-06-07:当前 registry schema 先用 `lightRagStatus=delete_retry_required` 表达失败状态,不额外扩写错误 message 字段,避免扩大持久化迁移面。
|
||||
|
||||
## 4. Phase B:Source 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 过滤返回 references;provider 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 selftest:agent 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-07:manifest、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 C:Locator 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 D:Agent / 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 E:Reasonix / manifest drift guard
|
||||
|
||||
目标:在完整 7-47 capability registry 实施前,先降低 Reasonix wrapper 与 Rust manifest 的漂移风险。
|
||||
|
||||
Checklist:
|
||||
|
||||
- [x] 增加一个轻量 selftest:Rust manifest 中 `mnote.knowledge_rag.*` 三个工具必须都存在于 Reasonix wrapper 映射。
|
||||
- [x] 增加一个反向 selftest:Reasonix 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-07:Reasonix wrapper selftest 会读取 Rust `hermes_tools/manifest.rs`,校验 `mnote.knowledge_rag.*` manifest tools 与 wrapper `MNOTE_TOOL_NAMES` / `REASONIX_TOOL_TO_MNOTE_TOOL` 双向一致。
|
||||
|
||||
## 8. Phase F:legacy 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 enrichment,local-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 specs,mnote-web 不可用时才回落到静态 fallback;Hermes 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 的“索引设置”仍是用户直接管理索引范围的产品 UI;Page AI 的 `mnote-local-index` capability 是 agent 能力开关。
|
||||
Sidebar 的“资料库 / 知识库设置”仍是用户直接管理 LightRAG source、索引范围和服务状态的产品 UI;Page 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:本能力包不写用户 source;source 管理 UI / API 另归知识库设置面,不通过 agent capability 暴露写入。
|
||||
- UI 文案:`知识库 / LightRAG · 可回跳来源`
|
||||
|
||||
## 8. 非目标
|
||||
|
||||
|
||||
+5
-2
@@ -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` 任务。
|
||||
>
|
||||
> Owner:07-ai / 03-rust-web / 01-tree-first-graph-kernel
|
||||
>
|
||||
+7
-4
@@ -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` 任务。
|
||||
>
|
||||
> Owner:07-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 @@ Owner:07-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 执行补充。
|
||||
|
||||
验收:
|
||||
|
||||
+22
-15
@@ -2,7 +2,7 @@
|
||||
|
||||
> 创建时间:2026-06-06
|
||||
>
|
||||
> 状态:`process`
|
||||
> 状态:`done`
|
||||
>
|
||||
> Owner:10-review / 03-rust-web / 05-editor-mainline / 07-ai
|
||||
|
||||
@@ -30,28 +30,35 @@
|
||||
|
||||
### P1:Local-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。
|
||||
|
||||
### P2:Page Aggregate local-first hard guard
|
||||
|
||||
- [ ] 新建或更新 `05-editor-mainline` process:local-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 写入回读。
|
||||
|
||||
### P3:7-18 agent edit clean/dirty smoke
|
||||
|
||||
- [x] 在不碰 LightRAG 的前提下,拆出 `design/07-ai/process/7-18-agent-edit-clean-dirty-smoke-checklist-v1.md`。
|
||||
- [ ] 验 clean buffer:agent 原生 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 buffer:agent 原生 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。
|
||||
|
||||
### P4:LightRAG diff review gate
|
||||
### P4:LightRAG 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`。
|
||||
|
||||
### P5:7-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`
|
||||
>
|
||||
> Owner:10-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. P0:LightRAG 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 truth:registry 必须以 MNote source path 为真相;symlink / input wrapper / parsed cache 只作为派生物。
|
||||
- [x] 核对 delete / prune 语义:`delete-source`、watcher stale sync、prune registry 不得删除用户原始 source。
|
||||
- [x] 核对 stale / deleted source:source 删除、hash 变化、rename / move 后,旧 LightRAG reference 不得继续作为有效 citation 暴露。
|
||||
- [x] 核对 status bridge:ingest / 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 dashboard:MNote 只打开或嵌入 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 references,HTTP 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 id;2026-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 smoke;2026-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. P1:7-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. P2:Local-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 parent;document 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 projection,sidebar 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. P3:Page 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 content,explicit 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. P4:7-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 B:UI 消费 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 pack,Page 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. P5:Local 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. P6:Design 状态治理
|
||||
|
||||
目标:减少后续 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. P0:LightRAG 7-50 post-commit audit。
|
||||
2. P1:7-18 agent file edit 归档收尾。
|
||||
3. P2:Local-folder browser event bus。
|
||||
4. P4:7-47 capability registry 口径重写与实施。
|
||||
5. P3:Page 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。
|
||||
@@ -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 稿
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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 只过滤返回的 references;provider 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();
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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, ®istry, "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"}),
|
||||
®istry,
|
||||
"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."}]
|
||||
}),
|
||||
®istry,
|
||||
"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")
|
||||
|
||||
@@ -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",
|
||||
"资源不存在",
|
||||
));
|
||||
.filter(|value| !value.is_empty());
|
||||
retired_convex_media_response(&context, "sign", asset_id)
|
||||
}
|
||||
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",
|
||||
)
|
||||
.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())
|
||||
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,
|
||||
})),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)"));
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 Aggregate:local-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`
|
||||
|
||||
@@ -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 检索后过滤 references,raw 仍可能是全局结果。',
|
||||
},
|
||||
},
|
||||
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);
|
||||
});
|
||||
@@ -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。
|
||||
|
||||
Reference in New Issue
Block a user