feat(rag): replace LiteParse flows with LightRAG provider

This commit is contained in:
lix-2026
2026-06-07 01:10:31 +08:00
parent f46c2fb5d0
commit 1f25364374
40 changed files with 7232 additions and 2350 deletions
+5 -2
View File
@@ -14,7 +14,7 @@
- `3000` 当前主壳已接入 Rust Web WebSocket push 主链(`/api/realtime/ws`+ SSE fallback`/api/tree/events`)的 snapshot / delta / resync consumer,并已有 browser smoke 验证(2026-05-17 WS 迁移 `57ec8322`);后续收口重点是统一 live cache 与减少补偿链,而不是把它描述成“还没接 live stream”。
- 文档页默认主编辑器已切到页面内 `leptos-tiptap` island`BlockNote` 已退出文档页默认主路径,只保留为历史参考实现 / 对照材料。
- Page Aggregate 当前已输出 `blockDocument / blockProjectionVersion / projectionSource``projectionSource=documents.content`),标题/正文/页面设置写入后 smoke 已通过(`task110``task-page-aggregate-body-sync-smoke``task-page-aggregate-options-sync-smoke`);页面/块 AI 最小工具链已通过 Rust Hermes tools 读取和写入块投影;但这仍是从 `documents.content` / local markdown content 投影出来的过渡态,不是 EditorBlockDocument 原生落库完成态。客户端 `PageAggregateClientState` reducer 仍在,页面域单一真源未完全闭环。
- 当前最优先的架构收口不是继续扩编辑器 UI,而是在 local-first MVP 基线上推进 `WorkspacePath/ObjectIdentity` runtime 消费统一、`DocumentBuffer/BufferStore``Page Aggregate` 单一真源收口、`tree command cutover` 收尾、`tree realtime event stream` live cache 统一(WS push 主链 2026-05-17 上线,SSE 降级为 fallback)和 agent diff / 冲突合并 / 本地索 / 分享同步产品化。AI 侧普通 Markdown 编辑主路径已收口为“当前文件引用 + selection + allowed roots + agent 原生 patch/diff + watcher 同步”;`mnote.doc.fetch``mnote.doc.markdown_edit``mnote.doc.apply_block_ops``mnote.block.*` 只作为 cloud / remote agent / compat fallback 或复杂结构辅助;`review session`/流式 apply 属于 Phase C(设计冻结,当前不实施),不扩新 AI 功能,不把粗粒度 `mnote.page.save` 当成精确块编辑主入口。
- 当前最优先的架构收口不是继续扩编辑器 UI,而是在 local-first MVP 基线上推进 `WorkspacePath/ObjectIdentity` runtime 消费统一、`DocumentBuffer/BufferStore``Page Aggregate` 单一真源收口、`tree command cutover` 收尾、`tree realtime event stream` live cache 统一(WS push 主链 2026-05-17 上线,SSE 降级为 fallback)和 agent diff / 冲突合并 / 本地轻量搜索 / 分享同步产品化。AI 侧普通 Markdown 编辑主路径已收口为“当前文件引用 + selection + allowed roots + agent 原生 patch/diff + watcher 同步”;知识库问答、资料 OCR / parse / index / RAG 主路径已收口到 LightRAG,不再维护 LiteParse / 本地 OCR / evidence 资料索引多套 provider`mnote.doc.fetch``mnote.doc.markdown_edit``mnote.doc.apply_block_ops``mnote.block.*` 只作为 cloud / remote agent / compat fallback 或复杂结构辅助;`review session`/流式 apply 属于 Phase C(设计冻结,当前不实施),不扩新 AI 功能,不把粗粒度 `mnote.page.save` 当成精确块编辑主入口。
## 组件定位
@@ -23,6 +23,7 @@
- `Mindmap``tree-first graph` 的一种视图和编辑挂件,不是对象真相层。
- `OnlyOffice` 是独立页面型编辑器,不直接嵌入 `BlockNote` 画布;正文中通常通过附件块跳转进入。
- `page-ai/block-edit-workflow` route 只保留为兼容门面或非 local source 的快捷路径,不作为 local-first 普通 Markdown 编辑默认主路径;local-first 下优先给 Hermes / Reasonix 授权文件引用并让 agent 使用自身文件编辑能力。`mnote.doc.markdown_edit` 是 cloud / remote agent / compat fallback`mnote.block.*` 保留为结构性辅助。
- `LightRAG` 是当前唯一默认知识库 RAG provider,负责资料库 OCR、parse、chunk、vector、graph、rerank、query 与跨文档问答。MNote 只维护 source registry、allowed roots / 权限、FileTree 灯号、资料库设置 UI、dashboard/status UI、citation/open-reference 映射和 agent tool facadeLiteParse、旧 OCR sidecar、旧 evidence search / local-index agent tools 已退役到历史 / recycle 边界。
- 树域三层模型:`Resource Tree`kernel 对象组织真源)→ `File Tree`(主组织投影,`{title}.md` 为页面正文行)→ `Page Tree`(导航投影,不持有结构真相)。涉及资源归属优先走 Rust kernel 的 `KernelObjectIdentity` / `KernelProjectionResourceKind`
## Runtime 模块定位
@@ -70,6 +71,7 @@
- 涉及 local-first AI 普通 Markdown 编辑时,优先沿“授权文件引用 + `AiAccessScope` + allowed roots + agent 原生 patch/diff + 文件版本冲突模型 + watcher 同步”推进;涉及 cloud / remote agent / 复杂结构辅助时,才沿 `mnote.doc.*` / `mnote.block.*` Hermes tools 与 Rust `EditorCommand` 推进。`mnote.page.save` 只作为页面级兜底写入工具。
- 涉及资源归属(哪个页面拥有哪个 mindmap/附件)、object identity 和资源生命周期时,优先沿 Resource Tree → File Tree projection → `tree.resource.*` 命令面推进;`tree.*` 是正式命令面,`documents.*` 只视为兼容层。
- 页面 AI 编辑当前 local-first 主路径为:页面定位到真实 `.md` 文件,MNote 计算 `AiAccessScope` / allowed roots / selectionHermes 或 Reasonix 在白名单目录内用自身 patch/diff/文件编辑能力写入,MNote 通过 watcher / refresh 同步 tiptap。`mnote.doc.markdown_edit` 只作为 cloud / remote agent / compat fallback`mnote.block.*` 保留为结构性辅助(拖拽排序等)。两层操作模型已获 CLI Main(Lark Doc)参考实现验证。流式 apply + suggest/review(参考 BlockNote AI)作为 Phase C 设计冻结,当前不实施。
- 涉及知识库资料索引 / OCR / RAG 时,默认沿 `mnote.knowledge_rag.*` / `/api/knowledge-rag/*` 和 LightRAG source registry 推进。图片源不能直接假设会被 LightRAG `/documents/scan` 识别,当前通过 MNote 生成 Markdown wrapper 后进入 LightRAGLightRAG 后台完成状态通过 MNote 有界 status bridge/backoff 同步到 registry 与 FileTree 灯号,而不是新增无界轮询。
- 需要架构判断时,优先参考:
- `/mnt/Data1T/mnote/ARCHITECTURE.md`
- `/mnt/Data1T/mnote/CURRENT_ARCHITECTURE.md`
@@ -89,8 +91,9 @@
- `/mnt/Data1T/mnote/design/04-tree-domain/done/4-24-resource-tree-filetree-pagetree-source-contract-checklist-v1.md`
- `/mnt/Data1T/mnote/design/05-editor-mainline/done/5-12-main-editor-object-tab-resource-alignment-checklist-v1.md`
- `/mnt/Data1T/mnote/design/10-review/done/16-mnote-web-runtime-module-maintainability-checklist-v1.md`
- `/mnt/Data1T/mnote/design/03-rust-web/process/3-23-sidebar-local-folder-resource-runtime-followup-v1.md`
- `/mnt/Data1T/mnote/design/03-rust-web/reference/3-23-sidebar-local-folder-resource-runtime-followup-v1.md`
- `/mnt/Data1T/mnote/design/07-ai/process/7-38-page-ai-sidebar-runtime-owner-split-v1.md`
- `/mnt/Data1T/mnote/design/07-ai/done/7-50-lightrag-knowledge-rag-provider-v1.md`
## 设计稿目录规则
+29 -6
View File
@@ -1,12 +1,14 @@
# MNOTE 当前架构梳理
> 更新时间:2026-05-26
> 更新时间:2026-06-07
>
> 2026-05-22 口径更新:产品形态已切换为 local-first workspace,且初步 MVP 已建立;本地文件夹是默认数据真相,Rust SQLite control-plane 承接 auth、membership、share grants、sync state、AI policy、ACP/Hermes runtime session 等默认控制面。Convex 仅保留为历史迁移源、显式 cloud source / compat / sync replica 边界;根 `convex/` functions 源码已软删除到 `recycle/20260522-convex-runtime-retirement/convex/`,不再作为 active deploy source。相关设计已完成并迁入 `design/02-convex-rust-long-term-architecture/done/2-2-local-first-workspace-convex-control-plane-v1.md` 与 `design/02-convex-rust-long-term-architecture/done/2-8-convex-replace-with-rust-sqlite-control-plane-v1.md`。本地 Markdown 图片与附件上传已新增 `/api/local-folder/assets/upload`,页面内上传默认写入 `{mdBase}.assets/` 并保存相对 Markdown 路径,不再走 Convex media asset。
>
> **当前阶段:local-first MVP 后阶段。** 3000 下文档页、Sidebar/File Tree/Page Tree 三层树模型、tree command`tree.*` preferred)、tree realtime WS 主链 / SSE fallback、本地 Markdown 上传、上传下载、AI 会话本地化与 VSCode-like agent 运行口径均已建立。剩余工作集中在 `WorkspacePath/ObjectIdentity` runtime 消费统一、`DocumentBuffer/BufferStore`、Page Aggregate compat 瘦身、tree command context、live cache 统一、VSCode-like 冲突合并 UI、agent diff 审计、本地索 / 分享 / 同步闭环,而不是继续扩张专用 page-ai fast-path 或 Convex 主存储链。
> **当前阶段:local-first MVP 后阶段。** 3000 下文档页、Sidebar/File Tree/Page Tree 三层树模型、tree command`tree.*` preferred)、tree realtime WS 主链 / SSE fallback、本地 Markdown 上传、上传下载、AI 会话本地化与 VSCode-like agent 运行口径均已建立。剩余工作集中在 `WorkspacePath/ObjectIdentity` runtime 消费统一、`DocumentBuffer/BufferStore`、Page Aggregate compat 瘦身、tree command context、live cache 统一、VSCode-like 冲突合并 UI、agent diff 审计、本地轻量搜索 / 分享 / 同步闭环,而不是继续扩张专用 page-ai fast-path 或 Convex 主存储链。
>
> 2026-05-26 口径更新:前端 runtime 大块已完成模块级拆分。`layout.rs` / `tree.rs` / `web_shell.rs` 不再是浏览器 JS 功能的主要符号定位入口;Sidebar、FileTree、document adapter、tree shell 等 runtime 入口位于 `rust/crates/mnote-web/browser/*.js`tiptap island 入口位于 `rust/spikes/leptos-tiptap-spike/src/editor_runtime/*.rs`。CodeGraph 可直接索引这些 JS / Rust 模块;当前 smoke 分类见 `scripts/TESTING_REFERENCE.md`。
>
> 2026-06-07 口径更新:知识库问答、资料 OCR / parse / index / RAG 已收口到 LightRAG 单外挂 provider。LiteParse、旧本地 OCR sidecar、旧 evidence search / local-index agent tools 不再作为默认 fallbackMNote 负责 source registry、权限、FileTree 灯号、资料库设置 UI、dashboard/status UI、citation/open-reference 映射和 agent facade。图片源通过 MNote 生成 Markdown wrapper 入 LightRAGLightRAG 后台完成状态通过有界 status bridge/backoff 回写 MNote registry。当前 LightRAG 文本 / extract / query / VLM 模型口径为 `aigc/qwen-3.5`。
本文只描述当前仓库中真实成立的主线结构,以及当前最优先的架构收口点。
@@ -26,6 +28,7 @@
4. 文档页默认主编辑器已切到页面内 `leptos-tiptap` island
5. `BlockNote` 已退出文档页默认主路径,仅作为历史参考实现 / 对照材料保留
6. 页面 AI 当前最合理的长期形态是:MNote 只负责页面定位、白名单目录权限、agent runtime 管理、文件变更同步;Hermes / Reasonix 直接在授权工作区内编辑本地文件。`page_ai_workflow` 只保留为 debug / 历史兼容门面,`mnote.doc.markdown_edit` 不作为 local-first 默认正文编辑入口,只在显式 cloud / remote / compat 且无法直接授权文件访问的边界中作为受控 fallback;`mnote.block.*` 只保留为复杂结构辅助层
7. 知识库资料处理主线是 LightRAGOCR、parse、chunk、向量、图谱、RAG query 和跨文档召回由 LightRAG 承担;MNote 不再并行维护 LiteParse / evidence SQLite / OCR sidecar 作为资料库索引真相
一句话收口:
@@ -35,7 +38,7 @@
> **主 Web 执行面当前以 `mnote-web` 为 3000 ownerNext App Router 只保留为 legacy compat、交互 island bundle source 与显式 debug/迁移辅助边界。**
>
> **页面 AI 的 local-first 主路径应尽量贴近 VSCode:当前页面解析成真实 `.md` 文件,Hermes / Reasonix 在授权目录白名单内直接读写,tiptap 只消费后台文件变化后的最新投影。**
> **页面 AI 的 local-first 主路径应尽量贴近 VSCode:当前页面解析成真实 `.md` 文件,Hermes / Reasonix 在授权目录白名单内直接读写,tiptap 只消费后台文件变化后的最新投影。知识库问答另走 LightRAG provider,经 MNote source registry 和 citation/open-reference 回到本地资源。**
## 2. 当前主线目录
@@ -93,7 +96,7 @@
- `rust/crates/mnote-web/browser/tree-shell-runtime.js`debug/internal tree shell entrypoint,子模块负责 page tree、filetree、picker、render、DOM、icons 和 state。
- `rust/spikes/leptos-tiptap-spike/src/editor_runtime/`tiptap island runtime 模块集合;根 `lib.rs` 只保留 wasm entrypoint、Leptos shell 信号编排和仍未稳定外置的薄壳。
对应完成记录见 `design/10-review/done/16-mnote-web-runtime-module-maintainability-checklist-v1.md`。剩余专项边界不再回到 raw string 问题,而是分别由 `design/03-rust-web/process/3-23-sidebar-local-folder-resource-runtime-followup-v1.md``design/07-ai/process/7-38-page-ai-sidebar-runtime-owner-split-v1.md` 承接。
对应完成记录见 `design/10-review/done/16-mnote-web-runtime-module-maintainability-checklist-v1.md`。剩余专项边界不再回到 raw string 问题,而是分别由 `design/03-rust-web/reference/3-23-sidebar-local-folder-resource-runtime-followup-v1.md``design/07-ai/process/7-38-page-ai-sidebar-runtime-owner-split-v1.md` 承接。
## 4. 文档页主链
@@ -353,7 +356,27 @@ OnlyOffice 仍然是:
- `/mnt/Data1T/mnote/design/07-ai/process/7-18-local-first-agent-file-editing-control-plane-v1.md`
- `/mnt/Data1T/mnote/design/old/07-ai/process/7-14-local-first-ai-markdown-editing-convergence-v1.md`(历史回收稿;不再作为当前实现依据)
### 8.5 定向 Bug Hunt 与质量基线
### 8.5 Knowledge RAG / LightRAG
目标:
- 把资料库 OCR、解析、索引、图谱、召回和跨文档问答统一交给 LightRAG。
- MNote 只保留 source registry、allowed roots / 权限、FileTree 灯号、资料库设置 UI、LightRAG dashboard/status UI、citation/open-reference 映射和 agent tool facade。
- LiteParse、旧 OCR sidecar、旧 evidence search / local-index agent tools 作为历史迁移和 recycle 边界,不再作为默认 fallback。
当前进度(2026-06-07):
- ✅ LightRAG 是当前唯一默认知识库 RAG provider;当前模型口径为 `aigc/qwen-3.5`
- ✅ MNote `source registry` 记录 `workspaceId/rootUri/sourcePath/sourceHash/lightRagDocId/stale/deletedAt/status`FileTree 灯号从 registry 映射为已索引 / 正在索引 / 失败 / 未索引。
- ✅ 图片源通过 Markdown wrapper 入 LightRAG,避免 LightRAG `/documents/scan` 忽略原始图片文件。
- ✅ MNote 在 ingest / reindex 后通过有界 status bridge/backoff 对照 LightRAG `/documents`,把后台完成状态同步回 registry、设置面板和 FileTree。
- ✅ Agent 默认使用 `mnote.knowledge_rag.status/query/open_reference`,不再暴露旧 `mnote.evidence.*` 作为资料库检索主路径。
对应设计稿:
- `/mnt/Data1T/mnote/design/07-ai/done/7-50-lightrag-knowledge-rag-provider-v1.md`
### 8.6 定向 Bug Hunt 与质量基线
目标:
@@ -393,4 +416,4 @@ OnlyOffice 仍然是:
当前真正的卡点已经从“能不能跑”变成:
> **管理员目录授权、文件版本冲突合并、agent diff 审计、本地索 / 分享 / 同步如何产品化;Page Aggregate ClientState、projection / command / realtime 兼容链如何继续瘦身。**
> **管理员目录授权、文件版本冲突合并、agent diff 审计、本地轻量搜索 / 分享 / 同步如何产品化;知识库 RAG 已交给 LightRAGPage Aggregate ClientState、projection / command / realtime 兼容链如何继续瘦身。**
+17 -3
View File
@@ -1,6 +1,6 @@
# 当前完整架构
> 更新时间:2026-05-26
> 更新时间:2026-06-07
>
> 范围:`/mnt/Data1T/mnote` 当前可见实现、local-first MVP 后阶段主线口径、历史退役边界和后续功能缺口。
@@ -18,6 +18,7 @@
- Rust kernel / projection / command 持有树、页面、资源和权限语义。
- tiptap 是 Markdown 的前端显示与交互层,不是 agent 的主工作面。
- Hermes / Reasonix 默认应像 VSCode 中的 agent 一样,在授权目录白名单内用自身 patch / diff / 文件编辑能力修改文件。
- LightRAG 是当前默认知识库 RAG provider,负责资料库 OCR / parse / index / RAGMNote 负责 source registry、权限、FileTree 灯号、dashboard/status UI 和 citation/open-reference 映射。
- Rust SQLite control-plane 是默认 auth、membership、share grants、sync state、AI policy、ACP/Hermes runtime session 控制面;Convex / 服务端不再是默认正文、附件、AI 会话全文主存储,也不再是默认控制面,只保留历史迁移源、显式 cloud source、compat 和 sync replica 边界。
上位设计已完成并迁入:
@@ -64,6 +65,16 @@
- `/api/page-ai/block-edit-workflow` 不再作为 local-first 普通正文编辑主路径。
- AI session 全文在 local source 下默认写入 `ai-sessions/private/*.jsonl``ai-sessions/shared/<share-id>/*.jsonl`ACP/Hermes runtime session 默认落到 SQLite control-planeConvex 只作为显式 legacy/cloud metadata / audit / sync replica 边界。
### 2.5 Knowledge RAG / LightRAG
- 知识库问答、资料 OCR、parse、chunk、vector、graph、rerank、query 统一由 LightRAG 承担;当前模型口径为 `aigc/qwen-3.5`
- MNote 不复制 LightRAG 图谱或 chunk,不把 LightRAG storage 当用户正文真相;原始 source 仍属于 local-folder / Resource Tree。
- MNote 维护 `<workspace>/.mnote/index/lightrag-source-registry.json`,用于映射 `workspaceId/rootUri/sourcePath/sourceHash/lightRagDocId/stale/deletedAt/status`
- FileTree 的资料库灯号来自 LightRAG source registry:绿灯已索引、黄灯正在索引或删除提交中、红灯失败 / 过期、无灯未索引或已移除。
- 图片源先由 MNote 生成 Markdown wrapper,再交给 LightRAG scan;不能假设 LightRAG 会直接索引原始 `png/jpg`
- MNote 在 ingest / reindex 后用有界 status bridge/backoff 同步 LightRAG 后台完成状态,刷新 registry、资料库面板和 FileTree。
- Agent 只通过 `mnote.knowledge_rag.status/query/open_reference` 访问知识库;旧 LiteParse、OCR sidecar、`mnote.evidence.*` 与本地资料索引工具已退役为历史 / recycle 边界。
## 3. 已完成 MVP 收口
- Local-first workspace 上位设计和 checklist 已完成,迁入 `design/02-convex-rust-long-term-architecture/done/2-2-*`
@@ -98,7 +109,7 @@
- 减少 polling 或重复补偿链。
6. **Runtime owner 剩余专项**
- `design/03-rust-web/process/3-23-sidebar-local-folder-resource-runtime-followup-v1.md` 继续承接 local folder / resource 编排中仍适合拆出的稳定 helper
- `design/03-rust-web/reference/3-23-sidebar-local-folder-resource-runtime-followup-v1.md` 记录 local folder / resource 编排拆分后的参考边界;新的执行项应另起 process 稿
- `design/07-ai/process/7-38-page-ai-sidebar-runtime-owner-split-v1.md` 继续承接 Page AI sidebar runtime owner 细化。
7. **管理员目录授权 UI / API**
@@ -113,9 +124,10 @@
- local-first agent 应回收 changed files、diff summary、tool/run id、actor、workspace root、permission level。
- audit 默认本地落盘,同步开启时再上报控制面。
10. **本地全文搜索、引用和索引**
10. **本地轻量搜索与知识库 RAG 边界**
- 本地 `.md` workspace 需要独立索引:全文搜索、反链、页面引用、资源引用、标签。
- 不能依赖 Convex search 才能搜索本地工作区。
- 多资料 OCR / parse / index / RAG 不再由本地 evidence / LiteParse 承担,默认走 LightRAG provider。
11. **分享与同步闭环**
- 需要把 share grants、shared workspace cache、shared AI session、只读/可写权限和冲突处理连成产品级闭环。
@@ -142,5 +154,7 @@
-`/api/documents/save` 是长期正文保存主入口。”
- “BlockNote 是当前默认编辑器或系统事实源。”
- “Next App Router / wolai-frontend 是 3000 主运行时。”
- “LiteParse / 本地 OCR sidecar / evidence search 仍是资料库 RAG 默认 fallback。”
- “MNote 需要为 LightRAG 结果再建一套 RAG 索引真相。”
历史文件如果需要保留这些说法,必须明确标注为 `[recycle]`、legacy、review snapshot 或 compat / cloud source 背景。
+6 -1
View File
@@ -9,6 +9,7 @@
- Rust SQLite control-plane 已承接默认 auth、membership、share grants、sync state、AI policy、ACP/Hermes runtime session 等控制面;Convex / 服务端不再是默认正文、附件、AI 会话全文主存储,只保留历史迁移源、显式 cloud source、compat 和 sync replica 边界。
- `mnote-web` 是当前 3000 owner;旧 Next / React / wolai-frontend 已移入 `recycle/`,默认不作为当前实现依据。
- 前端 runtime 已完成第一轮模块级拆分;查浏览器 JS 功能默认先看 `rust/crates/mnote-web/browser/*.js`,查 tiptap island 行为默认先看 `rust/spikes/leptos-tiptap-spike/src/editor_runtime/*.rs`。不要把 `layout.rs` / `tree.rs` / `web_shell.rs` 的历史 raw string 当作当前符号定位入口。
- Knowledge RAG 当前统一到 LightRAGLiteParse、旧 OCR sidecar、旧 evidence search / local-index agent tools 不再作为默认 fallbackLightRAG 负责 OCR / parse / index / RAGMNote 负责 source registry、权限、FileTree 灯号、资料库 UI、dashboard/status 和 citation/open-reference。当前模型口径为 `aigc/qwen-3.5`
## Stack
@@ -24,6 +25,8 @@
|------|----------|
| `rust/crates/mnote-web/` | Rust web gateway (axum + Leptos SSR, `:3000`) |
| `rust/crates/mnote-web/browser/` | Browser runtime modules for sidebar, filetree, document adapter, tree shell, Page AI panel host |
| `rust/crates/mnote-web/src/routes/knowledge_rag.rs` | MNote LightRAG connector routes: status, ingest, query, delete, prune, open-reference |
| `rust/crates/mnote-web/src/hermes_tools/knowledge_rag.rs` | Hermes / Reasonix-facing knowledge RAG tool facade |
| `rust/crates/core-protocol/` | Kernel types, projection protocol, tree/graph terms |
| `rust/crates/bridge-runtime/` | Kernel query/command, projection bridge, source normalization |
| `rust/spikes/leptos-tiptap-spike/src/editor_runtime/` | Leptos-tiptap island runtime modules; use before editing root `lib.rs` |
@@ -80,6 +83,7 @@
- **Rust workspace**: edition 2021, resolver "2", MIT license. All crates inherit workspace version (`rust/Cargo.toml:2-8`).
- **Convex boundary**: root `convex/` has been retired to `recycle/20260522-convex-runtime-retirement/convex/`; `infra/convex/` has been retired to `recycle/20260522-convex-runtime-retirement/infra/convex/`; do not recreate either as an active deploy source without a new architecture decision. `recycle/wolai-frontend/convex` is also historical. Keep Rust `transport/convex.rs` and `storage-convex-bridge` as explicit cloud/compat boundaries; do not mechanically delete them with the retired functions source.
- **AI editing path**: local-first Markdown editing should prefer `currentFile + selection + allowedRoots / aiAccessScope + agent native patch/diff + watcher sync`. `mnote.doc.*` / `mnote.block.*` are cloud, remote, compat, or complex-structure helpers.
- **Knowledge RAG path**: use `mnote.knowledge_rag.status/query/open_reference` and `/api/knowledge-rag/*`. Do not use `mnote.evidence.*`, LiteParse, or local OCR sidecar as the default retrieval/indexing path. Image sources enter LightRAG through a Markdown wrapper; MNote status bridge/backoff syncs LightRAG background completion back to the registry and FileTree.
- **Bug tracking**: bugs organized by design domain directory (e.g. `bugs/04-tree-domain/process/`), moved to `done/` when fixed (`bugs/README.md`).
- **Design docs**: organized by domain in `design/`, with `process/` (in-progress) and `done/` (completed) subdirs (`design/README.md`).
@@ -89,6 +93,7 @@
- **`.aionrs/`, `.claw/`, `.codex/`, `.gemini/`** — session data from other AI tools, not project code. Don't read or edit.
- **`recycle/`** — retired code, not current implementation. Don't use as evidence for current behavior.
- **Convex availability**: local-folder / Rust projection smoke should not require Convex. Auth, share grants, ACP/Hermes runtime state, and AI policy default to SQLite control-plane; Convex is required only for tests that explicitly cover legacy cloud source, compat, sync replica, migration, or rollback behavior.
- **MVP 后阶段优先级**: prefer WorkspacePath/ObjectIdentity runtime consumption, DocumentBuffer/BufferStore, Page Aggregate compat slimming, tree command context/context key, live cache unification, agent diff audit, conflict merge, local index, and share/sync productization.
- **LightRAG boundary**: LightRAG storage is derived cache, not user source truth. MNote registry is the mapping authority for source path, doc id, stale/deleted state, FileTree lights, and citation routing.
- **MVP 后阶段优先级**: prefer WorkspacePath/ObjectIdentity runtime consumption, DocumentBuffer/BufferStore, Page Aggregate compat slimming, tree command context/context key, live cache unification, agent diff audit, conflict merge, local lightweight search, and share/sync productization. Knowledge RAG belongs to LightRAG, not a revived local evidence/LiteParse index.
- **Rust wasm32 target** required (`rust/rust-toolchain.toml`), needed for `tree-shell-runtime-wasm` crate.
- **`rust/target/`** is gitignored and large — `cargo build` runs from scratch if cache is missing.
@@ -0,0 +1,757 @@
# 7-50 [done] LightRAG Knowledge RAG Provider v1
> 创建时间:2026-06-06
>
> 当前状态:`DONE`
>
> Owner07-ai / knowledge-rag / plugin-ui / external-provider
>
> 取代:`design/old/07-ai/process/7-49-local-understanding-graphrag-kernel-v1.md`
>
> 参考代码:
> - `reference-code/LightRAG`
>
> 本机源码部署:
> - source`/mnt/Data1T/Mnote_data/lightrag/LightRAG`
> - storage`/mnt/Data1T/Mnote_data/lightrag/rag_storage`
> - input spike`/mnt/Data1T/Mnote_data/lightrag/inputs`
> - systemd user service`mnote-lightrag.service`
> - WebUI`http://127.0.0.1:9621`
> - current model`aigc/qwen-3.5`
>
> 上位依据:
> - `/mnt/Data1T/mnote/ARCHITECTURE.md`
> - `/mnt/Data1T/mnote/CURRENT_ARCHITECTURE.md`
> - `design/07-ai/process/7-46-document-evidence-retrieval-kernel-v1.md`
> - `design/07-ai/process/7-48-paperless-ngx-reference-resource-ingestion-job-index-v1.md`
> - `design/03-rust-web/done/3-25-local-folder-mineru-ocr-sidecar-v1.md`
## 1. 第一结论
MNote 后续知识库问答主线改为 **LightRAG 单外挂 Provider**,不继续推进 7-49 的自研 Understanding GraphRAG Kernel / mindmap projection 路线。
目标是减少冗余项目和冲突:
- LightRAG 负责知识库问答相关的 OCR、文档解析调度、MinerU / Docling 接入、sidecar、chunk、向量、图谱、rerank 和跨文档问答。
- MNote 负责 workspace 权限、source truth、resource ownership、引用 UI、agent 授权、任务入口和 LightRAG 结果打开方式。
- LiteParse / MNote 旧解析定位链路退役;旧 OCR / evidence / 本地资料索引入口不再作为默认 fallbackPDF / Office / 图片 OCR 与资料库索引统一交给 LightRAG。
- PageIndex 暂停,不作为当前默认双引擎;未来只在少量书籍确实需要章节树精读时再做定向树索引。
- 插件系统暂不开发完整市场式框架;第一版只做一个薄的 `KnowledgeRagProvider` / `LightRAGAdapter`,让 LightRAG 像插件一样不干扰 MNote 正常功能。
## 2. 为什么放弃 7-49
7-49 的核心假设是 MNote 自己构建本地 Understanding GraphRAG Kernel。这个方向问题不在能力,而在冗余:
- LightRAG 已经覆盖 graph + vector + rerank + parser pipeline 的主体能力。
- 自研 understanding graph 会和 LightRAG 图谱、evidence SQLite、mindmap projection 形成三套相似真相。
- mindmap projection 不能覆盖 NotebookLM 式多书问答的核心体验,反而会牵引 UI 和数据模型提前复杂化。
- 用户当前需求是“很多本书 / 很多篇文献作为资源,agent 能查阅、问答、溯源”,优先级高于自研图谱可视化。
因此 7-49 退役,只保留为历史参考,不再作为 process 设计稿。
## 3. LightRAG 能覆盖的边界
本轮已按最新 LightRAG 代码重新建索引确认:
- LightRAG v1.5 已合并 RagAnything 路线,支持 MinerU / Docling 多模态解析。
- 支持 `legacy` / `native` / `mineru` / `docling` 解析引擎。
- 支持 Fix / Recursive / Vector / Paragraph 四种切分。
- MinerU 支持 `official` / `local` 两种模式;MNote 已有 MinerU token,可先走 official spike。
- sidecar 包含 `blocks.jsonl``drawings.json``tables.json``equations.json`、assets。
- block positions 支持 `bbox`,可表达 page + rectangle。
- chunk 可以通过 `sidecar.refs` 追溯到 block/table/drawing/equation。
关键限制:
- LightRAG 没有集成 LiteParse 本身。
- LightRAG OCR 能力来自 MinerU / Docling 服务,不是内置 OCR 模型。
- LightRAG `/query` 默认引用粒度偏粗,通常是 `reference_id + file_path + chunk content`MNote 需要额外 adapter 反查 sidecar,生成现有 EvidenceLocator。
结论:LightRAG 可以成为主解析/RAG provider,但不能直接替代 MNote 的权限、locator 和引用 UI。
## 4. 新架构边界
本设计的核心边界是:**LightRAG 是独立本地知识库底座服务,MNote 是 UI + source manager + permission layer**。
这不是把 LightRAG 深度嵌入 MNote,也不是让 MNote 复制 LightRAG 内部索引。LightRAG 可以直接对 MNote 暴露的本地文件夹做 RAG / OCR;MNote 只提供允许访问的目录、用户操作入口、任务控制、结果展示和引用打开。
```text
用户资料库
PDF / EPUB / DOCX / PPTX / Markdown / 图片
|
v
MNote LightRAG Connector
- allowed roots
- workspaceId / rootUri
- source file identity
- task control UI
- reference open action
|
v
LightRAG Service / Dashboard
- MinerU / Docling parser
- sidecar
- chunk / vector / graph / rerank
- query / query_data
- task state
|
v
MNote Answer Renderer
- answer
- citations
- LightRAG reference
- MNote open action
- locator fallback
```
MNote 不复制 LightRAG 的图谱,不把 LightRAG 的 chunk 当正文真相。LightRAG 是派生知识库;原始文件仍属于 MNote local-folder / resource tree。
### 4.1 OCR / 索引归属
知识库问答相关的 OCR、parse、chunk、vector、graph、rerank、query index 全部归 LightRAG。
MNote 不再为“问一堆书 / 问一堆论文”维护第二套 OCR / parse / chunk / vector / graph。此前 MNote 为 OCR、索引、source-map、evidence graph 维护过多套派生链路,已经制造了过多 bug 和职责冲突;7-50 的目标就是把这部分降为一个外部 provider。
MNote 可以保留轻量 UI 索引,但它只服务 MNote 正常功能:
- 页面标题、Markdown 正文快速搜索。
- tag / backlink / 最近页面。
- 文件树和资源元数据。
- 当前编辑器内搜索。
区分标准:
| 场景 | Owner |
| --- | --- |
| 搜我的笔记页面、标签、最近编辑、backlink | MNote 轻索引 |
| 问多本书 / 多篇论文 / PDF / 扫描件 / Office 附件 | LightRAG |
| 解析 PDF / OCR 图片 / 建向量 / 建图谱 / rerank | LightRAG |
| 点击来源、打开 MNote resource tab、显示降级来源 | MNote connector |
### 4.2 插件式不干扰原则
LightRAG 第一阶段必须像无害插件:
- 不修改用户 Markdown 正文。
- 不写 MNote 文件树中的业务文件。
- 不接管 MNote 普通搜索。
- 不让 MNote UI 依赖 LightRAG 才能打开页面。
- 不在 MNote active code 中复制 LightRAG pipeline。
- 不把 LightRAG parser/cache/index 失败扩散为 MNote 文件树或编辑器故障。
MNote 和 LightRAG 的连接面应尽量小:
- 配置 LightRAG endpoint / working dir / token 注入方式。
- 给 LightRAG 一组 allowed roots 或 source list。
- 触发 ingest / reindex / delete source。
- 查询 LightRAG result。
- 把 LightRAG reference 映射为 MNote open action。
- 在 MNote 中嵌入或打开 LightRAG dashboard,用于任务控制和 graph 查看。
### 4.3 Source Truth / 删除语义
7-50 的基线不采用“上传一份副本到 LightRAG staging 目录再索引”的默认模式。这个模式会制造两个问题:
- 反复真相:MNote 本地文件是一份,LightRAG input/staging 又是一份,用户编辑、移动、删除后两边容易不一致。
- 删除源后仍可检索:LightRAG 索引是派生缓存,如果没有收到 source delete / stale 标记,旧 chunk / vector / graph 仍可能继续回答。
因此 7-50 的 source truth 规则是:
- 原始资料真相永远是 MNote local-folder / resource tree 中的 host absolute path。
- LightRAG 的 storage、sidecar、chunk、vector、graph 都是派生缓存。
- MNote 默认不把用户资料复制到 LightRAG input 目录;除非 LightRAG API 当前阶段只能 upload,才允许使用临时受控 staging,并且必须记录为 spike 降级方案。
- LightRAG connector 必须维护 source registry
- `workspaceId`
- `rootUri`
- `sourcePath`
- `sourceHash`
- `lightRagDocId`
- `indexedAt`
- `deletedAt`
- `stale`
- source 文件删除、移动、重命名或 hash 变化后,MNote watcher 必须把 registry 标记为 stale,并触发 LightRAG delete / reindex。
- 删除 source 后,query scope 必须过滤 stale / deleted source;如果 LightRAG 侧删除尚未完成,MNote 层不得把该 source 的 citation 当成有效来源展示。
- LightRAG 官方 `DELETE /documents/delete_document` 能删除文档状态、chunks、vector、graph`delete_file=true` 还会删除 input 目录文件。MNote 集成默认只删除 LightRAG 派生索引,不删除用户原始文件。
- 改 parser、embedding dim、storage backend、source parser hint 等影响索引一致性的配置时,必须走“delete old doc -> reindex source”,不能隐式覆盖旧索引。
这个边界决定部署方式:Docker 可以用于隔离 demo,但不作为 7-50 与 MNote connector 的基线。7-50 基线采用源码 / host 部署,保证 LightRAG 能看到 MNote 的真实本地路径、watcher 能做删除同步,MNote adapter 也能以同一套路径身份做引用打开。
### 4.4 Source Truth Spike 结果
2026-06-06 已完成一个受控 source-truth spike
- MNote source 文件:`/mnt/Data1T/Mnote_data/lightrag/source-spike/mnote-source-truth-spike.txt`
- LightRAG input 入口:`/mnt/Data1T/Mnote_data/lightrag/inputs/mnote-source-truth-spike.txt`,以 symlink 指向 source 文件。
- 触发方式:`POST /documents/scan`
- LightRAG doc id`doc-0092d6976b80946591c9d129e92d989a`
- 处理结果:`processed``chunks_count=1``parse_engine=legacy``file_path=mnote-source-truth-spike.txt`
- `query` 能回答 `CARBOXY_PROTECTOR_ALPHA_7349`,并返回 reference。
- `query/data` 能返回 entities / relationships / chunks / references,其中 chunk 带 `chunk_id``file_path`
- `DELETE /documents/delete_document` + `delete_file=false` 删除后,documents 为空,`query/data` 返回 `no_results``query` 返回 no-context 且 references 为空。
- 删除 LightRAG 派生索引没有删除 MNote source 文件;symlink 被 LightRAG scan 移入 `inputs/__parsed__/`
结论:
- symlink scan 可以避免复制 source 内容,但 LightRAG 内部 `file_path` 只保留 basename,不保留 host absolute path。
- 因此 MNote connector 仍必须维护 source registry,把 `workspaceId/rootUri/sourcePath/sourceHash/lightRagDocId/indexedAt/deletedAt/stale` 映射到 LightRAG doc id 和 basename。
- `delete_file=false` 是 MNote 删除 LightRAG 派生索引的默认语义;不能让 LightRAG 删除用户 source。
- source delete / move / rename 的自动触发仍需要 MNote watcher + registry,不能仅依赖 LightRAG scan。
### 4.5 Ingestion Path Spike 结果
2026-06-06 继续补测 SDK / REST upload / input scan / symlink scan 四种入口:
| 入口 | 测试方式 | 结果 | 对 MNote connector 的判断 |
| --- | --- | --- | --- |
| SDK `ainsert` | 独立临时 storage 调用 `LightRAG.ainsert(..., file_paths=/mnt/.../source.txt)` | `processed`chunk token 命中;source 文件未被删除;`file_path` 仍被规整成 basename | 可作为 adapter 内部能力,但不能依赖 LightRAG 保存 host absolute path;仍要 registry |
| REST `/documents/text` | 调用 text API`file_source` 传 host absolute path | `processed``process_options=F``file_path` 被规整成 basename | 适合纯文本 / Markdown 内容直入,但无法表达真实附件生命周期 |
| REST `/documents/upload` | multipart 上传一个文本文件 | `processed`,文件被放入 `inputs/__parsed__/``file_path` 为上传文件名 | 只适合作 fallback / UI 手动上传,不适合作 MNote 默认 source truth |
| `input_dir` real file + `/documents/scan` | 写入真实 input 文件后触发 scan | `processed`scan 会把 input 文件移入 `__parsed__` | 可用于受控 staging,但会制造第二份 source,不作为默认 |
| symlink + `/documents/scan` | `inputs/*.txt` symlink 指向 MNote source 文件 | `processed`symlink 被移入 `inputs/__parsed__/`,原始 source 保留;`file_path` 为 symlink basename | 当前最接近 MNote source-truth 基线;必须由 registry 管理 symlink basename 到 host source |
额外观察:
- `/documents/scan` 在 pipeline busy 时会返回 `scanning_skipped_pipeline_busy`,不会排队;MNote connector 必须做 job ledger / retry,而不能把一次 scan 失败视作 ingestion 完成。
- SDK、text API、upload、scan 都不会保留 host absolute path 到 LightRAG `file_path`LightRAG 引用只能作为 provider-local referenceMNote 侧必须用 `source registry` 反查。
- 本轮临时 server storage 中产生的四个测试 doc 已用 `DELETE /documents/delete_document` + `delete_file=false` 删除;独立 SDK storage 留在 `/tmp/mnote-lightrag-sdk-*` 作为一次性验证产物。
### 4.6 MNote Connector First Cut
2026-06-06 已落第一版 MNote connector,不引入完整插件市场:
- 新增 `rust/crates/mnote-web/src/routes/knowledge_rag.rs`
- 新增 routes
- `GET /api/knowledge-rag/status`
- `POST /api/knowledge-rag/ingest`
- `POST /api/knowledge-rag/query`
- `POST /api/knowledge-rag/open-reference`
- `POST /api/knowledge-rag/delete-source`
- 新增 source registry`.mnote/index/lightrag-source-registry.json`
- `ingest` 默认创建 `input_dir` symlink 后触发 `/documents/scan`,不复制用户 source 内容。
- `delete-source` 调用 LightRAG `DELETE /documents/delete_document` 时固定 `delete_file=false`,只删除派生索引,不删除用户 source。
- `query` 只把 LightRAG raw result 作为 provider 结果返回;MNote 额外生成 `references`,用 registry 把 LightRAG `file_path` 映射回 MNote source。
- `open-reference` 未命中 registry 或 source 已 stale/deleted 时返回 `locatorDegraded=true`,不伪造 page / bbox。
- Hermes/Page AI manifest 新增:
- `mnote.knowledge_rag.status`
- `mnote.knowledge_rag.query`
- `mnote.knowledge_rag.open_reference`
- 新增 skill`skills/mnote-knowledge-rag/SKILL.md`
- Reasonix ACP wrapper 新增对应工具:`mnote_knowledge_rag_status` / `mnote_knowledge_rag_query` / `mnote_knowledge_rag_open_reference`
- Route smoke 已通过:
- `GET /api/knowledge-rag/status` 返回 LightRAG `healthy` / `webui_available=true`
- `POST /api/knowledge-rag/ingest``knowledge-rag-route-probe.md` 创建 symlink 并触发 `scanning_started`
- registry 同步到 `lightRagDocId=doc-0b7eabd99e33100ed94fc929143bb632``indexedAtMs` 有值。
- `POST /api/knowledge-rag/query` 返回 1 条 registry 命中的 reference`locatorDegraded=false`
- `POST /api/knowledge-rag/delete-source` 返回 `deletion_started`registry 标记 `stale=true/deletedAtMs`,原始 source 文件仍存在。
- Hermes manifest 带 `mnote-knowledge-rag` capability pack 和三项 `mnote.knowledge_rag.*` tool。
2026-06-06 运行态复核发现:3000 dev runtime 继承了历史 `LIGHTRAG_API_KEY` 环境变量,导致 MNote connector 优先使用旧 key,请求当前 `mnote-lightrag.service` 返回 `403 Invalid API Key`。已修正 `lightrag_api_key()` 优先级为 `MNOTE_LIGHTRAG_API_KEY` -> LightRAG source `.env` -> 通用 `LIGHTRAG_API_KEY` 兜底,避免旧 LightRAG 环境污染 MNote connector。临时 3027 server 带 `LIGHTRAG_API_KEY=bad-legacy-key` 复测,status / query 均可通过 source `.env` 正确访问当前 LightRAG。
### 4.7 PDF / MinerU Sidecar 验证结果
2026-06-06 已用 `mnote-e2e` local workspace 通过 MNote connector 跑 1 本书籍 PDF、2 篇论文 PDF、1 个扫描 PDF:
| 样本 | MNote source | LightRAG doc | 结果 | 观察 |
| --- | --- | --- | --- | --- |
| 书籍 PDF | `knowledge-rag-fixtures-7-50/book-q1-fy25-earnings.pdf` | `doc-32765c52c02777e6c8f8bc452ac0157e` | `processed` | MinerU`chunks_count=38``mm_chunks=25` |
| 论文 PDF | `knowledge-rag-fixtures-7-50/paper-brotli-comparison.pdf` | `doc-06f4c43d18a0398beeb0f31e91b81466` | `processed` | MinerU`chunks_count=6``mm_chunks=3` |
| 论文 PDF | `knowledge-rag-fixtures-7-50/paper-masterformat-partial.pdf` | `doc-633d81d17d26727bf600f54a7591657b` | `processed` | MinerU`chunks_count=1` |
| 扫描 / 图片 PDF | `knowledge-rag-fixtures-7-50/scan-image-start.pdf` | `doc-14a5926e5c673f446d2be735259c622f` | `processed` | MinerU`chunks_count=1` |
补充观察:
- PDF 不应强制 `parserHint=native`LightRAG 当前 native hint 不支持 PDF,之前强制 native 的 PDF 已验证会失败。
- 对同内容扫描 PDF 再入库时,LightRAG 会标记 duplicate,并通过 `metadata.original_doc_id` 指向原始 docMNote registry 当前只把 processed doc 当成可索引完成,duplicate failed 不应作为有效 citation。
- Sidecar 样本实际落在 `/mnt/Data1T/Mnote_data/lightrag/inputs/__parsed__/*.{pdf}.parsed/*.blocks.jsonl` 与对应 `*.mineru_raw/`,不提交原文 fixture。
- `blocks.jsonl` 的 content block 已包含 `positions: [{ type: "bbox", anchor: "1", range: [...] }]`,其中 `anchor` 可作为页码来源,`range` 可转为 bbox。
- LightRAG `kv_store_text_chunks.json``/query/data` chunks 当前没有 `refs` 字段;chunk 只能拿到 `chunk_id``file_path``reference_id``content`。因此 MNote 先把 query reference enrich 出 `chunkId` / `quote`,再用 `file_path -> parsed sidecar -> blocks.jsonl -> positions` 反查 page/bbox。
- 已新增第一版 `LightRAGSidecarLocator`:用 chunk content / chunk id fallback 匹配 sidecar block,生成 MNote `EvidenceLocator`;匹配失败时继续显示 locator degraded,不伪造页码。
当前已能把一部分 LightRAG PDF 引用升级成真正的 PDF page/bbox EvidenceLocator,但仍不是无损映射:因为 LightRAG chunk 缺少 `refs`MNote 当前依赖 chunk quote 与 sidecar block 文本近似匹配。后续如果 LightRAG 暴露 chunk refs,应改为 refs 优先、文本匹配 fallback。
### 4.8 运行态复核结果
2026-06-06 运行态复核:
- `mnote-lightrag.service` 为 systemd user service,当前 active running;入口为 `/mnt/Data1T/Mnote_data/lightrag/LightRAG/.venv/bin/lightrag-server`,配置来自 `/mnt/Data1T/Mnote_data/lightrag/LightRAG/.env`
- `GET /api/knowledge-rag/status` 返回 LightRAG `healthy``webui_available=true``dashboardUrl=http://127.0.0.1:9621`
- `GET http://127.0.0.1:9621/` 跳转 `/webui/``GET /webui/` 返回 200;但尚未做完整浏览器交互验证 dashboard 的 documents / graph / task 面板。
- `scripts/task529-knowledge-rag-citation-resource-tab-smoke.js` 已通过:`POST /api/knowledge-rag/query` 能召回 `knowledge-rag-fixtures-7-50/scan-image-start.pdf`citationUrl 打开本地 PDF resource tab,带 `page=1` / `bbox=171,126,648,152`,页面内 evidence 高亮渲染。
- `POST /api/hermes/tools/mnote/call` 调用 `mnote.knowledge_rag.query` 已通过,工具层能返回 references 与 `citationMarkdown`;这验证的是 agent tool 检索通道,不等于 Page AI 真实最终回答质量已验收。
- 召回已经可用,但排序还未验收:`scan image start` 查询能召回目标扫描 PDF,不过 top-1 可能是其他 PDF,后续需要专门评估 query mode / rerank / source scope。
- MNote topbar 已新增独立“资料库问答”设置入口,不混入本地索引设置;该面板能读取 `/api/knowledge-rag/status`,展示 LightRAG healthy 状态、dashboard/input/storage、source registry,并提供相对路径 source 输入、ingest、刷新、打开 dashboard 动作。浏览器 smoke 已验证面板在 3000 打开后显示 `LightRAG healthy,已登记 5 个来源`
- `/api/knowledge-rag/ingest` 已支持 sourcePath 指向文件或目录;目录会在授权 root 内递归展开受支持文件,跳过 `.mnote` / `.git` / `node_modules` / `target` / `__parsed__` / `.venv`,并限制单次最多 200 个文件,避免误把整个工作区无限制灌入 LightRAG。对应单元测试已覆盖目录展开、内部目录跳过和不支持扩展名拒绝。
- 资料库问答设置面板已支持从当前 filetree 选中项填入 sourcePath,并在 ingest 输入行展示 `待索引` / `索引中` / `已提交` / `需重试` / `失败` 状态。浏览器 smoke 已验证选中 `knowledge-rag-fixtures-7-50` 后点击“使用选中文件树项”能把该目录写入 source 输入框。
- 运行态继续发现 `3000` dev runtime 继承了旧 Windows `INPUT_DIR=F:/SOFT/MNOTE/LightRAG/inputs` / `WORKING_DIR=F:/SOFT/MNOTE/LightRAG/data`,导致 sidecar locator 在当前 Linux LightRAG storage 中查不到 `blocks.jsonl``task529` 一度降级。已修正 connector 路径优先级为 `MNOTE_LIGHTRAG_*` -> LightRAG source `.env` -> 通用 legacy env -> 默认值,并补 `lightrag_paths_prefer_source_env_over_legacy_process_env` 单测。
- `task529-knowledge-rag-citation-resource-tab-smoke.js` 复跑通过:`scan image start` 当前 top reference 为 `knowledge-rag-fixtures-7-50/scan-image-start.pdf``citationMarkdown=[scan-image-start.pdf · p.1](...)``citationUrl``resourceTab/page=1/bbox=171,126,648,152/blockId=f69c1af99063b608b4031a3c51864265`,浏览器资源标签页能渲染 PDF 和 evidence 高亮。
- Agent 工具输出已改为 `mnote.knowledge_rag.agent_query_result.v1` 紧凑结构,优先给 `references/citations/citationMarkdown/locator`,不再把 LightRAG raw chunks 放在工具输出前面,避免 Page AI 最终回答看不到 citation 或把 raw retrieval 当回答。
- `hybrid` mode 已在 MNote connector 中别名到 `mix`,避免 agent 复用本地 evidence search 的 `hybrid` 习惯时召回偏离;同时对 mapped references 做 query lexical rerank,让明确文件名 / 关键词的命中优先出现在 references 前列。`query_ranking_prefers_exact_source_and_quote_match``query_mode_aliases_hybrid_to_mix_for_lightrag` 已覆盖。
- 新增 `scripts/task530-knowledge-rag-page-ai-final-answer-smoke.js`,真实浏览器打开 Page AI drawer,切到 Reasonix,发送资料库问题并验证最终可见回答包含 `scan-image-start.pdf · p.1` 的可点击 resourceTab citation,且不泄漏 raw JSON / 工具名 / 检索过程叙述。该 smoke 已通过。
- 新增 `scripts/task531-lightrag-dashboard-ui-smoke.js`,从 MNote `/api/knowledge-rag/status` 读取 `dashboardUrl` 后打开 LightRAG WebUI,真实浏览器验证 Documents / Knowledge Graph / Retrieval 三个入口可见:Documents 显示 `Completed (5)` / `Fail (4)` / 扫描 PDF doc id 与 summaryGraph 显示 `Connected`Retrieval 显示 `Query Mode` / `KG Top K` / `Connected`。截图落在 `tmp/task531-lightrag-dashboard-ui-smoke/`
### 4.9 旧搜索 / Evidence 退役结果
LightRAG 引入后,旧搜索链路先完成回归确认,随后按最新产品决策退役 LiteParse / evidence fallback。当前分工是:
- `/api/evidence/search` 已返回 `410 Gone` / `mnote_evidence_search_retired`,不再作为 agent fallback。
- `mnote.evidence.search/read/open``mnote.index.status/refresh/update_settings` 不再暴露在 Hermes / Reasonix manifest 与 capability pack 中。
- `/api/local-folder/ocr/jobs/status/read/insert/delete` 已返回 `410 Gone` / `mnote_local_ocr_retired`,旧 OCR sidecar API 不再作为 UI 或 agent 通道。
- `mnote-local-index` skill 退役;兼容 alias `mnote-document-evidence` / `mnote-local-index` 映射到 `mnote-knowledge-rag`
- `/api/search/documents` 继续保留 MNote 页面 / Markdown resource 普通搜索,但 `includeOcr` 不再读取 `.mnote/ocr-index.json`PDF / Office / 图片 OCR 与资料库问答走 LightRAG。
- `/api/knowledge-rag/query` / `mnote.knowledge_rag.query` 是资料库问答和跨资料 citation 的默认路径。
- FileTree `data-index-status` 已改为读取 LightRAG source registry,而不是 LiteParse sidecar / evidence.sqlite / local OCR index。
2026-06-06 已补跑旧链路 smoke,作为退役前基线;随后相关 active smoke 已软归档到 `recycle/20260606-liteparse-retirement/`
- `scripts/task528-document-evidence-liteparse-agent-smoke.js` 通过:直接 `/api/evidence/search` 命中 `Printer test page``mnote.evidence.search` 返回同一 PDF evidence 的 `quote/page/bbox/sourceMapPath`Reasonix ACP 实际调用 `mnote_evidence_search`LiteParse sidecar 与 `.mnote/index/evidence.sqlite` 均存在。
- `scripts/task529-local-search-result-open-locator-smoke.js` 通过:`/api/search/documents` 搜索 `三甲基硅酯` 后,点击结果仍停留在当前文档页并打开 Markdown resource tab,定位到 `docs/Silicon.md` 命中段落,截图落在 `tmp/task529-local-search-result-open-locator-smoke/`
- `scripts/task452-local-search-index-browser-smoke.js` 已按当前独立索引设置 surface 更新并通过:显式保存 `includePaths=["."]` 后刷新索引,初次搜索能找到新建 Markdown;导航页和文档页都能打开独立索引面板,不回落到页面设置页签;`/api/search/local-index/backlinks``/api/search/local-index/tags` 仍返回 200;重命名后刷新索引,搜索结果更新到新路径且不再返回旧路径。
- `scripts/task526-local-folder-ocr-api-smoke.js` 已从 active scripts 移到 recyclePage AI raw resource target smoke 已改为确认不再携带 `ocrContext` / `ocrRootRelativePath`
结论:旧 evidence 能力曾验证可用,但当前默认产品路径已切到 LightRAG;历史 sidecar 只作为归档数据解释材料,不再驱动新的 OCR / 索引 / agent 检索。
### 4.10 DOCX / Office Ingestion 验证结果
2026-06-06 新增 `scripts/task532-knowledge-rag-docx-ingestion-smoke.js`,用现有 OnlyOffice smoke DOCX 生成带唯一 marker 的 DOCX fixture,走 MNote `/api/knowledge-rag/ingest` 入库并真实查询:
- DOCX source`knowledge-rag-fixtures-7-50/docx-rag-smoke-1780745370830.docx`
- LightRAG doc id`doc-2f561f74048372a3f66b7bbe8a6e713a`
- LightRAG file path`mnote-4e26c0dd3d2bf575-docx-rag-smoke-1780745370830.docx`
- 查询 marker`DOCX RAG SMOKE 1780745370830`
- 结果:LightRAG 能解析 DOCX 正文并召回包含 marker 的 chunkMNote registry 能把 LightRAG file_path 映射回 workspace source path。
- citationDOCX 当前没有 PDF page/bbox sidecar locator,因此 `locatorDegraded=true`;但 connector 已补 fallback citation URL,把引用打开到同工作区 Markdown host 页并恢复 DOCX resource tab,浏览器验证 `panelKind=office``panelResourcePath=knowledge-rag-fixtures-7-50/docx-rag-smoke-1780745370830.docx`
结论:DOCX ingestion、召回、source registry 映射和资源 tab 回跳已跑通;DOCX 仍没有页码/bbox 级定位。PPTX/XLSX 还未作为 7-50 验收样本补测。
### 4.11 Source Watcher / Stale Sync 验证结果
2026-06-06 已把 LightRAG source registry 同步接入 MNote 现有 local-folder watcher,而不是新增第二套 watcher
- `rust/crates/mnote-web/src/routes/local_folder_events.rs` 的 tree live watcher 收到 `watch_batch` 前,会调用 `knowledge_rag::sync_registry_for_root(...)`
- `knowledge_rag::sync_registry_with_documents(...)` 会同步 LightRAG `/documents` 状态,并额外检查 registry 中 source 的当前文件状态。
- source 文件不存在时:registry 标记 `stale=true``deletedAtMs`、清空 `lightRagDocId/indexedAtMs`,并 best-effort 调用 LightRAG `DELETE /documents/delete_document`,固定 `delete_file=false`
- source hash 变化时:registry 标记 `stale=true`、清空旧 `lightRagDocId/indexedAtMs`、更新 `sourceHash`,并 best-effort 删除旧 LightRAG doc,避免旧 chunk 继续作为有效引用。
- `mapped_references(...)` 过滤 stale / deleted registry 命中的 references;即使 LightRAG 删除尚未完成,MNote query 结果也不把该 source 当成有效 citation 返回。
新增验证:
- `cargo test -p mnote-web knowledge_rag -- --nocapture` 通过 14 项;其中 `source_state_marks_deleted_and_changed_entries_stale` 覆盖删除 / hash 变化 registry 状态。
- `scripts/task533-knowledge-rag-source-watcher-sync-smoke.js` 通过:真实 ingest `knowledge-rag-fixtures-7-50/watcher-stale-1780745838741.md`,打开 `/api/local-folder/events?treeLive=true`,删除 source 后收到 `Remove(File)` watch_batch;不调用 status 的情况下直接读 registry,确认 `stale=true``deletedAtMs``lightRagDocId=null``indexedAtMs=null`;随后 query marker 不再返回该 source 的有效 reference。
当前边界:delete / missing source 的 watcher 闭环已浏览器验证;hash 变化由单元测试覆盖;rename / move 本质上会让旧 source path missing,走同一 stale/delete 逻辑,但尚未单独补浏览器 smoke。hash 变化后的自动“重建新 doc”仍保守为 stale + 需要重新 ingest,避免在任意文件保存事件上自动把大文件重新灌入 LightRAG。
### 4.12 Source 管理 UI / Query Scope 验证结果
2026-06-06 继续补齐 source 管理和查询范围:
- 资料库问答设置面板的 registry 行新增 `重索引` / `删除索引` 操作。
- `重索引` 对单个 source 调用 `/api/knowledge-rag/ingest`,用于 stale / retry source 的行级重试。
- `删除索引` 对单个 source 调用 `/api/knowledge-rag/delete-source`,仍固定 `delete_file=false`,只删除 LightRAG 派生索引,不删除用户 source。
- `mnote.knowledge_rag.query` / `/api/knowledge-rag/query` 新增 `sourcePaths` 参数,接受 workspace 相对文件或目录;MNote 对 mapped references 做 source scope 过滤。
- LightRAG `/query/data` 在部分情况下只返回 `data.chunks`,不返回可直接消费的 top-level referencesMNote connector 已补 chunks fallback,把 chunk 的 `file_path/reference_id` 生成 reference candidate,再走 registry 映射和 source scope。
- 修复了一个 stale registry 匹配 bug:没有 `doc_id` 的 reference 不得错误命中 `lightRagDocId=None` 的已删除 entry。
新增验证:
- `cargo test -p mnote-web knowledge_rag -- --nocapture` 通过 17 项;新增覆盖 source scope、chunks fallback reference、已删除 entry 不误匹配。
- `scripts/task534-knowledge-rag-source-management-scope-smoke.js` 通过:创建 alpha/beta 两个 Markdown sources`sourcePaths=[alpha]` 时 scoped query 只返回 alpha;浏览器打开资料库问答面板,确认 source row 有行级 `重索引` / `删除索引`;点击 `删除索引` 后 UI 显示已删除,原始 alpha 文件仍存在;删除后 scoped query 不再返回 alpha reference。
结论:source 管理 UI 已从“只能批量输入路径”推进到 registry 行级 reindex/deletequery scope 已可按 source 文件或目录过滤 MNote references。排序层已有 query lexical rerank 和 `hybrid -> mix` alias;真实 rerank 模型仍保持关闭,后续仅在样本证明需要时评估。
## 5. Provider 合同
第一版只需要一个内部 provider 合同,不做完整插件系统。
```rust
KnowledgeRagProvider {
status(workspace_id, root_uri) -> ProviderStatus
configure_sources(source_refs, options) -> ConfigureResult
ingest_or_reindex(source_refs, options) -> IngestJob
query(question, scope, options) -> RagAnswer
query_data(question, scope, options) -> RagRetrievalData
open_reference(reference_id, chunk_id?) -> OpenReferencePlan
delete_source(source_ref) -> DeleteResult
}
```
LightRAGAdapter 实现该合同。
MNote 侧保留:
- `workspaceId`
- `rootUri`
- `allowedRoots`
- resource source path
- owner document / resource tab mapping
- open action / locator fallback
- citationMarkdown / citationUrl 生成
- LightRAG dashboard embedding
LightRAG 侧保留:
- parser routing
- OCR / MinerU / Docling
- sidecar artifacts
- vector / graph storage
- query modes
- reranker
- parse cache
- task state / graph view / dashboard
## 6. 初始配置
第一阶段优先把 LightRAG 以源码 / host 方式独立跑起来,再让 MNote 通过 connector 访问。不要先改 MNote 主链。
部署原则:
- 7-50 基线:源码编译 / host service。
- Docker:只用于隔离 demo 或确认官方 dashboard,不作为 MNote connector 默认路径。
- LightRAG 服务绑定 `127.0.0.1`,不暴露公网。
- LightRAG storage 放到 MNote 控制范围外的派生缓存目录,例如 `/mnt/Data1T/Mnote_data/lightrag/rag_storage`
- LightRAG source 读取 MNote allowed roots 中的真实文件路径;如果官方 scan 只能扫描单一 input dir,先用最小 source spike 确认是否支持 host path / symlink / SDK 直入,再决定是否需要 adapter。
- 任何 staging / upload 降级都必须带 registry 映射和删除同步,不得成为长期主线。
LightRAG 初始配置优先使用官方 MinerU API,减少本地服务数量:
```bash
HOST=127.0.0.1
PORT=9621
CORS_ORIGINS=http://localhost:3000,http://127.0.0.1:3000
WORKING_DIR=/mnt/Data1T/Mnote_data/lightrag/rag_storage
INPUT_DIR=/mnt/Data1T/Mnote_data/lightrag/inputs
LIGHTRAG_KV_STORAGE=JsonKVStorage
LIGHTRAG_VECTOR_STORAGE=NanoVectorDBStorage
LIGHTRAG_GRAPH_STORAGE=NetworkXStorage
LIGHTRAG_DOC_STATUS_STORAGE=JsonDocStatusStorage
LLM_BINDING=openai
LLM_BINDING_HOST=http://127.0.0.1:20128/v1
LLM_MODEL=aigc/qwen-3.5
LLM_TIMEOUT=180
MAX_ASYNC_LLM=4
KEYWORD_MAX_ASYNC_LLM=4
QUERY_MAX_ASYNC_LLM=4
VLM_LLM_BINDING=openai
VLM_LLM_BINDING_HOST=http://127.0.0.1:20128/v1
VLM_LLM_MODEL=aigc/qwen-3.5
VLM_MAX_ASYNC_LLM=1
EMBEDDING_BINDING=openai
EMBEDDING_BINDING_HOST=http://127.0.0.1:20128/v1
EMBEDDING_MODEL=nvidia/baai/bge-m3
EMBEDDING_DIM=1024
EMBEDDING_TOKEN_LIMIT=8192
EMBEDDING_SEND_DIM=false
EMBEDDING_FUNC_MAX_ASYNC=8
EMBEDDING_BATCH_NUM=32
EMBEDDING_TIMEOUT=60
ENABLE_LLM_CACHE=false
ENABLE_LLM_CACHE_FOR_EXTRACT=true
CHUNK_SIZE=1200
CHUNK_OVERLAP_SIZE=100
TOP_K=40
CHUNK_TOP_K=20
MAX_ENTITY_TOKENS=6000
MAX_RELATION_TOKENS=8000
MAX_TOTAL_TOKENS=30000
KG_CHUNK_PICK_METHOD=VECTOR
RELATED_CHUNK_NUMBER=5
COSINE_THRESHOLD=0.2
MAX_PARALLEL_INSERT=3
MAX_PARALLEL_PARSE_NATIVE=5
MAX_PARALLEL_PARSE_MINERU=2
MAX_PARALLEL_ANALYZE=3
MAX_UPLOAD_SIZE=0
LIGHTRAG_PARSER=*:native-iteP,*:mineru-iteP,*:legacy-R
MINERU_API_MODE=official
MINERU_API_TOKEN=<from MNote secret store>
MINERU_OFFICIAL_ENDPOINT=https://mineru.net
MINERU_MODEL_VERSION=vlm
MINERU_IS_OCR=false
MINERU_LANGUAGE=ch
MINERU_ENABLE_TABLE=true
MINERU_ENABLE_FORMULA=true
MINERU_POLL_INTERVAL_SECONDS=2
MINERU_MAX_POLLS=300
VLM_PROCESS_ENABLE=true
SUMMARY_LANGUAGE=Chinese
ENTITY_EXTRACTION_USE_JSON=true
```
当前默认文本 LLM、extract/query 与 VLM 已统一为 `aigc/qwen-3.5`,服务入口为 `http://127.0.0.1:20128/v1`。此前 `deepseek-v4-flash` / Nemotron VLM 结论只保留为历史模型筛选记录,不再作为当前默认配置。embedding 继续使用 OmniRoute / NVIDIA 的 `nvidia/baai/bge-m3`,维度固定为 `1024`,必须在首次 ingest 前确定,后续不能随意改。
VLM 候选实测结论:
- `nvidia/nemotron-nano-12b-v2-vl:free`OpenRouter 直连可用;红色矩形识别正确约 2.7s,真实杯子图片约 6.1s;曾作为免费 VLM canary 对照,当前默认已切到 `aigc/qwen-3.5`
- `nvidia/nemotron-3-nano-omni-30b-a3b-reasoning:free`OpenRouter 直连返回空内容,不采用。
- `nvidia/nemotron-3.5-content-safety:free`:主要返回安全分类,不适合作通用 VLM。
- OmniRoute / NVIDIA 的 Nemotron VLM 路线:当前多返回图片 unavailable / 无法查看图片,暂不采用。
- `agnes-2.0-flash`:文本问答可用;图像输入可识别简单图片,但 VLM 延迟不稳定,真实图片可到 80s+,降为备选。
- `geminipay/gemini-3.1-flash-lite-preview`:通过简单图像输入测试,响应约 1s,但属于收费账号,不作为默认。
- `cx/gpt-5.4-mini` / `codex/gpt-5.4-mini` / `xx/gpt-5.4-mini`:可调用,但对图像回答“无法判断”,不适合作为 VLM 默认。
- `agnes-image-2.0-flash` / `agnes-image-2.1-flash` 属于图像生成 / 编辑方向,不是 LightRAG 所需的 image-to-text VLM;按 chat completions 测试返回 404,暂不纳入 LightRAG VLM。
复杂截图补测:
- 测试图:MNote Page AI 复杂网页截图,压缩到 `888x650`、约 `41KB`
- `nvidia/nemotron-nano-12b-v2-vl:free`:约 `5.9s`,能提取中心标题和关键 token,但把右侧标题误读为“页面截面视图”。
- `agnes-2.0-flash`:约 `9.6s`,中心标题、右侧“页面 AI”和 token 提取更准确。
- 历史结论:Nemotron 免费 VLM 速度和可用性较好,曾适合作 canary;当前默认已切到 `aigc/qwen-3.5`,高精度 UI/OCR 场景仍需继续用样本评估。
本地 embedding 平台补测:
- 本机 Ollama 已有 `bge-m3:latest`embedding 维度 `1024`,模型 F16,约 `1.1GB`,当前通过 Ollama 100% CPU 执行。
- 64 段中文文本 embeddingOllama 本地 `bge-m3``33.7s`OmniRoute / NVIDIA `nvidia/baai/bge-m3``1.6s`
- Ollama 运行 `bge-m3` 后新增 runner RSS 约 `1.3GB`GPU 显存基本不变。
- 当前不建议切本地 embedding 默认;若要本地化,应优先评估 llama.cpp / CUDA 或 quantized GGUF server,而不是直接用 Ollama F16 CPU 路线。
Embedding / rerank 当前建议:
- 默认 embedding 继续使用 `nvidia/baai/bge-m3`,不要在正式样本 ingestion 前频繁切换;embedding 模型或维度变化会要求重建索引。
- 本地 embedding 只保留为后续优化方向,优先路线是 `llama.cpp + bge-m3 GGUF + CUDA`Ollama F16 CPU 路线不适合作默认批量建库。
- 备选 embedding 中,`nvidia/nv-embedqa-e5-v5` 实测可用且维度 `1024`,但需要 `input_type=query/passage` 这类 asymmetric 参数;LightRAG 接入复杂度高于当前 `bge-m3`,暂不切换。
- `openrouter/openai/text-embedding-3-small` 可用但维度 `1536`,切换会改变索引维度;当前不采用。
- rerank 先保持 `RERANK_BINDING=null`。OmniRoute 中可见 `nvidia/nv-rerankqa-mistral-4b-v3`,但当前 `/v1/rerank``/rerank``/v2/rerank``/v1/ranking` 等常见端点实测 404,暂不能直接按 LightRAG rerank binding 配。
- 若真实样本出现“召回足够但排序差”,优先本地评估 `bge-reranker-v2-m3``Qwen3-Reranker-0.6B`,通过 Cohere-compatible / vLLM-compatible 服务接入 LightRAG。
不要直接套用泛化配置里的旧变量名:
- `LLM_API_KEY` / `LLM_API_BASE` 应改为 LightRAG v1.5 的 `LLM_BINDING_API_KEY` / `LLM_BINDING_HOST`
- `EMBEDDING_API_KEY` / `EMBEDDING_API_BASE` 应改为 `EMBEDDING_BINDING_API_KEY` / `EMBEDDING_BINDING_HOST`
- `SERVER_HOST` / `SERVER_PORT` 应改为 `HOST` / `PORT`
- `CHUNK_OVERLAP` 应改为 `CHUNK_OVERLAP_SIZE`
- `KV_STORAGE` / `VECTOR_STORAGE` / `GRAPH_STORAGE` / `DOC_STATUS_STORAGE` 应改为 `LIGHTRAG_*_STORAGE`
- `TEXTRACT_USE_OCR` / `OCR_ENGINE` / `OCR_WORKERS` 不是当前 LightRAG v1.5 API server 的 MinerU 路线变量;当前应使用 `LIGHTRAG_PARSER` + `MINERU_API_MODE` + `MINERU_*`
`ENABLE_LLM_CACHE=false` 是有意设置:source delete / stale 语义验证前,不缓存最终问答,避免删除源后自然语言回答仍从 cache 命中。`ENABLE_LLM_CACHE_FOR_EXTRACT=true` 保留,只缓存抽取过程,降低重复建库成本。
后续大量书籍批处理时再评估本地 MinerU:
```bash
MINERU_API_MODE=local
MINERU_LOCAL_ENDPOINT=http://localhost:8000
```
默认 query 策略:
- 宽泛跨书总结:`mode=mix``mode=global`,开启 rerank。
- 指定文档细节:先在 MNote scope 限制到 source,再用 LightRAG query。
- 只要需要可点击来源,必须走 `query_data` 或补充读取 chunk/sidecar,不能只消费自然语言 answer。
第一阶段可接受 MNote 只打开 LightRAG 自身 UI / dashboard,先验证 ingestion、OCR、query、graph view 和引用质量;MNote 内嵌 UI 延后。
## 7. LiteParse 退役结果
LiteParse 已从默认路径退役:
- [x] LightRAG 能处理当前样本 PDF / DOCX / 图片 PDF。(PDF / 图片 PDF 已跑通;DOCX 已通过 `task532` 验证 ingestion / query / resource tab fallback citation
- [x] MinerU official token 路线在 MNote secret 注入后可跑通。
- [x] LightRAG sidecar `blockid -> positions -> page/bbox` 能映射到 MNote EvidenceLocator。(已通过扫描 PDF smoke;当前为 quote/block 文本匹配 fallback,不是 refs 无损映射)
- [x] 查询结果能返回可点击 citation,打开到 resource tab / page / bbox。(`task529-knowledge-rag-citation-resource-tab-smoke` 已通过)
- [x] 旧 evidence search 已完成退役前回归,随后 `/api/evidence/search` / agent evidence tools 从默认路径移除。
- [x] 删除或重建知识库不会删除用户原始文件。
- [x] 旧 LiteParseProvider 的测试覆盖有替代路径或明确归档说明。(见 7.1LightRAG PDF/DOCX/扫描 PDF 路线作为新资料默认)
### 7.1 LiteParse -> LightRAG Cutover
当前切换策略是“active 退役 + recycle 软归档 + 新资料默认 LightRAG”:
| 代码 / 能力 | 当前状态 | Cutover 决策 |
| --- | --- | --- |
| `evidence_parse.rs` / `LiteParseProvider` | active provider 返回 `liteparse_retired`;历史实现软归档到 `recycle/20260606-liteparse-retirement/` | 不再调用 `lit` / `liteparse` CLI |
| `local_search_index.rs` 的资源 parse sidecar 入库 | 不再为 PDF / Office 调用 LiteParse parse sidecar | PDF / Office / 图片 OCR 走 LightRAG source registry + query |
| `/api/evidence/search``mnote.evidence.search/read/open` | `/api/evidence/search` 返回 retiredagent manifest 不再暴露 evidence tools | 使用 `mnote.knowledge_rag.query/open_reference` |
| `/api/local-folder/ocr/*``.mnote/ocr-index.json` 检索 | HTTP API 返回 retiredactive UI 不再调用旧 OCR sidecar`includeOcr` 不再追加 OCR sidecar 搜索结果 | 图片 / PDF / Office 统一通过 `mnote.knowledge_rag.status/ingest/query/delete-source` |
| `skills/mnote-local-index/SKILL.md` | 软归档到 recycleactive capability pack 删除 | 兼容 alias 映射到 `mnote-knowledge-rag` |
| FileTree `data-index-status` | 从 LightRAG registry 读取 indexed / indexing / failed | 旧 evidence.sqlite / OCR index 不再决定文件树勾选状态 |
| `skills/mnote-knowledge-rag/SKILL.md` | 新资料库问答优先走 LightRAG | 作为多书 / 多论文 / PDF / DOCX 的默认入口 |
| 7-46 / 7-48 evidence 设计稿 | 仍解释旧 sidecar / evidence index 生命周期 | 保留为历史解释 / migration note |
执行顺序:
1. 退役 LiteParse active provider 和 agent evidence / local-index capability。
2. 顶栏 OCR / 本地索引入口并入 LightRAG 资料库问答入口。
3. FileTree 勾选 / 失败状态从 LightRAG registry 读取。
4. FileTree 右键“生成 OCR”替换成“加入资料库索引”。
5. 资源页 / Page AI target / Reasonix ACP 不再调用旧 OCR/evidence/index 工具。
6. 历史 LiteParse / local OCR 代码、skill、smoke 软归档到 recycle。
退役顺序:
1. 新导入资料默认走 LightRAG。
2. Agent 工具默认只暴露 LightRAG knowledge RAG。
3. FileTree 状态绑定 LightRAG source registry。
4. 历史 LiteParse sidecar 只保留 migration note,不再作为 UI / agent fallback。
### 7.2 MNote / LightRAG Coupling Review Fix
2026-06-06 人工复核后继续收口 MNote 与 LightRAG 的耦合状态:
- 顶栏只保留 1 个 `资料库问答` 入口;历史 OCR task toggle 不再由 resource tab runtime 动态创建。
- 删除 source 后,`/api/knowledge-rag/delete-source` 会把 registry 标为 `delete_submitted``delete_completed`;后续 `/api/knowledge-rag/status` 会对照 LightRAG `/documents` 自动把已消失的 doc 标为 `delete_completed`
- FileTree 状态语义调整为:`indexed` 绿灯、`indexing` 黄灯、`failed` 红灯、未索引或 `delete_completed` 无灯;`delete_submitted` 作为黄灯,不再被误显示成失败。
- 新增 `/api/knowledge-rag/prune-registry` 与 UI `清除失效记录`,用于一键清理已删除 / 过期 / 失败的 registry 记录;仍在 `delete_submitted` 的记录不会被清掉,避免隐藏 LightRAG 后台删除尚未确认的状态。
- 新增索引输入去重,同一批重复 source 不会重复提交;相同 `sourcePath + sourceHash` 已登记且未过期时,非 `force` ingest 会返回 `already_registered`
- 新增索引 / 删除 / prune 成功后会触发 `mnote:knowledge-rag-source-updated`,设置面板和 FileTree 会立即刷新;LightRAG busy 时也会刷新 registry 并把 source 保持为等待 / 处理中状态。
- 设置面板不再默认显示空输入行的 `待索引` 标签,避免与上方 source registry 状态重复。
- Dashboard URL 在 LAN 访问 MNote 时会把 `127.0.0.1` / `localhost` 改写为当前 MNote host;如果 LightRAG 服务自身仍只绑定 loopback,下一步应改 LightRAG bind host 或做 MNote 反向代理。
- 2026-06-06 追加复核:LightRAG 已切到 `aigc/qwen-3.5`MNote status 面板会显示当前 LLM model`/api/knowledge-rag/status` 额外返回 LightRAG `/documents` 摘要和状态分组,UI 同时显示 `MNote 登记来源数``LightRAG 文档数`,并把未映射到 MNote registry 的 LightRAG 文档列为 `LightRAG 未映射` 分类。
- 资料库设置 UI 调整为“操作在上、列表在下”,来源列表新增分类过滤:`需处理``索引中``失败/过期``已索引``已移除``LightRAG 未映射``全部`;默认优先显示需处理项,减少成功项噪音。
- 补齐 `travel_explore` 本地 SVG mask,避免资料库入口在 Material Symbols 字体不可用时显示成黑色方块。
本轮验证:
- `node --check` 覆盖 `sidebar-page-settings-runtime.js``sidebar-tree-runtime.js``document-resource-tab-runtime.js``sidebar-tree-live-apply-runtime.js`
- `cargo check -p mnote-web` 通过。
- `cargo test -p mnote-web knowledge_rag -- --nocapture` 通过 21 项。
- `cargo test -p mnote-web local_file_tree_marks_lightrag_indexed_indexing_and_failed_source_files -- --nocapture` 通过,覆盖 indexed / indexing / failed / delete_submitted / delete_completed。
- `cargo test -p mnote-web sidebar_settings_runtime_routes_index_and_ocr_to_lightrag_settings -- --nocapture``page_layout_exposes_lightrag_knowledge_rag_settings_only` 通过。
- 浏览器复核 `http://127.0.0.1:3000/`topbar `资料库问答` 入口数量为 1,旧 OCR / 本地索引入口数量为 0;面板显示 `LightRAG healthy`、有 `清除失效记录`,空输入行不显示 `待索引`;旧 deleted registry 已同步为 `LightRAG 已移除` 且 FileTree 无灯。
### 7.3 Status Bridge / Image Wrapper Follow-up
2026-06-07 复核确认:LightRAG 切到 `aigc/qwen-3.5` 后索引速度恢复,但 MNote 仍必须主动同步后台完成状态,不能假设 LightRAG 会向 MNote 推送事件。
当前补充口径:
- LightRAG 当前文本 LLM、extract/query 与 VLM 统一使用 `aigc/qwen-3.5`VLM host 为 `http://127.0.0.1:20128/v1`status 为 healthy。此前 `deepseek-v4-flash` 只作为历史调试背景,不再作为当前默认模型口径。
- MNote ingest / reindex 后启动有界 status bridge/backoff,对照 LightRAG `/documents` 同步 `processing/submitted -> processed/failed`,再刷新 source registry、资料库设置面板和 FileTree 灯号;这不是长期无界轮询。
- LightRAG Documents 页面看不到原始图片文件是预期边界:当前 `/documents/scan` 不直接索引 `png/jpg`MNote 为图片 source 生成 Markdown wrapper,再由 LightRAG 索引 wrapper 文档,同时 registry 仍映射回原始图片路径。
- `MNote 登记来源数``LightRAG 文档数` 应优先看是否一一映射;未映射文档只作为 LightRAG 残留 / 外部手动导入提示,不作为 MNote source truth。
- 清空历史时只清 LightRAG 派生 documents / input wrapper / parsed cache 与 MNote registry,不删除 workspace 原始资料。
## 8. 不做事项
- 不继续开发 7-49 的 `mnote.understanding.*` 工具。
- 不继续开发自研 understanding.sqlite。
- 不做 mindmap projection 作为知识库默认视图。
- 不默认接入 PageIndex。
- 不把 Understand Anything dashboard 作为 MNote 主 dashboard。
- 不同时默认维护 LiteParse + LightRAG + PageIndex 多套 provider。
- 不让 LightRAG 写用户 Markdown 正文。
- 不把 LightRAG sidecar 当用户可编辑真相。
- 不让 MNote 为 LightRAG 结果再建一套 RAG 索引。
- 不为了 LightRAG 改动 MNote 文件树、编辑器、普通搜索的主路径。
## 9. 插件系统判断
当前不开发完整插件系统,但按插件边界实施。
原因:
- 近期真实需要的知识库 provider 只有 LightRAG 一个。
- 过早插件化会引入 manifest、生命周期、权限、版本、升级、隔离和 UI 管理成本。
- MNote 现在更需要稳定 provider boundary,而不是多 provider marketplace。
- LightRAG 已经有自己的任务、图谱、解析和 dashboard 能力,MNote 不应复制一套控制面。
第一版只做“无害集成”:
- 配置项:LightRAG endpoint / working dir / MinerU token secret key / parser mode。
- 状态页:provider status、job status、storage path、最近错误、dashboard URL。
- 工具层:query / ingest / reindex / delete source / open reference。
- UI 层:先外链或 iframe 嵌入 LightRAG dashboard,用于任务控制、graph 查看和 ingestion 状态。
当出现第二个稳定 provider,且必须和 LightRAG 并存时,再抽正式插件系统。
## 10. 实施 Checklist
### A. 退役 7-49
- [x] 7-49 从 active `design/07-ai/process` 移出。
- [x] 7-49 标记为 `[recycle]`
- [x] 撤回 `mnote.understanding.*` route / tool / skill 接线。
- [x] 删除未落地主线的 `understanding.rs` untracked 代码。
- [x] 7-50 明确不再沿 Docker staging 作为 MNote connector 基线。
### B. LightRAG Spike
- [x] 固定 LightRAG 参考版本和源码 / host 运行方式。
- [x] 建立最小 LightRAG server / SDK 本地运行脚本。
- [x] 验证 LightRAG 源码运行依赖版本;如 Python 3.14 不兼容,改用专用 Python 3.12/3.13 venv。
- [x] 验证 OmniRoute OpenAI-compatible 模型在 LightRAG client 下可用;当前默认已从历史 `deepseek-v4-flash` 切到 `aigc/qwen-3.5`
- [x] 挑选快速 / 便宜的 OmniRoute 视觉模型,`geminipay/gemini-3.1-flash-lite-preview` 可用但因收费不作为默认。
- [x] 筛选 OpenRouter 免费 VLM 与 Agnes 免费模型;当前默认已统一为 `aigc/qwen-3.5`,历史 VLM canary 仅保留为对照记录。
- [x] 验证 `nvidia/baai/bge-m3` embedding 维度和 LightRAG 配置一致。
- [x] 测试本地 Ollama `bge-m3` embedding 代价,并与 OmniRoute / NVIDIA embedding 对照。
- [x] 筛选 embedding / rerank 候选并冻结当前默认:embedding 继续 `nvidia/baai/bge-m3`rerank 暂不开。
- [x] 使用复杂 MNote UI 截图补测 VLM OCR / UI 理解能力。
- [x] 接入 MinerU official token,不把 token 暴露给前端。
- [x] 对照外部 env 建议,补齐 LightRAG v1.5 实际识别的 storage / chunk / query / concurrency / MinerU 参数。
- [x] 用 symlink scan 跑通受控 source 文件 ingestion,避免默认复制 source 内容。
- [x] 明确 source registry 字段和 stale / deleted 过滤规则。
- [x] 验证 `query_data` 返回 chunk/reference。
- [x] 验证 `DELETE /documents/delete_document` 删除后旧 source 不再可检索。
- [x] 验证 SDK / upload / symlink / scan 四种路径中哪一种最适合 MNote connector 长期使用。
- [x] 验证 MNote source 删除后能触发 LightRAG delete,不删除用户原始文件。(显式 `delete-source` route 与 watcher stale sync 均已覆盖;watcher smoke 见 `task533`
- [x] 用 1 本书籍 PDF、1 个扫描 PDF、2 篇论文跑 ingestion。
- [x] 保存 sidecar 样本到 `tmp/` 或测试 fixture,不提交敏感原文。(样本保留在 LightRAG `inputs/__parsed__` 派生缓存,不提交)
- [x] 验证 LightRAG dashboard 能查看任务、graph、文档状态。(`task531-lightrag-dashboard-ui-smoke.js` 已通过:Documents 显示 completed/fail/doc summaryKnowledge Graph / Retrieval 均显示 connected
- [x] 验证 chunk 能反查 sidecar 并生成 locator。(当前不是 refs 无损映射,而是 `file_path + quote/chunk content` 匹配 sidecar block
- [x] 验证 sidecar position 能生成 MNote EvidenceLocator。(`task529` 已验证 PDF resource tab / page / bbox / 高亮)
### C. MNote Adapter
- [x] 新增内部 `KnowledgeRagProvider` 合同。
- [x] 新增 `LightRAGAdapter`,只封装 HTTP / SDK 调用,不复制 LightRAG 逻辑。(当前落为 `knowledge_rag` route adapter,未额外抽 trait
- [x] 新增 provider status route。
- [x] 新增 dashboard URL / open route。
- [x] 新增 ingestion / reindex job 控制 route。
- [x] 新增 query route。
- [x] 新增 open reference resolver。
- [x] Hermes / Page AI 只看到 MNote tool,不直接看到 LightRAG 内部路径。
- [x] MNote 普通页面搜索、文件树、编辑器不依赖 LightRAG 可用性。
- [x] Connector 避免被历史 `LIGHTRAG_API_KEY` 污染,优先使用 MNote 专用 key 或 LightRAG source `.env`
### D. UI / Agent 闭环
- [x] Page AI 能选择“资料库问答”模式。(通过 `mnote-knowledge-rag` capability pack 暴露)
- [x] Agent skill 只暴露少量知识库工具:status、query、open_reference。(reindex/ingest 暂只保留 HTTP route,不暴露给只读 agent
- [x] 回答必须带 citations。(skill 要求引用 `references` / `citationMarkdown`
- [x] citation 能打开到原文 resource tab。(`task529` 已通过)
- [x] citation 能展示 page / bbox / quote 降级状态。(已返回 `chunkId` / `quote`,并能在扫描 PDF 样本生成 page/bbox;未命中时继续降级,不伪造)
- [x] 没有 locator 时明确显示“来源定位降级”,不伪造页码。
- [x] Page AI 真实回答能使用 RAG citation,而不是只验证 tool 层。(`task530-knowledge-rag-page-ai-final-answer-smoke.js` 已通过:Reasonix 最终可见回答引用 `scan-image-start.pdf · p.1` 可点击 resourceTab 链接)
- [x] MNote 中可以进入 LightRAG UI 进行任务控制和 graph 查看。(通过 MNote `/api/knowledge-rag/status``dashboardUrl` 打开 WebUI`task531` 已浏览器验证 Documents / Knowledge Graph / Retrieval
### E. LiteParse 退役
- [x] 对照 LiteParse 当前能力和 LightRAG sidecar 能力。(见 7.1
- [x] 列出仍依赖 LiteParse 的代码路径。(见 7.1
- [x] 把 LiteParse 默认 provider 切到 LightRAG,不再 fallback 到 evidence。
- [x] 删除 active UI 中的 LiteParse / 本地索引 / OCR 专属入口。
- [x] FileTree 勾选 / 失败状态改为读取 LightRAG source registry。
- [x] 软归档 LiteParse / local OCR 历史代码与 smoke 到 `recycle/20260606-liteparse-retirement/`
### F. 当前剩余闭环
当前 7-50 v1 已可归档;下面是本轮收口项与非阻塞后续:
- [x] LightRAG 资料库设置 UI:不能混入现有 MNote 本地索引设置;需要独立 surface,显示 provider status、dashboard URL、working dir、input dir、source registry、最近错误。(已落独立 topbar 入口与状态面板)
- [x] LightRAG source 管理 UI:支持从当前 local-folder 中选择要索引的文件 / 目录,调用 `/api/knowledge-rag/ingest`,展示 indexing / processed / stale / deleted / retryRequired 状态。(已支持手填、从 filetree 选中项填入路径、输入行状态、registry 行级 reindex/delete`task534` 已浏览器验证)
- [x] LightRAG dashboard 接入:第一步可以外链打开 `dashboardUrl`,第二步再评估 iframe;必须浏览器验证 documents / graph / task 状态可见。(`task531` 已完成外链 dashboard 的 documents / graph / retrieval 验证;iframe 仍作为后续体验评估,不阻塞 v1)
- [x] source watcher 自动同步:source 删除 / 移动 / 重命名 / hash 变化后,自动标记 registry stale,并触发 delete / reindex;不能只依赖显式 `delete-source` route。(`task533` 验证删除事件;hash 变化有单测;重命名 / 移动复用旧 path missing 逻辑;自动重建新 doc 当前保守为 stale + 重新 ingest
- [x] 查询 scope 与排序:支持按选中 source / 目录限制 RAG 查询;`hybrid -> mix` alias 和 MNote lexical rerank 已先落地,仍需评估 `mix/global/local``topK/chunkTopK` 与 rerank。(`sourcePaths` 已落 route / Hermes / Reasonix schema`task534` 验证按 source 文件过滤;rerank 仍按 7-50 结论默认关闭)
- [x] Page AI 真实回答 smoke:不仅验证 Hermes tool 能返回 references,还要验证 Page AI 最终回答使用 `citationMarkdown`,不会把 retrieval raw 当最终答案,也不会伪造页码。(`task530` 已通过)
- [x] DOCX / Office 样本 ingestion:补测 LightRAG 对 DOCX / Office 附件的解析与 citation 回跳能力。(`task532` 已验证 DOCX 解析、marker 召回、registry 映射和 DOCX resource tab fallback citationPPTX/XLSX 未纳入本轮)
- [x] 旧 evidence search 退役:保留历史回归证据,但 active route / agent tool 不再作为 fallback;新检索与 OCR / 索引统一走 LightRAG。
- [x] LiteParse 退役设计:见 7.1;结论已从冻结 fallback 更新为 active 退役 + recycle 软归档。
## 11. 验收标准
- 用户能把多本书 / 多篇论文纳入同一个资料库。
- LightRAG 可以在不改动 MNote 普通功能的情况下完成 OCR、parse、index、query。
- LightRAG 读取的是 MNote 允许的真实 source,或有明确 registry 映射的临时 staging;不能存在无人管理的第二份资料真相。
- agent 能回答跨资料问题。
- 回答不能只给“看起来合理”的总结,必须有来源。
- 至少 PDF 页码和 chunk quote 可追溯。
- 对支持 bbox 的 sidecar,能打开到页面区域或保留 bbox metadata。
- 删除知识库索引不会删除用户原始文件。
- 删除 / 移动 / 重命名 source 后,旧索引不会继续作为有效来源参与回答;删除同步失败时必须显示 stale 状态。
- MNote active 代码中不再出现未完成的 `mnote.understanding.*` 主线接线。
- LightRAG 不可用时,MNote 文件树、编辑器、普通页面搜索仍可正常使用。
@@ -0,0 +1,643 @@
# 7-49 [recycle] Local Understanding GraphRAG Kernel v1
> 创建时间:2026-06-06
>
> 当前状态:`RECYCLED`
>
> 退役时间:2026-06-06
>
> 退役原因:该稿以自研 Understanding GraphRAG Kernel / mindmap projection 为主线,已被 LightRAG 单外挂知识库问答路线取代。相关代码接线已撤回;后续不再按本文推进。
>
> Owner07-ai / 03-rust-web / control-plane / 01-tree-first-graph-kernel
>
> 上位依据:
> - `/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/process/7-48-paperless-ngx-reference-resource-ingestion-job-index-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`
>
> 参考来源:
> - `reference-code/Understand-Anything`
> - `reference-code/PageIndex`
> - `reference-code/BookRAG`
> - `reference-code/Kwipu`
> - `sqlite-graphrag` / `sqlite-knowledge-graph` 类本地 GraphRAG 方案
## 1. 第一结论
MNote 需要新增一层 `Local Understanding GraphRAG Kernel`。它不是替换当前 evidence search,而是在 `EvidenceLocator` 之上生成可查询的本地理解图,让 agent 能回答跨章节、跨资料的系统性问题。
目标链路:
```text
原始文件 / Markdown / PDF / Word / OCR provider result
-> Evidence Kernel: quote + locator + source-map
-> Structure Tree: document / section / page / table / list
-> Understanding Graph Builder: concept / entity / claim / relation
-> SQLite GraphRAG Store: graph + FTS + embedding + locator
-> Query Planner: simple / complex / global
-> Evidence-backed Answer: answer item -> graph edge -> EvidenceLocator
```
主适配策略:
- `Understand Anything`:作为“通用理解图生成器”的工程骨架参考,采用扫描、确定性抽取、LLM 语义抽取、graph reviewer、增量更新和 JSON graph artifact 思路。
- `sqlite-graphrag / sqlite-knowledge-graph`:作为本地持久化和查询引擎参考,默认落 SQLite,不新增常驻 graph 服务。
- `PageIndex / BookRAG`:只作为算法参考,借鉴 structure tree、tree search、query planning、hierarchy + graph + evidence mapping,不直接集成其运行时。
- 默认 LLM runtimeUnderstanding Graph 的语义抽取和 reviewer 默认走 OmniRoute combo `DeepSeek-v4-flash`,用于充分利用当前 NVIDIA 免费资源;但 provider / combo / model 必须可配置,不允许写死到 schema 或 graph 数据中。
关键修正:**OCR 结果、parse 结果、source-map、搜索索引和理解图都是派生数据,默认不应写入用户正文目录。** 它们应写入 MNote 管理的 SQLite / artifact store / graph store,并通过 locator 回到用户原始文件。
## 2. 数据真相与派生数据边界
### 2.1 真相分层
| 数据 | 默认真相 | 默认存放 | 说明 |
| --- | --- | --- | --- |
| 用户 Markdown 正文 | 用户 `.md` 文件 | 用户 workspace | 可编辑、可同步、可备份 |
| 用户附件 / PDF / Word / 图片 | 用户原始文件 | 用户 workspace | MNote 不替代原始资源 |
| OCR 文本 | 派生 artifact | MNote SQLite / artifact store | 可删除重建,不污染用户目录 |
| parse 文本 | 派生 artifact | MNote SQLite / artifact store | 可删除重建 |
| source-map / bbox / page map | 派生定位 artifact | MNote SQLite / artifact store | 可由 parser/OCR 重建 |
| search index | 派生索引 | SQLite FTS / graph store | 不作为正文真相 |
| understanding graph | 派生理解图 | SQLite graph tables | node/edge 必须能追溯 locator |
### 2.2 不再默认写用户目录
旧方案曾把 OCR 结果写入 owner Markdown 同目录的 `{pageStem}.ocr/`,并把本地索引写入 `.mnote/index/`。这解决了可见性和可迁移性,但会把大量机器派生数据混入用户目录:
- 用户文件树被 OCR / parse / source-map / index 污染。
- 同步、备份、搜索、分享时很难区分正文和派生物。
- 多用户 / share / team workspace 中,派生数据权限和正文权限容易混淆。
- graph / index rebuild 后会产生大量无业务意义的文件变更。
新口径:
- 用户目录只保存用户原始内容和用户显式插入的引用。
- OCR / parse / index / graph 默认落到 MNote 管理存储。
- 如果用户需要导出 OCR 文本,可提供显式 `export OCR``insert OCR link/content`,但这不是默认路径。
- 当前阶段旧 OCR sidecar 和旧 `.mnote/index` 都视为测试派生数据,不做兼容读取,不做迁移导入;切到新存储后可直接清理。
## 3. 推荐存储形态
### 3.1 Control-plane artifact store
派生文本和 source-map 不建议全部塞进普通 control-plane 表,也不建议回到用户目录。第一版可以用 SQLite + content-addressed blob 表:
```sql
derived_artifacts(
artifact_id TEXT PRIMARY KEY,
workspace_id TEXT NOT NULL,
root_uri TEXT NOT NULL,
owner_document_id TEXT,
owner_document_path TEXT,
source_root_relative_path TEXT NOT NULL,
artifact_kind TEXT NOT NULL, -- ocr_text | parse_text | source_map | page_image | thumbnail
provider TEXT NOT NULL,
source_hash TEXT NOT NULL,
content_hash TEXT NOT NULL,
content_text TEXT,
content_blob_path TEXT,
mime_type TEXT,
size_bytes INTEGER,
created_at_ms INTEGER NOT NULL,
updated_at_ms INTEGER NOT NULL,
stale INTEGER NOT NULL DEFAULT 0
);
```
原则:
- 小文本可以直接 `content_text`
- 大 source-map / page image / provider 原始结果可进入 MNote 管理的 artifact blob 目录,表内只存 hash 和路径。
- blob 目录必须在 MNote data root 下,而不是用户 workspace 下。
- artifact 必须用 `workspace_id + root_uri + source_root_relative_path + source_hash` 绑定原始文件版本。
### 3.2 Evidence / GraphRAG SQLite
`.mnote/index/evidence.sqlite` 不迁移为新真相。新 evidence / understanding store 应直接创建在 MNote 管理存储,例如:
```text
/mnt/Data1T/Mnote_data/control-plane/indexes/{workspace_hash}/evidence.sqlite
/mnt/Data1T/Mnote_data/control-plane/indexes/{workspace_hash}/understanding.sqlite
```
或统一放到 control-plane DB 的附属数据库中:
```text
ATTACH '.../indexes/{workspace_hash}/evidence.sqlite' AS evidence;
ATTACH '.../indexes/{workspace_hash}/understanding.sqlite' AS understanding;
```
第一版建议仍使用独立 SQLite 文件,避免 control-plane 主库被大 FTS / embedding / graph 写放大拖慢。
## 4. Understanding Graph Schema
### 4.1 Node
```sql
understanding_node(
node_id TEXT PRIMARY KEY,
workspace_id TEXT NOT NULL,
root_uri TEXT NOT NULL,
node_kind TEXT NOT NULL, -- document | section | page | table | list | concept | entity | claim | method | condition
canonical_name TEXT NOT NULL,
display_name TEXT NOT NULL,
summary TEXT,
language TEXT,
metadata_json TEXT NOT NULL DEFAULT '{}',
evidence_locator_json TEXT,
source_artifact_id TEXT,
confidence REAL NOT NULL DEFAULT 1.0,
created_by TEXT NOT NULL, -- deterministic | llm | reviewer | user
source_hash TEXT NOT NULL,
updated_at_ms INTEGER NOT NULL
);
```
### 4.2 Edge
```sql
understanding_edge(
edge_id TEXT PRIMARY KEY,
workspace_id TEXT NOT NULL,
root_uri TEXT NOT NULL,
from_node_id TEXT NOT NULL,
to_node_id TEXT NOT NULL,
edge_kind TEXT NOT NULL,
relation_label TEXT NOT NULL,
evidence_locator_json TEXT,
source_artifact_id TEXT,
confidence REAL NOT NULL DEFAULT 1.0,
created_by TEXT NOT NULL,
source_hash TEXT NOT NULL,
updated_at_ms INTEGER NOT NULL
);
```
通用 `edge_kind`
- `document_contains_section`
- `section_contains_page`
- `section_contains_table`
- `section_contains_claim`
- `concept_has_subtype`
- `concept_has_example`
- `concept_defined_by`
- `entity_has_property`
- `method_has_condition`
- `claim_supported_by`
- `table_row_supports_claim`
- `term_alias_of`
- `mentions`
- `related_to`
注意:不为“保护基”这种单一领域写专用 schema。化学、生物、财务、法律文档都应落到通用 concept / entity / claim / property / condition / evidence 关系上。
### 4.3 Search Surfaces
SQLite store 同时提供:
- FTS`node.display_name / summary / claim text / artifact text`
- embedding:可选,先作为 table/provider 抽象,不强制第一版。
- graph query:固定 relation traversal。
- tree query:按 document / section / page / table 聚合。
## 5. Builder Pipeline
### 5.1 确定性抽取
默认不先调用 LLM,先从 artifacts 做可重复抽取:
- Markdown headings / wikilinks / frontmatter / resource links。
- PDF / Word source-map 的 page、section、table、list。
- OCR / parse artifact 的段落、页、表格行。
- 文件系统资源归属、owner document、source hash。
输出:
```text
document -> section -> page -> block/table/list
```
### 5.2 LLM 语义抽取
LLM 只做确定性结构无法得到的语义层:
- concept / entity。
- claim / definition。
- subtype / example / alias。
- method / condition / property。
- table row 到 claim 的解释性关系。
LLM 输出必须是结构化 JSON,且每条 node/edge 必须带输入 evidence id 或 locator。
默认 LLM runtime
```json
{
"understandingGraph": {
"semanticExtractor": {
"provider": "omniroute",
"routeKind": "combo",
"comboName": "DeepSeek-v4-flash",
"modelHint": "deepseek-v4-flash",
"purpose": "semantic_extraction",
"fallback": "deterministic_only"
},
"graphReviewer": {
"provider": "omniroute",
"routeKind": "combo",
"comboName": "DeepSeek-v4-flash",
"modelHint": "deepseek-v4-flash",
"purpose": "graph_review",
"enabledByDefault": false
}
}
}
```
配置原则:
- `DeepSeek-v4-flash` 只作为默认推荐 combo,理由是适合后台异步批处理,并能优先利用当前 NVIDIA 免费资源。
- 实际 runtime 通过 OmniRoute combo / model registry 解析当前可用 ID,例如 `deepseek-v4-flash` 或 provider-scoped `deepseek/deepseek-v4-flash`,MNote 侧不把具体上游 ID 写死进 graph schema。
- MNote 不直接持有外部 provider key;鉴权、配额、fallback 和账号健康检查由 OmniRoute 承接。
- 无额度、离线或 provider 失败时,任务进入 retry / failed,或降级为 `deterministic_only`;不得把未完成的 LLM graph build 标记为成功。
- semantic extractor 和 reviewer 可分别覆盖模型:抽取默认便宜高速,reviewer 可在 spike 或显式 rebuild 时换成更高质量模型。
- prompt、日志和 graph artifact 不记录 provider token、上传 URL 或完整外部响应,只记录 job id、model route、失败阶段和可审计摘要。
禁止:
- 只输出无来源结论。
- 把 LLM 推理结果当原文事实。
- 在没有 locator 的情况下写入高置信 graph edge。
### 5.3 Graph Reviewer
借鉴 Understand Anything 的 reviewer,但第一版不做大模型全量审查。先做三类检查:
- 结构检查:node/edge 引用是否存在,locator JSON 是否可解析。
- 证据检查:edge 的 `evidence_locator_json` 能否读回 quote。
- 去重检查:alias / canonical_name 是否过度分裂。
LLM reviewer 只在 spike 阶段或用户显式重建时运行。
### 5.4 增量更新
增量粒度:
```text
source file hash
-> artifact hash
-> section subtree hash
-> graph node/edge source_hash
```
文件变化时:
- 原始文件 hash 未变:不重建。
- artifact 变:只删除并重建该 source_hash 派生的 node/edge。
- section 变:只重建对应 subtree。
- graph schema version 变:排队重建当前 workspace graph。
## 6. Query Planner
Planner 负责把用户问题变成 retrieval plan,而不是让 agent 直接猜关键词。
### 6.1 Query Types
借鉴 BookRAG
- `simple`:单点事实,走 evidence search / node search。
- `complex`:多个子问题,分别检索后合成。
- `global`:列举、统计、总结、分类,走 tree / graph aggregation。
“羧酸/羧基的保护基有哪些,列举 5 种”应属于:
```json
{
"queryType": "global",
"operation": "LIST",
"targetConcept": "羧酸保护基",
"constraints": {
"limit": 5
}
}
```
执行策略:
1. term normalize:羧酸 / 羧基 / COOH / CO2H / carboxylic acid / carboxyl。
2. graph search:找 concept node。
3. relation traversal`concept_has_subtype` / `concept_has_example`
4. evidence read:回读每个候选的 locator。
5. answer synthesize:仅用回读证据组织答案。
### 6.2 Tool Contract
新增 MNote tools
- `mnote.understanding.status`
- `mnote.understanding.rebuild`
- `mnote.understanding.cleanup_legacy`
- `mnote.understanding.jobs`
- `mnote.understanding.search_nodes`
- `mnote.understanding.traverse`
- `mnote.understanding.query`
- `mnote.understanding.read_evidence`
- `mnote.understanding.mindmap_projection`
其中 `mnote.understanding.query` 返回:
```json
{
"ok": true,
"queryType": "global",
"operation": "LIST",
"items": [
{
"name": "苄酯",
"nodeId": "concept:...",
"supportingEdges": ["edge:..."],
"evidence": [
{
"quote": "...",
"locator": { "schema": "mnote.evidence_locator.v1" },
"citationMarkdown": "[...](/documents/...)"
}
],
"confidence": 0.82
}
],
"diagnostics": {
"planner": "understanding_graph",
"fallbackUsed": false
}
}
```
## 7. Understanding Dashboard 与任务面板
2026-06-06 路线修正:官方 Understand Anything dashboard 的价值不是“mindmap”,而是完整的 graph explorer:图布局、节点/边类型过滤、layer/community、搜索高亮、节点详情、guided tour、path finder、export、schema warning 和源码/证据面板。当前 MNote simplemindmap 不应继续作为 7-49 主 UI 承载。
第一阶段改为 **官方 UA dashboard 走通优先**
- 暂缓 `understanding_mindmap_projection` 的产品化 UI 工作。
- 先安装/运行官方 UA dashboard,用 UA 原生 `knowledge-graph.json` 或 MNote 转换出的 UA-compatible graph 检查体验。
- 若 dashboard 效果符合预期,再决定是 vendor/fork UA dashboard 组件,还是在 MNote Web 中实现同等的 `Understanding Dashboard`
- mindmap 只保留为后续轻量能力:answer-level 子图、导出、插入文档旁边;不再要求覆盖完整 Understanding Graph Explorer。
### 7.1 Official UA Dashboard Spike
第一阶段目标:
```text
MNote understanding graph / evidence graph
-> UA-compatible knowledge-graph.json
-> official Understand Anything dashboard
-> browser-visible graph explorer spike
```
验收:
- dashboard 能打开并渲染图,不依赖 MNote mindmap。
- 至少能展示 document / section / concept / claim / evidence source 节点。
- 支持搜索、节点点击、相邻关系高亮、类型过滤或 layer/legend 中至少两类交互。
- 节点详情能看到 summary、created_by/confidence、source artifact、EvidenceLocator 摘要。
- 能从节点或证据 panel 回到 MNote evidence/open action,或者明确记录第一阶段暂缺的 deep-link 差距。
边界:
- 官方 dashboard 只是体验 spike,不是 MNote 的长期事实源。
- 不把 `.understand-anything/knowledge-graph.json` 当成 MNote graph 真相;真相仍在 `understanding.sqlite` / evidence store。
- 不绕过 MNote workspace/root 权限直接读用户文件。
- 不把 UA dashboard 的代码库 schema 原样扩展成 MNote 长期 schema;需要通过 adapter 投影。
### 7.2 Mindmap Projection(暂缓产品化)
保留只读派生 projection,但不作为第一阶段主 UI:
```text
understanding graph node/edge
-> understanding_mindmap_projection
-> MNote mindmap view
```
原则:
- mindmap view 是派生展示,不是 graph 真相,也不是用户手工 mindmap 资源真相。
- 真相仍在 `understanding.sqlite` 的 node / edge / evidence locator 表中。
- projection 可按 document / section / concept / query result 生成,支持刷新和重建。
- 节点点击后展示 summary、created_by、confidence、source artifact、evidence quote 和 locator open action。
- 边点击后展示 relation_label、edge_kind、支持证据和 reviewer 状态。
- 支持过滤 `deterministic | llm | reviewer | user` 来源,避免把 LLM 抽取和确定性结构混在一起。
后续 UI surface
- graph query 答案页可打开“查看理解图”,只展示本次答案涉及的 node / edge / evidence 子图。
- 文档页资源 tab 可提供 `导出为 mindmap``插入子图`
- 不再把完整 workspace understanding graph 塞进 simplemindmap。
### 7.3 后台任务面板
Understanding Graph 属于异步派生数据系统,必须有后台任务可见性。后台面板至少展示:
- job kind`resource_parse``local_ocr``evidence_index_refresh``understanding_graph_build``understanding_graph_review``understanding_mindmap_projection_refresh`
- job state`queued | running | retrying | failed | done | stale`
- 输入范围:workspace、root、document、section subtree、source artifact。
- LLM runtimeprovider、combo、model route、purpose、retry count。
- 产出统计:artifact count、node count、edge count、locator count、mindmap node count。
- 质量指标:locator 回读成功率、抽取 recall / precision、reviewer 发现的问题类型。
- 失败诊断:OCR 噪声、parser 失败、OmniRoute 无额度、LLM JSON schema invalid、locator 断裂、schema version mismatch。
- 操作:retry、rebuild selected scope、mark stale、open evidence、open mindmap projection。
边界:
- 后台任务面板只读展示 job 账本和触发显式 rebuild,不允许绕过权限直接读 SQLite。
- LLM prompt、provider token、上传 URL 和完整外部响应不在 UI 展示。
- projection refresh 不能通过轮询长期驱动,优先由 ResourceWorkJob 状态变化和 realtime event 推送触发。
## 8. 与现有 7-46 / 7-48 的关系
### 8.1 7-46 Evidence Kernel
`7-46` 仍是 quote / locator / open action 的基础。但其文件布局需要直接切到新存储:
- `source-map.json` 不再写入 `{pageStem}.ocr/`
- `evidence.sqlite` 不再写入用户 workspace 的 `.mnote/index/`
-`{pageStem}.ocr/``.mnote/index/` 不作为 runtime fallback,不新增 legacy reader。
### 8.2 7-48 Resource Work Kernel
`7-48``ResourceWorkJob` 应扩展为派生数据任务账本:
- `local_ocr`
- `resource_parse`
- `evidence_index_refresh`
- `understanding_graph_build`
- `understanding_graph_review`
- `understanding_mindmap_projection_refresh`
- `workspace_sanity_check`
任务结果应记录 artifact id、node/edge count、locator count、失败阶段和 rebuild reason。
## 9. 旧派生数据清理
当前阶段所有既有 OCR / index 都视为测试派生数据。为降低实现复杂度,不做旧格式兼容、导入器或双写。
### 9.1 清理范围
可清理对象:
-`{pageStem}.ocr/` 目录。
-`.mnote/index/` 目录。
-`search-index.json`
-`evidence.sqlite`
- 旧 source-map / bbox / OCR provider 结果文件。
不可清理对象:
- 用户 Markdown 正文。
- 用户附件、PDF、Word、图片原始文件。
- 用户显式创建的 mindmap / office / resource 文件。
- 用户显式导出的 OCR 文本。
### 9.2 清理方式
新增一次性 cleanup job
- job kind`legacy_derived_data_cleanup`
- 默认只扫描当前 workspace root 下的已知派生路径。
- 清理前记录删除清单、文件数量、总字节数和 root。
- 清理后强制触发 `resource_parse``evidence_index_refresh``understanding_graph_build`
- 清理失败不得影响用户原始文件打开,只标记派生数据 stale。
运行时口径:
- 新 search / evidence / understanding routes 只读 control-plane index store。
- watcher 不再监听旧 `.mnote/index` 写入。
- UI 不再展示旧 OCR sidecar 作为系统文件;如果用户目录中仍残留,只按普通文件显示或由 cleanup job 删除。
- status API 只返回 `storageLocation: "control_plane"`,不保留 `workspace_legacy` 状态。
## 10. 权限与隐私
- 派生 artifact 和 graph 必须继承原始 root / workspace / grant 权限。
- 用户失去 root 访问权限后,不得再从 graph 查询到该 root 的 node/edge。
- share workspace 下,graph query 必须按 user / workspace / rootUri 过滤。
- OCR provider token、上传 URL、provider 原始响应不进入 graph。
- graph node/edge 只存必要 quote / summary;完整 artifact 读取必须走权限检查。
- 派生数据删除策略应跟随 workspace disconnect / revoke / explicit cleanup。
- mindmap projection 必须继承 graph query 的权限过滤,不得展示用户无权访问的 node / edge。
## 11. 第一阶段 Spike
目标不是做完整产品,而是证明 GraphRAG 层真的解决 FTS 失败的系统性问题。
### 11.1 输入
- 使用当前测试账号已有保护基书资源。
- 读取现有 evidence / OCR / parse artifact。
- 不写用户目录。
### 11.2 实现范围
1. 建一个最小 `understanding.sqlite`
2. 从 evidence blocks 生成 section tree。
3. 对一个章节范围做 LLM 语义抽取:
- concept
- subtype/example
- definition/claim
- locator
4. 写入 node/edge。
5. 实现一个 CLI 或 route
- 输入问题:“羧酸/羧基的保护基有哪些,列举 5 种”
- 输出 graph-backed items + citation。
6. 输出本次 spike 的 UA-compatible dashboard graph
- 至少包含目标 concept、候选 subtype/example、supporting edge、evidence locator/source 节点。
- 能在官方 UA dashboard 中打开并完成搜索、节点点击、过滤/图例等基础交互。
7. 后台任务面板能看到 build / extraction / dashboard projection refresh 的状态和失败原因。
### 11.3 验收
- 不能只靠 FTS topK 直接回答。
- 每个条目必须有 node/edge 和 EvidenceLocator。
- 每个 EvidenceLocator 必须能用 `mnote.evidence.read/open` 回读或打开。
- dashboard graph 必须能解释答案来源:答案条目 -> graph edge -> EvidenceLocator。
- job 面板必须能看到 OmniRoute route、node/edge count、locator 回读率和失败阶段。
- 手标至少 30 个候选实体,抽取 recall >= 70% 才继续。
- precision 低于 70% 时必须记录错误类型:OCR 噪声、术语归一失败、LLM 幻觉、章节边界错误、关系 schema 不足。
- 增量更新单个 source artifact 后,不触发整库 full rebuild。
## 12. 非目标
- 不把 Understand Anything、BookRAG、PageIndex、Kwipu 整包引入 MNote runtime。
- 不把官方 UA dashboard 当成 MNote 长期事实源;第一阶段只作为体验 spike / adapter 验证。
- 不新增默认常驻 graph 服务。
- 不把 graph DB 当正文真相。
- 不把 understanding dashboard / mindmap projection 当用户手工 mindmap 真相。
- 不让 agent 直接读 SQLite 文件绕过 tool / permission。
- 不把 OCR 文本默认插入用户 Markdown。
- 不兼容旧 OCR sidecar 或 `.mnote/index` 作为 runtime fallback。
## 13. 后续实施顺序
2026-06-06 复核口径:mempalace 中能命中 7-49 前期只读勘察与 sqlite-graphrag 评估,但未找到当前 `7-49-local-understanding-graphrag-kernel-v1.md` 的完成记录;另有 2026-06-01 旧 `7-49 external audit / OpenClaw provider tests` 日志,属于同编号历史碰撞,不能作为本文完成证据。以下勾选只按当前实际代码确认。
- [x] 冻结派生数据新存储口径:control-plane artifact store + control-plane index store。
- 证据:`local_search_index.rs``local_index_storage_dir()` 默认写入 `/mnt/Data1T/Mnote_data/control-plane/indexes/{workspace_hash}``evidence_sqlite_path()` / `understanding_sqlite_path()` 均落在该目录;`evidence.sqlite` 已新增 `derived_artifacts` 表存 parse/OCR/source-map 派生文本;status 返回 `storageLocation: "control_plane"``derivedArtifactCount`
- [ ] 给 7-46 直接切到 control-plane storage,删除 workspace legacy writer / reader 分支。
- 复核:evidence/search index 主路径已切 control-plane index store;但 OCR sidecar、source-map/旧 sidecar 读取和 7-46 全链路仍未完全收口,不能勾选。
- [x] 新增 `understanding.sqlite` 最小 schema。
- 证据:`routes/understanding.rs` 已创建 `understanding_meta``understanding_node``understanding_edge`,并通过 `understanding_sqlite_path()` 持久化。
- [x] 实现 `legacy_derived_data_cleanup``mnote.understanding.cleanup_legacy`,显式清理旧 OCR sidecar / `.mnote/index` 测试派生数据。
- 证据:`local_search_index::legacy_derived_data_cleanup()` 扫描 `.mnote/index``*.ocr` 目录;`routes/understanding::cleanup_legacy()` 要求显式 `dryRun`,写操作走 workspace write access,清理后刷新 evidence index 并尝试重建 understanding graph。
- [x] 实现 section tree builder。
- 证据:`rebuild_understanding_graph()``evidence_block.section_path_json` 生成 `section` node 和 `document_contains_section` edge,并生成 `claim` node / `section_contains_claim` edge。当前只是最小 section/claim tree,不含 page/table/list 完整结构。
- [x] 增加 Understanding Graph LLM runtime 配置:默认 OmniRoute combo `DeepSeek-v4-flash`,支持 extractor / reviewer 分别覆盖。
- 证据:`default_llm_runtime()` 已输出 `semanticExtractor` / `graphReviewer` 两套配置;支持 `MNOTE_UNDERSTANDING_EXTRACTOR_*``MNOTE_UNDERSTANDING_REVIEWER_*` 环境变量分别覆盖,reviewer 默认关闭并保留 `deterministic_only` fallback。
- [ ] 实现最小 LLM semantic extractor + reviewer。
- 复核:当前抽取是 `deterministic_term_extractor` / `deterministic_protecting_group_normalizer`,没有实际 LLM 调用、JSON schema 校验或 reviewer。
- [x] 实现 `mnote.understanding.query`
- 证据:HTTP route、Hermes tool manifest、tool dispatch 均已注册;查询先读 `understanding.sqlite` 的 concept edge,再 fallback evidence graph / sqlite search。
- [ ] 安装并走通官方 UA dashboard spike,暂缓 MNote mindmap 产品化适配。
- 复核:已确认官方 dashboard 功能面更接近 7-49 需求;但尚未把 MNote graph 转成 UA-compatible `knowledge-graph.json` 并真实打开 dashboard。
- [ ] 增加后台 Understanding job 面板,展示 build / review / dashboard projection refresh 状态。
- 复核:`mnote.understanding.jobs` 已有合成 jobs API/tool,并返回 review、locator parse success rate 与失败阶段;但没有后台 UI 面板,也未接真实 ResourceWorkJob 账本。
- [ ] 跑保护基书 spike,并记录 recall / precision / locator 回跳率和 mindmap 可解释性。
- 复核:代码里有最小单元测试覆盖羧酸保护基 query variant 和 term extraction;未见真实保护基书 spike、人工标注 30 个实体、recall/precision 记录或浏览器验证证据。
- [ ] 若 spike 通过,再扩到多文档 workspace 和 UI。
- 复核:当前仍是单 workspace/root 的最小 kernel/tool 路径,未进入多文档 UI 产品化。
## 14. 2026-06-06 实际代码完成情况复核
### 14.1 已完成的最小能力
- [x] control-plane index store`search-index.json``evidence.sqlite``understanding.sqlite` 默认位于 control-plane 管理目录,不写入用户 workspace 的旧 `.mnote/index`
- [x] control-plane artifact table`evidence.sqlite.derived_artifacts` 已存 `parse_text` / `ocr_text` / `source_map`,带 workspace/root/source hash/content hash/stale 字段;`local_index.status` 返回 `derivedArtifactCount`
- [x] Understanding HTTP routes`/api/understanding/status|rebuild|cleanup_legacy|jobs|search_nodes|traverse|query|read_evidence|mindmap_projection` 已注册。
- [x] Hermes tools`mnote.understanding.*` 九个工具已在 manifest、dispatch、skill allowlist 中注册。
- [x] 最小 deterministic graph builder:从 `evidence_resource` / `evidence_block` 生成 document/resource、section、claim、concept node 与 contains/support/example edge。
- [x] EvidenceLocator 回读桥:`mnote.understanding.read_evidence` 复用 `mnote.evidence.read` 的 read payload。
- [x] 旧派生数据显式 cleanup:支持 dry-run plan 和 apply,范围限定旧 `.mnote/index``*.ocr` sidecar。
- [x] LLM runtime 配置面:默认 OmniRoute combo `DeepSeek-v4-flash`extractor / reviewer 可分别通过环境变量覆盖。
- [x] 确定性 graph reviewer:重建后记录 `last_review_json`,检查 edge node 引用、locator JSON parse、canonical name 重复,并在 jobs/status 返回 locator parse success rate 与失败阶段。
- [x] mindmap projection 证据链增强:projection 节点 metadata 已包含 `supportingEdges` / `supportingEdgeKinds` / `evidence` / `confidence` / `createdBy`;该能力降级为 answer-level 辅助,不再作为主 UI。
- [x] 最小测试:`understanding_rebuild_query_and_mindmap_use_control_plane_store``legacy_cleanup_removes_only_derived_sidecars`、query variant 和 term extraction 单测已存在。
### 14.2 未完成或只能算部分完成
- [ ] artifact runtime cutover:已有 `derived_artifacts` 表和 parse/OCR/source-map 写入;但 parse/OCR runtime 仍会写 legacy sidecar 文件,尚未改成只依赖 artifact store/blob store。
- [ ] 7-46 evidence kernel 全面切流:evidence/search index 路径已切,但旧 OCR sidecar / source-map / fallback 边界未完全删除。
- [ ] LLM semantic extractor:没有调用 OmniRoute / DeepSeek-v4-flash,也没有结构化 LLM graph artifact。
- [ ] LLM Graph reviewer:确定性结构/locator/去重检查已落地;但还没有显式 rebuild 时可启用的 LLM reviewer。
- [ ] 增量更新:`understanding.rebuild` 仍是按当前 evidence 全量重建;没有 source artifact / section subtree 粒度的 node/edge 删除重建。
- [ ] 后台任务面板:无 UI surfacejobs API 已返回 review/locator 指标,但仍未接真实 ResourceWorkJob 账本。
- [ ] 官方 UA dashboard spike:尚未安装/启动 dashboard,也没有 MNote -> UA-compatible graph adapter。
- [ ] 资源 tab / workspace `Understanding Dashboard`:当前没有浏览器 runtime / SSR UI 入口。
- [ ] 保护基书 spike 验收:没有真实资源验证、locator 回跳率、recall/precision 或截图/浏览器证据。
### 14.3 当前归档判断
本文不能移动到 `done`。当前实际完成的是 `Local Understanding GraphRAG Kernel` 的最小 deterministic kernel/tool spike;产品化条件仍卡在 artifact store、LLM extractor/reviewer、任务账本/UI、真实保护基书 spike 和增量更新。
@@ -258,6 +258,26 @@ import {
if (window.CSS && typeof window.CSS.escape === 'function') return window.CSS.escape(text);
return text.replace(/["\\]/g, '\\$&');
};
const localFileOpenUrl = (rootUri, path) => {
const normalizedRootUri = String(rootUri || '').trim();
const normalizedPath = String(path || '').trim();
if (!normalizedRootUri || !normalizedPath) return '';
const url = new URL('/api/local-folder/files/open', window.location.origin);
url.searchParams.set('rootUri', normalizedRootUri);
url.searchParams.set('path', normalizedPath);
return url.toString();
};
const resourceHrefFromUrlState = (rootUri, path, title) => {
const fileUrl = localFileOpenUrl(rootUri, path);
if (!fileUrl) return '';
if (/\.pdf$/i.test(String(title || path || ''))) {
const url = new URL('/pdf-preview', window.location.origin);
url.searchParams.set('fileUrl', fileUrl);
url.searchParams.set('fileName', title || path);
return url.toString();
}
return fileUrl;
};
const applyDocumentEvidenceLocatorFromUrl = (root) => {
if (!(root instanceof HTMLElement)) return;
const url = currentUrl();
@@ -289,7 +309,7 @@ import {
const url = currentUrl();
const raw = String(url.searchParams.get('resourceTab') || '').trim();
if (!raw) return null;
const objectIdentity = raw.includes('::') ? raw.slice(raw.indexOf('::') + 2) : raw;
const objectIdentity = raw.startsWith('resource:') ? raw : (raw.includes('::') ? raw.slice(raw.indexOf('::') + 2) : raw);
if (!objectIdentity.startsWith('resource:file:')) return null;
const rootUri = currentWebShellRootUri();
if (!rootUri) return null;
@@ -299,6 +319,7 @@ import {
const path = rest.slice(prefix.length).replace(/^\/+/, '');
if (!path) return null;
const title = path.split('/').filter(Boolean).pop() || path;
const href = resourceHrefFromUrlState(rootUri, path, title);
return {
objectIdentity,
documentId: currentWebShellDocumentId(),
@@ -309,6 +330,8 @@ import {
assetId: `local:asset:${path}`,
title,
fileName: title,
href,
officeUrl: href,
openTarget: 'active-tab',
page: url.searchParams.get('page') || undefined,
bbox: url.searchParams.get('bbox') || undefined,
@@ -1021,18 +1044,22 @@ import {
});
}, { once: true });
const restoreResourceTabFromUrl = () => {
const restoreInput = restoreResourceTabInputFromUrl();
if (!restoreInput) return Promise.resolve(false);
return openResourceInActiveTab(restoreInput).catch((error) => {
console.warn('mnote resource tab URL 恢复失败', error);
return false;
});
};
if (paneRuntimes.length) {
Promise.all(paneRuntimes.map((paneRuntime) => mountPane(paneRuntime))).then(() => {
const restoreInput = restoreResourceTabInputFromUrl();
if (restoreInput) {
return openResourceInActiveTab(restoreInput).catch((error) => {
console.warn('mnote resource tab URL 恢复失败', error);
return false;
});
}
return false;
return restoreResourceTabFromUrl();
}).catch((error) => {
console.error('mnote multi-pane editor mount failed', error);
});
} else {
void restoreResourceTabFromUrl();
}
})();
@@ -1373,17 +1373,17 @@ export const createResourceTabRuntime = (dependencies = {}) => {
const statusNode = entry.panel.querySelector('[data-mnote-local-ocr-status-text]');
if (statusNode instanceof HTMLElement) {
statusNode.textContent = message || (
normalizedStatus === 'done' ? 'OCR 已完成'
: normalizedStatus === 'running' ? 'OCR 处理中'
: normalizedStatus === 'failed' ? 'OCR 失败'
: normalizedStatus === 'stale' ? 'OCR 需更新'
: 'OCR 未生成'
normalizedStatus === 'done' ? '资料库已索引'
: normalizedStatus === 'running' ? '资料库索引中'
: normalizedStatus === 'failed' ? '资料库索引失败'
: normalizedStatus === 'stale' ? '资料库需更新'
: '资料库未索引'
);
}
const openButton = entry.panel.querySelector('[data-mnote-local-ocr-action="open"]');
if (openButton instanceof HTMLButtonElement) openButton.disabled = !entry.localOcrJob?.ocrRootRelativePath;
if (openButton instanceof HTMLButtonElement) openButton.disabled = true;
const insertButton = entry.panel.querySelector('[data-mnote-local-ocr-action="insert"]');
if (insertButton instanceof HTMLButtonElement) insertButton.disabled = !entry.localOcrJob?.ocrRootRelativePath;
if (insertButton instanceof HTMLButtonElement) insertButton.disabled = true;
window.dispatchEvent(new CustomEvent('mnote:local-ocr-job-updated', {
detail: {
status: normalizedStatus,
@@ -1394,15 +1394,50 @@ export const createResourceTabRuntime = (dependencies = {}) => {
}));
};
const knowledgeRagJobFromRegistryEntry = (registryEntry, rootUri) => {
if (!registryEntry || typeof registryEntry !== 'object') return null;
const sourceRootRelativePath = String(registryEntry.sourceRootRelativePath || registryEntry.source_path || '').trim();
if (!sourceRootRelativePath) return null;
const indexed = Boolean(registryEntry.indexedAtMs || registryEntry.indexed_at_ms) && Boolean(registryEntry.lightRagDocId || registryEntry.light_rag_doc_id);
const failed = registryEntry.deletedAtMs != null || registryEntry.deleted_at_ms != null || registryEntry.stale === true;
const status = failed ? 'failed' : indexed ? 'done' : 'running';
return {
taskKind: 'knowledge_rag',
jobId: String(registryEntry.sourceId || registryEntry.source_id || `knowledge-rag:${sourceRootRelativePath}`),
sourceRootRelativePath,
rootUri: String(registryEntry.rootUri || rootUri || '').trim(),
ocrRootRelativePath: '',
provider: 'lightrag',
status,
stageLabel: status === 'done' ? '资料库已索引' : status === 'failed' ? '资料库需重建' : '资料库索引中',
stale: registryEntry.stale === true,
updatedAtMs: Number(registryEntry.updatedAtMs || registryEntry.updated_at_ms || Date.now()),
finishedAtMs: status === 'done' ? Number(registryEntry.indexedAtMs || registryEntry.indexed_at_ms || Date.now()) : null,
error: status === 'failed' ? 'knowledge_rag_source_stale_or_deleted' : '',
lightRagDocId: String(registryEntry.lightRagDocId || registryEntry.light_rag_doc_id || ''),
lightRagFilePath: String(registryEntry.lightRagFilePath || registryEntry.light_rag_file_path || ''),
};
};
const knowledgeRagRegistryEntries = (payload) => {
const registry = payload && payload.registry && typeof payload.registry === 'object' ? payload.registry : null;
return Array.isArray(registry?.entries) ? registry.entries : [];
};
const readLocalOcrStatus = async (entry) => {
if (!isLocalOcrSourceEntry(entry)) return null;
const url = new URL('/api/local-folder/ocr/status', window.location.origin);
const url = new URL('/api/knowledge-rag/status', window.location.origin);
url.searchParams.set('rootUri', entry.rootUri);
url.searchParams.set('sourceRootRelativePath', entry.path);
const workspaceId = String(entry.workspaceId || currentWebShellWorkspaceId() || '').trim();
if (workspaceId) url.searchParams.set('workspaceId', workspaceId);
const response = await fetch(url.toString(), { cache: 'no-store', headers: { accept: 'application/json' } });
const payload = await response.json().catch(() => null);
if (!response.ok || !payload || payload.ok !== true) return null;
return payload.job || null;
const targetPath = String(entry.path || '').trim().replace(/\\/g, '/').replace(/^\/+/, '');
const registryEntry = knowledgeRagRegistryEntries(payload).find((candidate) => {
return String(candidate?.sourceRootRelativePath || '').trim().replace(/\\/g, '/').replace(/^\/+/, '') === targetPath;
});
return knowledgeRagJobFromRegistryEntry(registryEntry, entry.rootUri);
};
const localOcrTaskState = {
@@ -1475,7 +1510,7 @@ export const createResourceTabRuntime = (dependencies = {}) => {
toggle.addEventListener('click', (event) => {
if (toggle.getAttribute('data-mnote-action') === 'open-ocr-settings') {
event.preventDefault();
window.dispatchEvent(new CustomEvent('mnote:open-local-ocr-settings'));
window.dispatchEvent(new CustomEvent('mnote:open-knowledge-rag-settings'));
return;
}
void runManualLocalOcrForActiveTarget(toggle).catch((error) => {
@@ -1506,7 +1541,7 @@ export const createResourceTabRuntime = (dependencies = {}) => {
const statusTextForLocalOcrJob = (job) => {
const status = String(job?.status || '').trim();
if (job?.stageLabel) return String(job.stageLabel);
if (job?.taskKind === 'local_index') {
if (job?.taskKind === 'local_index' || job?.taskKind === 'knowledge_rag') {
return status === 'done' ? '索引已完成'
: status === 'failed' ? '索引失败'
: status === 'running' ? '索引中'
@@ -1557,20 +1592,7 @@ export const createResourceTabRuntime = (dependencies = {}) => {
document.body.appendChild(dock);
}
let toggle = document.querySelector('[data-testid="mnote-local-ocr-task-toggle"]');
if (!(toggle instanceof HTMLButtonElement)) {
const actions = document.querySelector('.wolai-topbar-actions');
toggle = document.createElement('button');
toggle.type = 'button';
toggle.className = 'wolai-icon-button mnote-local-ocr-task-toggle';
toggle.setAttribute('title', 'OCR 设置');
toggle.setAttribute('aria-label', 'OCR 设置');
toggle.setAttribute('data-testid', 'mnote-local-ocr-task-toggle');
toggle.setAttribute('data-mnote-action', 'open-ocr-settings');
toggle.innerHTML = '<span class="material-symbols-outlined" data-icon="document_scanner" aria-hidden="true"></span><span class="mnote-local-ocr-task-badge" data-mnote-local-ocr-task-count hidden>0</span>';
if (actions instanceof HTMLElement) actions.appendChild(toggle);
else document.body.appendChild(toggle);
}
bindLocalOcrTopbarAction();
if (toggle instanceof HTMLButtonElement) bindLocalOcrTopbarAction();
if (dock.getAttribute('data-mnote-local-ocr-bound') === 'true') return dock;
dock.setAttribute('data-mnote-local-ocr-bound', 'true');
dock.addEventListener('click', (event) => {
@@ -1617,23 +1639,7 @@ export const createResourceTabRuntime = (dependencies = {}) => {
}
const openButton = event.target instanceof Element ? event.target.closest('[data-mnote-local-ocr-task-open]') : null;
if (openButton instanceof HTMLElement) {
const sourcePath = openButton.getAttribute('data-mnote-local-ocr-task-open') || '';
const job = localOcrTaskState.jobsBySource.get(sourcePath) || null;
if (job) {
void openResourceInActiveTab({
kind: 'markdown',
title: String(job.ocrRootRelativePath || '').split('/').filter(Boolean).pop() || 'OCR',
path: job.ocrRootRelativePath,
objectIdentity: `local-ocr:${job.ocrRootRelativePath}`,
assetId: `local-ocr:${job.ocrRootRelativePath}`,
documentId: job.ownerDocumentId || currentWebShellDocumentId() || '',
ownerDocumentId: job.ownerDocumentId || currentWebShellDocumentId() || '',
workspaceId: currentWebShellWorkspaceId() || '',
sourceKind: 'local_folder',
rootUri: localOcrTaskState.rootUri || '',
resourceKind: 'markdown',
}).catch((error) => console.warn('mnote local OCR 任务打开失败', error));
}
window.dispatchEvent(new CustomEvent('mnote:open-knowledge-rag-settings'));
}
const retryButton = event.target instanceof Element ? event.target.closest('[data-mnote-local-ocr-task-retry]') : null;
if (retryButton instanceof HTMLElement) {
@@ -1670,7 +1676,7 @@ export const createResourceTabRuntime = (dependencies = {}) => {
.filter((node) => node instanceof HTMLButtonElement);
taskToggles.forEach((toggle) => {
const opensSettings = toggle.getAttribute('data-mnote-action') === 'open-ocr-settings';
const label = runningCount > 0 ? `${runningCount} OCR 处理中,打开 OCR 设置` : (jobs.length > 0 ? `${jobs.length} OCR 任务,打开 OCR 设置` : 'OCR 设置');
const label = runningCount > 0 ? `${runningCount}资料库索引处理中,打开资料库设置` : (jobs.length > 0 ? `${jobs.length}资料库任务,打开资料库设置` : '资料库设置');
const taskLabel = runningCount > 0 ? `${runningCount} 个后台任务正在运行` : (jobs.length > 0 ? `${jobs.length} 个后台任务` : '后台任务');
toggle.setAttribute('title', opensSettings ? label : taskLabel);
toggle.setAttribute('aria-label', opensSettings ? label : taskLabel);
@@ -1681,7 +1687,7 @@ export const createResourceTabRuntime = (dependencies = {}) => {
badge.textContent = String(runningCount > 0 ? runningCount : jobs.length);
badge.hidden = jobs.length === 0;
} else {
toggle.textContent = runningCount > 0 ? `OCR ${runningCount} 处理中` : `OCR ${jobs.length}`;
toggle.textContent = runningCount > 0 ? `索引 ${runningCount} 处理中` : `索引 ${jobs.length}`;
}
});
const drawer = dock.querySelector('[data-testid="mnote-local-ocr-task-drawer"]');
@@ -1731,7 +1737,7 @@ export const createResourceTabRuntime = (dependencies = {}) => {
row.setAttribute('data-mnote-local-ocr-task-row', String(job.sourceRootRelativePath || ''));
row.setAttribute('data-mnote-local-ocr-task-status', String(job.status || ''));
row.setAttribute('data-mnote-local-ocr-task-category', localOcrTaskCategory(job));
const title = String(job.sourceRootRelativePath || '').split('/').filter(Boolean).pop() || 'OCR';
const title = String(job.sourceRootRelativePath || '').split('/').filter(Boolean).pop() || '资料库索引';
const category = localOcrTaskCategory(job);
const progress = localOcrTaskProgress(job);
row.innerHTML = '<div class="mnote-local-ocr-task-main"><div class="mnote-local-ocr-task-title-line"><strong></strong><em></em></div><span></span><div class="mnote-local-ocr-task-progress" role="progressbar"><i></i></div></div><div class="mnote-local-ocr-task-actions"><button type="button" data-mnote-local-ocr-task-open>打开</button><button type="button" data-mnote-local-ocr-task-retry>重试</button><button type="button" data-mnote-local-ocr-task-clear>清除</button><button type="button" data-mnote-local-ocr-task-delete>删除</button></div>';
@@ -1766,7 +1772,7 @@ export const createResourceTabRuntime = (dependencies = {}) => {
if (open instanceof HTMLButtonElement) {
open.setAttribute('data-mnote-local-ocr-task-open', String(job.sourceRootRelativePath || ''));
open.disabled = !job.ocrRootRelativePath;
open.hidden = job.taskKind === 'local_index';
open.hidden = job.taskKind === 'local_index' || job.taskKind === 'knowledge_rag';
}
const retry = row.querySelector('[data-mnote-local-ocr-task-retry]');
if (retry instanceof HTMLButtonElement) {
@@ -1780,7 +1786,7 @@ export const createResourceTabRuntime = (dependencies = {}) => {
const deleteOcr = row.querySelector('[data-mnote-local-ocr-task-delete]');
if (deleteOcr instanceof HTMLButtonElement) {
deleteOcr.setAttribute('data-mnote-local-ocr-task-delete', String(job.sourceRootRelativePath || ''));
deleteOcr.hidden = job.taskKind === 'local_index' || !job.ocrRootRelativePath;
deleteOcr.hidden = job.taskKind === 'local_index' || !String(job.sourceRootRelativePath || '').trim();
}
list.appendChild(row);
});
@@ -1790,13 +1796,15 @@ export const createResourceTabRuntime = (dependencies = {}) => {
const normalizedRoot = String(rootUri || '').trim();
if (!normalizedRoot) return;
localOcrTaskState.rootUri = normalizedRoot;
const url = new URL('/api/local-folder/ocr/jobs', window.location.origin);
const url = new URL('/api/knowledge-rag/status', window.location.origin);
url.searchParams.set('rootUri', normalizedRoot);
const workspaceId = String(currentWebShellWorkspaceId() || '').trim();
if (workspaceId) url.searchParams.set('workspaceId', workspaceId);
const response = await fetch(url.toString(), { cache: 'no-store', headers: { accept: 'application/json' } });
const payload = await response.json().catch(() => null);
if (!response.ok || !payload || payload.ok !== true) return;
(Array.isArray(payload.jobs) ? payload.jobs : []).forEach((job) => {
if (job && typeof job === 'object') job.rootUri = normalizedRoot;
knowledgeRagRegistryEntries(payload).forEach((entry) => {
const job = knowledgeRagJobFromRegistryEntry(entry, normalizedRoot);
updateLocalOcrTaskState(job);
});
renderLocalOcrTaskDock();
@@ -1827,74 +1835,37 @@ export const createResourceTabRuntime = (dependencies = {}) => {
};
const openLocalOcrSidecar = async (entry, job) => {
const target = job || entry?.localOcrJob || null;
const ocrPath = String(target?.ocrRootRelativePath || '').trim();
if (!ocrPath || typeof openResourceInActiveTab !== 'function') return false;
const title = ocrPath.split('/').filter(Boolean).pop() || 'OCR';
return await openResourceInActiveTab({
kind: 'markdown',
title,
path: ocrPath,
objectIdentity: `local-ocr:${ocrPath}`,
assetId: `local-ocr:${ocrPath}`,
documentId: String(entry.ownerDocumentId || entry.documentId || currentWebShellDocumentId() || '').trim(),
ownerDocumentId: String(entry.ownerDocumentId || entry.documentId || currentWebShellDocumentId() || '').trim(),
workspaceId: String(entry.workspaceId || currentWebShellWorkspaceId() || '').trim(),
sourceKind: 'local_folder',
rootUri: String(entry.rootUri || '').trim(),
resourceKind: 'markdown',
paneRole: normalizePaneRole(entry.paneRole),
});
window.dispatchEvent(new CustomEvent('mnote:open-knowledge-rag-settings'));
return false;
};
const insertLocalOcrLink = async (entry, job) => {
const target = job || entry?.localOcrJob || null;
const ocrPath = String(target?.ocrRootRelativePath || '').trim();
if (!ocrPath) return false;
const response = await fetch('/api/local-folder/ocr/insert', {
method: 'POST',
headers: { 'content-type': 'application/json', accept: 'application/json' },
body: JSON.stringify({
rootUri: String(entry.rootUri || '').trim(),
documentId: String(entry.ownerDocumentId || entry.documentId || currentWebShellDocumentId() || '').trim(),
ocrRootRelativePath: ocrPath,
mode: 'link',
}),
});
const payload = await response.json().catch(() => null);
if (!response.ok || !payload || payload.ok !== true) {
throw new Error(payload?.error?.message || `local_ocr_insert_failed_${response.status}`);
}
setLocalOcrStatus(entry, 'done', 'OCR 链接已插入正文', target);
return true;
throw new Error('OCR sidecar 已退役,请使用 LightRAG 资料库引用与问答。');
};
const createLocalOcrJob = async (entry) => {
if (!isLocalOcrSourceEntry(entry)) return null;
const provider = localOcrProvider();
const entryRootUri = String(entry.rootUri || '').trim();
if (entryRootUri) localOcrTaskState.rootUri = entryRootUri;
const startedAt = Date.now();
const pendingJob = localOcrJobSnapshotFromEntry(entry, 'running', provider, '处理中', startedAt);
setLocalOcrStatus(entry, 'running', 'OCR 处理中', pendingJob);
const pendingJob = localOcrJobSnapshotFromEntry(entry, 'running', 'lightrag', '资料库索引中', startedAt);
pendingJob.taskKind = 'knowledge_rag';
setLocalOcrStatus(entry, 'running', '资料库索引中', pendingJob);
updateLocalOcrTaskState(pendingJob);
const workspaceId = String(entry.workspaceId || currentWebShellWorkspaceId() || '').trim();
const body = {
workspaceId,
rootUri: String(entry.rootUri || '').trim(),
documentId: String(entry.ownerDocumentId || entry.documentId || currentWebShellDocumentId() || '').trim(),
sourceRootRelativePath: String(entry.path || '').trim(),
provider,
sources: [{ sourcePath: String(entry.path || '').trim() }],
};
if (provider === 'mock') {
body.mockMarkdown = `# OCR Result\n\n${entry.title || entry.path} OCR UI smoke text`;
}
const response = await fetch('/api/local-folder/ocr/jobs', {
const response = await fetch('/api/knowledge-rag/ingest', {
method: 'POST',
headers: { 'content-type': 'application/json', accept: 'application/json' },
body: JSON.stringify(body),
});
const payload = await response.json().catch(() => null);
if (!response.ok || !payload || payload.ok !== true) {
const message = payload?.error?.message || payload?.message || payload?.error?.code || `local_ocr_job_failed_${response.status}`;
const message = payload?.error?.message || payload?.message || payload?.error?.code || `knowledge_rag_ingest_failed_${response.status}`;
const failedJob = {
...pendingJob,
status: 'failed',
@@ -1907,10 +1878,22 @@ export const createResourceTabRuntime = (dependencies = {}) => {
updateLocalOcrTaskState(failedJob);
throw new Error(message);
}
const job = payload.job && typeof payload.job === 'object' ? { ...payload.job, rootUri: entryRootUri } : null;
const registryEntry = knowledgeRagRegistryEntries(payload).find((candidate) => {
return String(candidate?.sourceRootRelativePath || '').trim().replace(/\\/g, '/').replace(/^\/+/, '') === String(entry.path || '').trim().replace(/\\/g, '/').replace(/^\/+/, '');
});
const job = knowledgeRagJobFromRegistryEntry(registryEntry, entryRootUri) || {
...pendingJob,
status: payload.retryRequired ? 'running' : 'done',
stageLabel: payload.retryRequired ? 'LightRAG 忙碌,等待重试' : '资料库已提交索引',
updatedAtMs: Date.now(),
finishedAtMs: payload.retryRequired ? null : Date.now(),
};
const displayStatus = localOcrDisplayStatus(job, 'done');
setLocalOcrStatus(entry, displayStatus, displayStatus === 'stale' ? 'OCR 需更新' : 'OCR 已完成', job);
setLocalOcrStatus(entry, displayStatus, displayStatus === 'stale' ? '资料库需更新' : statusTextForLocalOcrJob(job), job);
if (job) updateLocalOcrTaskState(job);
window.dispatchEvent(new CustomEvent('mnote:knowledge-rag-source-updated', {
detail: { rootUri: entryRootUri, workspaceId, sourceRootRelativePath: String(entry.path || '').trim(), result: payload }
}));
return job;
};
@@ -1919,27 +1902,24 @@ export const createResourceTabRuntime = (dependencies = {}) => {
if (!sourcePath) return false;
const job = localOcrTaskState.jobsBySource.get(sourcePath) || null;
const rootUri = String(job?.rootUri || localOcrTaskState.rootUri || currentWebShellRootUri() || '').trim();
const response = await fetch('/api/local-folder/ocr/delete', {
const response = await fetch('/api/knowledge-rag/delete-source', {
method: 'POST',
headers: { 'content-type': 'application/json', accept: 'application/json' },
body: JSON.stringify({
rootUri,
sourceRootRelativePath: sourcePath,
workspaceId: String(currentWebShellWorkspaceId() || '').trim(),
sourcePath,
}),
});
const payload = await response.json().catch(() => null);
if (!response.ok || !payload || payload.ok !== true) {
throw new Error(payload?.error?.message || `local_ocr_delete_failed_${response.status}`);
}
if (payload.deleted !== true) {
throw new Error('local_ocr_delete_noop');
throw new Error(payload?.error?.message || `knowledge_rag_delete_failed_${response.status}`);
}
localOcrTaskState.jobsBySource.delete(sourcePath);
renderLocalOcrTaskDock();
const ocrPath = String(job?.ocrRootRelativePath || '').trim();
if (ocrPath) {
dispatchLocalOcrFileTreeRefresh({ ...job, status: 'stale', updatedAtMs: Date.now(), ocrRootRelativePath: ocrPath }, localOcrTaskState.rootUri);
}
window.dispatchEvent(new CustomEvent('mnote:knowledge-rag-source-updated', {
detail: { rootUri, workspaceId: String(currentWebShellWorkspaceId() || '').trim(), sourceRootRelativePath: sourcePath, result: payload }
}));
return true;
};
@@ -2118,14 +2098,14 @@ export const createResourceTabRuntime = (dependencies = {}) => {
});
});
}
setLocalOcrStatus(entry, 'idle', 'OCR 未生成', null);
setLocalOcrStatus(entry, 'idle', '资料库未索引', null);
ensureLocalOcrTaskDock();
ensureLocalOcrTaskEvents(entry.rootUri);
void loadLocalOcrJobs(entry.rootUri).catch(() => undefined);
void readLocalOcrStatus(entry).then((job) => {
if (!job) return;
const displayStatus = localOcrDisplayStatus(job, 'done');
setLocalOcrStatus(entry, displayStatus, displayStatus === 'stale' ? 'OCR 需更新' : 'OCR 已完成', job);
setLocalOcrStatus(entry, displayStatus, displayStatus === 'stale' ? '资料库需更新' : statusTextForLocalOcrJob(job), job);
updateLocalOcrTaskState(job);
}).catch(() => undefined);
};
@@ -421,70 +421,77 @@ export const createSidebarFileTreeCommandRuntime = (dependencies = {}) => {
}
async function runLocalOcrForDetail(detail, trigger) {
var sourceRootRelativePath = localOcrSourceRelativePath(detail);
var rootUri = localOcrRootUri(detail, trigger);
var documentId = String(detail && detail.documentId || currentDocumentId() || '').trim();
if (!sourceRootRelativePath || !rootUri || !documentId) {
throw new Error('缺少 OCR 来源、rootUri 或 owner documentId');
}
if (!isLocalOcrSourceFileName(String(detail && (detail.title || detail.fileName) || sourceRootRelativePath))) {
throw new Error('OCR 仅支持图片和 PDF');
}
var provider = localOcrProvider();
var body = {
rootUri: rootUri,
documentId: documentId,
sourceRootRelativePath: sourceRootRelativePath,
provider: provider
};
if (provider === 'mock') {
body.mockMarkdown = '# OCR Result\n\n' + (detail.title || sourceRootRelativePath) + ' OCR menu smoke text';
}
document.documentElement.setAttribute('data-mnote-local-ocr-menu-status', 'running');
var response = await fetch('/api/local-folder/ocr/jobs', {
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;
}
async function ingestKnowledgeRagForDetail(detail, trigger) {
var sourceRootRelativePath = localOcrSourceRelativePath(detail)
|| String(detail && (detail.localRelativePath || detail.path || detail.title) || '').trim();
var rootUri = localOcrRootUri(detail, trigger);
if (!sourceRootRelativePath || !rootUri) {
throw new Error('缺少资料库来源或 rootUri');
}
var workspaceId = String(detail && detail.workspaceId || resolveWorkspaceId(document.body) || '').trim();
document.documentElement.setAttribute('data-mnote-knowledge-rag-menu-status', 'running');
document.documentElement.setAttribute('data-mnote-knowledge-rag-menu-path', sourceRootRelativePath);
var response = await fetch('/api/knowledge-rag/ingest', {
method: 'POST',
headers: { 'content-type': 'application/json', accept: 'application/json' },
body: JSON.stringify(body)
body: JSON.stringify({
workspaceId: workspaceId,
rootUri: rootUri,
sources: [{ sourcePath: sourceRootRelativePath }]
})
});
var payload = await response.json().catch(function() { return null; });
if (!response.ok || !payload || payload.ok !== true) {
var message = payload && payload.error && payload.error.message ? payload.error.message : 'OCR 生成失败';
document.documentElement.setAttribute('data-mnote-local-ocr-menu-status', 'failed');
if (!response.ok || !payload || payload.ok === false) {
var message = payload && payload.message ? payload.message : '资料库索引失败';
document.documentElement.setAttribute('data-mnote-knowledge-rag-menu-status', 'failed');
throw new Error(message);
}
var job = payload.job || {};
document.documentElement.setAttribute('data-mnote-local-ocr-menu-status', String(job.status || 'done'));
if (job.ocrRootRelativePath) {
document.documentElement.setAttribute('data-mnote-local-ocr-menu-path', String(job.ocrRootRelativePath));
if (typeof openLocalResourceInActiveTab === 'function') {
await openLocalResourceInActiveTab({
path: String(job.ocrRootRelativePath),
title: String(job.ocrRootRelativePath).split('/').filter(Boolean).pop() || 'OCR',
kind: 'markdown',
assetId: 'local-ocr:' + String(job.ocrRootRelativePath),
documentId: documentId,
workspaceId: String(detail && detail.workspaceId || resolveWorkspaceId(document.body) || '').trim(),
rootUri: rootUri
});
}
}
window.dispatchEvent(new CustomEvent('mnote:local-ocr-job-updated', {
detail: { status: String(job.status || 'done'), job: job, rootUri: rootUri, sourceRootRelativePath: sourceRootRelativePath }
document.documentElement.setAttribute('data-mnote-knowledge-rag-menu-status', payload.retryRequired ? 'retry' : 'done');
window.dispatchEvent(new CustomEvent('mnote:knowledge-rag-source-updated', {
detail: { rootUri: rootUri, workspaceId: workspaceId, sourceRootRelativePath: sourceRootRelativePath, result: payload }
}));
return job;
return payload;
}
function handleTreeContextMenuAction(action, detail, trigger) {
closeTreeContextMenu();
detail = detail || {};
if (action === 'local-ocr') {
recordFileTreeAction('local-ocr', detail);
recordFileTreeAction('knowledge-rag-index', detail);
recordFileTreeActionStatus('pending', detail);
void runLocalOcrForDetail(detail, trigger).then(function(job) {
recordFileTreeActionStatus(String(job && job.status || 'done'), detail);
recordFileTreeActionStatus(job && job.retryRequired ? 'retry' : 'done', detail);
}).catch(function(error) {
recordFileTreeActionStatus('failed', Object.assign({}, detail, { fallback: 'alert' }));
window.alert(error && error.message ? error.message : 'OCR 生成失败');
window.alert(error && error.message ? error.message : '资料库索引失败');
});
return;
}
if (action === 'knowledge-rag-index') {
recordFileTreeAction('knowledge-rag-index', detail);
recordFileTreeActionStatus('pending', detail);
void ingestKnowledgeRagForDetail(detail, trigger).then(function() {
recordFileTreeActionStatus('done', detail);
}).catch(function(error) {
recordFileTreeActionStatus('failed', Object.assign({}, detail, { fallback: 'alert' }));
window.alert(error && error.message ? error.message : '资料库索引失败');
});
return;
}
@@ -1010,10 +1017,10 @@ 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 (localOcrSupported) {
var ocrItem = { action: 'local-ocr', icon: 'auto_awesome', label: '生成 OCR', when: '!workspace.readonly' };
var insertAt = isAttachment ? 11 : isAsset ? 3 : -1;
if (insertAt >= 0) items.splice(insertAt, 0, ocrItem);
if (currentSourceKind() === 'local_folder' && detail.rowKind !== 'index') {
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);
}
items.forEach(function(item) {
if (item.when !== undefined && !evaluateSidebarFileTreeWhen(ctx, item.when)) {
@@ -443,43 +443,7 @@ export function createSidebarPageAiTargetRuntime(context) {
}
async function fetchPageAiOcrSidecarContext(editorTarget) {
if (currentSourceKind() !== 'local_folder') return null;
var target = editorTarget && typeof editorTarget === 'object' ? editorTarget : {};
var workspacePath = target.workspacePath && typeof target.workspacePath === 'object' ? target.workspacePath : {};
var relativePath = String(workspacePath.relativePath || target.path || '').trim();
var resourceKind = String(target.resourceKind || target.editorKind || workspacePath.resourceKind || '').trim();
if (!relativePath || !pageAiOcrEligiblePath(relativePath) || !pageAiOcrEligibleResourceKind(resourceKind)) return null;
var rootUri = String(workspacePath.rootUri || currentRootUri() || '').trim();
if (!rootUri) return null;
try {
var statusUrl = new URL('/api/local-folder/ocr/status', window.location.origin);
statusUrl.searchParams.set('rootUri', rootUri);
statusUrl.searchParams.set('sourceRootRelativePath', relativePath);
var statusResponse = await fetch(statusUrl.toString(), { cache: 'no-store', headers: { accept: 'application/json' } });
var statusPayload = await statusResponse.json().catch(function() { return null; });
var job = statusResponse.ok && statusPayload && statusPayload.ok === true ? statusPayload.job : null;
var ocrPath = String(job && job.ocrRootRelativePath || '').trim();
if (!ocrPath || String(job.status || '') === 'failed') return null;
var readUrl = new URL('/api/local-folder/ocr/read', window.location.origin);
readUrl.searchParams.set('rootUri', rootUri);
readUrl.searchParams.set('ocrRootRelativePath', ocrPath);
var readResponse = await fetch(readUrl.toString(), { cache: 'no-store', headers: { accept: 'application/json' } });
var readPayload = await readResponse.json().catch(function() { return null; });
if (!readResponse.ok || !readPayload || readPayload.ok !== true) return null;
return {
schema: 'mnote.local_ocr_context.v1',
source: 'local_ocr_sidecar',
sourceRootRelativePath: relativePath,
ocrRootRelativePath: ocrPath,
status: String(job.status || ''),
stale: job.stale === true,
provider: String(job.provider || ''),
modelVersion: String(job.modelVersion || ''),
plainTextPreview: pageAiOcrBodyPreview(readPayload.markdown || ''),
};
} catch (_) {
return null;
}
return null;
}
async function pageAiEnrichOcrContextRefs(contextRefs, agentTargetPackage, editorTarget) {
@@ -71,6 +71,15 @@ export function createSidebarPageSettingsRuntime(context) {
return Object.assign({ 'localOcr.autoEnabled': false }, pageUiState.localOcrPreferences || {});
}
function currentKnowledgeRagSummary() {
return pageUiState.knowledgeRagSummary || {};
}
var knowledgeRagBridgeTimer = 0;
var knowledgeRagBridgeGeneration = 0;
var knowledgeRagBridgeAttempt = 0;
var knowledgeRagBridgeDelays = [1200, 2000, 3200, 5000, 8000, 13000, 21000, 34000];
function pageWidthModeLabel(mode) {
if (mode === 'inherit') return '继承默认';
if (mode === 'readable') return '阅读';
@@ -337,6 +346,12 @@ export function createSidebarPageSettingsRuntime(context) {
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) {
ragTrigger.setAttribute('data-state', ragOpen ? 'open' : 'closed');
ragTrigger.setAttribute('aria-expanded', ragOpen ? 'true' : 'false');
}
}
function createPageOptionRow(key, type) {
@@ -380,8 +395,8 @@ export function createSidebarPageSettingsRuntime(context) {
return '' +
'<label class="wolai-page-setting-row" data-page-setting-row="localOcrAutoEnabled">' +
'<span class="wolai-page-setting-copy">' +
'<span class="wolai-page-setting-label">自动 OCR</span>' +
'<span class="wolai-page-setting-hint">默认关闭;仅作为搜索和 AI 索引补充处理图片与图片型 PDF</span>' +
'<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>';
@@ -659,27 +674,53 @@ export function createSidebarPageSettingsRuntime(context) {
}
function ensureLocalOcrSettingsPopover() {
var existing = document.querySelector('[data-testid="mnote-local-ocr-settings-popover"]');
if (existing instanceof HTMLElement) return existing;
var popover = document.createElement('div');
popover.className = 'wolai-page-settings-popover mnote-local-ocr-settings-popover';
popover.setAttribute('data-testid', 'mnote-local-ocr-settings-popover');
popover.setAttribute('data-mnote-surface', 'local-ocr-settings');
popover.hidden = true;
popover.innerHTML = '' +
'<div class="wolai-page-settings-panel mnote-settings-panel mnote-local-ocr-settings-panel" role="dialog" aria-modal="false" aria-label="OCR 设置">' +
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="资料库问答设置">' +
'<div class="mnote-settings-panel-head">' +
'<strong>OCR 设置</strong>' +
'<button type="button" class="mnote-settings-panel-close" data-settings-action="close" aria-label="关闭 OCR 设置">×</button>' +
'<strong>资料库问答</strong>' +
'<button type="button" class="mnote-settings-panel-close" data-settings-action="close" aria-label="关闭资料库问答设置">×</button>' +
'</div>' +
'<div class="wolai-page-settings-section">' +
createLocalOcrAutoRow() +
'<div class="wolai-page-settings-index-actions">' +
'<button type="button" data-local-ocr-settings-action="run-active">识别当前资源</button>' +
'<button type="button" data-local-ocr-settings-action="tasks">查看 OCR 任务</button>' +
'</div>' +
'<div class="wolai-page-settings-index-panel" data-testid="mnote-knowledge-rag-settings-panel">' +
'<div class="wolai-page-settings-index-status" data-testid="mnote-knowledge-rag-status"></div>' +
'<div class="mnote-knowledge-rag-meta" data-testid="mnote-knowledge-rag-meta"></div>' +
'<section class="wolai-page-settings-index-group mnote-knowledge-rag-control-group">' +
'<div class="wolai-page-settings-index-title">资料操作</div>' +
'<div class="mnote-knowledge-rag-source-inputs" data-testid="mnote-knowledge-rag-source-inputs">' +
knowledgeRagSourceInputRow('') +
'</div>' +
'<div class="mnote-knowledge-rag-source-tools">' +
'<button type="button" class="wolai-page-settings-index-add" data-knowledge-rag-action="add-source">新增来源</button>' +
'<button type="button" class="wolai-page-settings-index-add" data-knowledge-rag-action="use-filetree-selection">使用选中文件树项</button>' +
'</div>' +
'<div class="wolai-page-settings-index-actions">' +
'<button type="button" data-knowledge-rag-action="refresh">刷新状态</button>' +
'<button type="button" data-knowledge-rag-action="ingest">索引来源</button>' +
'<button type="button" data-knowledge-rag-action="prune">清除失效记录</button>' +
'<button type="button" data-knowledge-rag-action="open-dashboard">打开 LightRAG</button>' +
'</div>' +
'</section>' +
'<section class="wolai-page-settings-index-group">' +
'<div class="wolai-page-settings-index-title">资料来源</div>' +
'<div class="mnote-knowledge-rag-source-filters" data-testid="mnote-knowledge-rag-source-filters"></div>' +
'<div class="mnote-knowledge-rag-source-list" data-testid="mnote-knowledge-rag-sources"></div>' +
'</section>' +
'</div>' +
'</div>';
}
function ensureKnowledgeRagSettingsPopover() {
var existing = document.querySelector('[data-testid="mnote-knowledge-rag-settings-popover"]');
if (existing instanceof HTMLElement) return existing;
var popover = document.createElement('div');
popover.className = 'wolai-page-settings-popover mnote-knowledge-rag-settings-popover';
popover.setAttribute('data-testid', 'mnote-knowledge-rag-settings-popover');
popover.setAttribute('data-mnote-surface', 'knowledge-rag-settings');
popover.hidden = true;
popover.innerHTML = createKnowledgeRagSettingsPanelHtml();
document.body.appendChild(popover);
return popover;
}
@@ -1015,6 +1056,656 @@ export function createSidebarPageSettingsRuntime(context) {
}
}
function knowledgeRagScopeKey() {
return [
currentSourceKind(),
currentRootUri(),
resolveWorkspaceId(document.body)
].join('|');
}
function knowledgeRagIsAvailable() {
return currentSourceKind() === 'local_folder' && Boolean(currentRootUri());
}
function knowledgeRagSourceInputRow(value) {
return '' +
'<div class="mnote-knowledge-rag-source-input-row" data-knowledge-rag-source-input-row="true">' +
'<input type="text" class="wolai-page-settings-index-path" data-knowledge-rag-source-input="true" value="' + escapeHtml(value || '') + '" placeholder="文件或目录,例如 docs/book.pdf / docs" spellcheck="false" />' +
'<span class="mnote-knowledge-rag-input-status" data-knowledge-rag-input-status-label="true" hidden></span>' +
'<button type="button" class="wolai-page-settings-index-remove" data-knowledge-rag-action="remove-source" aria-label="删除资料来源">×</button>' +
'</div>';
}
function knowledgeRagSourceInputValues() {
var popover = ensureKnowledgeRagSettingsPopover();
return Array.from(popover.querySelectorAll('[data-knowledge-rag-source-input]')).map(function(input) {
return input instanceof HTMLInputElement ? input.value.trim() : '';
}).filter(Boolean).filter(function(value, index, list) {
return list.indexOf(value) === index;
});
}
function knowledgeRagCurrentFilter() {
return pageUiState.knowledgeRagSourceFilter || 'attention';
}
function setKnowledgeRagSourceFilter(filter) {
pageUiState.knowledgeRagSourceFilter = String(filter || 'attention').trim() || 'attention';
renderKnowledgeRagSettings();
}
function knowledgeRagRegistryEntryCategory(entry) {
var deleted = entry && entry.deletedAtMs;
var stale = entry && entry.stale;
var providerStatus = String(entry && entry.lightRagStatus || '').trim();
var failed = providerStatus === 'failed';
var deleting = providerStatus === 'delete_submitted';
var deletedDone = providerStatus === 'delete_completed';
var docId = String(entry && entry.lightRagDocId || '');
var indexed = entry && entry.indexedAtMs && docId && !deleted && !stale && !failed && !deleting && !deletedDone;
if (deletedDone) return 'removed';
if (failed || stale || deleted) return 'failed';
if (indexed) return 'indexed';
return 'indexing';
}
function knowledgeRagProviderDocuments(status) {
var documents = status && status.documents && typeof status.documents === 'object' ? status.documents : {};
return Array.isArray(documents.documents) ? documents.documents : [];
}
function knowledgeRagStatusHasInFlight(status) {
var registry = status && status.registry && typeof status.registry === 'object' ? status.registry : {};
var entries = Array.isArray(registry.entries) ? registry.entries : [];
if (entries.some(function(entry) {
var providerStatus = String(entry && entry.lightRagStatus || '').trim();
return !entry?.deletedAtMs && !entry?.stale && !(entry?.indexedAtMs && entry?.lightRagDocId)
&& ['submitted', 'pending', 'processing', 'parsed', ''].includes(providerStatus);
})) return true;
var groups = status && status.documents && status.documents.rawStatusGroups && typeof status.documents.rawStatusGroups === 'object'
? status.documents.rawStatusGroups
: {};
return ['pending', 'parse', 'parsed', 'analyze', 'processing', 'process'].some(function(key) {
return Number(groups[key] || 0) > 0;
});
}
function scheduleKnowledgeRagStatusBridge(reason) {
if (!knowledgeRagIsAvailable()) return;
if (knowledgeRagBridgeTimer) window.clearTimeout(knowledgeRagBridgeTimer);
knowledgeRagBridgeGeneration += 1;
knowledgeRagBridgeAttempt = 0;
runKnowledgeRagStatusBridge(knowledgeRagBridgeGeneration, reason || 'source-updated');
}
function runKnowledgeRagStatusBridge(generation, reason) {
if (generation !== knowledgeRagBridgeGeneration || !knowledgeRagIsAvailable()) return;
var delay = knowledgeRagBridgeDelays[Math.min(knowledgeRagBridgeAttempt, knowledgeRagBridgeDelays.length - 1)];
knowledgeRagBridgeTimer = window.setTimeout(async function() {
if (generation !== knowledgeRagBridgeGeneration || !knowledgeRagIsAvailable()) return;
knowledgeRagBridgeAttempt += 1;
await loadKnowledgeRagStatus(true, { silentLoading: true, notifyUpdated: true, reason: reason || 'bridge' });
var status = currentKnowledgeRagSummary().status || {};
if (knowledgeRagStatusHasInFlight(status) && knowledgeRagBridgeAttempt < knowledgeRagBridgeDelays.length) {
runKnowledgeRagStatusBridge(generation, reason || 'bridge');
}
}, delay);
}
function knowledgeRagUnmappedProviderRows(status, entries) {
var docs = knowledgeRagProviderDocuments(status);
if (!docs.length) return [];
var mappedDocIds = new Set();
var mappedFilePaths = new Set();
(Array.isArray(entries) ? entries : []).forEach(function(entry) {
var docId = String(entry && entry.lightRagDocId || '').trim();
var filePath = String(entry && entry.lightRagFilePath || '').trim();
if (docId) mappedDocIds.add(docId);
if (filePath) mappedFilePaths.add(filePath);
});
return docs.filter(function(doc) {
var docId = String(doc && doc.id || '').trim();
var filePath = String(doc && doc.filePath || '').trim();
return !(docId && mappedDocIds.has(docId)) && !(filePath && mappedFilePaths.has(filePath));
}).map(function(doc) {
return {
kind: 'provider',
category: String(doc.status || doc.statusGroup || '').toLowerCase().includes('fail') ? 'unmapped_failed' : 'unmapped',
doc: doc
};
});
}
function knowledgeRagVisibleForFilter(row, filter) {
if (filter === 'all') return true;
if (filter === 'attention') return row.category !== 'indexed';
if (filter === 'unmapped') return row.category === 'unmapped' || row.category === 'unmapped_failed';
if (filter === 'failed') return row.category === 'failed' || row.category === 'unmapped_failed';
return row.category === filter;
}
function renderKnowledgeRagSourceFilters(popover, rows) {
var filtersNode = popover.querySelector('[data-testid="mnote-knowledge-rag-source-filters"]');
if (!(filtersNode instanceof HTMLElement)) return;
var counts = rows.reduce(function(acc, row) {
acc.all += 1;
if (row.category !== 'indexed') acc.attention += 1;
if (row.category === 'indexing') acc.indexing += 1;
if (row.category === 'failed' || row.category === 'unmapped_failed') acc.failed += 1;
if (row.category === 'indexed') acc.indexed += 1;
if (row.category === 'removed') acc.removed += 1;
if (row.category === 'unmapped' || row.category === 'unmapped_failed') acc.unmapped += 1;
return acc;
}, { all: 0, attention: 0, indexing: 0, failed: 0, indexed: 0, removed: 0, unmapped: 0 });
var current = knowledgeRagCurrentFilter();
var options = [
['attention', '需处理'],
['indexing', '索引中'],
['failed', '失败/过期'],
['indexed', '已索引'],
['removed', '已移除'],
['unmapped', 'LightRAG 未映射'],
['all', '全部']
];
filtersNode.innerHTML = options.map(function(item) {
var key = item[0];
var label = item[1];
return '<button type="button" data-knowledge-rag-action="filter-sources" data-knowledge-rag-filter="' + key + '" data-active="' + (current === key ? 'true' : 'false') + '">' + escapeHtml(label) + ' <span>' + String(counts[key] || 0) + '</span></button>';
}).join('');
}
function renderKnowledgeRagSources(popover, entries, status) {
var sourcesNode = popover.querySelector('[data-testid="mnote-knowledge-rag-sources"]');
if (!(sourcesNode instanceof HTMLElement)) return;
var registryRows = (Array.isArray(entries) ? entries : []).map(function(entry) {
return { kind: 'registry', category: knowledgeRagRegistryEntryCategory(entry), entry: entry };
});
var rows = registryRows.concat(knowledgeRagUnmappedProviderRows(status || {}, entries));
renderKnowledgeRagSourceFilters(popover, rows);
var filter = knowledgeRagCurrentFilter();
var items = rows.filter(function(row) {
return knowledgeRagVisibleForFilter(row, filter);
});
if (!rows.length) {
sourcesNode.innerHTML = '<div class="wolai-page-settings-index-empty">还没有登记到 LightRAG 的资料来源</div>';
return;
}
if (!items.length) {
sourcesNode.innerHTML = '<div class="wolai-page-settings-index-empty">当前分类没有资料来源</div>';
return;
}
sourcesNode.innerHTML = items.map(function(row) {
if (row.kind === 'provider') {
var doc = row.doc || {};
var providerPath = String(doc.filePath || doc.id || '');
var docStatus = String(doc.status || doc.statusGroup || '');
var failedDoc = row.category === 'unmapped_failed';
return '' +
'<div class="mnote-knowledge-rag-source-row" data-knowledge-rag-source-row="true" data-knowledge-rag-source-kind="provider" data-knowledge-rag-source-path="' + escapeHtml(providerPath) + '">' +
'<span class="wolai-page-settings-index-status-dot" data-index-status="' + (failedDoc ? 'fault' : 'indexed') + '" title="' + escapeHtml(docStatus || 'LightRAG 文档') + '"></span>' +
'<div class="mnote-knowledge-rag-source-main">' +
'<strong title="' + escapeHtml(providerPath) + '">' + escapeHtml(providerPath || 'LightRAG 文档') + '</strong>' +
'<span>' + escapeHtml('LightRAG 未映射 · ' + (docStatus || 'unknown') + (doc.id ? ' · ' + doc.id : '')) + '</span>' +
'</div>' +
'<div class="mnote-knowledge-rag-source-actions"><button type="button" disabled>只读</button></div>' +
'</div>';
}
var entry = row.entry || {};
var deleted = entry && entry.deletedAtMs;
var stale = entry && entry.stale;
var providerStatus = String(entry && entry.lightRagStatus || '').trim();
var failed = providerStatus === 'failed';
var deleting = providerStatus === 'delete_submitted';
var deletedDone = providerStatus === 'delete_completed';
var docId = String(entry && entry.lightRagDocId || '');
var indexed = entry && entry.indexedAtMs && docId && !deleted && !stale && !failed && !deleting && !deletedDone;
var kind = deletedDone ? '' : (deleting ? 'indexing' : (failed || stale || deleted ? 'fault' : (indexed ? 'indexed' : 'indexing')));
var title = String(entry && entry.sourceRootRelativePath || entry && entry.lightRagFilePath || '');
var status = deletedDone ? 'LightRAG 已移除'
: deleting ? '删除已提交'
: deleted ? '已删除'
: stale ? '已过期'
: failed ? '索引失败'
: indexed ? '已索引'
: (docId || providerStatus ? '正在索引' : '待提交');
var meta = docId ? status + ' · ' + docId : status;
var sourcePath = String(entry && entry.sourceRootRelativePath || '');
var canReindex = Boolean(sourcePath && !deleting);
var canDelete = Boolean(sourcePath && !deleted && !deletedDone && !deleting);
return '' +
'<div class="mnote-knowledge-rag-source-row" data-knowledge-rag-source-row="true" data-knowledge-rag-source-path="' + escapeHtml(sourcePath) + '">' +
'<span class="wolai-page-settings-index-status-dot" data-index-status="' + kind + '" title="' + escapeHtml(status) + '"></span>' +
'<div class="mnote-knowledge-rag-source-main">' +
'<strong title="' + escapeHtml(title) + '">' + escapeHtml(title || '未命名来源') + '</strong>' +
'<span>' + escapeHtml(meta) + '</span>' +
'</div>' +
'<div class="mnote-knowledge-rag-source-actions">' +
'<button type="button" data-knowledge-rag-action="reindex-source" data-knowledge-rag-source-path="' + escapeHtml(sourcePath) + '"' + (canReindex ? '' : ' disabled') + '>重索引</button>' +
'<button type="button" data-knowledge-rag-action="delete-source" data-knowledge-rag-source-path="' + escapeHtml(sourcePath) + '"' + (canDelete ? '' : ' disabled') + '>删除索引</button>' +
'</div>' +
'</div>';
}).join('');
}
function renderKnowledgeRagSettings(popover) {
popover = popover || ensureKnowledgeRagSettingsPopover();
var statusNode = popover.querySelector('[data-testid="mnote-knowledge-rag-status"]');
var metaNode = popover.querySelector('[data-testid="mnote-knowledge-rag-meta"]');
var dashboardButton = popover.querySelector('[data-knowledge-rag-action="open-dashboard"]');
if (!(statusNode instanceof HTMLElement)) return;
if (!knowledgeRagIsAvailable()) {
statusNode.textContent = '资料库问答仅在本地工作区可用';
if (metaNode instanceof HTMLElement) metaNode.innerHTML = '';
renderKnowledgeRagSourceFilters(popover, []);
renderKnowledgeRagSources(popover, [], {});
if (dashboardButton instanceof HTMLButtonElement) dashboardButton.disabled = true;
return;
}
var summary = currentKnowledgeRagSummary();
if (summary.loading) {
statusNode.textContent = '正在读取 LightRAG 状态...';
if (metaNode instanceof HTMLElement) metaNode.innerHTML = '';
renderKnowledgeRagSourceFilters(popover, []);
renderKnowledgeRagSources(popover, [], {});
if (dashboardButton instanceof HTMLButtonElement) dashboardButton.disabled = true;
return;
}
if (summary.error) {
statusNode.textContent = 'LightRAG 状态读取失败:' + summary.error;
if (metaNode instanceof HTMLElement) metaNode.innerHTML = '';
renderKnowledgeRagSourceFilters(popover, []);
renderKnowledgeRagSources(popover, [], {});
if (dashboardButton instanceof HTMLButtonElement) dashboardButton.disabled = true;
return;
}
var status = summary.status || {};
var health = status.health && typeof status.health === 'object' ? status.health : {};
var healthBody = health.health && typeof health.health === 'object' ? health.health : {};
var healthy = health.ok === true && String(healthBody.status || '') === 'healthy';
var registry = status.registry && typeof status.registry === 'object' ? status.registry : {};
var entries = Array.isArray(registry.entries) ? registry.entries : [];
var providerDocs = knowledgeRagProviderDocuments(status);
statusNode.textContent = healthy
? 'LightRAG healthyMNote 登记 ' + entries.length + ' 个来源,LightRAG 文档 ' + providerDocs.length + ' 个'
: 'LightRAG 未就绪';
if (metaNode instanceof HTMLElement) {
var dashboardUrl = publicKnowledgeRagDashboardUrl(status);
var inputDir = String(status.inputDir || '');
var workingDir = String(healthBody.working_directory || '');
var config = healthBody.configuration && typeof healthBody.configuration === 'object' ? healthBody.configuration : {};
var llmModel = String(config.llm_model || config.llmModel || '');
var vlmModel = String(config.vlm_model || config.vlmModel || '');
metaNode.innerHTML = '' +
'<div><span>Dashboard</span><code>' + escapeHtml(dashboardUrl || '未配置') + '</code></div>' +
'<div><span>Model</span><code>' + escapeHtml(llmModel || '未上报') + (vlmModel ? ' / VLM ' + escapeHtml(vlmModel) : '') + '</code></div>' +
'<div><span>Input</span><code>' + escapeHtml(inputDir || '未配置') + '</code></div>' +
'<div><span>Storage</span><code>' + escapeHtml(workingDir || '未上报') + '</code></div>';
}
renderKnowledgeRagSources(popover, entries, status);
if (dashboardButton instanceof HTMLButtonElement) dashboardButton.disabled = !publicKnowledgeRagDashboardUrl(status);
}
function publicKnowledgeRagDashboardUrl(status) {
var url = String(status && (status.dashboardUrl || status.endpoint) || '').trim();
if (!url) return '';
try {
var parsed = new URL(url, window.location.href);
if (['127.0.0.1', 'localhost', '::1'].includes(parsed.hostname) && window.location.hostname && !['127.0.0.1', 'localhost', '::1'].includes(window.location.hostname)) {
parsed.hostname = window.location.hostname;
return parsed.toString();
}
} catch (_) {}
return url;
}
async function loadKnowledgeRagStatus(force, options) {
options = options || {};
if (!knowledgeRagIsAvailable()) {
renderKnowledgeRagSettings();
return;
}
var scopeKey = knowledgeRagScopeKey();
var current = currentKnowledgeRagSummary();
if (!force && current.scopeKey === scopeKey && !current.error && !current.loading) {
renderKnowledgeRagSettings();
return;
}
if (!options.silentLoading) {
pageUiState.knowledgeRagSummary = {
scopeKey: scopeKey,
loading: true,
error: ''
};
renderKnowledgeRagSettings();
}
try {
var params = new URLSearchParams();
params.set('workspaceId', resolveWorkspaceId(document.body));
params.set('rootUri', currentRootUri());
var response = await fetch('/api/knowledge-rag/status?' + params.toString(), {
headers: { accept: 'application/json' }
});
var payload = await response.json().catch(function(){ return null; });
if (!response.ok || !payload || payload.ok !== true) {
throw new Error(payload && payload.message ? payload.message : 'knowledge_rag_status_' + response.status);
}
pageUiState.knowledgeRagSummary = {
scopeKey: scopeKey,
loading: false,
error: '',
status: payload
};
if (options.notifyUpdated) {
notifyKnowledgeRagSourceUpdated({ action: 'status-sync', reason: options.reason || 'bridge', status: payload });
}
} catch (error) {
pageUiState.knowledgeRagSummary = {
scopeKey: scopeKey,
loading: false,
error: error instanceof Error ? error.message : String(error),
status: {}
};
}
renderKnowledgeRagSettings();
}
function addKnowledgeRagSourceInput() {
var popover = ensureKnowledgeRagSettingsPopover();
var node = popover.querySelector('[data-testid="mnote-knowledge-rag-source-inputs"]');
if (!(node instanceof HTMLElement)) return;
node.insertAdjacentHTML('beforeend', knowledgeRagSourceInputRow(''));
var inputs = Array.from(node.querySelectorAll('[data-knowledge-rag-source-input]'));
var last = inputs[inputs.length - 1];
if (last instanceof HTMLInputElement) last.focus();
}
function knowledgeRagFileTreeSelectionPaths() {
var rows = Array.from(document.querySelectorAll(
'#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-selected="true"][data-local-relative-path]'
));
if (!rows.length) {
rows = Array.from(document.querySelectorAll(
'#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-active="true"][data-local-relative-path]'
));
}
return rows.map(function(row) {
return String(row.getAttribute('data-local-relative-path') || '').trim();
}).filter(Boolean).filter(function(value, index, list) {
return list.indexOf(value) === index;
});
}
function useKnowledgeRagFileTreeSelection() {
var selectedPaths = knowledgeRagFileTreeSelectionPaths();
if (!selectedPaths.length) {
pageUiState.knowledgeRagSummary = Object.assign({}, currentKnowledgeRagSummary(), {
scopeKey: knowledgeRagScopeKey(),
loading: false,
error: '请先在文件树中选中要索引的文件或目录'
});
renderKnowledgeRagSettings();
return;
}
var popover = ensureKnowledgeRagSettingsPopover();
var node = popover.querySelector('[data-testid="mnote-knowledge-rag-source-inputs"]');
if (!(node instanceof HTMLElement)) return;
var existing = knowledgeRagSourceInputValues();
var next = existing.concat(selectedPaths).filter(function(value, index, list) {
return list.indexOf(value) === index;
});
node.innerHTML = next.map(knowledgeRagSourceInputRow).join('');
}
function notifyKnowledgeRagSourceUpdated(detail) {
window.dispatchEvent(new CustomEvent('mnote:knowledge-rag-source-updated', {
detail: Object.assign({
rootUri: currentRootUri(),
workspaceId: resolveWorkspaceId(document.body)
}, detail || {})
}));
}
function removeKnowledgeRagSourceInput(button) {
var row = button && button.closest ? button.closest('[data-knowledge-rag-source-input-row]') : null;
if (row instanceof HTMLElement) row.remove();
var popover = ensureKnowledgeRagSettingsPopover();
var node = popover.querySelector('[data-testid="mnote-knowledge-rag-source-inputs"]');
if (node instanceof HTMLElement && !node.querySelector('[data-knowledge-rag-source-input]')) {
node.innerHTML = knowledgeRagSourceInputRow('');
}
}
function resetKnowledgeRagSourceInputs() {
var popover = ensureKnowledgeRagSettingsPopover();
var node = popover.querySelector('[data-testid="mnote-knowledge-rag-source-inputs"]');
if (node instanceof HTMLElement) node.innerHTML = knowledgeRagSourceInputRow('');
}
async function ingestKnowledgeRagSources() {
if (!knowledgeRagIsAvailable()) return;
var sources = knowledgeRagSourceInputValues();
if (!sources.length) {
pageUiState.knowledgeRagSummary = Object.assign({}, currentKnowledgeRagSummary(), {
scopeKey: knowledgeRagScopeKey(),
loading: false,
error: '请先填写要索引的资料路径'
});
renderKnowledgeRagSettings();
return;
}
setKnowledgeRagInputStatuses(Object.fromEntries(sources.map(function(path) {
return [path, 'running'];
})));
pageUiState.knowledgeRagSummary = Object.assign({}, currentKnowledgeRagSummary(), {
scopeKey: knowledgeRagScopeKey(),
loading: true,
error: ''
});
renderKnowledgeRagSettings();
try {
var response = await fetch('/api/knowledge-rag/ingest', {
method: 'POST',
headers: { 'content-type': 'application/json', accept: 'application/json' },
body: JSON.stringify({
workspaceId: resolveWorkspaceId(document.body),
rootUri: currentRootUri(),
sources: sources.map(function(path) { return { sourcePath: path }; })
})
});
var payload = await response.json().catch(function(){ return null; });
if (!response.ok || !payload) {
throw new Error(payload && payload.message ? payload.message : 'knowledge_rag_ingest_' + response.status);
}
if (payload.retryRequired) {
setKnowledgeRagInputStatuses(Object.fromEntries(sources.map(function(path) {
return [path, 'retry'];
})));
await loadKnowledgeRagStatus(true);
notifyKnowledgeRagSourceUpdated({ action: 'ingest', sources: sources, result: payload, retryRequired: true });
scheduleKnowledgeRagStatusBridge('ingest-retry');
pageUiState.knowledgeRagSummary = Object.assign({}, currentKnowledgeRagSummary(), {
scopeKey: knowledgeRagScopeKey(),
loading: false,
error: 'LightRAG 正在处理其他任务,请稍后刷新或重试'
});
renderKnowledgeRagSettings();
return;
}
var configured = Array.isArray(payload.configuredSources) ? payload.configuredSources : [];
var done = {};
configured.forEach(function(item) {
var requested = String(item && item.requestedSourcePath || item && item.sourceRootRelativePath || '').trim();
if (requested) done[requested] = 'done';
});
setKnowledgeRagInputStatuses(done, 'done');
await loadKnowledgeRagStatus(true);
resetKnowledgeRagSourceInputs();
notifyKnowledgeRagSourceUpdated({ action: 'ingest', sources: sources, result: payload });
scheduleKnowledgeRagStatusBridge('ingest');
} catch (error) {
setKnowledgeRagInputStatuses(Object.fromEntries(sources.map(function(path) {
return [path, 'failed'];
})));
pageUiState.knowledgeRagSummary = Object.assign({}, currentKnowledgeRagSummary(), {
scopeKey: knowledgeRagScopeKey(),
loading: false,
error: error instanceof Error ? error.message : String(error)
});
renderKnowledgeRagSettings();
}
}
async function ingestSingleKnowledgeRagSource(sourcePath) {
sourcePath = String(sourcePath || '').trim();
if (!sourcePath || !knowledgeRagIsAvailable()) return;
setKnowledgeRagInputStatuses({ [sourcePath]: 'running' });
pageUiState.knowledgeRagSummary = Object.assign({}, currentKnowledgeRagSummary(), {
scopeKey: knowledgeRagScopeKey(),
loading: true,
error: ''
});
renderKnowledgeRagSettings();
try {
var response = await fetch('/api/knowledge-rag/ingest', {
method: 'POST',
headers: { 'content-type': 'application/json', accept: 'application/json' },
body: JSON.stringify({
workspaceId: resolveWorkspaceId(document.body),
rootUri: currentRootUri(),
sources: [{ sourcePath: sourcePath }],
force: true
})
});
var payload = await response.json().catch(function(){ return null; });
if (!response.ok || !payload) {
throw new Error(payload && payload.message ? payload.message : 'knowledge_rag_reindex_' + response.status);
}
if (payload.retryRequired) {
await loadKnowledgeRagStatus(true);
notifyKnowledgeRagSourceUpdated({ action: 'reindex', sourceRootRelativePath: sourcePath, result: payload, retryRequired: true });
scheduleKnowledgeRagStatusBridge('reindex-retry');
pageUiState.knowledgeRagSummary = Object.assign({}, currentKnowledgeRagSummary(), {
scopeKey: knowledgeRagScopeKey(),
loading: false,
error: 'LightRAG 正在处理其他任务,请稍后重试'
});
renderKnowledgeRagSettings();
return;
}
await loadKnowledgeRagStatus(true);
notifyKnowledgeRagSourceUpdated({ action: 'reindex', sourceRootRelativePath: sourcePath, result: payload });
scheduleKnowledgeRagStatusBridge('reindex');
} catch (error) {
pageUiState.knowledgeRagSummary = Object.assign({}, currentKnowledgeRagSummary(), {
scopeKey: knowledgeRagScopeKey(),
loading: false,
error: error instanceof Error ? error.message : String(error)
});
renderKnowledgeRagSettings();
}
}
async function deleteKnowledgeRagSource(sourcePath) {
sourcePath = String(sourcePath || '').trim();
if (!sourcePath || !knowledgeRagIsAvailable()) return;
pageUiState.knowledgeRagSummary = Object.assign({}, currentKnowledgeRagSummary(), {
scopeKey: knowledgeRagScopeKey(),
loading: true,
error: ''
});
renderKnowledgeRagSettings();
try {
var response = await fetch('/api/knowledge-rag/delete-source', {
method: 'POST',
headers: { 'content-type': 'application/json', accept: 'application/json' },
body: JSON.stringify({
workspaceId: resolveWorkspaceId(document.body),
rootUri: currentRootUri(),
sourcePath: sourcePath
})
});
var payload = await response.json().catch(function(){ return null; });
if (!response.ok || !payload || payload.ok !== true) {
throw new Error(payload && payload.message ? payload.message : 'knowledge_rag_delete_' + response.status);
}
await loadKnowledgeRagStatus(true);
notifyKnowledgeRagSourceUpdated({ action: 'delete', sourceRootRelativePath: sourcePath, result: payload });
} catch (error) {
pageUiState.knowledgeRagSummary = Object.assign({}, currentKnowledgeRagSummary(), {
scopeKey: knowledgeRagScopeKey(),
loading: false,
error: error instanceof Error ? error.message : String(error)
});
renderKnowledgeRagSettings();
}
}
async function pruneKnowledgeRagRegistry() {
if (!knowledgeRagIsAvailable()) return;
pageUiState.knowledgeRagSummary = Object.assign({}, currentKnowledgeRagSummary(), {
scopeKey: knowledgeRagScopeKey(),
loading: true,
error: ''
});
renderKnowledgeRagSettings();
try {
var response = await fetch('/api/knowledge-rag/prune-registry', {
method: 'POST',
headers: { 'content-type': 'application/json', accept: 'application/json' },
body: JSON.stringify({
workspaceId: resolveWorkspaceId(document.body),
rootUri: currentRootUri()
})
});
var payload = await response.json().catch(function(){ return null; });
if (!response.ok || !payload || payload.ok !== true) {
throw new Error(payload && payload.message ? payload.message : 'knowledge_rag_prune_' + response.status);
}
await loadKnowledgeRagStatus(true);
notifyKnowledgeRagSourceUpdated({ action: 'prune', result: payload });
} catch (error) {
pageUiState.knowledgeRagSummary = Object.assign({}, currentKnowledgeRagSummary(), {
scopeKey: knowledgeRagScopeKey(),
loading: false,
error: error instanceof Error ? error.message : String(error)
});
renderKnowledgeRagSettings();
}
}
function openKnowledgeRagDashboard() {
var status = currentKnowledgeRagSummary().status || {};
var url = publicKnowledgeRagDashboardUrl(status);
if (!url) return;
window.open(url, '_blank', 'noopener');
}
function knowledgeRagInputStatusText(status) {
if (status === 'running') return '索引中';
if (status === 'done') return '已提交';
if (status === 'retry') return '需重试';
if (status === 'failed') return '失败';
return '待索引';
}
function setKnowledgeRagInputStatuses(statusByPath, fallbackStatus) {
var popover = ensureKnowledgeRagSettingsPopover();
Array.from(popover.querySelectorAll('[data-knowledge-rag-source-input-row]')).forEach(function(row) {
var input = row.querySelector('[data-knowledge-rag-source-input]');
var label = row.querySelector('[data-knowledge-rag-input-status-label]');
if (!(input instanceof HTMLInputElement) || !(label instanceof HTMLElement)) return;
var value = input.value.trim();
var status = statusByPath && statusByPath[value] || fallbackStatus || 'idle';
label.textContent = knowledgeRagInputStatusText(status);
label.setAttribute('data-status', status);
label.hidden = status === 'idle';
});
}
function renderPageSettingsPopover() {
var popover = ensurePageSettingsPopover();
var options = currentPageOptions();
@@ -1222,14 +1913,20 @@ export function createSidebarPageSettingsRuntime(context) {
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();
return isPageSettingsOpen() || isLocalIndexSettingsOpen() || isLocalOcrSettingsOpen() || isKnowledgeRagSettingsOpen();
}
function openPageSettingsPopover(initialTab) {
if (!currentDocumentId()) return;
closeLocalIndexSettingsPopover();
closeLocalOcrSettingsPopover();
closeKnowledgeRagSettingsPopover();
var popover = ensurePageSettingsPopover();
renderPageSettingsPopover();
setActivePageSettingsTab(initialTab || 'page');
@@ -1241,6 +1938,7 @@ export function createSidebarPageSettingsRuntime(context) {
function openPageIndexSettingsPopover() {
closePageSettingsPopover();
closeLocalOcrSettingsPopover();
closeKnowledgeRagSettingsPopover();
var popover = ensureLocalIndexSettingsPopover();
renderPageSettingsLocalIndex(popover);
popover.hidden = false;
@@ -1249,11 +1947,21 @@ export function createSidebarPageSettingsRuntime(context) {
}
function openLocalOcrSettingsPopover() {
openKnowledgeRagSettingsPopover();
}
function openKnowledgeRagSettingsPopover() {
closePageSettingsPopover();
closeLocalIndexSettingsPopover();
var popover = ensureLocalOcrSettingsPopover();
renderLocalOcrOptions(popover);
closeLocalOcrSettingsPopover();
var popover = ensureKnowledgeRagSettingsPopover();
renderKnowledgeRagSettings(popover);
popover.hidden = false;
void loadKnowledgeRagStatus(false).then(function() {
if (knowledgeRagStatusHasInFlight(currentKnowledgeRagSummary().status || {})) {
scheduleKnowledgeRagStatusBridge('open-panel');
}
});
updateStandaloneSettingsTriggerState();
}
@@ -1276,10 +1984,17 @@ export function createSidebarPageSettingsRuntime(context) {
updateStandaloneSettingsTriggerState();
}
function closeKnowledgeRagSettingsPopover() {
var popover = document.querySelector('[data-testid="mnote-knowledge-rag-settings-popover"]');
if (popover instanceof HTMLElement) popover.hidden = true;
updateStandaloneSettingsTriggerState();
}
function closeAllSettingsPopovers() {
closePageSettingsPopover();
closeLocalIndexSettingsPopover();
closeLocalOcrSettingsPopover();
closeKnowledgeRagSettingsPopover();
}
function togglePageSettingsPopover() {
@@ -1295,27 +2010,36 @@ export function createSidebarPageSettingsRuntime(context) {
openLocalOcrSettingsPopover();
});
window.addEventListener('mnote:knowledge-rag-source-updated', function() {
if (isKnowledgeRagSettingsOpen()) void loadKnowledgeRagStatus(true);
});
return {
addLocalIndexRange,
applyPageOptionsToShell,
closeAllSettingsPopovers,
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,
@@ -1331,6 +2055,16 @@ export function createSidebarPageSettingsRuntime(context) {
renderPageSettingsPopover,
setActivePageSettingsTab,
refreshLocalIndex,
addKnowledgeRagSourceInput,
deleteKnowledgeRagSource,
ingestKnowledgeRagSources,
ingestSingleKnowledgeRagSource,
loadKnowledgeRagStatus,
openKnowledgeRagDashboard,
pruneKnowledgeRagRegistry,
removeKnowledgeRagSourceInput,
setKnowledgeRagSourceFilter,
useKnowledgeRagFileTreeSelection,
togglePageSettingsPopover,
updatePageSettingsTriggerState,
writeGlobalShowHeadingNumbers,
@@ -1163,7 +1163,7 @@ export const createSidebarTreeLiveApplyRuntime = (dependencies = {}) => {
var ownerDocumentId = fileOwnerDocumentId(item, documentId, objectIdentity);
var iconKind = iconKindOf(item);
var indexStatus = String((item && item.indexStatus) || (item && item.resourceMeta && item.resourceMeta.indexStatus) || '').trim();
if (indexStatus !== 'indexed' && indexStatus !== 'failed') indexStatus = '';
if (!['indexed', 'indexing', 'failed'].includes(indexStatus)) indexStatus = '';
var cachedChildren = relativePath ? cachedFileTreeRows(relativePath) : [];
var title = isFileTreeProjectionPageRow(rowKind, assetId)
? fileTreePageTitle(rawTitle)
@@ -210,14 +210,25 @@ import { createSidebarPageSettingsRuntime } from './sidebar-page-settings-runtim
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);
const addLocalIndexRange = (...args) => sidebarPageSettings.addLocalIndexRange(...args);
const addKnowledgeRagSourceInput = (...args) => sidebarPageSettings.addKnowledgeRagSourceInput(...args);
const deleteKnowledgeRagSource = (...args) => sidebarPageSettings.deleteKnowledgeRagSource(...args);
const ingestKnowledgeRagSources = (...args) => sidebarPageSettings.ingestKnowledgeRagSources(...args);
const ingestSingleKnowledgeRagSource = (...args) => sidebarPageSettings.ingestSingleKnowledgeRagSource(...args);
const loadKnowledgeRagStatus = (...args) => sidebarPageSettings.loadKnowledgeRagStatus(...args);
const openKnowledgeRagDashboard = (...args) => sidebarPageSettings.openKnowledgeRagDashboard(...args);
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);
const refreshLocalIndex = (...args) => sidebarPageSettings.refreshLocalIndex(...args);
const removeKnowledgeRagSourceInput = (...args) => sidebarPageSettings.removeKnowledgeRagSourceInput(...args);
const removeLocalIndexRange = (...args) => sidebarPageSettings.removeLocalIndexRange(...args);
const recordPageHistorySnapshot = (...args) => sidebarPageSettings.recordPageHistorySnapshot(...args);
const renderPageSettingsPopover = (...args) => sidebarPageSettings.renderPageSettingsPopover(...args);
@@ -1919,6 +1930,9 @@ import { createSidebarPageSettingsRuntime } from './sidebar-page-settings-runtim
window.addEventListener('wolai:local-assets-changed', function(event) {
applyAssetsChangedToFileTree(event.detail || {});
});
window.addEventListener('mnote:knowledge-rag-source-updated', function() {
if (currentSourceKind() === 'local_folder') void refreshLocalFolderSidebarSnapshot();
});
installMindmapAssetFetchObserver();
async function uploadFilesWithResolvedTarget(files, detail, options) {
@@ -2670,23 +2684,28 @@ import { createSidebarPageSettingsRuntime } from './sidebar-page-settings-runtim
var indexSettingsTrigger = closestAction(e.target, '[data-mnote-action="open-index-settings"]');
if (indexSettingsTrigger) {
e.preventDefault();
openPageIndexSettingsPopover();
openKnowledgeRagSettingsPopover();
return;
}
var knowledgeRagSettingsTrigger = closestAction(e.target, '[data-mnote-action="open-knowledge-rag-settings"]');
if (knowledgeRagSettingsTrigger) {
e.preventDefault();
openKnowledgeRagSettingsPopover();
return;
}
var ocrTaskTrigger = closestAction(e.target, '[data-mnote-action="toggle-ocr-tasks"]');
if (ocrTaskTrigger) {
e.preventDefault();
window.dispatchEvent(new CustomEvent('mnote:local-ocr-settings-action', {
detail: { action: 'tasks' }
}));
openKnowledgeRagSettingsPopover();
return;
}
var ocrSettingsTrigger = closestAction(e.target, '[data-mnote-action="open-ocr-settings"]');
if (ocrSettingsTrigger) {
e.preventDefault();
openLocalOcrSettingsPopover();
openKnowledgeRagSettingsPopover();
return;
}
@@ -2742,6 +2761,52 @@ import { createSidebarPageSettingsRuntime } from './sidebar-page-settings-runtim
return;
}
var knowledgeRagAction = closestAction(e.target, '[data-knowledge-rag-action]');
if (knowledgeRagAction) {
e.preventDefault();
var knowledgeRagActionName = knowledgeRagAction.getAttribute('data-knowledge-rag-action') || '';
if (knowledgeRagActionName === 'refresh') {
void loadKnowledgeRagStatus(true);
return;
}
if (knowledgeRagActionName === 'add-source') {
addKnowledgeRagSourceInput();
return;
}
if (knowledgeRagActionName === 'remove-source') {
removeKnowledgeRagSourceInput(knowledgeRagAction);
return;
}
if (knowledgeRagActionName === 'use-filetree-selection') {
useKnowledgeRagFileTreeSelection();
return;
}
if (knowledgeRagActionName === 'ingest') {
void ingestKnowledgeRagSources();
return;
}
if (knowledgeRagActionName === 'reindex-source') {
void ingestSingleKnowledgeRagSource(knowledgeRagAction.getAttribute('data-knowledge-rag-source-path') || '');
return;
}
if (knowledgeRagActionName === 'delete-source') {
void deleteKnowledgeRagSource(knowledgeRagAction.getAttribute('data-knowledge-rag-source-path') || '');
return;
}
if (knowledgeRagActionName === 'prune') {
void pruneKnowledgeRagRegistry();
return;
}
if (knowledgeRagActionName === 'filter-sources') {
setKnowledgeRagSourceFilter(knowledgeRagAction.getAttribute('data-knowledge-rag-filter') || 'attention');
return;
}
if (knowledgeRagActionName === 'open-dashboard') {
openKnowledgeRagDashboard();
return;
}
}
var pageSettingsAction = closestAction(e.target, '[data-page-settings-action]');
if (pageSettingsAction) {
e.preventDefault();
+26 -518
View File
@@ -1,17 +1,12 @@
use core_protocol::{
EvidenceBBox, EvidenceRange, ParsedResourceArtifact, ResourceSourceMap, SourceMapBlock,
SourceMapBlockKind, SourceMapPage, SourceMapSection, SourceMapTextItem,
PARSED_RESOURCE_ARTIFACT_SCHEMA, RESOURCE_SOURCE_MAP_SCHEMA,
ParsedResourceArtifact, ResourceSourceMap, SourceMapBlock, SourceMapBlockKind, SourceMapPage,
SourceMapSection, PARSED_RESOURCE_ARTIFACT_SCHEMA, RESOURCE_SOURCE_MAP_SCHEMA,
};
use serde_json::{json, Value};
use std::env;
use std::fs;
use std::future::Future;
use std::path::{Path, PathBuf};
use std::pin::Pin;
use std::time::UNIX_EPOCH;
use std::{env::split_paths, process::Command as StdCommand};
use tokio::process::Command;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ParseCapability {
@@ -101,14 +96,8 @@ impl ParseProvider for LiteParseProvider {
}
fn can_parse(&self, input: &ParseInput) -> ParseCapability {
if is_liteparse_path(&input.source_root_relative_path) {
match input.mode {
ParseProviderMode::Ocr => ParseCapability::Unsupported,
ParseProviderMode::Auto | ParseProviderMode::NoOcr => ParseCapability::Preferred,
}
} else {
ParseCapability::Unsupported
}
let _ = input;
ParseCapability::Unsupported
}
fn parse<'a>(
@@ -122,12 +111,8 @@ impl ParseProvider for LiteParseProvider {
pub fn default_parse_provider_id(input: &ParseInput) -> &'static str {
if is_markdown_path(&input.source_root_relative_path) {
"markdown"
} else if is_liteparse_path(&input.source_root_relative_path)
&& !matches!(input.mode, ParseProviderMode::Ocr)
{
"liteparse"
} else {
"mineru"
"lightrag"
}
}
@@ -138,153 +123,16 @@ pub fn select_parse_provider_id(
if is_markdown_path(&input.source_root_relative_path) {
return "markdown";
}
if is_liteparse_path(&input.source_root_relative_path) {
if matches!(input.mode, ParseProviderMode::Ocr) {
return "mineru";
}
if native_text_confidence.is_some_and(|confidence| confidence < 0.2) {
return "mineru";
}
return "liteparse";
}
"mineru"
let _ = native_text_confidence;
"lightrag"
}
async fn parse_liteparse_input(input: ParseInput) -> Result<ParseProviderOutput, ParseError> {
let source_path = input.root_path.join(&input.source_root_relative_path);
let metadata = read_liteparse_source_metadata(&source_path)?;
let bytes = read_liteparse_source_bytes(&source_path)?;
let output = Command::new(liteparse_bin())
.arg("parse")
.arg("--format")
.arg("json")
.arg("--no-ocr")
.arg("-q")
.arg(&source_path)
.output()
.await
.map_err(|error| {
ParseError::new(
"liteparse_command_failed",
format!("LiteParse 命令执行失败: {error}"),
)
})?;
parse_liteparse_command_output(input, metadata, bytes, output)
}
pub(crate) fn parse_liteparse_input_blocking(
input: ParseInput,
) -> Result<ParseProviderOutput, ParseError> {
let source_path = input.root_path.join(&input.source_root_relative_path);
let metadata = read_liteparse_source_metadata(&source_path)?;
let bytes = read_liteparse_source_bytes(&source_path)?;
let output = StdCommand::new(liteparse_bin())
.arg("parse")
.arg("--format")
.arg("json")
.arg("--no-ocr")
.arg("-q")
.arg(&source_path)
.output()
.map_err(|error| {
ParseError::new(
"liteparse_command_failed",
format!("LiteParse 命令执行失败: {error}"),
)
})?;
parse_liteparse_command_output(input, metadata, bytes, output)
}
pub(crate) fn liteparse_runtime_available() -> bool {
if let Ok(value) = env::var("MNOTE_LITEPARSE_BIN") {
let trimmed = value.trim();
if trimmed.is_empty() {
return false;
}
let path = Path::new(trimmed);
return path.components().count() > 1 && path.exists() || command_exists(trimmed);
}
command_exists("lit") || command_exists("liteparse")
}
fn read_liteparse_source_metadata(source_path: &Path) -> Result<fs::Metadata, ParseError> {
fs::metadata(source_path).map_err(|error| {
ParseError::new(
"liteparse_parse_stat_failed",
format!(
"无法读取 LiteParse 证据源状态 {}: {error}",
source_path.display()
),
)
})
}
fn read_liteparse_source_bytes(source_path: &Path) -> Result<Vec<u8>, ParseError> {
fs::read(source_path).map_err(|error| {
ParseError::new(
"liteparse_parse_read_failed",
format!(
"无法读取 LiteParse 证据源 {}: {error}",
source_path.display()
),
)
})
}
fn parse_liteparse_command_output(
input: ParseInput,
metadata: fs::Metadata,
bytes: Vec<u8>,
output: std::process::Output,
) -> Result<ParseProviderOutput, ParseError> {
let updated_at_ms = metadata
.modified()
.ok()
.and_then(|value| value.duration_since(UNIX_EPOCH).ok())
.map(|value| value.as_millis() as u64)
.unwrap_or_default();
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(ParseError::new(
"liteparse_command_failed",
format!("LiteParse 解析失败: {}", stderr.trim()),
));
}
let stdout = String::from_utf8(output.stdout).map_err(|error| {
ParseError::new(
"liteparse_output_utf8_invalid",
format!("LiteParse 输出不是 UTF-8: {error}"),
)
})?;
let value = serde_json::from_str::<Value>(&stdout).map_err(|error| {
ParseError::new(
"liteparse_output_json_invalid",
format!("LiteParse JSON 输出无效: {error}"),
)
})?;
let source_hash = binary_source_hash(&bytes, metadata.len(), updated_at_ms);
let markdown = liteparse_markdown(&value);
let source_map = liteparse_source_map(&input, &value, &source_hash);
let artifact = ParsedResourceArtifact {
schema: PARSED_RESOURCE_ARTIFACT_SCHEMA.into(),
provider: "liteparse".into(),
model_version: liteparse_version(&value),
owner_document_id: input.owner_document_id,
owner_document_path: input.owner_document_path,
source_root_relative_path: input.source_root_relative_path.clone(),
source_hash,
artifact_root_relative_path: format!("{}.parse.md", input.source_root_relative_path),
source_map_root_relative_path: format!(
"{}.source-map.json",
input.source_root_relative_path
),
updated_at_ms,
};
Ok(ParseProviderOutput {
artifact,
source_map,
markdown,
})
let _ = input;
Err(ParseError::new(
"liteparse_retired",
"LiteParse 已退役;PDF、Office、图片 OCR 与资料索引统一由 LightRAG provider 处理",
))
}
fn parse_markdown_input(input: ParseInput) -> Result<ParseProviderOutput, ParseError> {
@@ -337,270 +185,6 @@ fn parse_markdown_input(input: ParseInput) -> Result<ParseProviderOutput, ParseE
})
}
fn liteparse_bin() -> String {
env::var("MNOTE_LITEPARSE_BIN")
.ok()
.filter(|value| !value.trim().is_empty())
.unwrap_or_else(|| {
if command_exists("lit") {
"lit".into()
} else {
"liteparse".into()
}
})
}
fn command_exists(command: &str) -> bool {
let path = Path::new(command);
if path.components().count() > 1 {
return path.exists();
}
let Some(paths) = env::var_os("PATH") else {
return false;
};
split_paths(&paths).any(|base| base.join(command).exists())
}
fn liteparse_version(value: &Value) -> Option<String> {
value
.get("version")
.or_else(|| value.get("modelVersion"))
.or_else(|| value.get("model_version"))
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
.or_else(|| Some("liteparse-v2".into()))
}
fn liteparse_markdown(value: &Value) -> String {
for key in ["markdown", "text", "content"] {
if let Some(text) = value.get(key).and_then(Value::as_str) {
let text = text.trim();
if !text.is_empty() {
return text.to_string();
}
}
}
let lines = liteparse_pages(value)
.into_iter()
.flat_map(|(_, page)| liteparse_blocks(&page))
.filter_map(|block| liteparse_item_text(&block))
.collect::<Vec<_>>();
lines.join("\n\n")
}
fn liteparse_source_map(input: &ParseInput, value: &Value, source_hash: &str) -> ResourceSourceMap {
let mut pages = Vec::new();
let mut sections = Vec::new();
let mut section_path = Vec::<String>::new();
let mut global_char = 0_u64;
for (page_index, page_value) in liteparse_pages(value) {
let page_number = liteparse_page_number(&page_value).unwrap_or(page_index);
let blocks = liteparse_blocks(&page_value);
let mut source_blocks = Vec::new();
let mut text_items = Vec::new();
for (block_index, block) in blocks.iter().enumerate() {
let Some(text) = liteparse_item_text(block) else {
continue;
};
let block_id = liteparse_item_id(block)
.unwrap_or_else(|| format!("p{page_number}_b{}", block_index + 1));
let text_item_id = format!("p{page_number}_t{}", block_index + 1);
let block_type = liteparse_block_kind(block);
let char_range = EvidenceRange {
start: global_char,
end: global_char + text.chars().count() as u64,
};
global_char = char_range.end + 1;
let bbox = liteparse_bbox(block);
if matches!(block_type, SourceMapBlockKind::Heading) {
let level = liteparse_heading_level(block).unwrap_or(1).max(1);
section_path.truncate(level.saturating_sub(1));
section_path.push(text.clone());
sections.push(SourceMapSection {
id: format!(
"sec_{}",
stable_segment(&format!(
"{}:{}:{}",
input.source_root_relative_path,
page_number,
section_path.join("/")
))
),
title: text.clone(),
path: section_path.clone(),
page_start: Some(page_number),
page_end: Some(page_number),
block_ids: vec![block_id.clone()],
});
} else if let Some(section) = sections.last_mut() {
section.page_end = Some(page_number);
if !section.block_ids.iter().any(|id| id == &block_id) {
section.block_ids.push(block_id.clone());
}
}
text_items.push(SourceMapTextItem {
id: text_item_id,
text: text.clone(),
bbox: bbox.clone(),
char_range: Some(char_range.clone()),
});
source_blocks.push(SourceMapBlock {
id: block_id,
block_type,
text,
bbox,
char_range: Some(char_range),
});
}
pages.push(SourceMapPage {
page: page_number,
width: liteparse_number(&page_value, &["width", "pageWidth"]),
height: liteparse_number(&page_value, &["height", "pageHeight"]),
text_items,
blocks: source_blocks,
});
}
ResourceSourceMap {
schema: RESOURCE_SOURCE_MAP_SCHEMA.into(),
provider: "liteparse".into(),
model_version: liteparse_version(value),
owner_document_path: input.owner_document_path.clone(),
source_root_relative_path: input.source_root_relative_path.clone(),
source_hash: source_hash.to_string(),
page_count: pages.iter().map(|page| page.page).max(),
pages,
sections,
}
}
fn liteparse_pages(value: &Value) -> Vec<(u32, Value)> {
if let Some(pages) = value.get("pages").and_then(Value::as_array) {
return pages
.iter()
.enumerate()
.map(|(index, page)| ((index + 1) as u32, page.clone()))
.collect();
}
vec![(1, json!({ "blocks": liteparse_blocks(value) }))]
}
fn liteparse_blocks(page: &Value) -> Vec<Value> {
for key in ["blocks", "items", "textItems", "text_items", "elements"] {
if let Some(items) = page.get(key).and_then(Value::as_array) {
return items.clone();
}
}
if let Some(text) = liteparse_item_text(page) {
return vec![json!({ "text": text })];
}
Vec::new()
}
fn liteparse_item_text(value: &Value) -> Option<String> {
for key in ["text", "content", "markdown", "value"] {
if let Some(text) = value.get(key).and_then(Value::as_str) {
let trimmed = text.trim();
if !trimmed.is_empty() {
return Some(trimmed.to_string());
}
}
}
None
}
fn liteparse_item_id(value: &Value) -> Option<String> {
value
.get("id")
.or_else(|| value.get("blockId"))
.or_else(|| value.get("block_id"))
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
}
fn liteparse_page_number(value: &Value) -> Option<u32> {
for key in ["page", "pageNumber", "page_number", "page_no", "pageNo"] {
if let Some(page) = value.get(key).and_then(Value::as_u64) {
return u32::try_from(page.max(1)).ok();
}
}
if let Some(page_idx) = value.get("page_idx").and_then(Value::as_u64) {
return u32::try_from(page_idx + 1).ok();
}
None
}
fn liteparse_block_kind(value: &Value) -> SourceMapBlockKind {
match value
.get("type")
.or_else(|| value.get("blockType"))
.or_else(|| value.get("block_type"))
.and_then(Value::as_str)
.unwrap_or_default()
.to_ascii_lowercase()
.as_str()
{
"title" | "heading" | "header" => SourceMapBlockKind::Heading,
"table" => SourceMapBlockKind::Table,
"figure" => SourceMapBlockKind::Figure,
"image" => SourceMapBlockKind::Image,
"list" => SourceMapBlockKind::List,
"text" | "paragraph" => SourceMapBlockKind::Paragraph,
_ => SourceMapBlockKind::Text,
}
}
fn liteparse_heading_level(value: &Value) -> Option<usize> {
value
.get("level")
.or_else(|| value.pointer("/props/level"))
.and_then(Value::as_u64)
.map(|value| value as usize)
}
fn liteparse_bbox(value: &Value) -> Option<EvidenceBBox> {
if let Some(bbox) = value
.get("bbox")
.or_else(|| value.get("boundingBox"))
.or_else(|| value.get("bounding_box"))
.and_then(Value::as_array)
{
if bbox.len() != 4 {
return None;
}
return Some(EvidenceBBox {
x0: bbox[0].as_f64()?,
y0: bbox[1].as_f64()?,
x1: bbox[2].as_f64()?,
y1: bbox[3].as_f64()?,
});
}
let x = value.get("x").and_then(Value::as_f64)?;
let y = value.get("y").and_then(Value::as_f64)?;
let width = value
.get("width")
.or_else(|| value.get("w"))
.and_then(Value::as_f64)?;
let height = value
.get("height")
.or_else(|| value.get("h"))
.and_then(Value::as_f64)?;
Some(EvidenceBBox {
x0: x,
y0: y,
x1: x + width,
y1: y + height,
})
}
fn liteparse_number(value: &Value, keys: &[&str]) -> Option<f64> {
keys.iter()
.find_map(|key| value.get(*key).and_then(Value::as_f64))
}
fn markdown_source_map(input: &ParseInput, markdown: &str, source_hash: &str) -> ResourceSourceMap {
let mut section_path: Vec<String> = Vec::new();
let mut sections = Vec::new();
@@ -686,18 +270,6 @@ fn is_markdown_path(path: &str) -> bool {
.is_some_and(|ext| ext.eq_ignore_ascii_case("md") || ext.eq_ignore_ascii_case("markdown"))
}
fn is_liteparse_path(path: &str) -> bool {
Path::new(path)
.extension()
.and_then(|value| value.to_str())
.is_some_and(|ext| {
matches!(
ext.to_ascii_lowercase().as_str(),
"pdf" | "doc" | "docx" | "odt" | "ppt" | "pptx" | "odp" | "xls" | "xlsx" | "ods"
)
})
}
fn source_hash(content: &str, size: u64, updated_at_ms: u64) -> String {
format!(
"fnv1a64:{:016x}:size:{size}:mtime:{updated_at_ms}",
@@ -705,13 +277,6 @@ fn source_hash(content: &str, size: u64, updated_at_ms: u64) -> String {
)
}
fn binary_source_hash(content: &[u8], size: u64, updated_at_ms: u64) -> String {
format!(
"fnv1a64:{:016x}:size:{size}:mtime:{updated_at_ms}",
fnv1a64(content)
)
}
fn fnv1a64(bytes: &[u8]) -> u64 {
let mut hash = 0xcbf29ce484222325_u64;
for byte in bytes {
@@ -728,12 +293,6 @@ fn stable_segment(value: &str) -> String {
#[cfg(test)]
mod tests {
use super::*;
use std::sync::{Mutex, OnceLock};
fn env_lock() -> &'static Mutex<()> {
static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
LOCK.get_or_init(|| Mutex::new(()))
}
fn temp_root(name: &str) -> PathBuf {
let root = std::env::temp_dir().join(format!("{name}-{}", std::process::id()));
@@ -781,7 +340,7 @@ mod tests {
}
#[test]
fn default_provider_routes_text_pdf_to_liteparse_no_ocr() {
fn default_provider_routes_pdf_to_lightrag_after_liteparse_retirement() {
let input = ParseInput {
root_path: PathBuf::from("/workspace"),
root_uri: "file:///workspace".into(),
@@ -791,16 +350,13 @@ mod tests {
mode: ParseProviderMode::NoOcr,
};
let liteparse = LiteParseProvider;
assert_eq!(default_parse_provider_id(&input), "liteparse");
assert_eq!(liteparse.can_parse(&input), ParseCapability::Preferred);
assert_eq!(default_parse_provider_id(&input), "lightrag");
assert_eq!(liteparse.can_parse(&input), ParseCapability::Unsupported);
}
#[tokio::test]
#[cfg(unix)]
async fn liteparse_provider_maps_json_output_to_artifact_and_source_map() {
use std::os::unix::fs::PermissionsExt;
let _guard = env_lock().lock().expect("env lock");
async fn liteparse_provider_returns_retired_error() {
let root = temp_root("mnote-liteparse-provider");
fs::create_dir_all(root.join("docs").join("Page.assets")).expect("assets");
fs::write(
@@ -808,32 +364,6 @@ mod tests {
b"%PDF-1.4",
)
.expect("pdf");
let lit = root.join("fake-lit");
fs::write(
&lit,
r#"#!/usr/bin/env bash
cat <<'JSON'
{
"version": "liteparse-test",
"pages": [{
"page": 3,
"width": 612,
"height": 792,
"blocks": [
{"id": "b1", "type": "heading", "level": 1, "text": "解除条件", "bbox": [72, 120, 220, 150]},
{"id": "b2", "type": "text", "text": "提前三十日通知", "x": 72, "y": 160, "width": 228, "height": 28}
]
}]
}
JSON
"#,
)
.expect("fake lit");
let mut permissions = fs::metadata(&lit).expect("fake lit metadata").permissions();
permissions.set_mode(0o755);
fs::set_permissions(&lit, permissions).expect("chmod fake lit");
let old_bin = env::var("MNOTE_LITEPARSE_BIN").ok();
env::set_var("MNOTE_LITEPARSE_BIN", &lit);
let input = ParseInput {
root_path: root.clone(),
@@ -846,40 +376,15 @@ JSON
let output = LiteParseProvider
.parse(input)
.await
.expect("parse liteparse");
.expect_err("liteparse retired");
if let Some(value) = old_bin {
env::set_var("MNOTE_LITEPARSE_BIN", value);
} else {
env::remove_var("MNOTE_LITEPARSE_BIN");
}
assert_eq!(output.artifact.provider, "liteparse");
assert_eq!(
output.artifact.model_version.as_deref(),
Some("liteparse-test")
);
assert!(output.artifact.source_hash.starts_with("fnv1a64:"));
assert_eq!(output.markdown, "解除条件\n\n提前三十日通知");
assert_eq!(output.source_map.provider, "liteparse");
assert_eq!(output.source_map.pages[0].page, 3);
assert_eq!(
output.source_map.pages[0].blocks[1]
.bbox
.as_ref()
.unwrap()
.x0,
72.0
);
assert_eq!(
output.source_map.sections[0].path,
vec!["解除条件".to_string()]
);
assert_eq!(output.code, "liteparse_retired");
let _ = fs::remove_dir_all(&root);
}
#[test]
fn default_provider_routes_ocr_policy_to_mineru() {
fn default_provider_routes_ocr_policy_to_lightrag() {
let input = ParseInput {
root_path: PathBuf::from("/workspace"),
root_uri: "file:///workspace".into(),
@@ -888,11 +393,11 @@ JSON
source_root_relative_path: "Page.assets/spec.pdf".into(),
mode: ParseProviderMode::Ocr,
};
assert_eq!(default_parse_provider_id(&input), "mineru");
assert_eq!(default_parse_provider_id(&input), "lightrag");
}
#[test]
fn provider_selection_routes_scanned_pdf_or_image_to_mineru() {
fn provider_selection_routes_scanned_pdf_or_image_to_lightrag() {
let scanned_pdf = ParseInput {
root_path: PathBuf::from("/workspace"),
root_uri: "file:///workspace".into(),
@@ -901,12 +406,15 @@ JSON
source_root_relative_path: "Page.assets/scan.pdf".into(),
mode: ParseProviderMode::Auto,
};
assert_eq!(select_parse_provider_id(&scanned_pdf, Some(0.05)), "mineru");
assert_eq!(
select_parse_provider_id(&scanned_pdf, Some(0.05)),
"lightrag"
);
let image = ParseInput {
source_root_relative_path: "Page.assets/photo.png".into(),
..scanned_pdf
};
assert_eq!(select_parse_provider_id(&image, None), "mineru");
assert_eq!(select_parse_provider_id(&image, None), "lightrag");
}
}
@@ -0,0 +1,157 @@
use crate::app::AppState;
use crate::context::RequestContext;
use crate::error::WebError;
use crate::hermes_tools::ToolCallInput;
use crate::routes::knowledge_rag::{
KnowledgeRagOpenReferenceRequest, KnowledgeRagQueryRequest, KnowledgeRagStatusQuery,
};
use axum::extract::{Extension, Json, Query, State};
use serde_json::{json, Value};
pub async fn status(
state: &AppState,
context: &RequestContext,
input: &ToolCallInput,
) -> Result<Value, WebError> {
let args = input.args.clone().unwrap_or_else(|| json!({}));
let body = KnowledgeRagStatusQuery {
workspace_id: input.effective_workspace_id().or_else(|| {
args.get("workspaceId")
.and_then(Value::as_str)
.map(ToOwned::to_owned)
}),
root_uri: input.effective_root_uri().or_else(|| {
args.get("rootUri")
.and_then(Value::as_str)
.map(ToOwned::to_owned)
}),
};
let Json(payload) = crate::routes::knowledge_rag::status(
State(state.clone()),
Extension(context.clone()),
Query(body),
)
.await?;
Ok(payload)
}
pub async fn query(
state: &AppState,
context: &RequestContext,
input: &ToolCallInput,
) -> Result<Value, WebError> {
let mut args = input.args.clone().unwrap_or_else(|| json!({}));
if args.get("workspaceId").is_none() {
if let Some(workspace_id) = input.effective_workspace_id() {
args["workspaceId"] = json!(workspace_id);
}
}
if args.get("rootUri").is_none() {
if let Some(root_uri) = input.effective_root_uri() {
args["rootUri"] = json!(root_uri);
}
}
let body = serde_json::from_value::<KnowledgeRagQueryRequest>(args).map_err(|error| {
WebError::bad_request_code(
"mnote_knowledge_rag_query_payload_invalid",
format!("资料库问答参数无效: {error}"),
)
.with_context(context)
})?;
let Json(payload) = crate::routes::knowledge_rag::query_rag(
State(state.clone()),
Extension(context.clone()),
Json(body),
)
.await?;
Ok(compact_query_result_for_agent(payload))
}
pub async fn open_reference(
state: &AppState,
context: &RequestContext,
input: &ToolCallInput,
) -> Result<Value, WebError> {
let mut args = input.args.clone().unwrap_or_else(|| json!({}));
if args.get("workspaceId").is_none() {
if let Some(workspace_id) = input.effective_workspace_id() {
args["workspaceId"] = json!(workspace_id);
}
}
if args.get("rootUri").is_none() {
if let Some(root_uri) = input.effective_root_uri() {
args["rootUri"] = json!(root_uri);
}
}
let body =
serde_json::from_value::<KnowledgeRagOpenReferenceRequest>(args).map_err(|error| {
WebError::bad_request_code(
"mnote_knowledge_rag_open_reference_payload_invalid",
format!("资料库引用打开参数无效: {error}"),
)
.with_context(context)
})?;
let Json(payload) = crate::routes::knowledge_rag::open_reference(
State(state.clone()),
Extension(context.clone()),
Json(body),
)
.await?;
Ok(payload)
}
fn compact_query_result_for_agent(payload: Value) -> Value {
let references = payload
.get("references")
.and_then(Value::as_array)
.map(|items| {
items
.iter()
.take(8)
.map(compact_reference_for_agent)
.collect::<Vec<_>>()
})
.unwrap_or_default();
let citations = references
.iter()
.filter_map(|reference| {
reference
.get("citationMarkdown")
.and_then(Value::as_str)
.filter(|value| !value.trim().is_empty())
.map(ToOwned::to_owned)
})
.collect::<Vec<_>>();
json!({
"ok": payload.get("ok").cloned().unwrap_or_else(|| json!(true)),
"schema": "mnote.knowledge_rag.agent_query_result.v1",
"provider": payload.get("provider").cloned().unwrap_or_else(|| json!("lightrag")),
"answerGuidance": "Final answers must cite at least one returned citationMarkdown verbatim. If locatorDegraded is true, say the source location is degraded instead of inventing page or bbox.",
"references": references,
"citations": citations,
"sourceScope": payload.get("sourceScope").cloned().unwrap_or(Value::Array(Vec::new())),
"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),
})
}
fn compact_reference_for_agent(reference: &Value) -> Value {
let quote = reference
.get("quote")
.and_then(Value::as_str)
.map(|value| value.chars().take(700).collect::<String>())
.unwrap_or_default();
json!({
"schema": reference.get("schema").cloned().unwrap_or_else(|| json!("mnote.knowledge_rag.reference.v1")),
"provider": reference.get("provider").cloned().unwrap_or_else(|| json!("lightrag")),
"filePath": reference.get("filePath").cloned().unwrap_or(Value::Null),
"sourceRootRelativePath": reference.get("sourceRootRelativePath").cloned().unwrap_or(Value::Null),
"chunkId": reference.get("chunkId").cloned().unwrap_or(Value::Null),
"quote": quote,
"locator": reference.get("locator").cloned().unwrap_or(Value::Null),
"locatorDegraded": reference.get("locatorDegraded").cloned().unwrap_or(Value::Bool(true)),
"citationMarkdown": reference.get("citationMarkdown").cloned().unwrap_or(Value::Null),
"citationUrl": reference.get("citationUrl").cloned().unwrap_or(Value::Null),
})
}
@@ -12,12 +12,9 @@ pub fn manifest() -> Value {
context_resolve_target_tool(),
doc_fetch_tool(),
doc_find_tool(),
evidence_search_tool(),
evidence_read_tool(),
evidence_open_tool(),
index_status_tool(),
index_refresh_tool(),
index_update_settings_tool(),
knowledge_rag_status_tool(),
knowledge_rag_query_tool(),
knowledge_rag_open_reference_tool(),
block_fetch_tool(),
doc_plan_update_tool(),
block_replace_tool(),
@@ -293,6 +290,7 @@ fn doc_find_tool() -> Value {
})
}
#[allow(dead_code)]
fn evidence_search_tool() -> Value {
let mut properties = base_identity_properties();
if let Value::Object(map) = &mut properties {
@@ -340,6 +338,7 @@ fn evidence_search_tool() -> Value {
})
}
#[allow(dead_code)]
fn evidence_read_tool() -> Value {
let mut properties = base_identity_properties();
if let Value::Object(map) = &mut properties {
@@ -371,6 +370,7 @@ fn evidence_read_tool() -> Value {
})
}
#[allow(dead_code)]
fn evidence_open_tool() -> Value {
let mut properties = base_identity_properties();
if let Value::Object(map) = &mut properties {
@@ -391,6 +391,93 @@ fn evidence_open_tool() -> Value {
})
}
fn knowledge_rag_status_tool() -> Value {
let mut properties = base_identity_properties();
if let Value::Object(map) = &mut properties {
map.insert("rootUri".into(), json!({ "type": "string" }));
}
json!({
"name": "mnote.knowledge_rag.status",
"description": "查看 LightRAG 资料库 provider 状态、dashboard 地址、source registry 和同步状态。",
"schemaVersion": TOOL_SCHEMA_VERSION,
"capabilityScope": ["knowledge_rag.read", "evidence.read"],
"status": "available",
"annotations": tool_annotations(true, false, true, false),
"inputSchema": {
"type": "object",
"properties": properties
}
})
}
fn knowledge_rag_query_tool() -> Value {
let mut properties = base_identity_properties();
if let Value::Object(map) = &mut properties {
map.insert("rootUri".into(), json!({ "type": "string" }));
map.insert("query".into(), json!({ "type": "string" }));
map.insert("question".into(), json!({ "type": "string" }));
map.insert(
"mode".into(),
json!({ "type": "string", "enum": ["local", "global", "hybrid", "naive", "mix", "bypass"], "default": "mix" }),
);
map.insert("topK".into(), json!({ "type": "integer", "default": 40 }));
map.insert(
"chunkTopK".into(),
json!({ "type": "integer", "default": 20 }),
);
map.insert(
"includeChunkContent".into(),
json!({ "type": "boolean", "default": true }),
);
map.insert(
"sourcePaths".into(),
json!({
"type": "array",
"items": { "type": "string" },
"description": "可选 MNote workspace 相对路径范围;可传文件或目录,返回 references 会限制在这些来源内。"
}),
);
}
json!({
"name": "mnote.knowledge_rag.query",
"description": "向 LightRAG 资料库提问,返回 provider 原始结果和经 MNote registry 映射后的引用。",
"schemaVersion": TOOL_SCHEMA_VERSION,
"capabilityScope": ["knowledge_rag.read", "evidence.read"],
"status": "available",
"annotations": tool_annotations(true, false, true, false),
"inputSchema": {
"type": "object",
"required": ["workspaceId", "rootUri", "query"],
"properties": properties
}
})
}
fn knowledge_rag_open_reference_tool() -> Value {
let mut properties = base_identity_properties();
if let Value::Object(map) = &mut properties {
map.insert("rootUri".into(), json!({ "type": "string" }));
map.insert("reference".into(), json!({ "type": "object" }));
map.insert("referenceId".into(), json!({ "type": "string" }));
map.insert("filePath".into(), json!({ "type": "string" }));
map.insert("chunkId".into(), json!({ "type": "string" }));
}
json!({
"name": "mnote.knowledge_rag.open_reference",
"description": "把 LightRAG reference 映射为 MNote 可打开的本地 resource action;没有 registry 命中时明确返回定位降级。",
"schemaVersion": TOOL_SCHEMA_VERSION,
"capabilityScope": ["knowledge_rag.read", "evidence.read"],
"status": "available",
"annotations": tool_annotations(true, false, true, false),
"inputSchema": {
"type": "object",
"required": ["workspaceId", "rootUri", "filePath"],
"properties": properties
}
})
}
#[allow(dead_code)]
fn index_status_tool() -> Value {
let mut properties = base_identity_properties();
if let Value::Object(map) = &mut properties {
@@ -411,6 +498,7 @@ fn index_status_tool() -> Value {
})
}
#[allow(dead_code)]
fn index_refresh_tool() -> Value {
let mut properties = base_identity_properties();
if let Value::Object(map) = &mut properties {
@@ -431,6 +519,7 @@ fn index_refresh_tool() -> Value {
})
}
#[allow(dead_code)]
fn index_update_settings_tool() -> Value {
let mut properties = base_identity_properties();
if let Value::Object(map) = &mut properties {
@@ -744,7 +833,7 @@ fn page_get_tool() -> Value {
fn page_save_tool() -> Value {
json!({
"name": "mnote.page.save",
"description": "粗粒度兼容兜底:保存当前页面正文;本地 Markdown 普通编辑优先使用 agent 原生 patch/diff,只有整页覆盖/追加且其它工具无法表达时使用",
"description": "粗粒度 compat / cloud 兜底:保存当前页面正文;local-first 本地 Markdown 普通编辑禁止使用该工具,必须优先让 agent 原生 patch/diff 直接编辑授权文件;仅在用户明确要求整页覆盖/追加且其它工具无法表达时使用",
"schemaVersion": TOOL_SCHEMA_VERSION,
"capabilityScope": ["page.write"],
"status": "available",
@@ -795,7 +884,7 @@ fn available_tool(
fn doc_markdown_edit_tool() -> Value {
let mut tool = write_tool(
"mnote.doc.markdown_edit",
"兼容 / 远端代理 fallback:通过文本级搜索替换编辑 markdown 内容。local-first 本地 workspace 默认优先让 agent 原生 patch/diff 直接编辑授权文件;仅在需要 MNote 兼容工具、远端代理或结构校验时使用。",
"compat / remote / cloud fallback:通过文本级搜索替换编辑 markdown 内容。local-first 本地 workspace 的普通 Markdown 编辑禁止使用该工具,必须优先让 agent 原生 patch/diff 直接编辑授权文件;仅在远端代理、cloud/compat 或需要 MNote 结构校验时使用。",
["block.write", "page.write"],
json!({
"operations": {
@@ -4,6 +4,7 @@ pub mod context_tools;
pub mod doc;
pub mod evidence;
pub mod index;
pub mod knowledge_rag;
pub mod manifest;
pub mod onlyoffice_live;
pub mod page;
+20 -24
View File
@@ -36,24 +36,22 @@ const CAPABILITY_PACKS: &[MnoteCapabilityPack] = &[
content: include_str!("../../../../../skills/mnote-current-page/SKILL.md"),
},
MnoteCapabilityPack {
id: "mnote-local-index",
title: "本地索引与证据检索",
description: "检索本地文档证据,并管理本地索引范围、刷新和删除。",
id: "mnote-knowledge-rag",
title: "资料库问答",
description:
"通过 LightRAG provider 检索多本书、论文、PDF 和附件资料库,并返回可回跳来源。",
category: "knowledge",
agent_ids: &["hermes", "reasonix"],
read_only: false,
read_only: true,
requires_context_refs: &["folder"],
tool_names: &[
"mnote.context.snapshot",
"mnote.context.resolve_target",
"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",
],
content: include_str!("../../../../../skills/mnote-local-index/SKILL.md"),
content: include_str!("../../../../../skills/mnote-knowledge-rag/SKILL.md"),
},
MnoteCapabilityPack {
id: "mnote-local-file",
@@ -208,7 +206,7 @@ pub fn manifest_capabilities() -> Vec<Value> {
fn canonical_skill_id(skill_id: &str) -> &str {
match skill_id {
"mnote-document-evidence" => "mnote-local-index",
"mnote-document-evidence" | "mnote-local-index" => "mnote-knowledge-rag",
other => other,
}
}
@@ -364,33 +362,31 @@ mod tests {
}
#[test]
fn skill_registry_exposes_local_index_skill_to_agents() {
fn skill_registry_retired_local_index_in_favor_of_lightrag() {
let hermes_skills = skill_summaries_for_agent(Some("hermes"));
assert!(!hermes_skills
.iter()
.any(|skill| skill["id"] == "mnote-local-index"));
let skill = hermes_skills
.iter()
.find(|skill| skill["id"] == "mnote-local-index")
.expect("hermes should see local index skill");
assert_eq!(skill["readOnly"], false);
.find(|skill| skill["id"] == "mnote-knowledge-rag")
.expect("hermes should see LightRAG skill");
assert_eq!(skill["readOnly"], true);
assert!(skill["toolNames"]
.as_array()
.expect("tool names")
.iter()
.any(|name| name == "mnote.evidence.search"));
assert!(skill["toolNames"]
.as_array()
.expect("tool names")
.iter()
.any(|name| name == "mnote.index.update_settings"));
.any(|name| name == "mnote.knowledge_rag.query"));
assert!(!hermes_skills
.iter()
.any(|skill| skill["id"] == "mnote-document-evidence"));
}
#[test]
fn skill_read_keeps_document_evidence_compat_alias() {
fn skill_read_maps_document_evidence_alias_to_lightrag() {
let skill = find_skill("mnote-document-evidence", Some("reasonix"))
.expect("compat alias should resolve");
assert_eq!(skill.id, "mnote-local-index");
assert_eq!(skill.id, "mnote-knowledge-rag");
}
#[tokio::test]
+15 -6
View File
@@ -17,14 +17,23 @@ use serde_json::{json, Value};
use std::path::{Component, Path};
pub async fn search(
State(state): State<AppState>,
State(_state): State<AppState>,
Extension(context): Extension<RequestContext>,
Json(body): Json<EvidenceSearchRequest>,
Json(_body): Json<EvidenceSearchRequest>,
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
let response = search_payload(&state, &context, body).await?;
let mut headers = HeaderMap::new();
headers.insert("content-type", HeaderValue::from_static("application/json"));
Ok((StatusCode::OK, headers, Json(response)))
Ok((
StatusCode::GONE,
headers,
Json(json!({
"ok": false,
"code": "mnote_evidence_search_retired",
"message": "旧 LiteParse/evidence 搜索已退役;PDF、Office、图片 OCR 与资料索引统一走 /api/knowledge-rag/query",
"requestId": context.trace.request_id,
"traceId": context.trace.trace_id,
})),
))
}
pub(crate) async fn search_payload(
@@ -241,7 +250,7 @@ fn suggested_evidence_queries(query: &str) -> Vec<String> {
suggestions
}
fn citation_markdown_for_locator(locator: &EvidenceLocator) -> String {
pub(crate) fn citation_markdown_for_locator(locator: &EvidenceLocator) -> String {
let label = citation_label_for_locator(locator);
let url = citation_url_for_locator(locator);
format!(
@@ -280,7 +289,7 @@ fn citation_label_for_locator(locator: &EvidenceLocator) -> String {
parts.join(" · ")
}
fn citation_url_for_locator(locator: &EvidenceLocator) -> String {
pub(crate) fn citation_url_for_locator(locator: &EvidenceLocator) -> String {
let owner_document_id = locator.owner_document_id.trim();
let mut url = if owner_document_id.is_empty() {
let existing = locator.open_action.url.trim();
@@ -3,7 +3,7 @@ use crate::app::AppState;
use crate::context::RequestContext;
use crate::error::WebError;
use crate::hermes_tools::{
artifact, block, context_tools, doc, evidence, index, manifest, onlyoffice_live, page,
artifact, block, context_tools, doc, evidence, knowledge_rag, manifest, onlyoffice_live, page,
resource, skill, ToolCallInput,
};
use axum::extract::{Extension, Query, State};
@@ -362,14 +362,27 @@ pub(crate) async fn execute_mnote_tool_call(
"mnote.doc.find" => doc::doc_find(&state, &context, &input).await,
"docs_search" => evidence::legacy_docs_search(&state, &context, &input).await,
"docs_read" => evidence::legacy_docs_read(&state, &context, &input).await,
"mnote.evidence.search" => evidence::evidence_search(&state, &context, &input).await,
"mnote.evidence.read" => evidence::evidence_read(&state, &context, &input).await,
"mnote.evidence.open" => evidence::evidence_open(&state, &context, &input).await,
"mnote.index.status" => index::index_status(&state, &context, &input).await,
"mnote.index.refresh" => index::index_refresh(&state, &context, &input).await,
"mnote.index.update_settings" => {
index::index_update_settings(&state, &context, &input).await
"mnote.evidence.search" | "mnote.evidence.read" | "mnote.evidence.open" => {
Err(WebError::new(
StatusCode::GONE,
"mnote_evidence_tools_retired",
"旧 evidence / LiteParse tools 已退役;请使用 mnote.knowledge_rag.query/open_reference",
)
.with_context(&context))
}
"mnote.knowledge_rag.status" => knowledge_rag::status(&state, &context, &input).await,
"mnote.knowledge_rag.query" => knowledge_rag::query(&state, &context, &input).await,
"mnote.knowledge_rag.open_reference" => {
knowledge_rag::open_reference(&state, &context, &input).await
}
"mnote.index.status" | "mnote.index.refresh" | "mnote.index.update_settings" => Err(
WebError::new(
StatusCode::GONE,
"mnote_index_tools_retired",
"旧本地索引 tools 已退役;资料索引统一由 LightRAG provider 处理",
)
.with_context(&context),
),
"mnote.doc.plan_update" => doc::plan_update(&state, &context, &input).await,
"mnote.block.fetch" => block::block_fetch(&state, &context, &input).await,
"mnote.block.replace" => block::block_replace(&state, &context, &input).await,
@@ -709,6 +722,9 @@ fn is_read_tool(tool_name: &str) -> bool {
| "mnote.evidence.search"
| "mnote.evidence.read"
| "mnote.evidence.open"
| "mnote.knowledge_rag.status"
| "mnote.knowledge_rag.query"
| "mnote.knowledge_rag.open_reference"
| "mnote.index.status"
| "mnote.index.refresh"
| "mnote.block.fetch"
@@ -738,6 +754,9 @@ fn is_evidence_receipt_tool(tool_name: &str) -> bool {
| "mnote.evidence.search"
| "mnote.evidence.read"
| "mnote.evidence.open"
| "mnote.knowledge_rag.status"
| "mnote.knowledge_rag.query"
| "mnote.knowledge_rag.open_reference"
)
}
@@ -1604,23 +1623,29 @@ mod tests {
let payload: Value = serde_json::from_slice(&body).expect("json");
let manifest = &payload["manifest"];
let capabilities = manifest["capabilities"].as_array().expect("capabilities");
assert!(capabilities
assert!(!capabilities
.iter()
.any(|capability| capability["id"] == "mnote-local-index"));
assert!(capabilities
.iter()
.any(|capability| capability["id"] == "mnote-knowledge-rag"));
assert!(capabilities
.iter()
.any(|capability| capability["id"] == "mnote-onlyoffice-live"));
let tools = manifest["tools"].as_array().expect("tools");
let index_status = tools
assert!(!tools
.iter()
.find(|tool| tool["name"] == "mnote.index.status")
.expect("index status tool");
assert!(index_status["capabilityIds"]
.any(|tool| tool["name"] == "mnote.index.status"));
let knowledge_rag_query = tools
.iter()
.find(|tool| tool["name"] == "mnote.knowledge_rag.query")
.expect("knowledge rag query tool");
assert!(knowledge_rag_query["capabilityIds"]
.as_array()
.expect("index capability ids")
.expect("knowledge rag capability ids")
.iter()
.any(|id| id == "mnote-local-index"));
.any(|id| id == "mnote-knowledge-rag"));
let onlyoffice_batch_set = tools
.iter()
@@ -5573,7 +5598,7 @@ mod tests {
}
#[tokio::test]
async fn hermes_tools_local_index_update_and_status_manage_scope() {
async fn hermes_tools_local_index_tools_are_retired() {
let root = std::env::temp_dir().join(format!("mnote-index-tool-{}", std::process::id()));
let _ = fs::remove_dir_all(&root);
fs::create_dir_all(root.join(".mnote")).expect("metadata");
@@ -5583,11 +5608,7 @@ mod tests {
r#"{"workspaceId":"local-ws-index-tool","ownerId":"user_1","createdAt":"2026-06-04T00:00:00Z","capabilities":["local_files","search"]}"#,
)
.expect("manifest");
fs::write(
root.join("docs").join("indexed.md"),
"# Indexed\n\nindex-tool-token\n",
)
.expect("markdown");
fs::write(root.join("docs").join("indexed.md"), "# Indexed\n").expect("markdown");
let root_uri = format!("file://{}", root.display());
let app = app();
@@ -5625,18 +5646,16 @@ mod tests {
)
.await
.expect("update response");
assert_eq!(update_response.status(), StatusCode::OK);
assert_eq!(update_response.status(), StatusCode::GONE);
let update_body = to_bytes(update_response.into_body(), usize::MAX)
.await
.expect("update body");
let update_payload: Value = serde_json::from_slice(&update_body).expect("update json");
assert_eq!(
update_payload["result"]["settings"]["includePaths"][0],
"docs"
update_payload["code"].as_str(),
Some("mnote_index_tools_retired")
);
assert_eq!(update_payload["result"]["index"]["documentCount"], 1);
assert!(root.join(".mnote/index/search-index.json").exists());
assert!(root.join(".mnote/index/evidence.sqlite").exists());
assert!(!root.join(".mnote/index/search-index.json").exists());
let status_response = app
.oneshot(
@@ -5664,16 +5683,15 @@ mod tests {
)
.await
.expect("status response");
assert_eq!(status_response.status(), StatusCode::OK);
assert_eq!(status_response.status(), StatusCode::GONE);
let status_body = to_bytes(status_response.into_body(), usize::MAX)
.await
.expect("status body");
let status_payload: Value = serde_json::from_slice(&status_body).expect("status json");
assert_eq!(
status_payload["result"]["result"]["settings"]["includePaths"][0],
"docs"
status_payload["code"].as_str(),
Some("mnote_index_tools_retired")
);
assert_eq!(status_payload["audit"]["effect"], "read");
let _ = fs::remove_dir_all(&root);
}
File diff suppressed because it is too large Load Diff
@@ -1,12 +1,15 @@
use crate::app::AppState;
use crate::context::RequestContext;
use crate::error::WebError;
use crate::routes::local_folder_source::{
decode_local_id_segment, ensure_local_workspace_read_access_with_state,
load_local_folder_file_tree_snapshot, load_local_folder_page_tree_snapshot,
local_folder_watch_revision, local_workspace_id_from_root_uri,
};
use crate::routes::snapshot_support::ProjectionSnapshot;
use crate::routes::{
knowledge_rag,
local_folder_source::{
decode_local_id_segment, ensure_local_workspace_read_access_with_state,
load_local_folder_file_tree_snapshot, load_local_folder_page_tree_snapshot,
local_folder_watch_revision, local_workspace_id_from_root_uri,
},
};
use axum::extract::{Extension, Query, State};
use axum::http::{HeaderMap, HeaderName, HeaderValue};
use axum::response::sse::{Event as SseEvent, Sse};
@@ -230,12 +233,19 @@ async fn build_tree_live_stream(
})?;
let stream = stream::unfold(
(Some(initial_payload), subscription, root_uri, workspace_id),
|(payload, mut subscription, root_uri, workspace_id)| async move {
(
Some(initial_payload),
subscription,
root_uri,
workspace_id,
state,
context,
),
|(payload, mut subscription, root_uri, workspace_id, state, context)| async move {
if let Some(payload) = payload {
return Some((
Ok(stream_event("snapshot", &payload)),
(None, subscription, root_uri, workspace_id),
(None, subscription, root_uri, workspace_id, state, context),
));
}
@@ -253,6 +263,13 @@ async fn build_tree_live_stream(
Err(_) => break,
}
}
let _ = knowledge_rag::sync_registry_for_root(
&state,
&context,
&root_uri,
Some(&workspace_id),
)
.await;
if let Some(batch_payload) = build_local_folder_watch_batch_payload(
&root_uri,
&workspace_id,
@@ -260,7 +277,7 @@ async fn build_tree_live_stream(
) {
return Some((
Ok(stream_event("watch_batch", &batch_payload)),
(None, subscription, root_uri, workspace_id),
(None, subscription, root_uri, workspace_id, state, context),
));
}
let error_payload = build_tree_live_error_payload(
@@ -271,7 +288,7 @@ async fn build_tree_live_stream(
);
return Some((
Ok(stream_event("tree_error", &error_payload)),
(None, subscription, root_uri, workspace_id),
(None, subscription, root_uri, workspace_id, state, context),
));
}
Err(RecvError::Lagged(_)) => continue,
@@ -9,7 +9,7 @@ use crate::routes::local_markdown_parser::{
file_stem_title, parse_markdown_attachment_refs, parse_markdown_page, split_frontmatter,
};
use crate::routes::snapshot_support::ProjectionSnapshot;
use crate::routes::{local_ocr, local_search_index};
use crate::routes::{knowledge_rag, local_ocr, local_search_index};
use axum::extract::{Extension, Multipart, Path as AxumPath, Query, State};
use axum::http::{header, HeaderMap, HeaderValue, StatusCode};
use axum::Json;
@@ -307,14 +307,16 @@ struct LocalFolderScanResult {
#[derive(Debug, Clone, Default)]
struct LocalFileTreeIndexState {
indexed_paths: BTreeSet<String>,
indexing_paths: BTreeSet<String>,
failed_paths: BTreeSet<String>,
}
impl LocalFileTreeIndexState {
fn load(root: &Path) -> Self {
local_search_index::local_evidence_source_statuses(root)
fn load(root: &Path, workspace_id: &str, root_uri: &str) -> Self {
knowledge_rag::knowledge_rag_source_statuses(root, workspace_id, root_uri)
.map(|statuses| Self {
indexed_paths: statuses.indexed_paths,
indexing_paths: statuses.indexing_paths,
failed_paths: statuses.failed_paths,
})
.unwrap_or_default()
@@ -334,6 +336,9 @@ impl LocalFileTreeIndexState {
if self.indexed_paths.contains(path) {
return Some("indexed".to_string());
}
if self.indexing_paths.contains(path) {
return Some("indexing".to_string());
}
None
}
}
@@ -2752,7 +2757,8 @@ fn load_local_folder_file_tree_scope_snapshot(
let workspace_id = local_workspace_id(&canonical_root);
let root_source_uri = file_uri_for_path(&canonical_root);
let metadata = load_local_folder_metadata(&canonical_root)?;
let index_state = LocalFileTreeIndexState::load(&canonical_root);
let index_state =
LocalFileTreeIndexState::load(&canonical_root, &workspace_id, &root_source_uri);
let parent_relative_path = parent_relative_path
.map(str::trim)
.filter(|value| !value.is_empty() && *value != ".")
@@ -13646,84 +13652,118 @@ fn main() {}
}
#[test]
fn local_file_tree_marks_indexed_and_failed_source_files() {
let root = temp_root("mnote-local-filetree-index-status");
fn local_file_tree_marks_lightrag_indexed_indexing_and_failed_source_files() {
let root = temp_root("mnote-lightrag-filetree-index-status");
init_workspace(&root);
std::fs::write(root.join("ok.pdf"), b"%PDF-1.4\nok").expect("ok pdf");
std::fs::write(root.join("pending.pdf"), b"%PDF-1.4\npending").expect("pending pdf");
std::fs::write(root.join("failed.pdf"), b"%PDF-1.4\nfailed").expect("failed pdf");
std::fs::write(root.join("deleting.pdf"), b"%PDF-1.4\ndeleting").expect("deleting pdf");
std::fs::write(root.join("removed.pdf"), b"%PDF-1.4\nremoved").expect("removed pdf");
std::fs::write(root.join("draft.md"), "# Draft\n").expect("draft");
let index_dir = root.join(".mnote").join("index");
std::fs::create_dir_all(&index_dir).expect("index dir");
let evidence_path = index_dir.join("evidence.sqlite");
let connection = rusqlite::Connection::open(&evidence_path).expect("evidence sqlite");
connection
.execute_batch(
r#"
CREATE TABLE evidence_resource(
resource_id TEXT PRIMARY KEY,
owner_document_id TEXT NOT NULL,
owner_document_path TEXT NOT NULL,
source_root_relative_path TEXT NOT NULL,
provider TEXT NOT NULL,
source_hash TEXT NOT NULL,
artifact_root_relative_path TEXT NOT NULL,
source_map_root_relative_path TEXT NOT NULL,
updated_at_ms INTEGER NOT NULL
);
"#,
)
.expect("evidence schema");
connection
.execute(
"INSERT INTO evidence_resource(resource_id, owner_document_id, owner_document_path, source_root_relative_path, provider, source_hash, artifact_root_relative_path, source_map_root_relative_path, updated_at_ms) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
rusqlite::params![
"local-resource:ok.pdf#parse",
"local-resource:ok.pdf",
"ok.pdf",
"ok.pdf",
"liteparse",
"hash",
"ok.ocr/ok.pdf.parse.md",
"ok.ocr/ok.pdf.source-map.json",
1_i64,
],
)
.expect("insert indexed evidence");
connection
.execute(
"INSERT INTO evidence_resource(resource_id, owner_document_id, owner_document_path, source_root_relative_path, provider, source_hash, artifact_root_relative_path, source_map_root_relative_path, updated_at_ms) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
rusqlite::params![
"local-md:draft.md",
"local-md:draft.md",
"draft.md",
"draft.md",
"markdown",
"hash",
"draft.md",
"draft.md.source-map.json",
1_i64,
],
)
.expect("insert markdown evidence");
let root_uri = format!("file://{}", root.display());
let search_index = json!({
"version": 1,
"builtAt": 1,
let workspace_id = local_workspace_id(&root);
let registry = json!({
"schema": "mnote.knowledge_rag.source_registry.v1",
"workspaceId": workspace_id,
"rootUri": root_uri,
"workspaceId": "local-filetree-index-status",
"indexedPaths": ["."],
"documents": [],
"resources": [
{"resourceId": "local-resource:ok.pdf", "resourceType": "pdf", "title": "ok", "path": "ok.pdf", "updatedAt": 1},
{"resourceId": "local-resource:failed.pdf", "resourceType": "pdf", "title": "failed", "path": "failed.pdf", "updatedAt": 1}
"updatedAtMs": 1,
"entries": [
{
"sourceId": "lightrag-source-ok",
"workspaceId": workspace_id,
"rootUri": root_uri,
"sourcePath": root.join("ok.pdf").display().to_string(),
"sourceRootRelativePath": "ok.pdf",
"sourceHash": "hash-ok",
"lightRagDocId": "doc-ok",
"lightRagStatus": "processed",
"lightRagFilePath": "ok.pdf",
"symlinkPath": "/tmp/ok.pdf",
"parserHint": null,
"indexedAtMs": 2,
"deletedAtMs": null,
"stale": false,
"updatedAtMs": 2
},
{
"sourceId": "lightrag-source-pending",
"workspaceId": workspace_id,
"rootUri": root_uri,
"sourcePath": root.join("pending.pdf").display().to_string(),
"sourceRootRelativePath": "pending.pdf",
"sourceHash": "hash-pending",
"lightRagDocId": null,
"lightRagStatus": "submitted",
"lightRagFilePath": "pending.pdf",
"symlinkPath": "/tmp/pending.pdf",
"parserHint": null,
"indexedAtMs": null,
"deletedAtMs": null,
"stale": false,
"updatedAtMs": 2
},
{
"sourceId": "lightrag-source-failed",
"workspaceId": workspace_id,
"rootUri": root_uri,
"sourcePath": root.join("failed.pdf").display().to_string(),
"sourceRootRelativePath": "failed.pdf",
"sourceHash": "hash-failed",
"lightRagDocId": null,
"lightRagStatus": "failed",
"lightRagFilePath": "failed.pdf",
"symlinkPath": "/tmp/failed.pdf",
"parserHint": null,
"indexedAtMs": null,
"deletedAtMs": 3,
"stale": true,
"updatedAtMs": 3
},
{
"sourceId": "lightrag-source-deleting",
"workspaceId": workspace_id,
"rootUri": root_uri,
"sourcePath": root.join("deleting.pdf").display().to_string(),
"sourceRootRelativePath": "deleting.pdf",
"sourceHash": "hash-deleting",
"lightRagDocId": "doc-deleting",
"lightRagStatus": "delete_submitted",
"lightRagFilePath": "deleting.pdf",
"symlinkPath": "/tmp/deleting.pdf",
"parserHint": null,
"indexedAtMs": 2,
"deletedAtMs": 3,
"stale": true,
"updatedAtMs": 3
},
{
"sourceId": "lightrag-source-removed",
"workspaceId": workspace_id,
"rootUri": root_uri,
"sourcePath": root.join("removed.pdf").display().to_string(),
"sourceRootRelativePath": "removed.pdf",
"sourceHash": "hash-removed",
"lightRagDocId": null,
"lightRagStatus": "delete_completed",
"lightRagFilePath": "removed.pdf",
"symlinkPath": "/tmp/removed.pdf",
"parserHint": null,
"indexedAtMs": null,
"deletedAtMs": 3,
"stale": true,
"updatedAtMs": 3
}
]
});
std::fs::write(
index_dir.join("search-index.json"),
format!("{}\n", serde_json::to_string_pretty(&search_index).unwrap()),
index_dir.join("lightrag-source-registry.json"),
format!("{}\n", serde_json::to_string_pretty(&registry).unwrap()),
)
.expect("search index");
.expect("lightrag registry");
let file_tree = load_local_folder_file_tree_snapshot(&format!("file://{}", root.display()))
.expect("file tree");
@@ -13737,7 +13777,10 @@ CREATE TABLE evidence_resource(
.and_then(|item| item["indexStatus"].as_str())
};
assert_eq!(status_for("ok.pdf"), Some("indexed"));
assert_eq!(status_for("pending.pdf"), Some("indexing"));
assert_eq!(status_for("failed.pdf"), Some("failed"));
assert_eq!(status_for("deleting.pdf"), Some("indexing"));
assert_eq!(status_for("removed.pdf"), None);
assert_eq!(status_for("draft.md"), None);
let _ = std::fs::remove_dir_all(&root);
@@ -2131,6 +2131,7 @@ mod tests {
}
#[tokio::test]
#[ignore = "local OCR HTTP routes are retired in favor of LightRAG"]
async fn local_ocr_jobs_route_writes_mock_sidecar_and_reads_status() {
let root = temp_root("mnote-local-ocr-route");
write_workspace_manifest(&root);
@@ -2255,6 +2256,7 @@ mod tests {
}
#[tokio::test]
#[ignore = "local OCR HTTP routes are retired in favor of LightRAG"]
async fn local_ocr_jobs_route_reuses_existing_done_sidecar_without_reprocessing() {
let root = temp_root("mnote-local-ocr-dedup-done");
write_workspace_manifest(&root);
@@ -2331,6 +2333,7 @@ mod tests {
}
#[tokio::test]
#[ignore = "local OCR HTTP routes are retired in favor of LightRAG"]
async fn local_ocr_jobs_route_recovers_existing_done_sidecar_when_index_is_missing() {
let root = temp_root("mnote-local-ocr-dedup-sidecar-recover");
write_workspace_manifest(&root);
@@ -2405,6 +2408,7 @@ mod tests {
}
#[tokio::test]
#[ignore = "local OCR HTTP routes are retired in favor of LightRAG"]
async fn local_ocr_jobs_route_reuses_existing_done_before_mineru_token_check() {
let old_mnote_token = std::env::var("MNOTE_MINERU_API_TOKEN").ok();
let old_mineru_token = std::env::var("MINERU_API_TOKEN").ok();
@@ -2484,6 +2488,7 @@ mod tests {
}
#[tokio::test]
#[ignore = "local OCR HTTP routes are retired in favor of LightRAG"]
async fn local_ocr_jobs_route_broadcasts_stage_updates() {
let root = temp_root("mnote-local-ocr-events");
write_workspace_manifest(&root);
@@ -2548,6 +2553,7 @@ mod tests {
}
#[tokio::test]
#[ignore = "local OCR HTTP routes are retired in favor of LightRAG"]
async fn local_ocr_jobs_route_rejects_missing_mineru_token() {
let old_mnote_token = std::env::var("MNOTE_MINERU_API_TOKEN").ok();
let old_mineru_token = std::env::var("MINERU_API_TOKEN").ok();
@@ -2584,6 +2590,7 @@ mod tests {
}
#[tokio::test]
#[ignore = "local OCR HTTP routes are retired in favor of LightRAG"]
async fn local_ocr_jobs_route_runs_mineru_runtime_against_http_mock() {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
.await
@@ -2832,6 +2839,7 @@ mod tests {
}
#[tokio::test]
#[ignore = "local OCR HTTP routes are retired in favor of LightRAG"]
async fn local_ocr_jobs_route_rejects_root_escape_source() {
let root = temp_root("mnote-local-ocr-escape");
write_workspace_manifest(&root);
@@ -2853,6 +2861,7 @@ mod tests {
}
#[tokio::test]
#[ignore = "local OCR HTTP routes are retired in favor of LightRAG"]
async fn local_ocr_jobs_route_rejects_non_image_pdf_source() {
let root = temp_root("mnote-local-ocr-unsupported");
write_workspace_manifest(&root);
@@ -2882,6 +2891,7 @@ mod tests {
}
#[tokio::test]
#[ignore = "local OCR HTTP routes are retired in favor of LightRAG"]
async fn local_ocr_jobs_route_writes_failed_mock_entry_with_redacted_error() {
let root = temp_root("mnote-local-ocr-failed");
write_workspace_manifest(&root);
@@ -2938,6 +2948,7 @@ mod tests {
}
#[tokio::test]
#[ignore = "local OCR HTTP routes are retired in favor of LightRAG"]
async fn local_ocr_insert_route_appends_explicit_ocr_link() {
let root = temp_root("mnote-local-ocr-insert");
write_workspace_manifest(&root);
@@ -1,7 +1,4 @@
use crate::error::WebError;
use crate::evidence_parse::{
ParseInput, ParseProviderMode, liteparse_runtime_available, parse_liteparse_input_blocking,
};
use crate::routes::local_folder_source::encode_local_id_segment;
use crate::routes::local_markdown_parser::{
parse_markdown_attachment_link, parse_markdown_page, split_frontmatter,
@@ -9,12 +6,12 @@ use crate::routes::local_markdown_parser::{
use crate::routes::local_ocr;
use control_plane::{ControlPlaneStore, UpsertUserInput, UpsertUserUiPreferenceInput};
use core_protocol::{
EvidenceLocator, EvidenceSearchMatchInfo, EvidenceSearchResult,
PARSED_RESOURCE_ARTIFACT_SCHEMA, ParsedResourceArtifact, ResourceSourceMap, SourceMapBlock,
EvidenceLocator, EvidenceSearchMatchInfo, EvidenceSearchResult, ParsedResourceArtifact,
ResourceSourceMap, SourceMapBlock, PARSED_RESOURCE_ARTIFACT_SCHEMA,
};
use rusqlite::{Connection, OptionalExtension, params};
use rusqlite::{params, Connection, OptionalExtension};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use serde_json::{json, Value};
use std::collections::BTreeSet;
use std::fs;
use std::path::{Component, Path, PathBuf};
@@ -183,49 +180,7 @@ pub(crate) fn query_local_search_index_with_settings(
}
}
}
if include_ocr && results.len() < limit.max(1) as usize {
for entry in local_ocr::ocr_index_entries(root_path)? {
if !index_relative_path_is_included(
&entry.source_root_relative_path,
&result_settings.include_paths,
) {
continue;
}
if let Some(page_id) = page_id {
if let Some(resource_path) = page_resource_path.as_deref() {
if entry.source_root_relative_path != resource_path {
continue;
}
} else if entry.owner_document_id != page_id {
continue;
}
}
if entry.status != "done" {
continue;
}
let ocr_path = root_path.join(&entry.ocr_root_relative_path);
let markdown = match fs::read_to_string(&ocr_path) {
Ok(markdown) => markdown,
Err(_) => continue,
};
if local_ocr::parse_ocr_frontmatter(&markdown).is_none() {
continue;
}
let body = local_ocr::strip_ocr_frontmatter(&markdown);
if !local_search_ocr_matches(&entry, body, &normalized_query, title_only, exact) {
continue;
}
results.push(local_search_ocr_projection(
&entry,
body,
root_uri,
&normalized_query,
));
if results.len() >= limit.max(1) as usize {
break;
}
}
}
let _ = include_ocr;
Ok(json!({
"enqueueAssetIds": [],
"projectionOwner": "rust-kernel",
@@ -2244,14 +2199,6 @@ fn write_evidence_sqlite_index(root_path: &Path, index: &LocalSearchIndex) -> Re
for resource in &index.resources {
insert_resource_evidence(&tx, root_path, index, resource)?;
}
for entry in local_ocr::ocr_index_entries(root_path)?
.into_iter()
.filter(|entry| {
index_relative_path_is_included(&entry.source_root_relative_path, &index.indexed_paths)
})
{
insert_ocr_evidence(&tx, root_path, index, &entry)?;
}
for document in &index.documents {
insert_document_graph_edges(&tx, document)?;
}
@@ -2373,15 +2320,6 @@ fn refresh_evidence_sqlite_index_for_path(
{
insert_resource_evidence(&tx, root_path, index, resource)?;
}
for entry in local_ocr::ocr_index_entries(root_path)?
.into_iter()
.filter(|entry| {
entry.ocr_root_relative_path == relative_path
|| entry.source_root_relative_path == relative_path
})
{
insert_ocr_evidence(&tx, root_path, index, &entry)?;
}
tx.execute(
"INSERT OR REPLACE INTO evidence_meta(key, value) VALUES('schema_version', ?1)",
params![EVIDENCE_SQLITE_SCHEMA_VERSION.to_string()],
@@ -2664,147 +2602,18 @@ fn insert_resource_evidence(
}
fn parse_resource_evidence_artifact(
root_path: &Path,
index: &LocalSearchIndex,
resource: &LocalSearchResource,
_root_path: &Path,
_index: &LocalSearchIndex,
_resource: &LocalSearchResource,
) -> Result<Option<crate::evidence_parse::ParseProviderOutput>, WebError> {
if !resource_parse_supported(resource) || !liteparse_runtime_available() {
return Ok(None);
}
let owner = evidence_owner_for_resource(index, resource);
let mut output = match parse_liteparse_input_blocking(ParseInput {
root_path: root_path.to_path_buf(),
root_uri: index.root_uri.clone(),
owner_document_id: owner.document_id.clone(),
owner_document_path: owner.path.clone(),
source_root_relative_path: resource.path.clone(),
mode: ParseProviderMode::NoOcr,
}) {
Ok(output) => output,
Err(_) => return Ok(None),
};
let sidecar = resource_parse_sidecar_paths(&owner.path, &resource.path);
write_parsed_resource_sidecars(root_path, &sidecar, &output.markdown, &output.source_map)?;
output.artifact.artifact_root_relative_path = sidecar.parse_root_relative_path;
output.artifact.source_map_root_relative_path = sidecar.source_map_root_relative_path.clone();
output.source_map.owner_document_path = owner.path;
Ok(Some(output))
}
fn resource_parse_supported(resource: &LocalSearchResource) -> bool {
matches!(resource.resource_type.as_str(), "pdf" | "office")
Ok(None)
}
fn parsed_resource_id(resource: &LocalSearchResource) -> String {
format!("{}#parse", resource.resource_id)
}
#[derive(Debug, Clone)]
struct EvidenceResourceOwner {
document_id: String,
path: String,
}
fn evidence_owner_for_resource(
index: &LocalSearchIndex,
resource: &LocalSearchResource,
) -> EvidenceResourceOwner {
if let Some(document) = index.documents.iter().find(|document| {
document.resource_refs.iter().any(|reference| {
normalize_local_reference_path(&document.path, reference) == resource.path
})
}) {
return EvidenceResourceOwner {
document_id: document.document_id.clone(),
path: document.path.clone(),
};
}
EvidenceResourceOwner {
document_id: resource.resource_id.clone(),
path: resource.path.clone(),
}
}
#[derive(Debug, Clone)]
struct ResourceParseSidecarPaths {
parse_root_relative_path: String,
source_map_root_relative_path: String,
}
fn resource_parse_sidecar_paths(
owner_document_path: &str,
source_root_relative_path: &str,
) -> ResourceParseSidecarPaths {
let owner_parent = Path::new(owner_document_path)
.parent()
.map(Path::to_path_buf)
.unwrap_or_default();
let owner_stem = Path::new(owner_document_path)
.file_stem()
.and_then(|value| value.to_str())
.unwrap_or("Page");
let source_leaf = Path::new(source_root_relative_path)
.file_name()
.and_then(|value| value.to_str())
.unwrap_or("source");
let sidecar_dir = owner_parent.join(format!("{owner_stem}.ocr"));
let parse_root_relative_path = sidecar_dir
.join(format!("{source_leaf}.parse.md"))
.to_string_lossy()
.replace('\\', "/");
let source_map_root_relative_path = sidecar_dir
.join(format!("{source_leaf}.source-map.json"))
.to_string_lossy()
.replace('\\', "/");
ResourceParseSidecarPaths {
parse_root_relative_path,
source_map_root_relative_path,
}
}
fn write_parsed_resource_sidecars(
root_path: &Path,
sidecar: &ResourceParseSidecarPaths,
markdown: &str,
source_map: &ResourceSourceMap,
) -> Result<(), WebError> {
let parse_path = root_path.join(&sidecar.parse_root_relative_path);
let source_map_path = root_path.join(&sidecar.source_map_root_relative_path);
for path in [&parse_path, &source_map_path] {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).map_err(|error| {
WebError::bad_request_code(
"evidence_parse_sidecar_create_failed",
format!(
"无法创建证据解析 sidecar 目录 {}: {error}",
parent.display()
),
)
})?;
}
}
fs::write(&parse_path, markdown.trim_end()).map_err(|error| {
WebError::bad_request_code(
"evidence_parse_sidecar_write_failed",
format!(
"无法写入证据解析 Markdown {}: {error}",
parse_path.display()
),
)
})?;
let source_map_json = serde_json::to_string_pretty(source_map)
.map_err(|error| WebError::internal(format!("证据 source-map 序列化失败: {error}")))?;
fs::write(&source_map_path, format!("{source_map_json}\n")).map_err(|error| {
WebError::bad_request_code(
"evidence_parse_sidecar_write_failed",
format!(
"无法写入证据 source-map {}: {error}",
source_map_path.display()
),
)
})
}
#[allow(dead_code)]
fn insert_ocr_evidence(
connection: &Connection,
root_path: &Path,
@@ -2863,6 +2672,7 @@ fn insert_ocr_evidence(
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,
@@ -3461,12 +3271,14 @@ fn count_evidence_blocks(path: &Path) -> Result<u64, WebError> {
.map_err(sqlite_error)
}
#[allow(dead_code)]
#[derive(Debug, Clone, Default)]
pub(crate) struct LocalEvidenceSourceStatuses {
pub(crate) indexed_paths: BTreeSet<String>,
pub(crate) failed_paths: BTreeSet<String>,
}
#[allow(dead_code)]
pub(crate) fn local_evidence_source_statuses(
root_path: &Path,
) -> Result<LocalEvidenceSourceStatuses, WebError> {
@@ -3509,21 +3321,6 @@ pub(crate) fn local_evidence_source_statuses(
}
}
}
for entry in local_ocr::ocr_index_entries(root_path)? {
match entry.status.as_str() {
"done" => {
statuses
.indexed_paths
.insert(entry.source_root_relative_path);
}
"failed" | "interrupted" => {
statuses
.failed_paths
.insert(entry.source_root_relative_path);
}
_ => {}
}
}
Ok(statuses)
}
@@ -3563,6 +3360,7 @@ fn local_resource_path_from_document_id(document_id: &str) -> Option<String> {
.filter(|value| !value.trim().is_empty())
}
#[allow(dead_code)]
fn path_source_map_path(path: &str) -> Option<String> {
if let Some(stripped) = path.strip_suffix(".ocr.md") {
return Some(format!("{stripped}.source-map.json"));
@@ -3681,6 +3479,7 @@ fn local_search_resource_matches(
}
}
#[allow(dead_code)]
fn local_search_ocr_matches(
entry: &local_ocr::OcrIndexEntry,
body: &str,
@@ -3758,6 +3557,7 @@ fn local_search_resource_projection(resource: &LocalSearchResource, root_uri: &s
})
}
#[allow(dead_code)]
fn local_search_ocr_projection(
entry: &local_ocr::OcrIndexEntry,
body: &str,
@@ -4375,55 +4175,41 @@ mod tests {
.iter()
.find(|item| item["documentId"].as_str() == Some("local-md:README.md"))
.expect("home result");
assert!(
home["tags"]
.as_array()
.unwrap()
.iter()
.any(|tag| tag.as_str() == Some("alpha"))
);
assert!(
home["backlinks"]
.as_array()
.unwrap()
.iter()
.any(|link| link.as_str() == Some("Daily"))
);
assert!(
home["backlinks"]
.as_array()
.unwrap()
.iter()
.any(|link| link.as_str() == Some("docs/child.md"))
);
assert!(
home["resourceRefs"]
.as_array()
.unwrap()
.iter()
.any(|reference| reference.as_str() == Some("assets/spec.pdf"))
);
assert!(
home["resourceRefs"]
.as_array()
.unwrap()
.iter()
.any(|reference| reference.as_str() == Some("maps/idea.mindmap.json"))
);
assert!(
home["resourceRefs"]
.as_array()
.unwrap()
.iter()
.any(|reference| reference.as_str() == Some("office/report.xlsx"))
);
assert!(
home["publicPath"]
.as_str()
.is_some_and(|path| path.starts_with(
"/documents/local-md:README.md?sourceKind=local_folder&rootUri=file%3A%2F%2F"
))
);
assert!(home["tags"]
.as_array()
.unwrap()
.iter()
.any(|tag| tag.as_str() == Some("alpha")));
assert!(home["backlinks"]
.as_array()
.unwrap()
.iter()
.any(|link| link.as_str() == Some("Daily")));
assert!(home["backlinks"]
.as_array()
.unwrap()
.iter()
.any(|link| link.as_str() == Some("docs/child.md")));
assert!(home["resourceRefs"]
.as_array()
.unwrap()
.iter()
.any(|reference| reference.as_str() == Some("assets/spec.pdf")));
assert!(home["resourceRefs"]
.as_array()
.unwrap()
.iter()
.any(|reference| reference.as_str() == Some("maps/idea.mindmap.json")));
assert!(home["resourceRefs"]
.as_array()
.unwrap()
.iter()
.any(|reference| reference.as_str() == Some("office/report.xlsx")));
assert!(home["publicPath"]
.as_str()
.is_some_and(|path| path.starts_with(
"/documents/local-md:README.md?sourceKind=local_folder&rootUri=file%3A%2F%2F"
)));
let evidence_db = root.join(".mnote").join("index").join("evidence.sqlite");
let connection = Connection::open(&evidence_db).expect("open evidence sqlite");
let markdown_edge_count: i64 = connection
@@ -4479,12 +4265,11 @@ mod tests {
Some("local-mdid:child-page")
);
assert!(
root.join(".mnote")
.join("index")
.join("search-index.json")
.exists()
);
assert!(root
.join(".mnote")
.join("index")
.join("search-index.json")
.exists());
let mindmap_projection = query_local_search_index(
&root,
&format!("file://{}", root.display()),
@@ -4497,19 +4282,19 @@ mod tests {
false,
)
.expect("mindmap projection");
assert!(
mindmap_projection["results"]
.as_array()
.unwrap()
.iter()
.any(|item| item["resourceType"].as_str() == Some("mindmap")
&& item["path"].as_str() == Some("maps/idea.mindmap.json")
&& item["publicPath"].as_str().is_some_and(|path| path
assert!(mindmap_projection["results"]
.as_array()
.unwrap()
.iter()
.any(|item| item["resourceType"].as_str() == Some("mindmap")
&& item["path"].as_str() == Some("maps/idea.mindmap.json")
&& item["publicPath"]
.as_str()
.is_some_and(|path| path
.starts_with("/?treeView=filetree&sourceKind=local_folder&rootUri="))
&& item["publicPath"]
.as_str()
.is_some_and(|path| !path.starts_with("/tree?")))
);
&& item["publicPath"]
.as_str()
.is_some_and(|path| !path.starts_with("/tree?"))));
let office_projection = query_local_search_index(
&root,
&format!("file://{}", root.display()),
@@ -4522,14 +4307,12 @@ mod tests {
false,
)
.expect("office projection");
assert!(
office_projection["results"]
.as_array()
.unwrap()
.iter()
.any(|item| item["resourceType"].as_str() == Some("office")
&& item["path"].as_str() == Some("office/report.xlsx"))
);
assert!(office_projection["results"]
.as_array()
.unwrap()
.iter()
.any(|item| item["resourceType"].as_str() == Some("office")
&& item["path"].as_str() == Some("office/report.xlsx")));
let pdf_projection = query_local_search_index(
&root,
&format!("file://{}", root.display()),
@@ -4542,14 +4325,12 @@ mod tests {
false,
)
.expect("pdf projection");
assert!(
pdf_projection["results"]
.as_array()
.unwrap()
.iter()
.any(|item| item["resourceType"].as_str() == Some("pdf")
&& item["path"].as_str() == Some("assets/spec.pdf"))
);
assert!(pdf_projection["results"]
.as_array()
.unwrap()
.iter()
.any(|item| item["resourceType"].as_str() == Some("pdf")
&& item["path"].as_str() == Some("assets/spec.pdf")));
let image_projection = query_local_search_index(
&root,
&format!("file://{}", root.display()),
@@ -4562,14 +4343,12 @@ mod tests {
false,
)
.expect("image projection");
assert!(
image_projection["results"]
.as_array()
.unwrap()
.iter()
.any(|item| item["resourceType"].as_str() == Some("image")
&& item["path"].as_str() == Some("assets/diagram.png"))
);
assert!(image_projection["results"]
.as_array()
.unwrap()
.iter()
.any(|item| item["resourceType"].as_str() == Some("image")
&& item["path"].as_str() == Some("assets/diagram.png")));
let _ = fs::remove_dir_all(&root);
}
@@ -5194,12 +4973,10 @@ mod tests {
let index = read_local_search_index(&root)
.expect("read index")
.expect("index exists");
assert!(
index
.documents
.iter()
.any(|document| document.path == "README.md")
);
assert!(index
.documents
.iter()
.any(|document| document.path == "README.md"));
let child = index
.documents
.iter()
@@ -5227,18 +5004,14 @@ mod tests {
let index = read_local_search_index(&root)
.expect("read index")
.expect("index exists");
assert!(
!index
.documents
.iter()
.any(|document| document.path == "docs/child.md")
);
assert!(
index
.documents
.iter()
.any(|document| document.path == "README.md")
);
assert!(!index
.documents
.iter()
.any(|document| document.path == "docs/child.md"));
assert!(index
.documents
.iter()
.any(|document| document.path == "README.md"));
let removed_evidence = query_evidence_sqlite_results(&root, "ChangedToken", None, 10)
.expect("removed evidence")
.expect("sqlite exists");
@@ -5251,7 +5024,7 @@ mod tests {
}
#[test]
fn local_search_ocr_sidecar_requires_include_ocr_and_returns_owner_page() {
fn local_search_ocr_sidecar_is_hidden_after_lightrag_retirement() {
let root = temp_root("mnote-local-search-ocr");
let root_uri = format!("file://{}", root.display());
let workspace_id = "local-ws-ocr";
@@ -5344,38 +5117,18 @@ mod tests {
true,
)
.expect("with ocr");
let result = with_ocr["results"]
.as_array()
.and_then(|items| items.first())
.expect("ocr result");
assert_eq!(
result["documentId"].as_str(),
Some("local-md:docs~2FPage.md")
);
assert_eq!(result["hasOcr"].as_bool(), Some(true));
assert_eq!(
result["ocrEvidence"]["sourceRootRelativePath"].as_str(),
Some("docs/Page.assets/photo.png")
);
assert_eq!(
result["ocrEvidence"]["ocrRootRelativePath"].as_str(),
Some("docs/Page.ocr/photo.png.ocr.md")
);
assert_eq!(with_ocr["results"].as_array().map(Vec::len), Some(0));
let index = read_local_search_index(&root)
.expect("read search index")
.expect("search index");
assert!(
!index
.documents
.iter()
.any(|document| document.path == "docs/Page.ocr/photo.png.ocr.md")
);
assert!(
!index
.documents
.iter()
.any(|document| document.path == "docs/Page.ocr/photo.png-704905.ocr.md")
);
assert!(!index
.documents
.iter()
.any(|document| document.path == "docs/Page.ocr/photo.png.ocr.md"));
assert!(!index
.documents
.iter()
.any(|document| document.path == "docs/Page.ocr/photo.png-704905.ocr.md"));
refresh_local_search_index_for_path(
&root,
&root_uri,
@@ -5386,12 +5139,10 @@ mod tests {
let refreshed_index = read_local_search_index(&root)
.expect("read refreshed search index")
.expect("refreshed search index");
assert!(
!refreshed_index
.documents
.iter()
.any(|document| document.path == "docs/Page.ocr/photo.png-704905.ocr.md")
);
assert!(!refreshed_index
.documents
.iter()
.any(|document| document.path == "docs/Page.ocr/photo.png-704905.ocr.md"));
let _ = fs::remove_dir_all(&root);
}
@@ -5492,21 +5243,15 @@ mod tests {
.expect("sqlite query")
.expect("sqlite exists");
assert!(results.len() >= 2);
assert!(
results
.iter()
.any(|result| result.source.owner_document_path == "README.md")
);
assert!(
results
.iter()
.any(|result| result.source.owner_document_path == "docs/child.md")
);
assert!(
results
.iter()
.all(|result| result.source.schema == "mnote.evidence_locator.v1")
);
assert!(results
.iter()
.any(|result| result.source.owner_document_path == "README.md"));
assert!(results
.iter()
.any(|result| result.source.owner_document_path == "docs/child.md"));
assert!(results
.iter()
.all(|result| result.source.schema == "mnote.evidence_locator.v1"));
let home_result = results
.iter()
.find(|result| result.source.owner_document_path == "README.md")
@@ -5664,16 +5409,12 @@ mod tests {
.expect("sqlite exists");
assert_eq!(context.len(), 2);
assert!(
context
.iter()
.all(|item| item.source.owner_document_path == "README.md")
);
assert!(
context
.iter()
.any(|item| item.quote.contains("ReadContextToken"))
);
assert!(context
.iter()
.all(|item| item.source.owner_document_path == "README.md"));
assert!(context
.iter()
.any(|item| item.quote.contains("ReadContextToken")));
let _ = fs::remove_dir_all(&root);
}
@@ -5771,18 +5512,16 @@ JSON
Some("docs/Page.assets/spec.pdf"),
"页面内搜索打开资源时应只限制到当前资源,而不是它的 owner Markdown"
);
assert!(
root.join("docs")
.join("Page.ocr")
.join("spec.pdf.parse.md")
.exists()
);
assert!(
root.join("docs")
.join("Page.ocr")
.join("spec.pdf.source-map.json")
.exists()
);
assert!(root
.join("docs")
.join("Page.ocr")
.join("spec.pdf.parse.md")
.exists());
assert!(root
.join("docs")
.join("Page.ocr")
.join("spec.pdf.source-map.json")
.exists());
let _ = fs::remove_dir_all(&root);
}
+34 -7
View File
@@ -11,9 +11,11 @@ mod hermes;
mod hermes_client;
mod hermes_tools;
mod kernel;
pub(crate) mod knowledge_rag;
mod local_folder_events;
mod local_folder_source;
mod local_markdown_parser;
#[allow(dead_code)]
mod local_ocr;
mod local_search_index;
mod media;
@@ -81,6 +83,21 @@ pub fn build_router(state: AppState) -> Router {
.route("/api/evidence/search", post(evidence::search))
.route("/api/evidence/read", post(evidence::read))
.route("/api/evidence/open", post(evidence::open))
.route("/api/knowledge-rag/status", get(knowledge_rag::status))
.route("/api/knowledge-rag/ingest", post(knowledge_rag::ingest))
.route("/api/knowledge-rag/query", post(knowledge_rag::query_rag))
.route(
"/api/knowledge-rag/open-reference",
post(knowledge_rag::open_reference),
)
.route(
"/api/knowledge-rag/delete-source",
post(knowledge_rag::delete_source),
)
.route(
"/api/knowledge-rag/prune-registry",
post(knowledge_rag::prune_registry),
)
.route(
"/mindmap/{doc_id}/{mindmap_id}",
get(mindmap_shell::mindmap_object_shell),
@@ -558,14 +575,24 @@ pub fn build_router(state: AppState) -> Router {
)
.route(
"/api/local-folder/ocr/jobs",
get(local_ocr::list_jobs)
.post(local_ocr::create_job)
.delete(local_ocr::delete_job),
any(knowledge_rag::retired_local_ocr_endpoint),
)
.route(
"/api/local-folder/ocr/status",
any(knowledge_rag::retired_local_ocr_endpoint),
)
.route(
"/api/local-folder/ocr/read",
any(knowledge_rag::retired_local_ocr_endpoint),
)
.route(
"/api/local-folder/ocr/insert",
any(knowledge_rag::retired_local_ocr_endpoint),
)
.route(
"/api/local-folder/ocr/delete",
any(knowledge_rag::retired_local_ocr_endpoint),
)
.route("/api/local-folder/ocr/status", get(local_ocr::status))
.route("/api/local-folder/ocr/read", get(local_ocr::read))
.route("/api/local-folder/ocr/insert", post(local_ocr::insert))
.route("/api/local-folder/ocr/delete", post(local_ocr::delete_job))
.route(
"/api/local-folder/workspaces/default",
post(local_folder_source::create_default_local_workspace),
+3 -1
View File
@@ -669,7 +669,9 @@ pub(crate) fn collect_filetree_render_rows(
.and_then(Value::as_str)
})
.map(str::trim)
.filter(|value| *value == "indexed" || *value == "failed")
.filter(|value| {
*value == "indexed" || *value == "indexing" || *value == "failed"
})
.map(ToOwned::to_owned),
selected,
})
+39 -47
View File
@@ -188,8 +188,7 @@ pub fn PageLayout(
<span class="wolai-public-pill" data-testid="wolai-public-state" hidden></span>
<button type="button" class="wolai-icon-button" title="星标置顶" aria-label="星标置顶" data-mnote-action="toggle-sidebar-shortcut" data-mnote-shortcut-kind="page"><span class="material-symbols-outlined" data-icon="star" aria-hidden="true"></span></button>
<button type="button" class="wolai-icon-button" title="演示" aria-label="演示"><span class="material-symbols-outlined" data-icon="slideshow" aria-hidden="true"></span></button>
<button type="button" class="wolai-icon-button mnote-local-ocr-task-toggle" title="OCR 设置" aria-label="OCR 设置" data-testid="mnote-local-ocr-task-toggle" data-mnote-action="open-ocr-settings"><span class="material-symbols-outlined" data-icon="document_scanner" aria-hidden="true"></span><span class="mnote-local-ocr-task-badge" data-mnote-local-ocr-task-count hidden>0</span></button>
<button type="button" class="wolai-icon-button" title="索引设置" aria-label="索引设置" data-testid="mnote-local-index-settings-toggle" data-mnote-action="open-index-settings"><span class="material-symbols-outlined" data-icon="manage_search" aria-hidden="true"></span></button>
<button type="button" class="wolai-icon-button" title="资料库问答" aria-label="资料库问答" data-testid="mnote-knowledge-rag-settings-toggle" data-mnote-action="open-knowledge-rag-settings"><span class="material-symbols-outlined" data-icon="travel_explore" aria-hidden="true"></span></button>
<button type="button" class="wolai-icon-button" title="评论" aria-label="评论"><span class="material-symbols-outlined" data-icon="comment" aria-hidden="true"></span></button>
<button type="button" class="wolai-icon-button" title="分享" aria-label="分享"><span class="material-symbols-outlined" data-icon="share" aria-hidden="true"></span></button>
<button type="button" class="wolai-icon-button" title="成员" aria-label="成员"><span class="material-symbols-outlined" data-icon="person_add" aria-hidden="true"></span></button>
@@ -201,7 +200,6 @@ pub fn PageLayout(
{children()}
</article>
<div class="wolai-floating-actions" aria-label="浮动操作">
<button type="button" data-testid="mnote-floating-task-toggle" class="wolai-floating-button wolai-floating-button--tasks mnote-local-ocr-task-toggle" title="后台任务" aria-label="后台任务" data-mnote-action="toggle-ocr-tasks"><span class="material-symbols-outlined" data-icon="pending_actions" aria-hidden="true"></span><span class="mnote-local-ocr-task-badge" data-mnote-local-ocr-task-count hidden>0</span></button>
<button type="button" data-testid="wolai-floating-ai" class="wolai-floating-button wolai-floating-button--ai" aria-label="AI 助手" title="点击 进入空间智能问答" data-mnote-action="open-page-ai"><span class="material-symbols-outlined material-symbols-filled" data-icon="auto_awesome" aria-hidden="true"></span></button>
</div>
</div>
@@ -489,61 +487,55 @@ mod tests {
}
#[test]
fn page_layout_exposes_standalone_index_and_ocr_settings() {
fn page_layout_exposes_lightrag_knowledge_rag_settings_only() {
let html = crate::ssr::render_view(leptos::view! {
<super::PageLayout current_nav="documents" topbar_title={"个人".to_string()}>
<main>"正文"</main>
</super::PageLayout>
});
assert!(html.contains(r#"data-testid="mnote-local-index-settings-toggle""#));
assert!(html.contains(r#"data-mnote-action="open-index-settings""#));
assert!(html.contains(r#"data-icon="manage_search""#));
assert!(html.contains(r#"data-testid="mnote-local-ocr-task-toggle""#));
assert!(html.contains(r#"data-mnote-action="open-ocr-settings""#));
assert!(html.contains(r#"data-testid="mnote-floating-task-toggle""#));
assert!(html.contains(r#"data-mnote-action="toggle-ocr-tasks""#));
assert!(html.contains(r#"data-icon="pending_actions""#));
assert!(!html.contains(r#"data-testid="mnote-local-index-settings-toggle""#));
assert!(!html.contains(r#"data-mnote-action="open-index-settings""#));
assert!(html.contains(r#"data-testid="mnote-knowledge-rag-settings-toggle""#));
assert!(html.contains(r#"data-mnote-action="open-knowledge-rag-settings""#));
assert!(html.contains(r#"data-icon="travel_explore""#));
assert!(crate::ssr::styles::MNOTE_CSS.contains(r#"data-icon="travel_explore""#));
assert!(!html.contains(r#"data-testid="mnote-local-ocr-task-toggle""#));
assert!(!html.contains(r#"data-mnote-action="open-ocr-settings""#));
assert!(!html.contains(r#"data-testid="mnote-floating-task-toggle""#));
assert!(!html.contains(r#"data-mnote-action="toggle-ocr-tasks""#));
}
#[test]
fn sidebar_settings_runtime_keeps_index_and_ocr_out_of_page_settings() {
assert!(SIDEBAR_PAGE_SETTINGS_RUNTIME_JS.contains("mnote-local-index-settings-popover"));
assert!(SIDEBAR_PAGE_SETTINGS_RUNTIME_JS.contains("mnote-local-ocr-settings-popover"));
assert!(SIDEBAR_PAGE_SETTINGS_RUNTIME_JS.contains("data-local-index-range-input"));
assert!(SIDEBAR_PAGE_SETTINGS_RUNTIME_JS.contains("data-index-status=\"' + kind"));
assert!(SIDEBAR_PAGE_SETTINGS_RUNTIME_JS.contains("if (kind === 'indexed')"));
fn sidebar_settings_runtime_routes_index_and_ocr_to_lightrag_settings() {
assert!(SIDEBAR_PAGE_SETTINGS_RUNTIME_JS.contains("mnote-knowledge-rag-settings-popover"));
assert!(SIDEBAR_PAGE_SETTINGS_RUNTIME_JS.contains("/api/knowledge-rag/status?"));
assert!(SIDEBAR_PAGE_SETTINGS_RUNTIME_JS.contains("/api/knowledge-rag/ingest"));
assert!(SIDEBAR_PAGE_SETTINGS_RUNTIME_JS.contains("/api/knowledge-rag/prune-registry"));
assert!(SIDEBAR_PAGE_SETTINGS_RUNTIME_JS.contains("use-filetree-selection"));
assert!(SIDEBAR_PAGE_SETTINGS_RUNTIME_JS.contains("data-knowledge-rag-action=\"prune\""));
assert!(SIDEBAR_PAGE_SETTINGS_RUNTIME_JS.contains("mnote-knowledge-rag-source-filters"));
assert!(SIDEBAR_PAGE_SETTINGS_RUNTIME_JS.contains("filter-sources"));
assert!(SIDEBAR_PAGE_SETTINGS_RUNTIME_JS.contains("LightRAG 未映射"));
assert!(SIDEBAR_PAGE_SETTINGS_RUNTIME_JS.contains("scheduleKnowledgeRagStatusBridge"));
assert!(SIDEBAR_PAGE_SETTINGS_RUNTIME_JS.contains("knowledgeRagStatusHasInFlight"));
assert!(SIDEBAR_PAGE_SETTINGS_RUNTIME_JS.contains("delete_submitted"));
assert!(SIDEBAR_PAGE_SETTINGS_RUNTIME_JS.contains("delete_completed"));
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(
"renderLocalIndexRangeRows(popover, currentLocalIndexRangeValues(popover).concat([''])"
),
"新增索引范围必须保留空白输入行,不能先过滤成默认 ."
SIDEBAR_TREE_RUNTIME_JS.contains("[data-mnote-action=\"open-knowledge-rag-settings\"]")
);
assert!(
SIDEBAR_PAGE_SETTINGS_RUNTIME_JS.contains("data-local-index-persisted=\"true\""),
"已保存索引范围必须锁定为不可直接修改"
);
assert!(
SIDEBAR_PAGE_SETTINGS_RUNTIME_JS
.contains("return currentLocalIndexRangeValues(popover).map"),
"删除最后一个索引范围后保存应提交空数组,不能强制回填 ."
);
assert!(
SIDEBAR_PAGE_SETTINGS_RUNTIME_JS
.contains("var savedIncludePaths = Array.isArray(settings.includePaths) ? settings.includePaths : [];"),
"索引面板默认不应把空配置回填成工作区根目录"
);
assert!(
SIDEBAR_PAGE_SETTINGS_RUNTIME_JS.contains(
"var includePaths = savedIncludePaths.length ? savedIncludePaths : indexedPaths;"
),
"无用户配置但已有索引缓存时仍应展示旧索引范围,便于删除"
);
assert!(SIDEBAR_PAGE_SETTINGS_RUNTIME_JS
.contains("data-local-ocr-settings-action=\"run-active\""));
assert!(SIDEBAR_TREE_RUNTIME_JS.contains("mnote:local-ocr-settings-action"));
assert!(SIDEBAR_TREE_RUNTIME_JS.contains("[data-mnote-action=\"toggle-ocr-tasks\"]"));
assert!(SIDEBAR_TREE_RUNTIME_JS.contains("detail: { action: 'tasks' }"));
assert!(SIDEBAR_TREE_RUNTIME_JS.contains("[data-knowledge-rag-action]"));
assert!(SIDEBAR_TREE_RUNTIME_JS.contains("useKnowledgeRagFileTreeSelection"));
assert!(SIDEBAR_TREE_RUNTIME_JS.contains("reindex-source"));
assert!(SIDEBAR_TREE_RUNTIME_JS.contains("deleteKnowledgeRagSource"));
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_TREE_RUNTIME_JS.contains("openPageIndexSettingsPopover();"));
assert!(!SIDEBAR_TREE_RUNTIME_JS.contains("openLocalOcrSettingsPopover();"));
assert!(!SIDEBAR_PAGE_SETTINGS_RUNTIME_JS.contains("data-page-settings-tab=\"index\""));
assert!(!SIDEBAR_PAGE_SETTINGS_RUNTIME_JS.contains("本地索引仅在本地工作区页面可用"));
assert!(
+168
View File
@@ -184,6 +184,7 @@ a:hover {
.material-symbols-outlined[data-icon="mode_comment"] { --mnote-icon-mask: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M5 5h14v10H9l-4 4V5Z' fill='none' stroke='black' stroke-width='2' stroke-linejoin='round'/%3E%3C/svg%3E"); }
.material-symbols-outlined[data-icon="person_add"] { --mnote-icon-mask: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Ccircle cx='9' cy='8' r='3.2' fill='none' stroke='black' stroke-width='2'/%3E%3Cpath d='M3.5 20a5.5 5.5 0 0 1 11 0M18 8v6M15 11h6' fill='none' stroke='black' stroke-width='2' stroke-linecap='round'/%3E%3C/svg%3E"); }
.material-symbols-outlined[data-icon="slideshow"] { --mnote-icon-mask: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M4 5h16v12H4z' fill='none' stroke='black' stroke-width='2' stroke-linejoin='round'/%3E%3Cpath d='m10 9 5 2-5 2zM9 21h6' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E"); }
.material-symbols-outlined[data-icon="travel_explore"] { --mnote-icon-mask: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Ccircle cx='11' cy='11' r='7' fill='none' stroke='black' stroke-width='2'/%3E%3Cpath d='M4 11h14M11 4a10 10 0 0 1 0 14M11 4a10 10 0 0 0 0 14M16.5 16.5 21 21' fill='none' stroke='black' stroke-width='2' stroke-linecap='round'/%3E%3Cpath d='M7.5 8.5h2.5l1 1.5 2.5-.5 1 2-2 1 .5 2.5h-3l-1.5-2-2 .5' fill='none' stroke='black' stroke-width='1.4' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E"); }
.material-symbols-outlined[data-icon="document_scanner"] { --mnote-icon-mask: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M7 3H5a2 2 0 0 0-2 2v2M17 3h2a2 2 0 0 1 2 2v2M7 21H5a2 2 0 0 1-2-2v-2M17 21h2a2 2 0 0 0 2-2v-2M7 8h10M7 12h10M7 16h7' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E"); }
.material-symbols-outlined[data-icon="article"] { --mnote-icon-mask: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M6 4h12v16H6zM9 8h6M9 12h6M9 16h4' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E"); }
.material-symbols-outlined[data-icon="sync"] { --mnote-icon-mask: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M20 6v5h-5M4 18v-5h5M7.5 9A6 6 0 0 1 18 6M16.5 15A6 6 0 0 1 6 18' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E"); }
@@ -1747,6 +1748,7 @@ body {
}
.sidebar-tree .tree-row[data-index-status="indexed"] .tree-kind-badge::after,
.sidebar-tree .tree-row[data-index-status="indexing"] .tree-kind-badge::after,
.sidebar-tree .tree-row[data-index-status="failed"] .tree-kind-badge::after {
position: absolute;
left: -1px;
@@ -1767,6 +1769,11 @@ body {
background: #38B86E;
}
.sidebar-tree .tree-row[data-index-status="indexing"] .tree-kind-badge::after {
content: "";
background: #D99A2B;
}
.sidebar-tree .tree-row[data-index-status="failed"] .tree-kind-badge::after {
content: "x";
background: #D94841;
@@ -3867,6 +3874,167 @@ body {
width: min(386px, calc(100vw - 24px));
}
.mnote-knowledge-rag-settings-panel {
width: min(440px, calc(100vw - 24px));
max-height: calc(100vh - 72px);
overflow-y: auto;
}
.mnote-knowledge-rag-meta {
display: flex;
flex-direction: column;
gap: 5px;
}
.mnote-knowledge-rag-meta div {
display: grid;
grid-template-columns: 72px minmax(0, 1fr);
gap: 8px;
align-items: baseline;
color: #8B8782;
font-size: 12px;
line-height: 18px;
}
.mnote-knowledge-rag-meta code {
min-width: 0;
overflow: hidden;
color: #1B1C1C;
font: 11px/16px "JetBrains Mono", "SFMono-Regular", Consolas, monospace;
text-overflow: ellipsis;
white-space: nowrap;
}
.mnote-knowledge-rag-source-list,
.mnote-knowledge-rag-source-inputs {
display: flex;
flex-direction: column;
gap: 6px;
}
.mnote-knowledge-rag-source-tools {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.mnote-knowledge-rag-control-group .wolai-page-settings-index-actions {
justify-content: flex-start;
}
.mnote-knowledge-rag-source-filters {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.mnote-knowledge-rag-source-filters button {
border: 1px solid #E3E1DE;
border-radius: 6px;
background: #FFFFFF;
color: #5F5A54;
font-size: 11px;
line-height: 18px;
padding: 2px 7px;
white-space: nowrap;
}
.mnote-knowledge-rag-source-filters button[data-active="true"] {
border-color: #B8DDBB;
background: #EAF7EA;
color: #166534;
}
.mnote-knowledge-rag-source-filters button span {
color: #8B8782;
font-variant-numeric: tabular-nums;
}
.mnote-knowledge-rag-source-row,
.mnote-knowledge-rag-source-input-row {
display: grid;
grid-template-columns: 12px minmax(0, 1fr) auto;
align-items: center;
gap: 8px;
}
.mnote-knowledge-rag-source-input-row {
grid-template-columns: minmax(0, 1fr) 56px 28px;
}
.mnote-knowledge-rag-input-status {
color: #8B8782;
font-size: 11px;
line-height: 16px;
text-align: right;
white-space: nowrap;
}
.mnote-knowledge-rag-input-status[data-status="done"] {
color: #15803D;
}
.mnote-knowledge-rag-input-status[data-status="running"],
.mnote-knowledge-rag-input-status[data-status="retry"] {
color: #B45309;
}
.mnote-knowledge-rag-input-status[data-status="failed"] {
color: #B91C1C;
}
.mnote-knowledge-rag-source-main {
display: flex;
min-width: 0;
flex-direction: column;
gap: 2px;
}
.mnote-knowledge-rag-source-main strong {
overflow: hidden;
color: #1B1C1C;
font-size: 12px;
font-weight: 600;
line-height: 17px;
text-overflow: ellipsis;
white-space: nowrap;
}
.mnote-knowledge-rag-source-main span {
overflow: hidden;
color: #8B8782;
font: 11px/16px "JetBrains Mono", "SFMono-Regular", Consolas, monospace;
text-overflow: ellipsis;
white-space: nowrap;
}
.mnote-knowledge-rag-source-actions {
display: flex;
gap: 6px;
justify-content: flex-end;
}
.mnote-knowledge-rag-source-actions button {
min-width: 0;
border: 1px solid #E3E1DE;
border-radius: 6px;
background: #FFFFFF;
color: #5F5A54;
font-size: 11px;
line-height: 18px;
padding: 2px 6px;
white-space: nowrap;
}
.mnote-knowledge-rag-source-actions button:hover:not(:disabled) {
background: #F4F3F3;
color: #1B1C1C;
}
.mnote-knowledge-rag-source-actions button:disabled {
opacity: 0.45;
}
.wolai-page-settings-tabs {
display: flex;
gap: 6px;
+1 -1
View File
@@ -36,7 +36,7 @@ node scripts/task490-runtime-surfaces-smoke.js
- Rust SSR 文档页 / Page Aggregatelocal-first 默认优先 `task167-local-markdown-title-body-options-no-convex-smoke.js`Page Aggregate browser conversion / compat fallback 改动补跑 `task522-page-aggregate-compat-fallback-contract.js`cloud/control-plane 文档可补跑 `task110-page-title-single-truth-smoke.js``task-page-aggregate-body-sync-smoke.js``task-page-aggregate-options-sync-smoke.js``task-page-aggregate-refresh-persistence-smoke.js`
- leptos-tiptap runtime surface`task490-runtime-surfaces-smoke.js`;需要验证保存回读可补跑 `task121-rust-web-editor-island-hydration-smoke.js`,但它仍使用 `/api/tree/commands create` 准备文档,不作为无 Convex 默认基线;需要验证浮层互斥和更多菜单状态时再跑 `task158-e30-menu-state-smoke.js`
- local-first 本地工作区:`task164-desktop-hot-local-folder-main-entry-smoke.js``task166-local-first-managed-workspace-no-convex-smoke.js``task167-local-markdown-title-body-options-no-convex-smoke.js``task436-local-markdown-open-document-external-change-smoke.js``task443-local-markdown-asset-upload-smoke.js``task451-local-markdown-conflict-resolution-ui-smoke.js``task452-local-search-index-browser-smoke.js``task453-local-folder-page-ai-changed-files-smoke.js`WorkspacePath / ObjectIdentity runtime 消费统一改动补跑 `task524-workspace-object-identity-matrix-smoke.js`
- local-folder OCR`task526-local-folder-ocr-api-smoke.js` 覆盖图片 resource tab 手动 OCR、工具条状态、mock OCR API、sidecar 落盘、jobs/status/read、全局 OCR 任务栏 / 任务抽屉、`includeOcr=true` 搜索 owner page、显式插入 OCR 链接和 OCR sidecar Markdown resource tab 打开;`TASK520_OCR_CONTEXT=1 node scripts/task520-page-ai-raw-resource-target-smoke.js` 覆盖 Page AI active image/PDF resource target 读取 OCR sidecar context
- 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 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`
+57 -112
View File
@@ -44,11 +44,9 @@ function isWriteMnoteTool(toolName) {
'mnote.context.snapshot',
'mnote.context.resolve_target',
'mnote.context.read_current_page',
'mnote.evidence.search',
'mnote.evidence.read',
'mnote.evidence.open',
'mnote.index.status',
'mnote.index.refresh',
'mnote.knowledge_rag.status',
'mnote.knowledge_rag.query',
'mnote.knowledge_rag.open_reference',
'mnote.doc.fetch',
'mnote.page.get',
'mnote.block.fetch',
@@ -248,8 +246,11 @@ if (process.env.MNOTE_REASONIX_ACP_SELFTEST === '1') {
if (!promptWithEnvelope.includes('native file tools')) {
throw new Error('selftest expected prompt to instruct native file tools');
}
if (!promptWithEnvelope.includes('Do not use MNote doc/page write tools for ordinary local Markdown edits.')) {
throw new Error('selftest expected prompt to forbid MNote doc/page write tools for ordinary local Markdown edits');
}
const evidencePayload = buildMnoteToolPayload(
'mnote.evidence.search',
'mnote.knowledge_rag.query',
{ query: 'ResourceBodyToken' },
{
workspaceId: 'ws_local',
@@ -266,8 +267,8 @@ if (process.env.MNOTE_REASONIX_ACP_SELFTEST === '1') {
if (evidencePayload.rootUri !== 'file:///tmp/mnote-local') {
throw new Error('selftest expected evidence payload to inherit local root context');
}
if (isWriteMnoteTool('mnote.evidence.search')) {
throw new Error('selftest expected evidence search to be read-only');
if (isWriteMnoteTool('mnote.knowledge_rag.query')) {
throw new Error('selftest expected knowledge RAG query to be read-only');
}
const successfulEvidenceToolResult = JSON.stringify({ ok: true, error: null, result: { ok: true } });
if (toolResultStatusFromContent(successfulEvidenceToolResult) !== 'completed') {
@@ -502,12 +503,9 @@ const MNOTE_TOOL_NAMES = [
'mnote.context.snapshot',
'mnote.context.resolve_target',
'mnote.context.read_current_page',
'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',
];
const REASONIX_TOOL_TO_MNOTE_TOOL = {
@@ -515,12 +513,9 @@ const REASONIX_TOOL_TO_MNOTE_TOOL = {
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_evidence_search: 'mnote.evidence.search',
mnote_evidence_read: 'mnote.evidence.read',
mnote_evidence_open: 'mnote.evidence.open',
mnote_index_status: 'mnote.index.status',
mnote_index_refresh: 'mnote.index.refresh',
mnote_index_update_settings: 'mnote.index.update_settings',
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) {
@@ -602,117 +597,65 @@ tools.register({
});
tools.register({
name: 'mnote_evidence_search',
description: '搜索 MNote 本地文档和资源证据,返回 quote、locator 与 openAction。查询语言与资料语言可能不一致时,先在提示层做轻量多语关键词扩展,再用简短关键词检索。',
name: 'mnote_knowledge_rag_status',
description: '查看 LightRAG 资料库 provider 状态、dashboard 地址和 MNote source registry。',
parameters: {
type: 'object',
properties: {
query: { type: 'string', description: '要搜索的问题或关键词' },
workspaceId: { type: 'string', description: 'MNote workspace ID,可省略并使用当前上下文' },
rootUri: { type: 'string', description: 'local folder rootUri,可省略并使用当前上下文' },
targetDocumentId: { type: 'string', description: '可选,限制到当前文档' },
includeResources: { type: 'boolean', description: '是否包含附件/资源标题' },
includeOcr: { type: 'boolean', description: '是否包含 OCR/source-map 证据' },
mode: { type: 'string', enum: ['hybrid', 'tree', 'graph'], description: '检索模式' },
topK: { type: 'integer', description: '最多返回结果数' },
scope: { type: 'object', description: '完整 EvidenceSearchScope,提供时优先使用' },
},
},
readOnly: true,
fn: async (args) => callMnoteTool(REASONIX_TOOL_TO_MNOTE_TOOL.mnote_knowledge_rag_status, args),
parallelSafe: true,
});
tools.register({
name: 'mnote_knowledge_rag_query',
description: '向 LightRAG 资料库提问,返回 answer、provider 原始结果和经 MNote registry 映射后的 references。回答必须引用返回来源。',
parameters: {
type: 'object',
properties: {
query: { type: 'string', description: '资料库问题' },
question: { type: 'string', description: '资料库问题,等价于 query' },
workspaceId: { type: 'string', description: 'MNote workspace ID,可省略并使用当前上下文' },
rootUri: { type: 'string', description: 'local folder rootUri,可省略并使用当前上下文' },
mode: { type: 'string', enum: ['mix', 'local', 'global', 'naive', 'bypass'], description: 'LightRAG query mode; use mix by default for knowledge-library questions' },
topK: { type: 'integer', description: 'LightRAG top_k' },
chunkTopK: { type: 'integer', description: 'LightRAG chunk_top_k' },
includeChunkContent: { type: 'boolean', description: '是否在 reference 中包含 chunk 内容' },
sourcePaths: {
type: 'array',
items: { type: 'string' },
description: '可选 MNote workspace 相对路径范围;可传文件或目录,返回 references 会限制在这些来源内。',
},
},
required: ['query'],
},
readOnly: true,
fn: async (args) => callMnoteTool(REASONIX_TOOL_TO_MNOTE_TOOL.mnote_evidence_search, args),
parallelSafe: true,
});
tools.register({
name: 'mnote_evidence_read',
description: '按 EvidenceLocator 读取原文证据及周边上下文。',
parameters: {
type: 'object',
properties: {
locator: { type: 'object', description: 'mnote_evidence_search 返回的 source/locator' },
context: {
type: 'object',
properties: {
beforeBlocks: { type: 'integer' },
afterBlocks: { type: 'integer' },
includeSectionSummary: { type: 'boolean' },
},
},
},
required: ['locator'],
},
readOnly: true,
fn: async (args) => callMnoteTool(REASONIX_TOOL_TO_MNOTE_TOOL.mnote_evidence_read, args),
parallelSafe: true,
});
tools.register({
name: 'mnote_evidence_open',
description: '把 EvidenceLocator 归一化为 MNote 可执行的打开/定位动作。',
parameters: {
type: 'object',
properties: {
locator: { type: 'object', description: 'mnote_evidence_search 返回的 source/locator' },
},
required: ['locator'],
},
readOnly: true,
fn: async (args) => callMnoteTool(REASONIX_TOOL_TO_MNOTE_TOOL.mnote_evidence_open, args),
parallelSafe: true,
});
tools.register({
name: 'mnote_index_status',
description: '查看 MNote 本地索引范围、缓存文件状态、文档数和 evidence block 数。',
parameters: {
type: 'object',
properties: {
workspaceId: { type: 'string', description: 'MNote workspace ID,可省略并使用当前上下文' },
rootUri: { type: 'string', description: 'local folder rootUri,可省略并使用当前上下文' },
},
},
readOnly: true,
fn: async (args) => callMnoteTool(REASONIX_TOOL_TO_MNOTE_TOOL.mnote_index_status, args),
parallelSafe: true,
});
tools.register({
name: 'mnote_index_refresh',
description: '按当前有效范围重建 MNote 本地搜索/evidence 缓存,不修改 Markdown 正文。',
parameters: {
type: 'object',
properties: {
workspaceId: { type: 'string', description: 'MNote workspace ID,可省略并使用当前上下文' },
rootUri: { type: 'string', description: 'local folder rootUri,可省略并使用当前上下文' },
},
},
readOnly: true,
fn: async (args) => callMnoteTool(REASONIX_TOOL_TO_MNOTE_TOOL.mnote_index_refresh, args),
fn: async (args) => callMnoteTool(REASONIX_TOOL_TO_MNOTE_TOOL.mnote_knowledge_rag_query, args),
parallelSafe: false,
});
tools.register({
name: 'mnote_index_update_settings',
description: '新增或删除 MNote 本地索引范围;includePaths 为空表示删除当前用户范围。',
name: 'mnote_knowledge_rag_open_reference',
description: '把 LightRAG reference 映射为 MNote 可打开的本地 resource action;没有 registry 命中时返回定位降级。',
parameters: {
type: 'object',
properties: {
reference: { type: 'object', description: 'mnote_knowledge_rag_query 返回的 reference' },
referenceId: { type: 'string' },
filePath: { type: 'string', description: 'LightRAG reference file_path' },
chunkId: { type: 'string' },
workspaceId: { type: 'string', description: 'MNote workspace ID,可省略并使用当前上下文' },
rootUri: { type: 'string', description: 'local folder rootUri,可省略并使用当前上下文' },
includePaths: { type: 'array', items: { type: 'string' }, description: 'root 内相对路径列表,空数组表示删除范围' },
scheduleMode: { type: 'string', enum: ['manual', 'daily', 'weekly', 'monthly'], description: '刷新计划' },
scheduleTime: { type: 'string', description: 'HH:mm' },
scheduleDate: { type: 'string', description: '可选日期' },
runOnChange: { type: 'boolean', description: '文件变化时是否自动刷新' },
dryRun: { type: 'boolean', description: 'true 只返回计划;false 写入设置并刷新索引' },
idempotencyKey: { type: 'string', description: '写入幂等键' },
},
required: ['includePaths', 'dryRun', 'idempotencyKey'],
required: ['filePath'],
},
readOnly: false,
fn: async (args) => callMnoteTool(REASONIX_TOOL_TO_MNOTE_TOOL.mnote_index_update_settings, args),
parallelSafe: false,
readOnly: true,
fn: async (args) => callMnoteTool(REASONIX_TOOL_TO_MNOTE_TOOL.mnote_knowledge_rag_open_reference, args),
parallelSafe: true,
});
// ── Session Store ────────────────────────────────────
@@ -751,10 +694,12 @@ onRequest('session/new', async (params) => {
'You are a helpful AI assistant inside MNote.',
'MNote capabilities are optional tools. Use them only when the user task requires MNote page, file, folder, attachment, or workspace context.',
'Use your native agent file tools for local file reads and edits inside allowed roots; MNote tools only provide target and context metadata.',
'Do not use MNote doc/page write tools for ordinary local Markdown edits.',
'Final answers must be direct user-facing answers. Do not narrate tool use, search steps, plans, or internal process; do not say phrases like "let me search", "I found", "I will check", or "让我".',
'<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-local-index — Search local documents with clickable evidence locators and manage local index scopes. When the query language may differ from the corpus language, infer likely corpus terms from filenames/titles/domain context, expand 2-6 concise multilingual keywords, and call mnote_evidence_search with the best short keyword queries. Do not assume the answer language from the corpus; answer in the user language and cite only retrieved evidence.',
'- 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-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>',
@@ -120,9 +120,7 @@ async function main() {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "mnote-task520-raw-target-"));
const rootUri = fileUrl(root);
const pagePath = "Page.md";
const expectOcrContext = process.env.TASK520_OCR_CONTEXT === "1";
const rawPath = expectOcrContext ? "Page/photo.png" : "Page/notes.txt";
const ocrPath = "Page.ocr/photo.png.ocr.md";
const rawPath = "Page/notes.txt";
const documentId = localMdDocumentId(pagePath);
const captured = [];
let caughtError = null;
@@ -130,41 +128,7 @@ async function main() {
fs.mkdirSync(path.join(root, "Page"), { recursive: true });
writeWorkspaceManifest(root, actorId, workspaceId);
fs.writeFileSync(path.join(root, pagePath), "# Page\n\nTask520 page\n", "utf8");
if (expectOcrContext) {
fs.writeFileSync(path.join(root, rawPath), Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a]));
fs.mkdirSync(path.join(root, "Page.ocr"), { recursive: true });
fs.writeFileSync(
path.join(root, ocrPath),
"---\nmnote_ocr_version: 1\nprovider: mock\nmodel_version: vlm\nowner_document: ../Page.md\nsource_path: ./Page/photo.png\nsource_root_relative_path: Page/photo.png\nsource_size: 6\nsource_mtime_ms: 1\nstatus: done\ncreated_at: 1\nupdated_at: 1\n---\n\nTask520 OCR sidecar context text\n",
"utf8",
);
fs.writeFileSync(
path.join(root, ".mnote", "ocr-index.json"),
`${JSON.stringify({
version: 1,
entries: {
[rawPath]: {
jobId: "ocr_task520",
ownerDocumentId: documentId,
ownerDocumentPath: pagePath,
sourceRootRelativePath: rawPath,
ocrRootRelativePath: ocrPath,
provider: "mock",
modelVersion: "vlm",
status: "done",
sourceSize: 6,
sourceMtimeMs: 1,
createdAtMs: 1,
updatedAtMs: 1,
plainTextPreview: "Task520 OCR sidecar context text",
},
},
}, null, 2)}\n`,
"utf8",
);
} else {
fs.writeFileSync(path.join(root, rawPath), "Task520 raw resource target\n", "utf8");
}
fs.writeFileSync(path.join(root, rawPath), "Task520 raw resource target\n", "utf8");
const browser = await chromium.launch({
headless: process.env.HEADFUL !== "1",
@@ -323,16 +287,12 @@ async function main() {
assert.strictEqual(runBody.targetPackage?.currentFile?.relativePath, rawPath, `targetPackage currentFile 应指向 raw resource: ${JSON.stringify(runBody.targetPackage)}`);
assert.strictEqual(runBody.targetPackage?.currentFile?.objectIdentity, "resource:file:" + rootUri + ":" + rawPath, `currentFile objectIdentity 不应退化为 [object Object]: ${JSON.stringify(runBody.targetPackage?.currentFile)}`);
assert(runBody.targetPackage?.allowedFiles?.includes(rawPath), `allowedFiles 应只包含 raw resource: ${JSON.stringify(runBody.targetPackage?.allowedFiles)}`);
if (expectOcrContext) {
assert.strictEqual(activeEditorRef.ocrContext?.source, "local_ocr_sidecar", `active_editor 应携带 OCR sidecar context: ${JSON.stringify(activeEditorRef)}`);
assert.strictEqual(activeEditorRef.ocrContext?.ocrRootRelativePath, ocrPath, `active_editor OCR path 不正确: ${JSON.stringify(activeEditorRef.ocrContext)}`);
assert(activeEditorRef.ocrContext?.plainTextPreview?.includes("Task520 OCR sidecar context text"), `active_editor OCR preview 缺失: ${JSON.stringify(activeEditorRef.ocrContext)}`);
assert.strictEqual(runBody.targetPackage?.ocrContext?.ocrRootRelativePath, ocrPath, `targetPackage 应携带 OCR sidecar context: ${JSON.stringify(runBody.targetPackage)}`);
assert.strictEqual(runBody.targetPackage?.currentFile?.ocrRootRelativePath, ocrPath, `currentFile 应携带 OCR sidecar path: ${JSON.stringify(runBody.targetPackage?.currentFile)}`);
}
assert.strictEqual(activeEditorRef.ocrContext, undefined, `OCR sidecar context 已退役,active_editor 不应携带 ocrContext: ${JSON.stringify(activeEditorRef)}`);
assert.strictEqual(runBody.targetPackage?.ocrContext, undefined, `OCR sidecar context 已退役,targetPackage 不应携带 ocrContext: ${JSON.stringify(runBody.targetPackage)}`);
assert.strictEqual(runBody.targetPackage?.currentFile?.ocrRootRelativePath, undefined, `OCR sidecar path 已退役,currentFile 不应携带 ocrRootRelativePath: ${JSON.stringify(runBody.targetPackage?.currentFile)}`);
const screenshot = await saveScreenshot(page, "01-raw-resource-target");
const result = { ok: true, task: TASK, baseUrl: BASE_URL, root, rootUri, documentId, rawPath, ocrPath: expectOcrContext ? ocrPath : "", screenshot, captured };
const result = { ok: true, task: TASK, baseUrl: BASE_URL, root, rootUri, documentId, rawPath, screenshot, captured };
fs.writeFileSync(RESULT_PATH, `${JSON.stringify(result, null, 2)}\n`, "utf8");
console.log(JSON.stringify(result, null, 2));
} catch (error) {
@@ -1,436 +0,0 @@
#!/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 { ensureAuthenticated, UI_TIMEOUT_MS } = require("./tree-shell-smoke-helpers");
const BASE_URL = (process.env.MNOTE_WEB_SMOKE_BASE_URL || process.env.MNOTE_UI_BASE_URL || "http://127.0.0.1:3000").replace(/\/+$/, "");
const TASK = "task526-local-folder-ocr-api-smoke";
const OUTPUT_DIR = path.join(process.cwd(), "tmp", TASK);
const RESULT_PATH = path.join(OUTPUT_DIR, "result.json");
function fileUrl(localPath) {
return `file://${localPath.split(path.sep).map((part, index) => (
index === 0 ? "" : encodeURIComponent(part)
)).join("/")}`;
}
function localMdDocumentId(relativePath) {
return `local-md:${Buffer.from(relativePath, "utf8")
.toString("hex")
.replace(/../g, (hex) => {
const code = Number.parseInt(hex, 16);
const ch = String.fromCharCode(code);
return /[A-Za-z0-9._-]/.test(ch) ? ch : `~${hex.toUpperCase()}`;
})}`;
}
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) {
const metadataDir = path.join(root, ".mnote");
fs.mkdirSync(metadataDir, { recursive: true });
fs.writeFileSync(
path.join(metadataDir, "workspace.json"),
`${JSON.stringify({
workspaceId: `local-ws:${ownerId}:task526`,
ownerId,
createdAt: new Date().toISOString(),
capabilities: ["local_files", "markdown_edit", "ocr"],
}, null, 2)}\n`,
"utf8",
);
}
async function writeResult(payload) {
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
fs.writeFileSync(RESULT_PATH, `${JSON.stringify({ ...payload, resultPath: RESULT_PATH }, null, 2)}\n`, "utf8");
}
async function ensureDocumentVisible(page, root, relativePath) {
await page.goto(documentUrl(root, relativePath), { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
if (new URL(page.url()).pathname === "/auth") {
const quickLogin = page.getByRole("button", { name: "测试账号快速登录" });
await quickLogin.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await quickLogin.click({ timeout: UI_TIMEOUT_MS });
await page.waitForURL((url) => url.pathname !== "/auth", { timeout: UI_TIMEOUT_MS }).catch(() => undefined);
await page.goto(documentUrl(root, relativePath), { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
}
await page.locator('[data-testid="mnote-leptos-tiptap-island-editor-root"]').first().waitFor({
state: "visible",
timeout: UI_TIMEOUT_MS,
});
}
async function main() {
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
const root = fs.mkdtempSync(path.join(os.tmpdir(), "mnote-task526-ocr-"));
const actorId = "mnote-e2e";
const workspaceId = `local-ws:${actorId}:task526`;
const relativePath = "docs/Page.md";
const documentId = localMdDocumentId(relativePath);
const sourceRootRelativePath = "docs/Page.assets/photo.png";
const failedSourceRootRelativePath = "docs/Page.assets/photo-failed.png";
const ocrToken = "TASK526_OCR_TOKEN";
writeWorkspaceManifest(root, actorId);
fs.mkdirSync(path.join(root, "docs", "Page.assets"), { recursive: true });
fs.writeFileSync(path.join(root, relativePath), "# OCR Page\n\n![photo](<./Page.assets/photo.png>)\n", "utf8");
const tinyPng = Buffer.from(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAFgwJ/l8FQOQAAAABJRU5ErkJggg==",
"base64",
);
fs.writeFileSync(path.join(root, sourceRootRelativePath), tinyPng);
fs.writeFileSync(path.join(root, failedSourceRootRelativePath), tinyPng);
const browser = await chromium.launch({ headless: true });
const context = await browser.newContext();
const page = await context.newPage();
const screenshots = {};
try {
await ensureAuthenticated(page, context.request);
await ensureDocumentVisible(page, root, relativePath);
await page.waitForFunction(() => {
const image = document.querySelector('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror img');
return image instanceof HTMLImageElement
&& image.complete
&& image.naturalWidth > 0
&& image.src.includes("Page.assets%2Fphoto.png")
&& !image.src.includes("%3C")
&& !image.src.includes("%3E");
}, null, { timeout: UI_TIMEOUT_MS });
screenshots.page = path.join(OUTPUT_DIR, "01-page.png");
await page.screenshot({ path: screenshots.page, fullPage: true });
await page.waitForFunction(() => Boolean(window.__mnoteDocumentPaneRuntime?.openResourceInActiveTab), null, {
timeout: UI_TIMEOUT_MS,
});
await page.evaluate(async ({ rootUri, documentId, sourceRootRelativePath, workspaceId }) => {
window.__MNOTE_LOCAL_OCR_PROVIDER = "mock";
const title = sourceRootRelativePath.split("/").filter(Boolean).pop() || "photo.png";
const fileUrl = new URL("/api/local-folder/files/open", window.location.origin);
fileUrl.searchParams.set("rootUri", rootUri);
fileUrl.searchParams.set("path", sourceRootRelativePath);
const opened = await window.__mnoteDocumentPaneRuntime.openResourceInActiveTab({
objectIdentity: `local-file:${sourceRootRelativePath}`,
assetId: `local-file:${sourceRootRelativePath}`,
title,
fileName: title,
kind: "image",
rootUri,
path: sourceRootRelativePath,
href: fileUrl.toString(),
documentId,
ownerDocumentId: documentId,
workspaceId,
sourceKind: "local_folder",
});
if (!opened) throw new Error("OCR source image resource tab did not open");
}, {
rootUri: fileUrl(root),
documentId,
sourceRootRelativePath,
workspaceId,
});
await page.locator('[data-mnote-resource-tab-panel]:not([hidden]) [data-testid="mnote-local-ocr-toolbar"]').first().waitFor({
state: "detached",
timeout: UI_TIMEOUT_MS,
});
const watchBatchBeforeOcr = await page.evaluate(() => document.documentElement.getAttribute("data-mnote-local-folder-watch-batch-applied") || "");
await page.getByTestId("mnote-local-ocr-task-toggle").waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await page.getByTestId("mnote-local-ocr-task-toggle").click({ timeout: UI_TIMEOUT_MS });
await page.getByTestId("mnote-local-ocr-settings-popover").waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await page.locator('[data-local-ocr-settings-action="run-active"]').click({ timeout: UI_TIMEOUT_MS });
await page.getByTestId("mnote-local-ocr-task-drawer").waitFor({ state: "hidden", timeout: UI_TIMEOUT_MS });
const topbarOcrButtonInfo = await page.getByTestId("mnote-local-ocr-task-toggle").evaluate((node) => {
const topbar = node.closest(".wolai-topbar-actions");
return {
inTopbar: Boolean(topbar),
action: node.getAttribute("data-mnote-action") || "",
label: node.getAttribute("aria-label") || "",
text: node.textContent || "",
badge: node.querySelector("[data-mnote-local-ocr-task-count]")?.textContent || "",
};
});
assert.equal(topbarOcrButtonInfo.inTopbar, true, `OCR 设置入口应位于右上角 topbar: ${JSON.stringify(topbarOcrButtonInfo)}`);
assert.equal(topbarOcrButtonInfo.action, "open-ocr-settings", `OCR 顶栏按钮应打开设置: ${JSON.stringify(topbarOcrButtonInfo)}`);
await page.waitForFunction(
(sourcePath) => {
const row = document.querySelector(`[data-mnote-local-ocr-task-row="${CSS.escape(sourcePath)}"]`);
return row && row.getAttribute("data-mnote-local-ocr-task-status") === "done";
},
sourceRootRelativePath,
{ timeout: UI_TIMEOUT_MS },
);
await page.getByTestId("mnote-local-ocr-task-toggle").click({ timeout: UI_TIMEOUT_MS });
await page.getByTestId("mnote-local-ocr-settings-popover").waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await page.locator('[data-local-ocr-settings-action="tasks"]').click({ timeout: UI_TIMEOUT_MS });
await page.getByTestId("mnote-local-ocr-task-drawer").waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
const uiOcrPath = await page.evaluate(async ({ rootUri, sourceRootRelativePath }) => {
const url = new URL("/api/local-folder/ocr/status", window.location.origin);
url.searchParams.set("rootUri", rootUri);
url.searchParams.set("sourceRootRelativePath", sourceRootRelativePath);
const response = await fetch(url.toString(), { cache: "no-store", headers: { accept: "application/json" } });
const payload = await response.json().catch(() => null);
return payload?.job?.ocrRootRelativePath || "";
}, {
rootUri: fileUrl(root),
sourceRootRelativePath,
});
assert(uiOcrPath && uiOcrPath.endsWith(".ocr.md"), `UI OCR path invalid: ${uiOcrPath}`);
await page.waitForFunction(
({ before, ocrPath }) => {
const root = document.documentElement;
const marker = root.getAttribute("data-mnote-local-ocr-filetree-refresh") || "";
const applied = root.getAttribute("data-mnote-local-folder-watch-batch-applied") || "";
return marker === ocrPath && applied && applied !== before;
},
{ before: watchBatchBeforeOcr, ocrPath: uiOcrPath },
{ timeout: UI_TIMEOUT_MS },
);
screenshots.ocrToolbar = path.join(OUTPUT_DIR, "02-ocr-toolbar.png");
await page.screenshot({ path: screenshots.ocrToolbar, fullPage: true });
const failedOcrRoute = async (route) => {
if (route.request().method() !== "POST") return route.fallback();
const body = route.request().postDataJSON();
if (body?.sourceRootRelativePath !== failedSourceRootRelativePath) return route.fallback();
return route.fulfill({
status: 401,
contentType: "application/json",
body: JSON.stringify({ ok: false, error: { message: "local_ocr_job_failed_401" } }),
});
};
await page.route("**/api/local-folder/ocr/jobs", failedOcrRoute);
await page.evaluate(async ({ rootUri, documentId, sourceRootRelativePath, workspaceId }) => {
window.__MNOTE_LOCAL_OCR_PROVIDER = "mineru";
const title = sourceRootRelativePath.split("/").filter(Boolean).pop() || "photo-failed.png";
const fileUrl = new URL("/api/local-folder/files/open", window.location.origin);
fileUrl.searchParams.set("rootUri", rootUri);
fileUrl.searchParams.set("path", sourceRootRelativePath);
const opened = await window.__mnoteDocumentPaneRuntime.openResourceInActiveTab({
objectIdentity: `local-file:${sourceRootRelativePath}`,
assetId: `local-file:${sourceRootRelativePath}`,
title,
fileName: title,
kind: "image",
rootUri,
path: sourceRootRelativePath,
href: fileUrl.toString(),
documentId,
ownerDocumentId: documentId,
workspaceId,
sourceKind: "local_folder",
});
if (!opened) throw new Error("OCR failed source image resource tab did not open");
}, {
rootUri: fileUrl(root),
documentId,
sourceRootRelativePath: failedSourceRootRelativePath,
workspaceId,
});
const failedImageTab = page.locator('.mnote-main-tab[data-mnote-tab-kind="image"]', { hasText: "photo-failed.png" }).first();
await failedImageTab.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await failedImageTab.click({ timeout: UI_TIMEOUT_MS });
await page.waitForFunction(
() => {
const active = document.querySelector('.mnote-main-tab.is-active[data-mnote-tab-kind="image"] .mnote-main-tab-title');
return active && (active.textContent || "").includes("photo-failed.png");
},
null,
{ timeout: UI_TIMEOUT_MS },
);
await page.locator('[data-mnote-resource-tab-panel]:not([hidden]) [data-testid="mnote-local-ocr-toolbar"]').first().waitFor({
state: "detached",
timeout: UI_TIMEOUT_MS,
});
await page.getByTestId("mnote-local-ocr-task-toggle").click({ timeout: UI_TIMEOUT_MS });
await page.getByTestId("mnote-local-ocr-settings-popover").waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await page.locator('[data-local-ocr-settings-action="run-active"]').click({ timeout: UI_TIMEOUT_MS });
await page.waitForFunction(
(sourcePath) => {
const row = document.querySelector(`[data-mnote-local-ocr-task-row="${CSS.escape(sourcePath)}"]`);
return row
&& row.getAttribute("data-mnote-local-ocr-task-status") === "failed"
&& (row.textContent || "").includes("local_ocr_job_failed_401");
},
failedSourceRootRelativePath,
{ timeout: UI_TIMEOUT_MS },
);
screenshots.ocrFailedTask = path.join(OUTPUT_DIR, "04-ocr-failed-task.png");
await page.screenshot({ path: screenshots.ocrFailedTask, fullPage: true });
await page.unroute("**/api/local-folder/ocr/jobs", failedOcrRoute);
const openResourceResult = await page.evaluate(async ({ rootUri, documentId, ocrRootRelativePath, workspaceId }) => {
const runtime = window.__mnoteDocumentPaneRuntime;
if (!runtime || typeof runtime.openResourceInActiveTab !== "function") {
throw new Error("缺少 openResourceInActiveTab runtime");
}
const title = ocrRootRelativePath.split("/").filter(Boolean).pop() || "OCR";
return await runtime.openResourceInActiveTab({
kind: "markdown",
title,
path: ocrRootRelativePath,
objectIdentity: `local-ocr:${ocrRootRelativePath}`,
assetId: `local-ocr:${ocrRootRelativePath}`,
documentId,
ownerDocumentId: documentId,
workspaceId,
sourceKind: "local_folder",
rootUri,
resourceKind: "markdown",
});
}, {
rootUri: fileUrl(root),
documentId,
ocrRootRelativePath: uiOcrPath,
workspaceId,
});
assert.equal(openResourceResult, true, "OCR sidecar resource tab should open");
await page.locator('[data-mnote-resource-tab-panel][data-resource-kind="markdown"]:not([hidden]) [data-testid="mnote-leptos-tiptap-island-editor-root"]').first().waitFor({
state: "visible",
timeout: UI_TIMEOUT_MS,
});
await page.waitForFunction(
() => {
const active = document.querySelector('[data-mnote-resource-tab-panel][data-resource-kind="markdown"]:not([hidden]) .ProseMirror');
return active && (active.textContent || "").includes("OCR UI smoke text");
},
null,
{ timeout: UI_TIMEOUT_MS },
).catch(async (error) => {
const debug = await page.evaluate(() => ({
activeTab: document.querySelector('.mnote-main-tab.is-active')?.outerHTML || '',
activePanels: Array.from(document.querySelectorAll('[data-mnote-resource-tab-panel]:not([hidden])')).map((panel) => ({
kind: panel.getAttribute('data-resource-kind') || '',
objectIdentity: panel.getAttribute('data-mnote-object-identity') || '',
text: (panel.textContent || '').slice(0, 200),
html: panel.innerHTML.slice(0, 500),
})),
marker: document.documentElement.getAttribute('data-mnote-local-ocr-filetree-open') || '',
}));
throw new Error(`${error.message}; debug=${JSON.stringify(debug)}`);
});
const fileTreeOpenResult = await page.evaluate((ocrRootRelativePath) => {
const parentPath = ocrRootRelativePath.split("/").slice(0, -1).join("/");
const parentRow = document.querySelector(`#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-local-relative-path="${CSS.escape(parentPath)}"]`);
if (!(parentRow instanceof HTMLElement)) return { ok: false, reason: "ocr_parent_row_missing", parentPath };
if (parentRow.getAttribute("aria-expanded") !== "true") {
const toggle = parentRow.querySelector('[data-rust-action="toggle"]');
if (toggle instanceof HTMLElement) toggle.click();
}
return { ok: true, parentPath };
}, uiOcrPath);
assert.equal(fileTreeOpenResult.ok, true, `OCR sidecar parent should exist in filetree: ${JSON.stringify(fileTreeOpenResult)}`);
const ocrFileTreeOpenResult = await page.waitForFunction(
(ocrRootRelativePath) => {
const rows = Array.from(document.querySelectorAll('#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"]'));
const exact = rows.find((row) => row.getAttribute("data-local-relative-path") === ocrRootRelativePath);
const fileName = ocrRootRelativePath.split("/").filter(Boolean).pop() || ocrRootRelativePath;
const fallback = rows.find((row) => {
const relativePath = row.getAttribute("data-local-relative-path") || "";
return relativePath.endsWith(`/${fileName}`) || relativePath === fileName || (row.textContent || "").includes(fileName);
});
const target = exact || fallback;
if (!(target instanceof HTMLElement)) return false;
target.click();
return {
ok: true,
exact: Boolean(exact),
relativePath: target.getAttribute("data-local-relative-path") || "",
};
},
uiOcrPath,
{ timeout: UI_TIMEOUT_MS },
).then((handle) => handle.jsonValue());
assert.equal(ocrFileTreeOpenResult.ok, true, `OCR sidecar filetree row should open: ${JSON.stringify(ocrFileTreeOpenResult)}`);
await page.waitForFunction(
() => document.documentElement.getAttribute("data-mnote-local-ocr-filetree-open") === "resource-tab",
null,
{ timeout: UI_TIMEOUT_MS },
);
await page.waitForFunction(
() => {
const active = document.querySelector('[data-mnote-resource-tab-panel][data-resource-kind="markdown"]:not([hidden]) .ProseMirror');
return active && (active.textContent || "").includes("OCR UI smoke text");
},
null,
{ timeout: UI_TIMEOUT_MS },
).catch(async (error) => {
const debug = await page.evaluate(() => ({
activeTab: document.querySelector('.mnote-main-tab.is-active')?.outerHTML || '',
activePanels: Array.from(document.querySelectorAll('[data-mnote-resource-tab-panel]:not([hidden])')).map((panel) => ({
kind: panel.getAttribute('data-resource-kind') || '',
objectIdentity: panel.getAttribute('data-mnote-object-identity') || '',
text: (panel.textContent || '').slice(0, 200),
html: panel.innerHTML.slice(0, 500),
})),
marker: document.documentElement.getAttribute('data-mnote-local-ocr-filetree-open') || '',
}));
throw new Error(`${error.message}; debug=${JSON.stringify(debug)}`);
});
screenshots.ocrResource = path.join(OUTPUT_DIR, "03-ocr-resource-tab.png");
await page.screenshot({ path: screenshots.ocrResource, fullPage: true });
await page.evaluate((sourcePath) => {
const row = document.querySelector(`[data-mnote-local-ocr-task-row="${CSS.escape(sourcePath)}"]`);
const clear = row && row.querySelector("[data-mnote-local-ocr-task-clear]");
if (!(clear instanceof HTMLButtonElement)) throw new Error("missing OCR clear button");
clear.click();
}, failedSourceRootRelativePath);
await page.waitForFunction(
(sourcePath) => !document.querySelector(`[data-mnote-local-ocr-task-row="${CSS.escape(sourcePath)}"]`),
failedSourceRootRelativePath,
{ timeout: UI_TIMEOUT_MS },
);
const deleteButtonVisible = await page.evaluate((sourcePath) => {
const row = document.querySelector(`[data-mnote-local-ocr-task-row="${CSS.escape(sourcePath)}"]`);
return Boolean(row && row.querySelector("[data-mnote-local-ocr-task-delete]"));
}, sourceRootRelativePath);
assert.equal(deleteButtonVisible, true, "已完成 OCR 任务应展示删除 OCR 按钮");
await writeResult({
ok: true,
task: TASK,
root,
documentId,
ocrRootRelativePath: uiOcrPath,
screenshots,
});
console.log(JSON.stringify({
ok: true,
task: TASK,
root,
documentId,
ocrRootRelativePath: uiOcrPath,
screenshots,
}, null, 2));
} catch (error) {
screenshots.failure = path.join(OUTPUT_DIR, "failure.png");
await page.screenshot({ path: screenshots.failure, fullPage: true }).catch(() => undefined);
await writeResult({
ok: false,
task: TASK,
error: error instanceof Error ? error.message : String(error),
root,
documentId,
screenshots,
});
throw error;
} finally {
await browser.close();
}
}
main().catch((error) => {
console.error(error instanceof Error ? error.stack || error.message : String(error));
process.exit(1);
});
@@ -1,387 +0,0 @@
#!/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 { execFileSync } = require("node:child_process");
const BASE_URL = (process.env.MNOTE_UI_BASE_URL || "http://127.0.0.1:3000").replace(/\/+$/, "");
const OUTPUT_DIR = path.join(process.cwd(), "tmp", "task528-document-evidence-liteparse-agent-smoke");
const RESULT_PATH = path.join(OUTPUT_DIR, "result.json");
const ACTOR_ID = "mnote-e2e";
const TEST_PASSWORD = process.env.MNOTE_E2E_PASSWORD || "MnoteE2E123!";
const RUN_REASONIX_ACP = process.env.MNOTE_TASK528_SKIP_REASONIX_ACP !== "1";
function fileUrl(localPath) {
return `file://${localPath}`;
}
function localMdDocumentId(relativePath) {
return `local-md:${relativePath}`;
}
async function fetchJson(pathname, init = {}) {
const response = await fetch(`${BASE_URL}${pathname}`, init);
const text = await response.text();
let payload = null;
try {
payload = text ? JSON.parse(text) : null;
} catch {
payload = text;
}
assert(
response.ok,
`${pathname} 请求失败: ${response.status} ${typeof payload === "string" ? payload : JSON.stringify(payload)}`,
);
return { payload, response };
}
async function postJson(pathname, data, headers = {}) {
return (await fetchJson(pathname, {
method: "POST",
headers: { "content-type": "application/json", ...headers },
body: JSON.stringify(data),
})).payload;
}
async function putJson(pathname, data, headers = {}) {
return (await fetchJson(pathname, {
method: "PUT",
headers: { "content-type": "application/json", ...headers },
body: JSON.stringify(data),
})).payload;
}
async function getText(pathname, headers = {}, timeoutMs = 180_000) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
const response = await fetch(`${BASE_URL}${pathname}`, {
method: "GET",
headers,
signal: controller.signal,
});
const text = await response.text();
assert(response.ok, `${pathname} 请求失败: ${response.status} ${text.slice(0, 500)}`);
return text;
} finally {
clearTimeout(timer);
}
}
async function signInCookie() {
const { response } = await fetchJson("/api/auth", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
action: "auth:signIn",
args: {
provider: "password",
params: {
account: ACTOR_ID,
password: TEST_PASSWORD,
flow: "signIn",
},
},
}),
});
const setCookie = response.headers.get("set-cookie") || "";
const session = setCookie.match(/mnote_session=[^;]+/u)?.[0];
assert(session, `登录响应缺少 mnote_session cookie: ${setCookie}`);
return session;
}
async function createAiGrant(rootUri) {
const payload = await postJson("/api/admin/access-policy/grants", {
userId: ACTOR_ID,
rootUri,
permission: "write",
recursive: true,
capabilities: ["ai"],
}, {
"x-mnote-actor-id": ACTOR_ID,
"x-mnote-actor-type": "admin",
});
assert(payload.grant?.id, "创建 AI 目录授权后缺少 grant id");
return payload.grant;
}
function assertEvidenceHit(hit, expected) {
assert(hit, `${expected.label} 缺少正文级 PDF evidence 命中`);
assert(String(hit.quote || "").includes("Printer test page"), `${expected.label} quote 不包含 PDF 正文 token`);
assert.strictEqual(hit.source?.ownerDocumentPath, expected.ownerRel, `${expected.label} ownerDocumentPath`);
assert.strictEqual(hit.source?.resourcePath, expected.pdfRel, `${expected.label} resourcePath`);
assert.strictEqual(hit.source?.resourceKind, "pdf", `${expected.label} resourceKind`);
assert(hit.source?.page, `${expected.label} 缺少 page locator`);
assert(hit.source?.bbox, `${expected.label} 缺少 bbox locator`);
assert(hit.source?.sourceMapPath, `${expected.label} 缺少 sourceMapPath`);
}
function decodeSseToolPayloads(sse) {
return String(sse || "")
.split(/\n\n+/u)
.map((eventText) => {
const eventName = eventText
.split(/\n/u)
.find((line) => line.startsWith("event:"))
?.slice("event:".length)
.trim();
const dataLines = eventText
.split(/\n/u)
.filter((line) => line.startsWith("data:"))
.map((line) => line.slice("data:".length).trimStart());
if (!dataLines.length) return null;
try {
return { event: eventName || null, payload: JSON.parse(dataLines.join("\n")) };
} catch {
return null;
}
})
.filter(Boolean);
}
function assertReasonixAcpEvidenceSse(sse) {
assert(sse.includes('"tool":"mnote_evidence_search"'), "Reasonix ACP SSE 未出现 mnote_evidence_search 工具调用");
assert(sse.includes("event: tool.completed"), "Reasonix ACP evidence 工具未标记为 completed");
assert(!sse.includes("event: tool.failed"), "Reasonix ACP evidence 工具被错误标记为 failed");
const payloads = decodeSseToolPayloads(sse);
const completedTool = payloads.find(({ event, payload }) =>
event === "tool.completed" && payload?.status === "completed"
);
assert(completedTool, "Reasonix ACP SSE 缺少 completed evidence tool payload");
const outputText = (completedTool.payload.output || [])
.map((item) => item?.content?.text || "")
.join("\n");
assert(outputText.includes('"quote":"Printer test page"'), "Reasonix ACP 工具结果未返回 PDF 正文 quote");
assert(outputText.includes('"page":1'), "Reasonix ACP 工具结果未返回 page locator");
assert(outputText.includes("mnote.agent_run_receipt.evidence.v1"), "Reasonix ACP 工具结果缺少 evidence run receipt");
}
async function runReasonixAcpEvidenceCheck(input) {
const { workspaceId, rootUri, documentId, actorHeaders } = input;
await createAiGrant(rootUri);
const sessionId = `task528_reasonix_tools_${Date.now().toString(36)}`;
const traceId = `task528-reasonix-tools-${Date.now().toString(36)}`;
const run = await postJson("/api/hermes/client/runs", {
workspaceId,
documentId,
sessionId,
sourceKind: "local_folder",
rootUri,
agentId: "reasonix",
profile: "reasonix",
acpRuntime: "reasonix",
contextScope: "page",
contextRefs: ["current_page", "folder"],
allowedRoots: [{ rootUri, permission: "write" }],
skillPreferences: {
mnote: {
"mnote-local-index": true,
"mnote-chat-only": false,
},
},
message: "请调用 mnote_evidence_search 搜索 Printer test page,然后用一句中文回答页码和 quote。必须使用工具,不能只说正在搜索。",
traceId,
pageContext: {
contextScope: "page",
node: { documentId, title: "EvidenceLive" },
aiContext: {
schema: "mnote.page_ai_context.v1",
workspaceId,
documentId,
scope: "page",
selectedText: "",
selectedBlockIds: [],
contextBlocks: [],
pageText: "",
pageXml: `<page id=\"${documentId}\"></page>`,
truncated: false,
warnings: [],
},
},
}, actorHeaders);
assert(run.ok === true && run.runId, `Reasonix ACP run 创建失败: ${JSON.stringify(run)}`);
const sse = await getText(`/api/hermes/client/events/${encodeURIComponent(run.runId)}`, actorHeaders);
fs.writeFileSync(path.join(OUTPUT_DIR, "reasonix-tools-events.sse"), sse, "utf8");
assertReasonixAcpEvidenceSse(sse);
fs.writeFileSync(path.join(OUTPUT_DIR, "reasonix-live-run.json"), `${JSON.stringify(run, null, 2)}\n`, "utf8");
return {
runId: run.runId,
sessionId: run.sessionId,
eventPath: path.join(OUTPUT_DIR, "reasonix-tools-events.sse"),
};
}
async function main() {
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
const root = fs.mkdtempSync(path.join(os.tmpdir(), "mnote-evidence-live-"));
const workspaceId = `local-ws:${ACTOR_ID}:task528-evidence`;
const rootUri = fileUrl(root);
const ownerRel = "EvidenceLive.md";
const pdfRel = "assets/default-testpage.pdf";
const documentId = localMdDocumentId(ownerRel);
const actorHeaders = {
"x-mnote-actor-id": ACTOR_ID,
"x-mnote-actor-type": "user",
"x-mnote-workspace-id": workspaceId,
};
fs.mkdirSync(path.join(root, ".mnote"), { recursive: true });
fs.mkdirSync(path.join(root, "assets"), { recursive: true });
fs.writeFileSync(
path.join(root, ".mnote", "workspace.json"),
`${JSON.stringify({
workspaceId,
ownerId: ACTOR_ID,
createdAt: new Date().toISOString(),
capabilities: ["local_files", "ai_sessions", "markdown_edit"],
}, null, 2)}\n`,
"utf8",
);
fs.copyFileSync("/usr/share/cups/data/default-testpage.pdf", path.join(root, pdfRel));
fs.writeFileSync(
path.join(root, ownerRel),
["# Evidence Live", "", "测试正文级 PDF evidence。", "", `[Printer PDF](${pdfRel})`, ""].join("\n"),
"utf8",
);
const cookie = await signInCookie();
const toolsPayload = (await fetchJson("/api/hermes/client/tools?scope=mnote&profile=reasonix", {
headers: { cookie },
})).payload;
const toolNames = (toolsPayload.tools || []).map((tool) => tool.name);
for (const name of [
"mnote.evidence.search",
"mnote.evidence.read",
"mnote.evidence.open",
"mnote.index.status",
"mnote.index.refresh",
"mnote.index.update_settings",
]) {
assert(toolNames.includes(name), `Reasonix tools 列表缺少 ${name}`);
}
const capabilitiesPayload = (await fetchJson("/api/hermes/client/capabilities?runtime=mnote&agentId=reasonix&profile=reasonix", {
headers: { cookie },
})).payload;
const mnoteCapabilities = (capabilitiesPayload.categories || []).flatMap((category) => category.capabilities || category.skills || []);
assert(
mnoteCapabilities.some((capability) => capability.id === "mnote-local-index" && capability.enabled !== false),
"Reasonix agent 缺少启用的 mnote-local-index 能力",
);
const settings = await putJson("/api/search/local-index/settings", {
workspaceId,
rootUri,
includePaths: ["."],
scheduleMode: "manual",
runOnChange: false,
}, actorHeaders);
assert.strictEqual(settings.ok, true, "local evidence index settings ok");
const refresh = await postJson("/api/search/local-index/refresh", { workspaceId, rootUri }, actorHeaders);
assert.strictEqual(refresh.ok, true, "local evidence index refresh ok");
const direct = await postJson("/api/evidence/search", {
query: "Printer test page",
scope: { workspaceId, rootUri, includeResources: true, includeOcr: true },
mode: "hybrid",
topK: 5,
}, actorHeaders);
const directHit = (direct.results || []).find((result) => String(result.quote || "").includes("Printer test page"));
assertEvidenceHit(directHit, { label: "direct", ownerRel, pdfRel });
const read = await postJson("/api/evidence/read", {
locator: directHit.source,
context: { beforeBlocks: 1, afterBlocks: 1, includeSectionSummary: true },
}, actorHeaders);
assert.strictEqual(read.ok, true, "evidence read ok");
assert(String(read.quote || "").includes("Printer test page"), "evidence read 未读回 PDF 正文 quote");
const open = await postJson("/api/evidence/open", { locator: directHit.source }, actorHeaders);
assert.strictEqual(open.ok, true, "evidence open ok");
assert(open.openAction?.params?.sourceMapPath, "evidence open 缺少 sourceMapPath params");
const toolEnvelope = await postJson("/api/hermes/tools/mnote/call", {
toolName: "mnote.evidence.search",
workspaceId,
documentId,
sourceKind: "local_folder",
rootUri,
actorId: ACTOR_ID,
profile: "reasonix",
sessionId: "task528_evidence_session",
runId: "task528_evidence_run",
toolCallId: "task528_evidence_tool_call",
args: { query: "Printer test page", includeResources: true, includeOcr: true, topK: 5 },
}, actorHeaders);
assert.strictEqual(toolEnvelope.ok, true, "MNote evidence tool envelope ok");
const toolResult = toolEnvelope.result || toolEnvelope;
const toolHit = (toolResult.results || []).find((result) => String(result.quote || "").includes("Printer test page"));
assertEvidenceHit(toolHit, { label: "agent-tool", ownerRel, pdfRel });
assert(String(toolHit.citationMarkdown || "").includes("](/documents/"), "agent tool 缺少可点击 citationMarkdown");
assert(String(toolHit.citationUrl || "").includes("resourceTab="), "agent tool citationUrl 缺少资源 tab 定位参数");
assert((toolEnvelope.audit?.evidenceIds || []).includes(toolHit.evidenceId), "agent tool audit 缺少 evidence id");
assert.strictEqual(toolEnvelope.audit?.runReceipt?.toolName, "mnote.evidence.search", "run receipt toolName");
const reasonixAcp = RUN_REASONIX_ACP
? await runReasonixAcpEvidenceCheck({ workspaceId, rootUri, documentId, actorHeaders })
: { skipped: true };
execFileSync(process.execPath, ["scripts/reasonix-acp-wrapper.mjs"], {
cwd: process.cwd(),
env: { ...process.env, MNOTE_REASONIX_ACP_SELFTEST: "1" },
stdio: ["ignore", "pipe", "pipe"],
});
const parseMd = path.join(root, "EvidenceLive.ocr", "default-testpage.pdf.parse.md");
const sourceMap = path.join(root, "EvidenceLive.ocr", "default-testpage.pdf.source-map.json");
const sqlitePath = path.join(root, ".mnote", "index", "evidence.sqlite");
assert(fs.existsSync(parseMd), `缺少 LiteParse parse sidecar: ${parseMd}`);
assert(fs.existsSync(sourceMap), `缺少 source-map sidecar: ${sourceMap}`);
assert(fs.existsSync(sqlitePath), `缺少 evidence sqlite: ${sqlitePath}`);
const ftsCount = Number(execFileSync(
"sqlite3",
[sqlitePath, "SELECT count(*) FROM evidence_fts WHERE evidence_fts MATCH 'Printer';"],
{ encoding: "utf8" },
).trim());
assert(ftsCount >= 1, `evidence.sqlite FTS 未命中 PDF 正文: ${ftsCount}`);
const result = {
ok: true,
baseUrl: BASE_URL,
root,
workspaceId,
documentId,
directEvidenceId: directHit.evidenceId,
toolEvidenceId: toolHit.evidenceId,
quote: directHit.quote,
page: directHit.source.page,
bbox: directHit.source.bbox,
ownerDocumentPath: directHit.source.ownerDocumentPath,
resourcePath: directHit.source.resourcePath,
sourceMapPath: directHit.source.sourceMapPath,
parseMd,
sourceMap,
sqlitePath,
ftsCount,
reasonixTools: toolNames.filter((name) => name.startsWith("mnote.evidence.")),
evidenceSkillEnabled: true,
receiptToolName: toolEnvelope.audit.runReceipt.toolName,
reasonixAcp,
};
fs.writeFileSync(RESULT_PATH, `${JSON.stringify(result, null, 2)}\n`, "utf8");
console.log(JSON.stringify(result, null, 2));
}
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,150 @@
#!/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 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 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");
const UI_TIMEOUT_MS = Number(process.env.MNOTE_SMOKE_UI_TIMEOUT_MS || 30_000);
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));
async function main() {
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
const browser = await chromium.launch({ headless: true, executablePath: CHROMIUM_EXECUTABLE_PATH });
const context = await browser.newContext({ viewport: { width: 1360, height: 900 } });
try {
const auth = await context.request.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",
},
},
},
});
assert(auth.ok(), `登录失败: ${auth.status()} ${await auth.text()}`);
const queryResponse = await context.request.post(`${BASE_URL}/api/knowledge-rag/query`, {
data: {
rootUri: ROOT_URI,
workspaceId: WORKSPACE_ID,
query: QUERY,
mode: "mix",
topK: 8,
chunkTopK: 8,
includeChunkContent: true,
},
});
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(String(reference.citationUrl || "").includes("resourceTab="), "query citationUrl 缺少 resourceTab");
assert(String(reference.citationMarkdown || "").includes("p."), "query citationMarkdown 缺少页码");
const openResponse = await context.request.post(`${BASE_URL}/api/knowledge-rag/open-reference`, {
data: {
rootUri: ROOT_URI,
workspaceId: WORKSPACE_ID,
reference: reference.reference,
filePath: reference.filePath,
chunkId: reference.chunkId,
},
});
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(String(openPayload.reference?.citationUrl || "").includes("resourceTab="), "open_reference citationUrl 缺少 resourceTab");
assert(String(openPayload.reference?.citationMarkdown || "").includes("p."), "open_reference citationMarkdown 缺少页码");
const page = await context.newPage();
const pageErrors = [];
const consoleErrors = [];
page.on("pageerror", (error) => pageErrors.push(String(error?.message || error)));
page.on("console", (message) => {
if (["error", "warning"].includes(message.type())) consoleErrors.push(`${message.type()}: ${message.text()}`);
});
await page.goto(`${BASE_URL}${reference.citationUrl}`, { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
await page.waitForFunction(
(expectedResource) => document.querySelector(`[data-mnote-resource-tab-panel][data-resource-path="${expectedResource}"]`),
EXPECTED_RESOURCE,
{ timeout: UI_TIMEOUT_MS },
);
await page.waitForTimeout(3_000);
const state = await page.evaluate((expectedResource) => {
const activeTab = document.querySelector(".mnote-main-tab.is-active, [data-mnote-tab-kind].is-active");
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']");
return {
url: location.href,
activeTabText: activeTab ? activeTab.textContent.trim().slice(0, 120) : "",
activeTabKind: activeTab ? activeTab.getAttribute("data-mnote-tab-kind") : "",
panelVisible: !!panel && !panel.hidden,
panelResourcePath: panel ? panel.getAttribute("data-resource-path") : "",
panelLocator: panel ? panel.getAttribute("data-mnote-evidence-locator") : "",
inlinePdfVisible: !!inlineViewer,
evidencePageRendered: !!evidencePage,
evidencePageNumber: evidencePage ? evidencePage.getAttribute("data-page-number") : "",
};
}, EXPECTED_RESOURCE);
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)}`);
const result = {
ok: true,
baseUrl: BASE_URL,
rootUri: ROOT_URI,
workspaceId: WORKSPACE_ID,
query: QUERY,
expectedResource: EXPECTED_RESOURCE,
citationUrl: reference.citationUrl,
citationMarkdown: reference.citationMarkdown,
page: reference.locator.page,
bbox: reference.locator.bbox,
openReferenceCitationUrl: openPayload.reference.citationUrl,
state,
pageErrors,
consoleErrors,
screenshot: SCREENSHOT_PATH,
};
fs.writeFileSync(RESULT_PATH, `${JSON.stringify(result, null, 2)}\n`, "utf8");
console.log(JSON.stringify(result, null, 2));
} finally {
await browser.close().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,277 @@
#!/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 { 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", "task530-knowledge-rag-page-ai-final-answer-smoke");
const RESULT_PATH = path.join(OUTPUT_DIR, "result.json");
const SCREENSHOT_PATH = path.join(OUTPUT_DIR, "page-ai-knowledge-rag-answer.png");
const CONTROL_PLANE_DB =
process.env.MNOTE_CONTROL_PLANE_DB_PATH || "/mnt/Data1T/Mnote_data/control-plane/control-plane.db";
const CHROME = process.env.MNOTE_PAGE_AI_CHROME || "/usr/bin/google-chrome";
const ACTOR_ID = "mnote-e2e";
const WORKSPACE_ID = "local-ws:mnote-e2e:my-space";
const ROOT_PATH = "/mnt/Data1T/Mnote_data/users/mnote-e2e/workspaces/my-space";
const ROOT_URI = `file://${ROOT_PATH}`;
const OWNER_REL = "knowledge-rag-fixtures-7-50/PageAiKnowledgeRagSmoke.md";
const DOCUMENT_ID = `local-md:${OWNER_REL.replaceAll("/", "~2F")}`;
function sqlQuote(value) {
return `'${String(value).replaceAll("'", "''")}'`;
}
function sqliteExec(sql) {
execFileSync("sqlite3", [CONTROL_PLANE_DB, sql], {
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
});
}
function ensureGrant() {
assert(fs.existsSync(CONTROL_PLANE_DB), `缺少 control-plane SQLite: ${CONTROL_PLANE_DB}`);
const now = new Date().toISOString();
sqliteExec(`
INSERT OR IGNORE INTO users (id, email, username, display_name, role, status, created_at, updated_at, revision)
VALUES (${sqlQuote(ACTOR_ID)}, 'mnote.e2e@example.com', ${sqlQuote(ACTOR_ID)}, ${sqlQuote(ACTOR_ID)}, 'user', 'active', ${sqlQuote(now)}, ${sqlQuote(now)}, 1);
INSERT OR REPLACE INTO workspaces (id, owner_user_id, name, kind, root_uri, root_path, source_kind, status, created_at, updated_at, revision)
VALUES (${sqlQuote(WORKSPACE_ID)}, ${sqlQuote(ACTOR_ID)}, 'MNote E2E Space', 'personal', ${sqlQuote(ROOT_URI)}, ${sqlQuote(ROOT_PATH)}, 'local_folder', 'active', ${sqlQuote(now)}, ${sqlQuote(now)}, 1);
INSERT OR REPLACE INTO directory_grants (id, user_id, workspace_id, root_uri, root_path, permission, recursive, capabilities_json, source, status, created_by, created_at, updated_at, revision)
VALUES ('grant_task530_knowledge_rag', ${sqlQuote(ACTOR_ID)}, ${sqlQuote(WORKSPACE_ID)}, ${sqlQuote(ROOT_URI)}, ${sqlQuote(ROOT_PATH)}, 'write', 1, '["ai"]', 'smoke', 'active', ${sqlQuote(ACTOR_ID)}, ${sqlQuote(now)}, ${sqlQuote(now)}, 1);
`);
}
function ensureOwnerPage() {
const ownerPath = path.join(ROOT_PATH, OWNER_REL);
fs.mkdirSync(path.dirname(ownerPath), { recursive: true });
if (!fs.existsSync(ownerPath)) {
fs.writeFileSync(
ownerPath,
["# Page AI Knowledge RAG Smoke", "", "This page is a stable Page AI smoke target for LightRAG retrieval.", ""].join("\n"),
"utf8",
);
}
}
async function signIn(context) {
const response = await context.request.post(`${BASE_URL}/api/auth`, {
data: {
action: "auth:signIn",
args: {
provider: "password",
params: {
account: ACTOR_ID,
password: process.env.MNOTE_E2E_PASSWORD || "MnoteE2E123!",
flow: "signIn",
},
},
},
timeout: UI_TIMEOUT_MS,
});
const text = await response.text();
assert(response.ok(), `/api/auth 登录失败: ${response.status()} ${text.slice(0, 500)}`);
}
async function selectReasonix(page) {
await page.locator("[data-page-ai-agent-button]").click({ timeout: UI_TIMEOUT_MS });
await page.locator("[data-page-ai-agent-popover]").waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await page.locator('[data-page-ai-agent-id="reasonix"]').first().click({ timeout: UI_TIMEOUT_MS });
await page.waitForFunction(
() => document.querySelector('[data-testid="wolai-page-ai-drawer"]')?.getAttribute("data-mnote-acp-runtime") === "reasonix",
null,
{ timeout: UI_TIMEOUT_MS },
);
}
async function newestAssistantText(page, initialCount) {
return await page.evaluate((countBefore) => {
const nodes = Array.from(
document.querySelectorAll('[data-testid="wolai-page-ai-drawer"] .wolai-page-ai-message--assistant'),
);
const node = nodes.slice(countBefore).at(-1) || nodes.at(-1);
const content = node?.querySelector(".wolai-page-ai-message-text");
return content ? content.textContent || "" : "";
}, initialCount);
}
async function newestAssistantLinks(page, initialCount) {
return await page.evaluate((countBefore) => {
const nodes = Array.from(
document.querySelectorAll('[data-testid="wolai-page-ai-drawer"] .wolai-page-ai-message--assistant'),
);
const node = nodes.slice(countBefore).at(-1) || nodes.at(-1);
if (!node) return [];
return Array.from(node.querySelectorAll("a")).map((anchor) => ({
text: anchor.textContent || "",
href: anchor.getAttribute("href") || "",
}));
}, initialCount);
}
function summarizeRun(body) {
return {
workspaceId: body.workspaceId || "",
documentId: body.documentId || "",
sourceKind: body.sourceKind || "",
rootUri: body.rootUri || "",
agentId: body.agentId || "",
profile: body.profile || "",
acpRuntime: body.acpRuntime || "",
contextRefs: Array.isArray(body.contextRefs)
? body.contextRefs.map((item) => (typeof item === "string" ? item : item?.kind || "")).filter(Boolean)
: [],
allowedRootCount: Array.isArray(body.allowedRoots) ? body.allowedRoots.length : 0,
mnoteKnowledgeRagDisabled: body.skillPreferences?.mnote?.["mnote-knowledge-rag"] === false,
message: body.message || "",
};
}
async function main() {
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
ensureGrant();
ensureOwnerPage();
const browser = await chromium.launch({
headless: process.env.MNOTE_PAGE_AI_VERIFY_HEADED !== "1",
executablePath: fs.existsSync(CHROME) ? CHROME : undefined,
});
const context = await browser.newContext({ viewport: { width: 1440, height: 960 } });
const page = await context.newPage();
const capturedRuns = [];
const consoleErrors = [];
const pageErrors = [];
page.on("console", (message) => {
if (["error", "warning"].includes(message.type())) {
consoleErrors.push({ type: message.type(), text: message.text() });
}
});
page.on("pageerror", (error) => pageErrors.push(String(error?.message || error)));
page.on("request", (request) => {
if (!request.url().includes("/api/hermes/client/runs") || request.method() !== "POST") return;
try {
capturedRuns.push(JSON.parse(request.postData() || "{}"));
} catch {
capturedRuns.push({ raw: request.postData() || "" });
}
});
try {
await signIn(context);
const documentUrl = new URL(`${BASE_URL}/documents/${encodeURIComponent(DOCUMENT_ID)}`);
documentUrl.searchParams.set("sourceKind", "local_folder");
documentUrl.searchParams.set("rootUri", ROOT_URI);
documentUrl.searchParams.set("workspaceId", WORKSPACE_ID);
await page.goto(documentUrl.toString(), { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
await page.getByTestId("wolai-floating-ai").click({ timeout: UI_TIMEOUT_MS });
await selectReasonix(page);
const assistantCount = await page
.locator('[data-testid="wolai-page-ai-drawer"] .wolai-page-ai-message--assistant')
.count();
const prompt = [
"请调用 mnote_knowledge_rag_query 检索资料库。",
"问题:scan image start 这份扫描 PDF 里出现了什么关键短语?",
"最终只输出一句中文结论,必须包含返回的 citationMarkdown 链接,不要描述检索过程,不要输出 raw JSON,不要编造页码。",
].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 });
await page.waitForFunction(
() => document.documentElement.getAttribute("data-mnote-page-ai-run-status") === "completed",
null,
{ timeout: Math.max(UI_TIMEOUT_MS, 180_000) },
);
await page.waitForFunction(
(countBefore) => {
const nodes = Array.from(
document.querySelectorAll('[data-testid="wolai-page-ai-drawer"] .wolai-page-ai-message--assistant'),
);
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");
},
assistantCount,
{ timeout: Math.max(UI_TIMEOUT_MS, 180_000) },
);
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(
assistantLinks.some((link) => link.text.includes("scan-image-start.pdf") && link.href.includes("resourceTab=")),
`可见回答缺少可点击 resourceTab citation: ${JSON.stringify(assistantLinks)}`,
);
assert(!assistantText.includes("citationMarkdown"), `可见回答泄漏工具字段名: ${assistantText}`);
assert(!assistantText.includes('"raw"'), `可见回答泄漏 raw JSON: ${assistantText}`);
assert(!assistantText.includes("mnote_knowledge_rag_query"), `可见回答泄漏工具名: ${assistantText}`);
assert(!/让我|我来|我先|查询返回|找到了|检索资料库/.test(assistantText), `可见回答包含检索过程叙述: ${assistantText}`);
assert(
capturedRuns.some((body) => body.acpRuntime === "reasonix" && body.profile === "reasonix"),
"未捕获到 Reasonix Page AI run",
);
assert(
capturedRuns.every((body) => body.sourceKind === "local_folder" && body.rootUri === ROOT_URI),
"Page AI run 未保持 local_folder/rootUri 上下文",
);
assert(
capturedRuns.some((body) => body.skillPreferences?.mnote?.["mnote-knowledge-rag"] !== false),
"Page AI run 禁用了 mnote-knowledge-rag",
);
await page.screenshot({ path: SCREENSHOT_PATH, fullPage: true });
const result = {
ok: true,
baseUrl: BASE_URL,
workspaceId: WORKSPACE_ID,
rootUri: ROOT_URI,
documentId: DOCUMENT_ID,
assistantText,
assistantLinks,
capturedRuns: capturedRuns.map(summarizeRun),
capturedRunsFullPath: path.join(OUTPUT_DIR, "captured-runs-full.json"),
pageErrors,
consoleErrors,
screenshot: SCREENSHOT_PATH,
};
fs.writeFileSync(
path.join(OUTPUT_DIR, "captured-runs-full.json"),
`${JSON.stringify(capturedRuns, null, 2)}\n`,
"utf8",
);
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),
capturedRuns,
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);
});
@@ -0,0 +1,234 @@
#!/usr/bin/env node
"use strict";
const assert = require("node:assert");
const fs = require("node:fs");
const path = require("node:path");
const JSZip = require("jszip");
const { chromium } = 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 SOURCE_DOCX = process.env.MNOTE_KNOWLEDGE_RAG_DOCX_SOURCE || path.join(process.cwd(), "tmp", "onlyoffice-direct-bridge.docx");
const FIXTURE_DIR = "knowledge-rag-fixtures-7-50";
const OUTPUT_DIR = path.join(process.cwd(), "tmp", "task532-knowledge-rag-docx-ingestion-smoke");
const RESULT_PATH = path.join(OUTPUT_DIR, "result.json");
const SCREENSHOT_PATH = path.join(OUTPUT_DIR, "docx-resource-tab.png");
const POLL_TIMEOUT_MS = Number(process.env.MNOTE_KNOWLEDGE_RAG_DOCX_TIMEOUT_MS || 10 * 60_000);
const UI_TIMEOUT_MS = Number(process.env.MNOTE_SMOKE_UI_TIMEOUT_MS || 30_000);
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 sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function xmlEscape(value) {
return String(value)
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&apos;");
}
async function writeDocxFixture(targetPath, marker) {
assert(fs.existsSync(SOURCE_DOCX), `缺少 DOCX 源文件: ${SOURCE_DOCX}`);
const zip = await JSZip.loadAsync(fs.readFileSync(SOURCE_DOCX));
const documentFile = zip.file("word/document.xml");
assert(documentFile, `DOCX 缺少 word/document.xml: ${SOURCE_DOCX}`);
const originalXml = await documentFile.async("string");
const paragraph = `<w:p><w:r><w:t>${xmlEscape(marker)}</w:t></w:r></w:p>`;
const nextXml = originalXml.includes("</w:body>")
? originalXml.replace("</w:body>", `${paragraph}</w:body>`)
: originalXml + paragraph;
zip.file("word/document.xml", nextXml);
fs.mkdirSync(path.dirname(targetPath), { recursive: true });
const buffer = await zip.generateAsync({ type: "nodebuffer" });
fs.writeFileSync(targetPath, buffer);
}
async function signIn(context) {
const response = await context.request.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",
},
},
},
});
assert(response.ok(), `登录失败: ${response.status()} ${await response.text()}`);
}
async function apiJson(context, method, url, data) {
const response = await context.request.fetch(url, {
method,
data,
headers: {
"x-mnote-actor-id": "mnote-e2e",
"x-mnote-actor-type": "user",
"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 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 findRegistryEntry(payload, sourcePath) {
const entries = payload?.registry?.entries;
return Array.isArray(entries) ? entries.find((entry) => entry.sourceRootRelativePath === sourcePath) : null;
}
async function ingest(context, sourcePath) {
const result = await apiJson(context, "POST", `${BASE_URL}/api/knowledge-rag/ingest`, {
rootUri: ROOT_URI,
workspaceId: WORKSPACE_ID,
sources: [{ sourcePath }],
});
assert(result.ok, `knowledge-rag ingest 失败: ${result.status} ${result.text.slice(0, 1000)}`);
return result.payload;
}
async function waitForIndexed(context, sourcePath) {
const startedAt = Date.now();
let attempts = 0;
let lastStatus = null;
let lastIngest = null;
while (Date.now() - startedAt < POLL_TIMEOUT_MS) {
attempts += 1;
lastStatus = await status(context);
const entry = findRegistryEntry(lastStatus, sourcePath);
if (entry && entry.indexedAtMs && entry.lightRagDocId && !entry.stale && !entry.deletedAtMs) {
return { entry, status: lastStatus, attempts, lastIngest };
}
if (!entry || attempts % 6 === 1) {
lastIngest = await ingest(context, sourcePath);
}
await sleep(5_000);
}
throw new Error(`等待 DOCX 入库超时: ${JSON.stringify({ sourcePath, lastIngest, lastStatus }, null, 2).slice(0, 4000)}`);
}
async function queryDocx(context, marker, sourcePath) {
const result = await apiJson(context, "POST", `${BASE_URL}/api/knowledge-rag/query`, {
rootUri: ROOT_URI,
workspaceId: WORKSPACE_ID,
query: marker,
mode: "mix",
topK: 8,
chunkTopK: 8,
includeChunkContent: true,
});
assert(result.ok, `knowledge-rag query 失败: ${result.status} ${result.text.slice(0, 1000)}`);
const references = Array.isArray(result.payload?.references) ? result.payload.references : [];
const reference = references.find((item) => item.sourceRootRelativePath === sourcePath);
assert(reference, `DOCX 查询缺少目标引用 ${sourcePath}: ${JSON.stringify(references, null, 2).slice(0, 3000)}`);
assert(String(reference.quote || reference.content || result.text).toLowerCase().includes(marker.toLowerCase()), `DOCX 引用缺少 marker: ${JSON.stringify(reference, null, 2).slice(0, 2000)}`);
assert(String(reference.citationUrl || "").includes("resourceTab="), `DOCX 引用缺少 resourceTab citationUrl: ${JSON.stringify(reference, null, 2)}`);
assert(String(reference.citationMarkdown || "").includes(path.basename(sourcePath)), `DOCX 引用缺少 citationMarkdown: ${JSON.stringify(reference, null, 2)}`);
return { payload: result.payload, reference };
}
async function openCitationInBrowser(context, reference, sourcePath) {
const page = await context.newPage();
await page.goto(`${BASE_URL}${reference.citationUrl}`, { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
await page.waitForFunction(
(expectedResource) => document.querySelector(`[data-mnote-resource-tab-panel][data-resource-path="${expectedResource}"]`),
sourcePath,
{ timeout: UI_TIMEOUT_MS },
);
await page.waitForTimeout(2_000);
const state = await page.evaluate((expectedResource) => {
const panel = document.querySelector(`[data-mnote-resource-tab-panel][data-resource-path="${expectedResource}"]`);
const activeTab = document.querySelector(".mnote-main-tab.is-active, [data-mnote-tab-kind].is-active");
return {
url: location.href,
panelVisible: !!panel && !panel.hidden,
panelResourcePath: panel ? panel.getAttribute("data-resource-path") : "",
panelKind: panel ? panel.getAttribute("data-resource-kind") : "",
activeTabText: activeTab ? activeTab.textContent.trim().slice(0, 160) : "",
bodyText: document.body.innerText.slice(0, 1000),
};
}, sourcePath);
await page.screenshot({ path: SCREENSHOT_PATH, fullPage: true });
await page.close().catch(() => undefined);
assert.equal(state.panelVisible, true, `DOCX citationUrl 未打开资源 tab: ${JSON.stringify(state, null, 2)}`);
assert.equal(state.panelResourcePath, sourcePath, `DOCX resource tab path 不匹配: ${JSON.stringify(state, null, 2)}`);
return state;
}
async function main() {
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
const marker = process.env.MNOTE_KNOWLEDGE_RAG_DOCX_MARKER || `DOCX RAG SMOKE ${Date.now()}`;
const fileName = `docx-rag-smoke-${Date.now()}.docx`;
const sourcePath = `${FIXTURE_DIR}/${fileName}`;
const targetPath = path.join(ROOT_PATH, sourcePath);
await writeDocxFixture(targetPath, marker);
const browser = await chromium.launch({
headless: true,
...(CHROMIUM_EXECUTABLE_PATH ? { executablePath: CHROMIUM_EXECUTABLE_PATH } : {}),
});
const context = await browser.newContext({ viewport: { width: 1360, height: 900 } });
try {
await signIn(context);
const ingestPayload = await ingest(context, sourcePath);
const indexed = await waitForIndexed(context, sourcePath);
const queried = await queryDocx(context, marker, sourcePath);
const browserState = await openCitationInBrowser(context, queried.reference, sourcePath);
const result = {
ok: true,
baseUrl: BASE_URL,
rootUri: ROOT_URI,
workspaceId: WORKSPACE_ID,
sourcePath,
marker,
lightRagDocId: indexed.entry.lightRagDocId,
lightRagFilePath: indexed.entry.lightRagFilePath,
ingestStatus: ingestPayload?.status,
attempts: indexed.attempts,
citationMarkdown: queried.reference.citationMarkdown,
citationUrl: queried.reference.citationUrl,
locatorDegraded: queried.reference.locatorDegraded,
browserState,
screenshot: SCREENSHOT_PATH,
};
fs.writeFileSync(RESULT_PATH, `${JSON.stringify(result, null, 2)}\n`, "utf8");
console.log(JSON.stringify(result, null, 2));
} finally {
await browser.close().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,235 @@
#!/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 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", "task533-knowledge-rag-source-watcher-sync-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_WATCHER_TIMEOUT_MS || 180_000);
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 sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function registryPath() {
return path.join(ROOT_PATH, ".mnote", "index", "lightrag-source-registry.json");
}
function readRegistry() {
return JSON.parse(fs.readFileSync(registryPath(), "utf8"));
}
function findEntry(sourcePath) {
const registry = readRegistry();
const entries = Array.isArray(registry.entries) ? registry.entries : [];
return entries.find((entry) => entry.sourceRootRelativePath === sourcePath) || null;
}
async function signIn(context) {
const response = await context.request.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",
},
},
},
});
assert(response.ok(), `登录失败: ${response.status()} ${await response.text()}`);
}
async function apiJson(context, method, url, data) {
const response = await context.request.fetch(url, {
method,
data,
headers: {
"x-mnote-actor-id": "mnote-e2e",
"x-mnote-actor-type": "user",
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 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;
}
async function ingest(context, sourcePath) {
const result = await apiJson(context, "POST", `${BASE_URL}/api/knowledge-rag/ingest`, {
rootUri: ROOT_URI,
workspaceId: WORKSPACE_ID,
sources: [{ sourcePath }],
});
assert(result.ok, `knowledge-rag ingest 失败: ${result.status} ${result.text.slice(0, 1000)}`);
return result.payload;
}
async function waitForIndexed(context, sourcePath) {
const startedAt = Date.now();
let attempts = 0;
while (Date.now() - startedAt < POLL_TIMEOUT_MS) {
attempts += 1;
await status(context);
const entry = findEntry(sourcePath);
if (entry && entry.indexedAtMs && entry.lightRagDocId && !entry.stale && !entry.deletedAtMs) {
return { entry, attempts };
}
if (!entry || attempts % 6 === 1) {
await ingest(context, sourcePath);
}
await sleep(5_000);
}
throw new Error(`等待 watcher source 入库超时: ${sourcePath}`);
}
async function openTreeLiveWatcher(page) {
await page.goto(`${BASE_URL}/`, { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
return page.evaluate(({ rootUri }) => new Promise((resolve, reject) => {
const params = new URLSearchParams({ rootUri, treeLive: "true" });
const source = new EventSource(`/api/local-folder/events?${params.toString()}`);
window.__task533Events = [];
window.__task533Source = source;
const timer = setTimeout(() => reject(new Error("treeLive watcher ready timeout")), 30_000);
source.addEventListener("snapshot", () => {
clearTimeout(timer);
resolve(true);
});
source.onerror = () => {
clearTimeout(timer);
reject(new Error("treeLive watcher error"));
};
}), { rootUri: ROOT_URI });
}
async function waitForWatchBatch(page, relativePath) {
return page.evaluate(({ expectedPath, timeoutMs }) => new Promise((resolve, reject) => {
const source = window.__task533Source;
if (!source) {
reject(new Error("treeLive watcher not opened"));
return;
}
const timer = setTimeout(() => reject(new Error("watch_batch timeout")), timeoutMs);
source.addEventListener("watch_batch", (event) => {
const payload = JSON.parse(event.data || "{}");
window.__task533Events.push(payload);
const changed = Array.isArray(payload.changedPaths) ? payload.changedPaths : [];
if (changed.some((item) => item.relativePath === expectedPath)) {
clearTimeout(timer);
resolve(payload);
}
});
}), { expectedPath: relativePath, timeoutMs: POLL_TIMEOUT_MS });
}
async function waitForRegistryStale(sourcePath) {
const startedAt = Date.now();
while (Date.now() - startedAt < POLL_TIMEOUT_MS) {
const entry = findEntry(sourcePath);
if (entry && entry.stale === true && entry.deletedAtMs && !entry.lightRagDocId && !entry.indexedAtMs) {
return entry;
}
await sleep(1_000);
}
throw new Error(`watcher 未把 source 标记 stale/deleted: ${JSON.stringify(findEntry(sourcePath), null, 2)}`);
}
async function queryAfterDelete(context, marker, sourcePath) {
const result = await apiJson(context, "POST", `${BASE_URL}/api/knowledge-rag/query`, {
rootUri: ROOT_URI,
workspaceId: WORKSPACE_ID,
query: marker,
mode: "mix",
topK: 8,
chunkTopK: 8,
includeChunkContent: true,
});
assert(result.ok, `knowledge-rag query 失败: ${result.status} ${result.text.slice(0, 1000)}`);
const references = Array.isArray(result.payload?.references) ? result.payload.references : [];
assert(
!references.some((item) => item.sourceRootRelativePath === sourcePath),
`删除后的 source 不应继续作为有效 reference: ${JSON.stringify(references, null, 2).slice(0, 3000)}`,
);
return result.payload;
}
async function main() {
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
fs.mkdirSync(path.join(ROOT_PATH, FIXTURE_DIR), { recursive: true });
const marker = `WATCHER RAG STALE ${Date.now()}`;
const sourcePath = `${FIXTURE_DIR}/watcher-stale-${Date.now()}.md`;
const absoluteSource = path.join(ROOT_PATH, sourcePath);
fs.writeFileSync(absoluteSource, `# Watcher stale\n${marker}\n`, "utf8");
const browser = await chromium.launch({
headless: true,
...(CHROMIUM_EXECUTABLE_PATH ? { executablePath: CHROMIUM_EXECUTABLE_PATH } : {}),
});
const context = await browser.newContext({ viewport: { width: 1280, height: 860 } });
const page = await context.newPage();
try {
await signIn(context);
await ingest(context, sourcePath);
const indexed = await waitForIndexed(context, sourcePath);
await openTreeLiveWatcher(page);
fs.unlinkSync(absoluteSource);
const watchBatch = await waitForWatchBatch(page, sourcePath);
const staleEntry = await waitForRegistryStale(sourcePath);
const queryPayload = await queryAfterDelete(context, marker, sourcePath);
const result = {
ok: true,
baseUrl: BASE_URL,
rootUri: ROOT_URI,
workspaceId: WORKSPACE_ID,
sourcePath,
marker,
initialDocId: indexed.entry.lightRagDocId,
indexedAttempts: indexed.attempts,
watchBatch,
staleEntry,
queryReferenceCount: Array.isArray(queryPayload.references) ? queryPayload.references.length : 0,
};
fs.writeFileSync(RESULT_PATH, `${JSON.stringify(result, null, 2)}\n`, "utf8");
console.log(JSON.stringify(result, null, 2));
} finally {
await browser.close().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,217 @@
#!/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 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", "task534-knowledge-rag-source-management-scope-smoke");
const RESULT_PATH = path.join(OUTPUT_DIR, "result.json");
const SCREENSHOT_PATH = path.join(OUTPUT_DIR, "source-management.png");
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);
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 sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
async function signIn(context) {
const response = await context.request.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",
},
},
},
});
assert(response.ok(), `登录失败: ${response.status()} ${await response.text()}`);
}
async function apiJson(context, method, url, data) {
const response = await context.request.fetch(url, {
method,
data,
headers: {
"x-mnote-actor-id": "mnote-e2e",
"x-mnote-actor-type": "user",
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 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 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 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 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 openKnowledgePanel(page) {
const url = new URL(`${BASE_URL}/`);
url.searchParams.set("sourceKind", "local_folder");
url.searchParams.set("rootUri", ROOT_URI);
url.searchParams.set("treeView", "filetree");
await page.goto(url.toString(), { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
await page.locator('[data-testid="mnote-knowledge-rag-settings-toggle"]').click({ timeout: UI_TIMEOUT_MS });
await page.locator('[data-testid="mnote-knowledge-rag-settings-popover"]').waitFor({
state: "visible",
timeout: UI_TIMEOUT_MS,
});
}
async function waitForSourceRow(page, sourcePath) {
const row = page.locator(`[data-knowledge-rag-source-row="true"][data-knowledge-rag-source-path="${sourcePath}"]`).first();
await row.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
return row;
}
async function main() {
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
fs.mkdirSync(path.join(ROOT_PATH, FIXTURE_DIR), { recursive: true });
const marker = `SOURCE SCOPE RAG ${Date.now()}`;
const sourceA = `${FIXTURE_DIR}/scope-alpha-${Date.now()}.md`;
const sourceB = `${FIXTURE_DIR}/scope-beta-${Date.now()}.md`;
fs.writeFileSync(path.join(ROOT_PATH, sourceA), `# Scope Alpha\n${marker}\nOnly alpha source should remain after scope filter.\n`, "utf8");
fs.writeFileSync(path.join(ROOT_PATH, sourceB), `# Scope Beta\n${marker}\nBeta source must be filtered when sourcePaths targets alpha.\n`, "utf8");
const browser = await chromium.launch({
headless: true,
...(CHROMIUM_EXECUTABLE_PATH ? { executablePath: CHROMIUM_EXECUTABLE_PATH } : {}),
});
const context = await browser.newContext({ viewport: { width: 1360, height: 900 } });
const page = await context.newPage();
try {
await signIn(context);
await ingest(context, [sourceA, sourceB]);
const indexedStatus = await waitForIndexed(context, [sourceA, sourceB]);
const scoped = await query(context, marker, [sourceA]);
const scopedReferences = Array.isArray(scoped.references) ? scoped.references : [];
assert(scopedReferences.length > 0, `sourcePaths scope 应至少返回 alpha: ${JSON.stringify(scoped, null, 2).slice(0, 3000)}`);
assert(
scopedReferences.every((reference) => reference.sourceRootRelativePath === sourceA),
`sourcePaths scope 不应返回非 alpha 来源: ${JSON.stringify(scopedReferences, null, 2).slice(0, 3000)}`,
);
await openKnowledgePanel(page);
const row = await waitForSourceRow(page, sourceA);
await row.locator('[data-knowledge-rag-action="reindex-source"]').waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
const deleteButton = row.locator('[data-knowledge-rag-action="delete-source"]');
await deleteButton.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await deleteButton.click({ timeout: UI_TIMEOUT_MS });
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("已删除");
},
sourceA,
{ timeout: UI_TIMEOUT_MS },
);
await page.screenshot({ path: SCREENSHOT_PATH, fullPage: true });
assert(fs.existsSync(path.join(ROOT_PATH, sourceA)), "UI 删除索引不应删除原始 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,
sourceScope: scoped.sourceScope,
scopedReferenceCount: scopedReferences.length,
alphaDocId: entryFor(indexedStatus, sourceA)?.lightRagDocId || null,
betaDocId: entryFor(indexedStatus, sourceB)?.lightRagDocId || null,
sourceAExistsAfterDelete: fs.existsSync(path.join(ROOT_PATH, sourceA)),
afterDeleteReferenceCount: afterReferences.length,
screenshot: SCREENSHOT_PATH,
};
fs.writeFileSync(RESULT_PATH, `${JSON.stringify(result, null, 2)}\n`, "utf8");
console.log(JSON.stringify(result, null, 2));
} finally {
await browser.close().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);
});
+17
View File
@@ -0,0 +1,17 @@
# MNote knowledge RAG
使用场景:用户要求“资料库问答”、跨多本书/论文/PDF/Office 附件问答,或明确需要 LightRAG 知识库结果。
工具使用:
- 先用 `mnote.knowledge_rag.status` 检查 LightRAG provider 是否可用、当前 root 是否有 source registry。
- 用 `mnote.knowledge_rag.query` 提问。默认 `mode=mix`,需要跨资料总结时可用 `global`,指定资料细节时优先传 `sourcePaths` 限制到对应文件或目录。
- 回答必须引用 `references` 中的来源;优先使用返回的 `citationMarkdown`
- 若需要打开来源,调用 `mnote.knowledge_rag.open_reference`,不要手拼 LightRAG dashboard、`file://` 或 provider 内部路径。
边界:
- LightRAG 是派生知识库 provider,不是 MNote Markdown 正文真相。
- `filePath` 是 LightRAG provider-local 名称;只有 registry 命中的 reference 才能映射回 MNote source。
- 如果返回 `locatorDegraded: true`,必须明说“来源定位降级”,不要伪造页码、bbox 或资源 tab 链接。
- LightRAG 不可用时,不要声称资料库已查到;提示用户启动或检查 LightRAG provider。旧 LiteParse / evidence 检索已退役,不作为 fallback。