feat(rag): replace LiteParse flows with LightRAG provider
This commit is contained in:
@@ -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 facade;LiteParse、旧 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 / selection,Hermes 或 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 后进入 LightRAG;LightRAG 后台完成状态通过 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
@@ -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 不再作为默认 fallback;MNote 负责 source registry、权限、FileTree 灯号、资料库设置 UI、dashboard/status UI、citation/open-reference 映射和 agent facade。图片源通过 MNote 生成 Markdown wrapper 入 LightRAG;LightRAG 后台完成状态通过有界 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. 知识库资料处理主线是 LightRAG:OCR、parse、chunk、向量、图谱、RAG query 和跨文档召回由 LightRAG 承担;MNote 不再并行维护 LiteParse / evidence SQLite / OCR sidecar 作为资料库索引真相
|
||||
|
||||
一句话收口:
|
||||
|
||||
@@ -35,7 +38,7 @@
|
||||
|
||||
> **主 Web 执行面当前以 `mnote-web` 为 3000 owner;Next 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 已交给 LightRAG;Page Aggregate ClientState、projection / command / realtime 兼容链如何继续瘦身。**
|
||||
|
||||
+17
-3
@@ -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 / RAG;MNote 负责 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-plane;Convex 只作为显式 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
@@ -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 当前统一到 LightRAG:LiteParse、旧 OCR sidecar、旧 evidence search / local-index agent tools 不再作为默认 fallback;LightRAG 负责 OCR / parse / index / RAG,MNote 负责 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`
|
||||
>
|
||||
> Owner:07-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 / 本地资料索引入口不再作为默认 fallback,PDF / 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 reference,MNote 侧必须用 `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` 指向原始 doc;MNote 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 与 summary,Graph 显示 `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 移到 recycle;Page 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 的 chunk;MNote registry 能把 LightRAG file_path 映射回 workspace source path。
|
||||
- citation:DOCX 当前没有 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 references;MNote 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/delete;query 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 段中文文本 embedding:Ollama 本地 `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.1;LightRAG 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` 返回 retired;agent manifest 不再暴露 evidence tools | 使用 `mnote.knowledge_rag.query/open_reference` |
|
||||
| `/api/local-folder/ocr/*` 与 `.mnote/ocr-index.json` 检索 | HTTP API 返回 retired;active UI 不再调用旧 OCR sidecar;`includeOcr` 不再追加 OCR sidecar 搜索结果 | 图片 / PDF / Office 统一通过 `mnote.knowledge_rag.status/ingest/query/delete-source` |
|
||||
| `skills/mnote-local-index/SKILL.md` | 软归档到 recycle;active 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 summary,Knowledge 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 citation;PPTX/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 单外挂知识库问答路线取代。相关代码接线已撤回;后续不再按本文推进。
|
||||
>
|
||||
> Owner:07-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 runtime:Understanding 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 runtime:provider、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 surface;jobs 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 });
|
||||
|
||||
if (paneRuntimes.length) {
|
||||
Promise.all(paneRuntimes.map((paneRuntime) => mountPane(paneRuntime))).then(() => {
|
||||
const restoreResourceTabFromUrl = () => {
|
||||
const restoreInput = restoreResourceTabInputFromUrl();
|
||||
if (restoreInput) {
|
||||
if (!restoreInput) return Promise.resolve(false);
|
||||
return openResourceInActiveTab(restoreInput).catch((error) => {
|
||||
console.warn('mnote resource tab URL 恢复失败', error);
|
||||
return false;
|
||||
});
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
if (paneRuntimes.length) {
|
||||
Promise.all(paneRuntimes.map((paneRuntime) => mountPane(paneRuntime))).then(() => {
|
||||
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,44 +443,8 @@ 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;
|
||||
}
|
||||
}
|
||||
|
||||
async function pageAiEnrichOcrContextRefs(contextRefs, agentTargetPackage, editorTarget) {
|
||||
var ocrContext = await fetchPageAiOcrSidecarContext(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-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-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>' +
|
||||
'<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 healthy,MNote 登记 ' + 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();
|
||||
|
||||
@@ -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,15 +96,9 @@ 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 {
|
||||
let _ = input;
|
||||
ParseCapability::Unsupported
|
||||
}
|
||||
}
|
||||
|
||||
fn parse<'a>(
|
||||
&'a self,
|
||||
@@ -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;
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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::{
|
||||
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 crate::routes::snapshot_support::ProjectionSnapshot;
|
||||
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(®istry).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"]
|
||||
assert!(home["tags"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|tag| tag.as_str() == Some("alpha"))
|
||||
);
|
||||
assert!(
|
||||
home["backlinks"]
|
||||
.any(|tag| tag.as_str() == Some("alpha")));
|
||||
assert!(home["backlinks"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|link| link.as_str() == Some("Daily"))
|
||||
);
|
||||
assert!(
|
||||
home["backlinks"]
|
||||
.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"]
|
||||
.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"]
|
||||
.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"]
|
||||
.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"]
|
||||
.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")
|
||||
assert!(root
|
||||
.join(".mnote")
|
||||
.join("index")
|
||||
.join("search-index.json")
|
||||
.exists()
|
||||
);
|
||||
.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"]
|
||||
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
|
||||
&& 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?")))
|
||||
);
|
||||
.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"]
|
||||
assert!(office_projection["results"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|item| item["resourceType"].as_str() == Some("office")
|
||||
&& item["path"].as_str() == Some("office/report.xlsx"))
|
||||
);
|
||||
&& 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"]
|
||||
assert!(pdf_projection["results"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|item| item["resourceType"].as_str() == Some("pdf")
|
||||
&& item["path"].as_str() == Some("assets/spec.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"]
|
||||
assert!(image_projection["results"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|item| item["resourceType"].as_str() == Some("image")
|
||||
&& item["path"].as_str() == Some("assets/diagram.png"))
|
||||
);
|
||||
&& 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
|
||||
assert!(index
|
||||
.documents
|
||||
.iter()
|
||||
.any(|document| document.path == "README.md")
|
||||
);
|
||||
.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
|
||||
assert!(!index
|
||||
.documents
|
||||
.iter()
|
||||
.any(|document| document.path == "docs/child.md")
|
||||
);
|
||||
assert!(
|
||||
index
|
||||
.any(|document| document.path == "docs/child.md"));
|
||||
assert!(index
|
||||
.documents
|
||||
.iter()
|
||||
.any(|document| document.path == "README.md")
|
||||
);
|
||||
.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
|
||||
assert!(!index
|
||||
.documents
|
||||
.iter()
|
||||
.any(|document| document.path == "docs/Page.ocr/photo.png.ocr.md")
|
||||
);
|
||||
assert!(
|
||||
!index
|
||||
.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")
|
||||
);
|
||||
.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
|
||||
assert!(!refreshed_index
|
||||
.documents
|
||||
.iter()
|
||||
.any(|document| document.path == "docs/Page.ocr/photo.png-704905.ocr.md")
|
||||
);
|
||||
.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
|
||||
assert!(results
|
||||
.iter()
|
||||
.any(|result| result.source.owner_document_path == "README.md")
|
||||
);
|
||||
assert!(
|
||||
results
|
||||
.any(|result| result.source.owner_document_path == "README.md"));
|
||||
assert!(results
|
||||
.iter()
|
||||
.any(|result| result.source.owner_document_path == "docs/child.md")
|
||||
);
|
||||
assert!(
|
||||
results
|
||||
.any(|result| result.source.owner_document_path == "docs/child.md"));
|
||||
assert!(results
|
||||
.iter()
|
||||
.all(|result| result.source.schema == "mnote.evidence_locator.v1")
|
||||
);
|
||||
.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
|
||||
assert!(context
|
||||
.iter()
|
||||
.all(|item| item.source.owner_document_path == "README.md")
|
||||
);
|
||||
assert!(
|
||||
context
|
||||
.all(|item| item.source.owner_document_path == "README.md"));
|
||||
assert!(context
|
||||
.iter()
|
||||
.any(|item| item.quote.contains("ReadContextToken"))
|
||||
);
|
||||
.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")
|
||||
assert!(root
|
||||
.join("docs")
|
||||
.join("Page.ocr")
|
||||
.join("spec.pdf.parse.md")
|
||||
.exists()
|
||||
);
|
||||
assert!(
|
||||
root.join("docs")
|
||||
.exists());
|
||||
assert!(root
|
||||
.join("docs")
|
||||
.join("Page.ocr")
|
||||
.join("spec.pdf.source-map.json")
|
||||
.exists()
|
||||
);
|
||||
.exists());
|
||||
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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!(
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -36,7 +36,7 @@ node scripts/task490-runtime-surfaces-smoke.js
|
||||
- Rust SSR 文档页 / Page Aggregate:local-first 默认优先 `task167-local-markdown-title-body-options-no-convex-smoke.js`;Page Aggregate browser conversion / compat fallback 改动补跑 `task522-page-aggregate-compat-fallback-contract.js`;cloud/control-plane 文档可补跑 `task110-page-title-single-truth-smoke.js`、`task-page-aggregate-body-sync-smoke.js`、`task-page-aggregate-options-sync-smoke.js`、`task-page-aggregate-refresh-persistence-smoke.js`
|
||||
- leptos-tiptap runtime surface:`task490-runtime-surfaces-smoke.js`;需要验证保存回读可补跑 `task121-rust-web-editor-island-hydration-smoke.js`,但它仍使用 `/api/tree/commands create` 准备文档,不作为无 Convex 默认基线;需要验证浮层互斥和更多菜单状态时再跑 `task158-e30-menu-state-smoke.js`
|
||||
- local-first 本地工作区:`task164-desktop-hot-local-folder-main-entry-smoke.js`、`task166-local-first-managed-workspace-no-convex-smoke.js`、`task167-local-markdown-title-body-options-no-convex-smoke.js`、`task436-local-markdown-open-document-external-change-smoke.js`、`task443-local-markdown-asset-upload-smoke.js`、`task451-local-markdown-conflict-resolution-ui-smoke.js`、`task452-local-search-index-browser-smoke.js`、`task453-local-folder-page-ai-changed-files-smoke.js`;WorkspacePath / ObjectIdentity runtime 消费统一改动补跑 `task524-workspace-object-identity-matrix-smoke.js`
|
||||
- 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`
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
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\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("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """)
|
||||
.replaceAll("'", "'");
|
||||
}
|
||||
|
||||
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);
|
||||
});
|
||||
@@ -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。
|
||||
Reference in New Issue
Block a user