diff --git a/design/07-ai/done/7-52-lightrag-image-ocr-search-chain-hardening-v1.md b/design/07-ai/done/7-52-lightrag-image-ocr-search-chain-hardening-v1.md new file mode 100644 index 00000000..d57106f2 --- /dev/null +++ b/design/07-ai/done/7-52-lightrag-image-ocr-search-chain-hardening-v1.md @@ -0,0 +1,148 @@ +# 7-52 LightRAG 图片 OCR 与搜索召回链路加固 v1 + +> 创建时间:2026-06-07 +> +> 当前状态:`DONE` +> +> Owner:07-ai / knowledge-rag / local-search +> +> 上位依据:`design/07-ai/done/7-50-lightrag-knowledge-rag-provider-v1.md` + +## 1. 问题结论 + +这不是 Page AI 某次回答措辞错误,而是 LightRAG 集成链路同时暴露了三层缺口: + +- 图片 ingest 缺口:MNote 对图片 source 写入 LightRAG input 时使用 Markdown wrapper,当前 indexed chunk 只有 `![image](...)` 占位,没有把 MinerU OCR 文本写入 LightRAG chunk / reference。 +- 工具契约缺口:`mnote_knowledge_rag_query` 把 `rawMetadata.keywords`、实体和关系计数暴露给 agent,但没有明确这些只是查询处理元数据,agent 容易把它误判为“source OCR 成功且已返回原文”。 +- 搜索召回缺口:`/api/search/documents` 仍是普通 local search,`includeOcr=true` 目前不代表会查 LightRAG OCR 文本,因此搜索“线程作用域”不会命中目标图片。 + +## 2. 现场证据 + +目标 source: + +```text +/mnt/Data1T/Mnote_data/users/mnote-e2e/workspaces/my-space/新页面233155/image copy 6.png +``` + +已确认的事实: + +- LightRAG source registry 中该图片为 `processed`,`lightRagDocId=doc-781c4b5f39f135886eb6062d8606058e`,`lightRagFilePath=mnote-d0985c21239b677d-image copy 6.png.md`。 +- `kv_store_text_chunks.json` 中对应 chunk 内容只有 `# image copy 6.png` 和 Markdown 图片占位。 +- 直接调用 LightRAG 的 MinerU parser CLI 解析原 PNG 可以识别出包含“线程作用域”的文本,并生成 `blocks.jsonl` 与 bbox。 +- `/query/data` 能在 metadata 里返回 `rawMetadata.keywords.high_level=["线程作用域"]`,但 references/chunks 没有返回这段 OCR 原文;这只能证明查询分析或图谱元数据命中,不能证明引用文本已暴露。 +- `/api/search/documents` 查询 `线程作用域` 返回 0;代码中 `include_ocr` 只进入 meta,没有参与搜索。 + +## 3. 目标边界 + +本修复继续保持 7-50 的边界: + +- LightRAG 是知识库 OCR / parse / chunk / vector / graph / query provider。 +- MNote 不复制 LightRAG 图谱,不把 LightRAG chunk 当正文真相。 +- MNote 必须负责 source registry、权限、引用打开、普通搜索入口的边界标注,以及 agent 工具契约。 +- 普通搜索可以显示“资料库 OCR 命中”,但不能变成必须依赖 LightRAG 服务才能打开页面或搜索普通 Markdown。 + +## 4. 修复方案 + +### P0:工具契约防误判 + +`mnote_knowledge_rag_query` 返回给 agent 的 compact result 必须明确: + +- `rawMetadata.keywords`、entity count、relation count 只是 provider 查询处理 / retrieval bookkeeping。 +- OCR 是否真正暴露,以 `references[].quote` 和 `references[].contentDiagnostics.ocrTextExposed` 为准。 +- 当 quote 为空或只有 Markdown 图片占位时,agent 必须说明“返回引用未暴露 OCR 原文”,不能声称 OCR 成功。 + +验收: + +- 图片 wrapper quote `# image.png\n\n![image.png]()` 被标记为 `quoteOnlyImagePlaceholder=true`、`ocrTextExposed=false`。 +- Agent 工具 compact result 不再把 raw metadata 当 OCR 成功证据;真实 Page AI 回答必须以该工具契约为准。 + +### P1:图片 ingest 让 OCR 文本进入 LightRAG 可引用内容 + +图片 source 不能只靠 Markdown wrapper 占位入库。需要二选一并以真实探针定案: + +1. 优先方案:扩展 LightRAG `/documents/scan` 支持 `.png/.jpg/.jpeg/.webp/.bmp/.tif/.tiff`,并让 parser routing 对图片走 MinerU。MNote 对图片直接 symlink 原图入 `inputs/`,registry 记录真实图片 source 和 LightRAG basename。 +2. 兼容方案:如果上游 scan 暂不支持图片,MNote connector 调用 LightRAG parser 能力为图片生成 provider sidecar,并写入包含 OCR 文本的受控 `.md` ingestion document,同时保留图片 openAction 指向原 source。该 `.md` 是 LightRAG staging 派生物,不进入 MNote 文件树正文。 + +无论选哪条,必须保证: + +- LightRAG chunk/reference 中能返回 OCR 文本,不只是图片占位。 +- `blocks.jsonl` 能通过 registry 反查到原图并生成 `EvidenceLocator`。 +- reindex 会删除旧 wrapper doc,避免同一图片同时存在“占位 doc”和“OCR doc”。 + +验收: + +- 重新索引目标图片后,`/query/data` 查询 `线程作用域` 的 references/chunks 至少一项包含 OCR 文本。 +- `sourceRootRelativePath` 仍映射到 `新页面233155/image copy 6.png`。 +- citation/openAction 打开原图资源,而不是打开派生 wrapper。 + +### P1:reference enrichment 与诊断 + +当 LightRAG 返回的 reference quote 为空、缺失或仅为图片占位时,MNote connector 应做有界补全: + +- 先按 `chunk_id` 查 `kv_store_text_chunks.json`。 +- 再按 registry 的 `lightRagFilePath` 查 `inputs/__parsed__/.parsed/*.blocks.jsonl`。 +- 对图片占位类 quote,不能直接拿占位去匹配 sidecar;应允许按 query terms / block text 补最相关 OCR block。 +- 返回 `contentDiagnostics`,标明 `quoteSource=chunk|sidecar|missing|placeholder`、`ocrTextExposed`、`locatorSource`。 + +验收: + +- OCR sidecar 已存在但 chunk 未带内容时,工具仍能返回一段真实 OCR quote,并标记来源为 `sidecar`。 +- 若 sidecar 不存在,工具返回明确诊断,不把 metadata 当证据。 + +### P1:普通搜索 `includeOcr=true` 语义落地 + +`includeOcr=true` 不能继续是 no-op。推荐先做“普通搜索 + 资料库 OCR 有界补充”: + +- 普通 Markdown / title / resource metadata 仍走本地轻索引。 +- 当 `includeOcr=true` 且普通结果不足 limit 时,追加查询 LightRAG source registry + sidecar/chunk 的 OCR 文本结果。 +- 搜索结果必须标注 `boundary.kind=ordinary_local_search_with_knowledge_ocr`,单条结果标注 `matchSource=knowledge_rag_ocr`。 +- LightRAG 不可用时普通搜索不失败,只设置 `degraded=true`、`degradedReason=knowledge_rag_unavailable`。 + +验收: + +- `/api/search/documents` 查询 `线程作用域` 且 `includeOcr=true` 能返回目标图片或 owner page。 +- `includeOcr=false` 保持原普通搜索语义。 +- 返回结果包含可打开的 resource locator/citation,不只是一条不可点击文本。 + +### P2:健康检查与 UI 暴露 + +资料库状态面板需要区分: + +- source 是否 registered。 +- LightRAG doc 是否 processed。 +- OCR text 是否进入 chunk/reference。 +- sidecar 是否存在且可用于 locator。 +- 普通搜索是否能通过 `includeOcr` 召回。 + +验收: + +- 对目标图片能显示“processed but OCR text not exposed in reference/chunk”这类可操作状态,而不是只有绿灯。 + +## 5. 执行 Checklist + +- [x] P0 工具契约:compact reference 增加 `contentDiagnostics` 并补单测。 +- [x] P0 agent 工具契约 smoke:确认 compact result 不再把 raw metadata 当 OCR 成功证据。 +- [x] P1 ingest spike:验证 LightRAG 直接 scan 图片扩展 vs MNote 调 parser 生成 OCR ingestion doc,选择一条实现。 +- [x] P1 reindex migration:删除旧图片 wrapper doc 后重新索引目标图片。 +- [x] P1 reference enrichment:sidecar/chunk/query term 补 quote,返回明确 `quoteSource`。 +- [x] P1 搜索:`includeOcr=true` 追加 knowledge OCR 命中,并保持 LightRAG 不可用时普通搜索降级。 +- [x] P1 browser/API smoke:`线程作用域` 在 knowledge-rag 和 `/api/search/documents` 两条入口都可召回并可打开来源。 +- [x] P2 状态 UI:把 OCR 文本是否真正进入 reference/chunk 暴露成诊断项。 + +## 7. 验收证据 + +2026-06-07 已完成目标图片重建索引: + +- registry:`新页面233155/image copy 6.png` -> `lightRagDocId=doc-ac03e578a6c6af7eb0a3224276e4b954`,`lightRagFilePath=mnote-d0985c21239b677d-image copy 6.[mineru].png`,`lightRagStatus=processed`。 +- LightRAG status diagnostics:`directImageScan=true`、`sidecarExists=true`、`ocrTextExposed=true`、`sidecarMeaningfulBlocks=2`。 +- `/api/knowledge-rag/query` 查询 `线程作用域`:首条 reference 映射到 `新页面233155/image copy 6.png`,`quoteSource=sidecar`,`contentDiagnostics.ocrTextExposed=true`,`locatorDegraded=false`,quote 含 `线程作用域`。 +- `/api/search/documents` 查询 `线程作用域` 且 `includeOcr=true`:返回 1 条 `knowledge_rag_ocr` 命中,path 为 `新页面233155/image copy 6.png`,snippet 含 `线程作用域`,boundary 为 `ordinary_local_search_with_knowledge_ocr`。 +- `/api/search/documents` 查询 `线程作用域` 且 `includeOcr=false`:返回 0 条,boundary 保持 `ordinary_local_search`,证明普通搜索未被强依赖 LightRAG。 + +## 8. 当前禁止事项 + +- 不把 `rawMetadata.keywords` 当 source OCR 证据。 +- 不恢复 LiteParse 为默认 fallback。 +- 不把 OCR 文本写回用户 Markdown 正文。 +- 不让普通搜索强依赖 LightRAG 才能返回 Markdown 页面结果。 +- 不保留同一图片的旧占位 wrapper doc 与新 OCR doc 双索引状态。 diff --git a/design/07-ai/process/7-54-raganything-multimodal-retrieval-alignment-v1.md b/design/07-ai/process/7-54-raganything-multimodal-retrieval-alignment-v1.md new file mode 100644 index 00000000..430b415c --- /dev/null +++ b/design/07-ai/process/7-54-raganything-multimodal-retrieval-alignment-v1.md @@ -0,0 +1,505 @@ +# 7-54 RAG-Anything 对照后的多模态检索设计 v1 + +> 创建时间:2026-06-08 +> +> 当前状态:`process` +> +> Owner:07-ai / 03-rust-web / knowledge-rag / search +> +> 取代:`7-53` 已放弃,不再作为搜索方案依据。 +> +> 参考代码: +> - `reference-code/RAG-Anything` +> - repo:`https://github.com/HKUDS/RAG-Anything` +> - commit:`1bbca28` +> - CodeGraph:`codegraph init && codegraph index . --force --quiet`,49 files / 922 nodes / 2308 edges +> - 关键文件:`raganything/query.py`、`raganything/processor.py`、`raganything/utils.py`、`raganything/config.py`、`README.md`、`docs/multimodal_rag_failure_modes.md` + +## 1. 新结论 + +7-53 的问题是仍在围绕“普通搜索是否混入 OCR”打转,设计重心不对。 + +RAG-Anything 的检索设计说明:成熟的 PDF / DOCX / Office / 图片检索,不应该依赖 MNote 自己扫 OCR sidecar 做字符串匹配,而应该把多模态内容作为一等内容写进 RAG 索引: + +```text +PDF / DOCX / Office / Image + -> parser content_list + -> text content -> LightRAG text insert + -> image/table/equation -> multimodal processor + -> templated multimodal chunks + -> LightRAG text_chunks + chunks_vdb + entities_vdb + graph + -> query uses vector + graph retrieval +``` + +所以 MNote 的目标应改为: + +1. 默认普通搜索仍可保持轻量本地搜索。 +2. 必须有明确的“资料库检索 / 全盘资料库检索”入口,覆盖用户显式纳入资料库索引范围的 PDF、DOCX、Office、图片 OCR。 +3. 全盘资料库检索必须走 LightRAG 的 chunk/reference 检索,不再走 MNote 侧 OCR sidecar 字符串补结果。 +4. 多模态资料的 ingest 要尽量变成 RAG-Anything 式的结构化 chunk / entity / relation,而不是只生成 wrapper Markdown。 +5. 搜索结果必须是可打开的来源命中;问答答案是另一层能力。 +6. “全盘”只表示在已配置资料库索引目录内全盘检索,不表示自动索引整个 local folder。 + +## 2. RAG-Anything 是怎么做检索的 + +### 2.1 查询入口分三种 + +`raganything/query.py` 提供三类 query。 + +第一类是纯文本查询: + +```python +query_param = QueryParam(mode=mode, **kwargs) +result = await self.lightrag.aquery(query, param=query_param, system_prompt=system_prompt) +``` + +它直接复用 LightRAG 的 `local/global/hybrid/naive/mix/bypass` 模式。README 推荐示例里人工检索常用 `mode="hybrid"`。 + +第二类是 VLM enhanced query: + +```python +query_param = QueryParam(mode=mode, only_need_prompt=True, **kwargs) +raw_prompt = await self.lightrag.aquery(query, param=query_param) +``` + +它先只拿 LightRAG 检索出来的 prompt/context,不直接生成最终答案;然后从检索上下文中提取 `Image Path:`,做安全目录校验,编码图片为 base64,再把文本 context + 图片一起交给 VLM 回答。 + +第三类是显式 multimodal query: + +```python +enhanced_query = await self._process_multimodal_query_content(query, multimodal_content) +result = await self.aquery(enhanced_query, mode=mode, system_prompt=system_prompt, **kwargs) +``` + +用户把表格、公式、图片等内容作为 query 附件传入时,它先用对应 processor 生成描述,再把描述拼回查询文本,最后仍走 LightRAG 检索。 + +### 2.2 ingest 不是 OCR sidecar,而是一等 chunk + +`raganything/utils.py` 先把 parser 的 `content_list` 分成: + +```text +text_content +multimodal_items +``` + +文本走 LightRAG 原生 `ainsert(...)`,并传 `file_paths` 用于引用。 + +多模态内容走 `raganything/processor.py`: + +1. 图片、表格、公式先用对应 processor 生成增强描述。 +2. `_apply_chunk_template(...)` 把原始路径、caption、footnote、邻近文本、章节路径、表格 body、公式文本、增强描述拼成可检索 chunk。 +3. `_convert_to_lightrag_chunks_type_aware(...)` 生成 LightRAG 标准 chunk: + +```python +{ + "content": formatted_chunk_content, + "tokens": tokens, + "full_doc_id": doc_id, + "chunk_order_index": chunk_order_index, + "file_path": file_ref, + "is_multimodal": True, + "modal_entity_name": entity_info["entity_name"], + "original_type": data["content_type"], + "page_idx": data["item_info"].get("page_idx", 0), +} +``` + +4. chunk 同时写入: + +```text +lightrag.text_chunks +lightrag.chunks_vdb +``` + +5. 多模态主实体写入: + +```text +knowledge graph +entities_vdb +full_entities +``` + +6. 对多模态 chunk 调 LightRAG `extract_entities(...)`,再追加 `belongs_to` 关系,最后调用 `merge_nodes_and_edges(...)` 合并回 graph。 +7. `doc_status.chunks_list/chunks_count` 追加多模态 chunk id。 + +这才是“全盘资料库可检索”的关键:图片、表格、公式不是旁路 OCR 文件,而是进入 LightRAG 的 chunk 向量库和图谱。 + +### 2.3 检索失败排查也围绕多模态索引 + +RAG-Anything 的 failure checklist 不是让 UI 再补一层字符串搜索,而是检查: + +- parser Markdown / `content_list.json` 是否可信。 +- table block 的 `table_body` 是否保留结构。 +- image path / caption / page index 是否对齐。 +- multimodal processing flags 是否启用。 +- embedding path 是否看到 enriched text,而不是只看到 raw OCR。 + +这对 MNote 很重要:搜索不准时,第一诊断点应该是“资料有没有以正确 chunk 进入 LightRAG”,不是前端结果列表怎么融合。 + +## 3. MNote 当前差距 + +当前 MNote 已经有: + +- `/api/knowledge-rag/ingest` +- `/api/knowledge-rag/query` +- source registry +- LightRAG `/documents/scan` +- `/query/data` + references 映射 +- citationUrl / citationMarkdown / locator enrich + +但普通搜索仍有一条不成熟路径: + +```text +/api/search/documents + -> local_search_index + -> includeOcr 时追加 query_knowledge_rag_ocr_sidecar_results +``` + +这条 `knowledge_rag_ocr_sidecar_results` 是 MNote 遍历 registry sidecar 后做文本匹配,不是 LightRAG vector/graph retrieval。它会让搜索看起来“用了 OCR”,但排序、召回、语义、图谱关系和多模态 chunk 都不是一套成熟系统。 + +另一个差距是 ingest。MNote 当前对 LightRAG scan 支持文件直接 symlink;对非 scan 支持文件生成 Markdown wrapper: + +```text +# file +![file]() +``` + +这对图片入口有帮助,但不等于 RAG-Anything 的 multimodal chunk / entity / relation 管线。后者会把图片、表格、公式的描述、上下文、页码、路径都写进 LightRAG chunk 和 graph。 + +另一个必须保留的边界是索引范围。MNote 是 local-folder 产品,不能默认把整个本地目录都纳入 LightRAG。用户需要人工控制哪些目录参与资料库检索,否则会带来: + +- 大目录首次索引成本不可控。 +- 隐私和权限边界不清。 +- 搜索噪音扩大,资料库结果质量下降。 +- 删除/移动/重建的 watcher 压力过高。 + +因此资料库检索依赖显式索引目录,而不是自动覆盖整个 workspace。 + +## 4. 新搜索边界 + +### 4.1 默认搜索 + +默认搜索只做快速定位: + +- Markdown 页面标题 +- Markdown 正文 +- 路径 +- 最近打开 / 最近修改 +- 本地轻索引资源元信息 + +默认不混入 OCR sidecar,也不混入 LightRAG 语义结果。 + +### 4.2 全盘资料库检索 + +搜索弹窗必须有明确入口: + +```text +范围:当前页 / 工作区 / 全盘资料库 +``` + +选择 `全盘资料库` 后: + +- 调 LightRAG retrieval,而不是 MNote sidecar grep。 +- 覆盖已纳入资料库索引目录的 PDF、DOCX、Office、图片 OCR、表格、公式、Markdown。 +- 返回 references/chunks 作为搜索结果。 +- 不默认生成 answer。 +- 每条结果有来源、snippet、类型、页码 / bbox / block / resource fallback。 +- 结果能直接打开对应 resource tab。 +- 对未入库目录明确显示“未纳入资料库索引”,而不是让用户误以为 LightRAG 漏检。 + +建议协议: + +```text +POST /api/knowledge-rag/search +``` + +请求: + +```json +{ + "workspaceId": "...", + "rootUri": "file:///...", + "query": "压缩率 Canterbury corpus", + "mode": "hybrid", + "topK": 12, + "chunkTopK": 24, + "includeChunkContent": true, + "includeReferences": true, + "answer": false +} +``` + +实现上可以先复用 `/api/knowledge-rag/query` 的 `/query/data` 调用,只是响应包装成 search results,不展示 answer。 + +### 4.2.1 资料库索引范围 + +MNote 需要把“全盘资料库检索”建立在显式索引范围上: + +```text +Knowledge Search Scope + includeRoots: + - docs/research + - resources/papers + excludePatterns: + - "**/draft-private/**" + - "**/.git/**" + - "**/node_modules/**" + runOnChange: true|false +``` + +产品语义: + +- `includeRoots` 是用户主动加入资料库索引的目录或文件。 +- `excludePatterns` 后期必须支持,用于排除私密目录、临时目录、构建产物、大型无关资产。 +- LightRAG 只保证检索这些已入库范围。 +- 普通本地搜索仍可搜索 Markdown / 本地轻索引,不要求 LightRAG 入库。 +- 如果用户在全盘资料库模式下搜不到某文件,UI 要能提示它是否未入库、入库中、失败、已排除或已删除。 + +实现上,source registry 需要从“单个 source 列表”升级为“索引范围 + source 状态”: + +```text +.mnote/index/lightrag-source-registry.json + indexedRoots[] + rootRelativePath + recursive + excludes[] + runOnChange + updatedAt + entries[] + sourceRootRelativePath + sourceHash + lightRagDocId + chunkIds + status + excludedBy +``` + +这保留了当前“人工控制哪些需要全盘搜索”的价值,同时给后续自动 watcher / 增量重建留出边界。 + +### 4.3 Knowledge Ask + +Knowledge Ask / Page AI 才生成答案: + +- 用同一套 LightRAG references。 +- 展示 `Context used`。 +- 引用使用 `citationMarkdown` / `citationUrl`。 +- 可选 VLM enhanced:先拿 retrieved context,再把命中的图片资源交给 VLM 参与回答。 + +### 4.4 当前页搜索 + +当前页内查找仍不走 LightRAG: + +- Markdown:editor / PageAggregate 内查找。 +- PDF:viewer 文本层 / sidecar locator。 +- Office:预览/解析层查找。 + +这是局部查找,不是全盘检索。 + +## 5. MNote 应参考 RAG-Anything 的实施方案 + +### Phase A:放弃 7-53,移除 sidecar 补结果主路径 + +目标:先停止把 sidecar grep 当成熟 OCR 搜索。 + +改动: + +1. 默认搜索关闭 `includeOcr`。 +2. `includeOcr` 不再调用 `query_knowledge_rag_ocr_sidecar_results` 作为普通结果补充。 +3. UI 增加 `全盘资料库` scope,先复用 `/api/knowledge-rag/query` 的 references 返回。 +4. 结果标注 `provider=lightrag`、`matchSource=lightrag_reference`。 + +验收: + +- 默认搜索短词不出现 PDF/OCR 假阳性。 +- 选择全盘资料库后可以搜到 PDF/DOCX/OCR 资料。 +- 搜索结果能打开对应 resource tab。 + +### Phase B:新增 references-only retrieval facade + +目标:把问答和人工检索分开。 + +新增: + +```text +POST /api/knowledge-rag/search +``` + +内部: + +- 调 LightRAG `/query/data`。 +- `include_references=true`。 +- `include_chunk_content=true`。 +- `mode=hybrid` 或 `mix`,后续用 benchmark 决定默认值。 +- 不把 answer 放入搜索结果。 +- 对 references 做 registry mapping、locator enrich、ranking。 + +验收: + +- API 返回 `mnote.knowledge_rag.search_results.v1`。 +- 每条 result 有 `citationUrl` / `citationMarkdown` / `locatorPrecision`。 +- 对无 locator 的命中明确降级到文件级打开。 + +### Phase C:RAG-Anything 式多模态 chunk 入库 + +目标:让图片、表格、公式、Office/PDF 结构进入 LightRAG retrieval,而不是只存在 sidecar。 + +设计: + +1. MNote parser 输出统一 `content_list`: + - `text` + - `image` + - `table` + - `equation` + - `generic` +2. text 走 LightRAG 文本 insert / scan。 +3. multimodal items 生成 templated chunk: + - source path + - page index + - block id / bbox + - caption / footnote + - table body + - equation text / latex + - neighbor text / section path + - enhanced description +4. 写入 LightRAG chunk/vector/graph,或通过扩展 LightRAG/RAG-Anything adapter 实现。 +5. registry 保存 `sourceHash -> lightRagDocId -> chunkIds -> locator map`。 + +验收: + +- 查询“图 3 展示了什么”能召回图片 chunk,而不是只召回邻近正文。 +- 查询表格中的指标能召回 table chunk,snippet 包含表格 body。 +- query result 能通过 chunkId 映射回 MNote resource tab 的 page/bbox/block。 + +### Phase D:VLM enhanced Ask + +目标:回答中真正看图,而不是只读图片 caption。 + +参考 RAG-Anything: + +1. 先让 LightRAG 返回 retrieval prompt/context。 +2. 从 context 中提取命中图片路径或 MNote resource id。 +3. 做 allowed roots / resource permission 校验。 +4. 把图片编码或转为可访问 asset URL。 +5. 文本 context + image 一起交给 VLM。 + +这一步只用于 Ask,不用于普通搜索结果。 + +## 6. 关键约束 + +- 全盘资料库检索是必需能力,不能因为默认关闭 OCR 而消失。 +- 默认搜索和全盘资料库检索必须是两个明确模式,不能暗中混合。 +- 不再把 sidecar grep 当成熟资料库搜索。 +- locator 不足时只能声明降级,不能伪造页码/bbox。 +- `file_path` 不能作为唯一真相;MNote registry 必须维护绝对路径、root-relative path、source hash、LightRAG doc id、chunk id、locator map。 +- 多模态 chunk 的 source path / image path 必须做权限和安全目录校验。 + +## 7. 立即建议 + +下一步不要继续修 7-53。 + +应直接做: + +1. 删除/停用普通搜索里的 OCR sidecar 补结果。 +2. 新增 `资料库索引范围` 设置,至少支持 include roots;excludes 先设计协议,后续实现。 +3. 新增 `全盘资料库` 搜索 scope,只检索已入库范围,调用 LightRAG references。 +4. 写 `7-54 Phase A/B` 的 smoke:默认搜索不混 OCR;未入库 PDF/DOCX 在资料库检索中明确提示未入库;加入索引目录后可搜到并打开。 +5. 后续再做 RAG-Anything 式多模态 chunk 入库。 + +这样既保留 MNote 的核心价值,也把检索系统拉回成熟 RAG 项目的设计方向。 + +## 8. Phase A/B 实施记录 + +时间:2026-06-08 + +已完成: + +1. 普通搜索不再把 `includeOcr=true` 转成 `query_knowledge_rag_ocr_sidecar_results` 补结果;`/api/search/documents` 的边界固定回 `ordinary_local_search`,`knowledgeRag=false`,`ocrSidecarFallback=false`。 +2. 新增 `POST /api/knowledge-rag/search`,作为 references-only retrieval facade: + - 调 LightRAG `/query/data` + - `include_references=true` + - 默认保留 `include_chunk_content=true` + - 复用 registry mapping / locator enrich / citationUrl / citationMarkdown + - 返回 `mnote.knowledge_rag.search_results.v1` + - 搜索结果标记 `provider=lightrag`、`matchSource=lightrag_reference` +3. source registry 增加兼容字段 `indexedRoots[]`,ingest 显式 source 时记录 include root: + - `rootRelativePath` + - `recursive` + - `excludePatterns` + - `runOnChange` + - `updatedAtMs` + 旧 registry JSON 缺少 `indexedRoots` 时默认空数组。 +4. 搜索弹窗新增 `全盘资料库`开关;默认工作区搜索发送 `includeOcr=false`,打开开关后调用 `/api/knowledge-rag/search`。 +5. `scripts/task538-knowledge-rag-source-scope-api-smoke.js` 增加 `/api/knowledge-rag/search` 验证,确认 sourcePaths post-filter 后只返回目标 source,且结果来自 LightRAG references 并可打开来源。 + +验证: + +```text +node --check rust/crates/mnote-web/browser/sidebar-tree-runtime.js +node --check scripts/task538-knowledge-rag-source-scope-api-smoke.js +cargo check --manifest-path rust/Cargo.toml -p mnote-web +cargo test --manifest-path rust/Cargo.toml -p mnote-web search_documents_route_is_owned_by_mnote_web -- --nocapture +cargo test --manifest-path rust/Cargo.toml -p mnote-web search_documents_local_folder_does_not_promote_evidence_sqlite_body_hits -- --nocapture +cargo test --manifest-path rust/Cargo.toml -p mnote-web indexed_root -- --nocapture +cargo test --manifest-path rust/Cargo.toml -p mnote-web references_only_search_result -- --nocapture +node scripts/task125-rust-web-search-server-first-smoke.js +node scripts/task538-knowledge-rag-source-scope-api-smoke.js +Playwright 登录态验证:默认搜索 includeOcr=false;全盘资料库开关调用 /api/knowledge-rag/search +``` + +`scripts/task128-rust-web-wolai-search-modal-smoke.js` 未作为本次验收依据:当前默认登录态要求下它直接访问 `/` 等待 topbar,曾因未建立登录态而超时。已用登录态 Playwright 脚本覆盖本次新增搜索 scope 行为。 + +未完成: + +- `excludePatterns` 仅进入 registry 协议,尚未实现 UI 编辑和 glob 过滤。 +- RAG-Anything 式 multimodal chunks / entities / relations 入库仍是 Phase C,未标完成。 + +## 9. Phase B.1:chunk 上下文段落级定位 + +时间:2026-06-08 + +新增结论: + +当前用户需要的是“搜索结果点击后落到对应段落”,不是必须文字级 bbox。对 DOCX / Office 这类当前缺少 page/bbox 的资料,短期不应强制改成 DOCX -> PDF viewer 管线;更低风险的路径是复用 LightRAG 已返回的 chunk 上下文做段落级定位。 + +参考 `rag-knowledge-system` 的可采用点: + +- 检索结果必须保留稳定 chunk metadata。 +- 点击定位不能只用 query 短词,应使用 chunk/snippet 上下文作为 anchor。 +- 目录/文件夹 scope 是 retrieval 层的过滤条件,不是 UI 结果列表后处理。 + +不直接照搬的点: + +- `rag-knowledge-system` 的 PDF highlight 先有 `page_num`,再在页内 `search_for(anchor)`;MNote 当前 DOCX sidecar 没有页码,所以只能先做到文档内段落级定位。 +- 对 DOCX 缺 page/bbox 时,不能伪造页码或 bbox,应返回 `locatorPrecision=paragraph` / `locatorDegraded=true`。 + +设计: + +```text +LightRAG /query/search + -> chunk content / occurrence + -> MNote registry mapping + -> sidecar block match + -> locator: + blockId + sourceMapPath + evidenceText = query-centered block/chunk context + locatorPrecision = bbox | paragraph | file + -> office-preview: + clean evidenceText + generate multiple anchors + score rendered paragraphs/tables by context overlap + scroll/highlight best paragraph + fallback to old short text candidates only after paragraph scoring fails +``` + +验收样例: + +- `吡咯烷`:不应因为短词优先命中 `N-甲基吡咯烷酮`,应优先选择与 returned chunk 上下文整体重合最高的段落。 +- `三乙基硅` / `三甲基硅`:存在 bbox 时仍使用 bbox;没有 bbox 时段落级定位,不伪造页码。 +- 同一已打开 DOCX 点击不同搜索结果时,resource tab 不重新加载,只通过 `postMessage` 更新 locator 并重新定位。 + +降级语义: + +- `locatorPrecision=bbox`:有 page + bbox,可页内精确框选。 +- `locatorPrecision=paragraph`:有 block/context,但缺 page/bbox,只保证段落级定位。 +- `locatorPrecision=file`:只能打开文件,定位信息不足。 diff --git a/design/07-ai/process/7-55-lightrag-docx-citation-rerank-alignment-v1.md b/design/07-ai/process/7-55-lightrag-docx-citation-rerank-alignment-v1.md new file mode 100644 index 00000000..839775c6 --- /dev/null +++ b/design/07-ai/process/7-55-lightrag-docx-citation-rerank-alignment-v1.md @@ -0,0 +1,525 @@ +# 7-55 LightRAG DOCX 引用定位与 rerank 对齐设计 v1 + +> 创建时间:2026-06-08 +> +> 当前状态:`process` +> +> Owner:07-ai / knowledge-rag / 03-rust-web / office-preview +> +> 关联: +> - `design/07-ai/done/7-50-lightrag-knowledge-rag-provider-v1.md` +> - `design/07-ai/done/7-51-lightrag-post-commit-hardening-v1.md` +> - `design/07-ai/process/7-54-raganything-multimodal-retrieval-alignment-v1.md` +> +> 参考代码: +> - LightRAG:`/mnt/Data1T/Mnote_data/lightrag/LightRAG` +> - NexusRAG:`/tmp/mnote-rag-eval-nexusrag` +> - MNote:`rust/crates/mnote-web/src/routes/knowledge_rag.rs` + +## 1. 背景 + +当前 DOCX / Office 资料检索的主要问题不是 LightRAG 完全不能检索,而是检索结果映射回 MNote resource tab 时容易错位。 + +已验证的现状: + +- MNote 已通过 `/api/knowledge-rag/query` 调 LightRAG `/query/data`,并请求 `include_references=true` / `include_chunk_content=true`。 +- LightRAG 返回的 reference / chunk 可以包含 `file_path`、`chunk_id`、`content`,但不直接返回 MNote 可用的 `resourcePath/page/bbox/blockId`。 +- MNote 当前 `map_reference_plan(...)` 会用 chunk quote / query 去 sidecar `.blocks.jsonl` 里反查 block,再生成 `EvidenceLocator`。 +- PDF / image 经 MinerU 或 Docling 解析后通常有 `positions.type=bbox`,可以转 page/bbox。 +- DOCX native parser 生成的是 `positions.type=paraid`,真实样本里大量 `range=[null,null]`,不能可靠转 page/bbox。 +- 当前 LightRAG 已有 reranker,不应在 MNote 重复实现一套通用 reranker。 + +本设计目标是把“LightRAG 已有能力”和“MNote 必须补的引用定位层”明确切开。 + +## 2. CodeGraph 核对结论 + +### 2.1 LightRAG 已有 reranker 主链 + +源码证据: + +- `lightrag/base.py:83` `QueryParam` 默认 `mode="mix"`。 +- `lightrag/base.py:148` `enable_rerank` 由 `RERANK_BY_DEFAULT` 控制,默认 true。 +- `lightrag/utils.py:3278` `apply_rerank_if_enabled(...)` 调 `global_config["rerank_model_func"]`,支持 index-based rerank result,并把 `rerank_score` 写回 chunk。 +- `lightrag/utils.py:3362` `process_chunks_unified(...)` 的顺序是 rerank -> `min_rerank_score` 过滤 -> `chunk_top_k` 截断 -> token truncation。 +- `lightrag/api/lightrag_server.py:1894` server 支持 `cohere` / `jina` / `aliyun` rerank binding;vLLM 可走 Cohere-compatible endpoint。 +- `docs/LightRAG-API-Server.md` 明确 rerank 是 query-time improvement,不需要重建索引。 + +当前本机运行态: + +```json +{ + "core_version": "1.5.1", + "configuration": { + "enable_rerank": false, + "rerank_binding": "null", + "rerank_model": null, + "min_rerank_score": 0.0 + }, + "rerank_queue_status": { + "available": false + } +} +``` + +结论:MNote 不新增通用 reranker。MNote 只负责配置、健康状态展示、请求参数透传和结果引用消费。 + +### 2.2 LightRAG 已有 DOCX native parser 与 sidecar + +源码证据: + +- `lightrag/pipeline.py:2897` `parse_native(...)` 对 pending DOCX 调 `extract_docx_blocks(...)`。 +- `lightrag/parser/docx/parse_document.py:1611` `extract_docx_blocks(...)` 按 heading / paragraph / table 拆 block。 +- `lightrag/parser/docx/ir_builder.py:304` DOCX block position 写为 `IRPosition(type="paraid", range=[uuid_start, uuid_end])`。 +- `lightrag/sidecar/writer.py:256` `.blocks.jsonl` content row 包含 `blockid/content/heading/parent_headings/level/positions`。 + +真实 sidecar 现状: + +```json +{ + "type": "content", + "blockid": "5c6cda46137773fba8cf06fcfc08c313", + "content": "...", + "heading": "Preface/Uncategorized", + "positions": [ + {"type": "paraid", "range": [null, null]} + ] +} +``` + +结论:DOCX 引用定位不能伪造成 PDF 式 page/bbox。短期正确目标是段落级 / block 级定位。 + +### 2.3 NexusRAG 的强参考点 + +NexusRAG 不是只针对 PDF。 + +- `backend/app/services/document_parser/docling_parser.py` 支持 `.pdf/.docx/.pptx/.html`。 +- `backend/app/services/document_parser/marker_parser.py` 支持 `.pdf/.docx/.pptx/.xlsx/.html/.epub`。 + +可借鉴点: + +- parser abstraction:不同 parser 都产出统一 `ParsedDocument`。 +- chunk contract:`EnrichedChunk` 持有 `source_file/document_id/page_no/heading_path/contextualized/images/tables`。 +- retrieval result:source card 展示文件名、页码、标题路径、相关性分数。 +- citation UI:答案内 citation badge 与 source card 分开,但共享同一 citation model。 + +不直接照搬点: + +- NexusRAG 的引用 ID / source card 是产品层 contract,不代表其 DOCX 一定有比 LightRAG 更强的源文档坐标。 +- MNote 已选 LightRAG 作为默认 provider,不能为了 citation UI 整套替换 RAG provider。 +- MNote 的 open-reference 必须回到 local-folder resource tab / Page Aggregate / Resource Tree,不应引入第二套文档 viewer 真相。 + +## 3. 设计原则 + +1. LightRAG 负责检索质量:parse、chunk、vector、graph、rerank、query。 +2. MNote 负责来源真相:source registry、权限、root-relative path、resource tab 打开、citation URL、locator 降级语义。 +3. 引用定位按精度分层,不伪造: + - `bbox`:有 page + bbox,可精确高亮。 + - `paragraph`:有 block / heading / context,可滚动到段落并高亮上下文。 + - `file`:只能打开文件。 +4. DOCX 默认先做 paragraph locator,不把 DOCX 强制转换成 PDF viewer 管线。 +5. rerank 只走 LightRAG provider。MNote 可以做 source scope post-filter 和 citation ranking,但不能把它包装成 provider rerank。 +6. Agent final answer 只能引用 MNote 过滤和映射后的 `citations/references`,不能引用 raw LightRAG chunks。 + +## 4. 目标架构 + +```text +LightRAG /query/data + -> raw.data.references / raw.data.chunks + -> MNote source registry + -> Evidence Mapping Layer + - match provider file_path/doc_id -> source entry + - match chunk_id/content/query -> sidecar block + - classify locatorPrecision + - build citationUrl/citationMarkdown + -> knowledge-rag result + - references[] + - citations[] + - raw kept for diagnostics only + -> Page AI / Search UI / Source Card + -> resource tab open-reference + - bbox: page + bbox highlight + - paragraph: block/context anchor highlight + - file: file-level open +``` + +## 5. 数据合同 + +### 5.1 MNote citation model + +新增内部规范,不要求一次性改完所有 API 字段,但 `knowledge-rag`、Page AI、搜索结果和 source card 应逐步收口到这组字段。 + +```rust +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct KnowledgeRagCitation { + pub schema: String, + pub provider: String, + pub citation_id: String, + pub source_id: Option, + pub source_path: String, + pub source_root_relative_path: String, + pub light_rag_doc_id: Option, + pub light_rag_file_path: String, + pub light_rag_chunk_id: Option, + pub block_id: Option, + pub heading_path: Vec, + pub quote: Option, + pub quote_source: String, + pub locator_precision: LocatorPrecision, + pub locator_degraded: bool, + pub citation_url: String, + pub citation_markdown: String, + pub relevance_score: Option, + pub diagnostics: CitationDiagnostics, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CitationDiagnostics { + pub quote_source: String, + pub provider_rerank_requested: bool, + pub provider_rerank_available: bool, + pub raw_reference_mapped: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum LocatorPrecision { + Bbox, + Paragraph, + File, +} +``` + +### 5.2 citation ID + +采用 NexusRAG 式短 ID,但 ID 只作为 UI/display contract,不作为事实主键。 + +格式: + +```text +[a3z1] 普通文本来源 +[IMG-p4f2] 图片来源 +``` + +生成规则: + +- 每次 query result 内稳定即可,不要求跨 query 永久稳定。 +- 基于 `provider + source_id + chunk_id/block_id + occurrence_index` 做 hash,base36/base62 截断 4 字符。 +- 同一 query 内冲突则加 salt 重算。 +- citation card 使用 `citation_id` 关联 answer badge。 + +### 5.3 locator precision 计算 + +```rust +fn locator_precision_from_block(block: &serde_json::Value) -> LocatorPrecision { + let has_bbox = block + .get("positions") + .and_then(|value| value.as_array()) + .is_some_and(|positions| { + positions.iter().any(|position| { + position.get("type").and_then(|value| value.as_str()) == Some("bbox") + && position.get("anchor").is_some() + && position.get("range").and_then(|value| value.as_array()).is_some() + }) + }); + + if has_bbox { + return LocatorPrecision::Bbox; + } + + let has_paragraph_anchor = block.get("blockid").and_then(|value| value.as_str()).is_some() + || block.get("heading").and_then(|value| value.as_str()).is_some() + || block + .get("positions") + .and_then(|value| value.as_array()) + .is_some_and(|positions| { + positions.iter().any(|position| { + position.get("type").and_then(|value| value.as_str()) == Some("paraid") + }) + }); + + if has_paragraph_anchor { + return LocatorPrecision::Paragraph; + } + + LocatorPrecision::File +} +``` + +## 6. DOCX paragraph locator + +### 6.1 后端 locator + +当 sidecar block 命中但没有 bbox: + +```json +{ + "locatorPrecision": "paragraph", + "locatorDegraded": true, + "locator": { + "resourceKind": "office", + "resourcePath": "资料/保护基.docx", + "blockId": "2844bf0675454eb5e83f97b596866e53", + "sourceMapPath": ".mnote/.../__parsed__/保护基.blocks.jsonl", + "openAction": { + "params": { + "provider": "lightrag", + "chunkId": "doc-xxx-chunk-001", + "searchQuery": "三乙基硅", + "evidenceText": "query-centered chunk/block context" + } + } + } +} +``` + +要求: + +- 有 `blockid` 必须传 `blockId`。 +- 有 sidecar path 必须传 `sourceMapPath`。 +- `evidenceText` 优先取 sidecar block content 的 query-centered window,其次 chunk content。 +- `page/bbox` 缺失时不得写假值。 +- `citationMarkdown` 文案可带“定位降级”,但链接仍应可点击打开 resource tab。 + +### 6.2 前端 office-preview anchor + +office-preview / docx-preview 收到 paragraph locator 后: + +```text +locator.evidenceText + -> normalize text + -> build anchors: + 1. query-centered phrase + 2. long rare terms + 3. heading + quote window + 4. fallback query + -> scan rendered paragraphs / table cells + -> score by token overlap + rare term bonus + heading match + -> scroll into view + transient highlight +``` + +禁止: + +- 只用 query 短词直接 `find()`,否则 `吡咯烷` 容易错跳到 `N-甲基吡咯烷酮`。 +- DOCX 无 page/bbox 时展示成精确页码。 +- 同一 resource tab 重新加载整个 document 来响应每次 citation click;应优先 postMessage 更新 locator。 + +## 7. Rerank 对齐 + +### 7.1 配置策略 + +MNote 不实现 rerank model 调用,只管理 LightRAG 配置状态。 + +推荐配置优先级: + +1. 用户显式关闭:`enable_rerank=false`。 +2. LightRAG health 显示 `configuration.enable_rerank=true` 且 `rerank_queue_status.available=true`:MNote query 默认传 `enable_rerank=true`。 +3. LightRAG health 显示 rerank 不可用:MNote 仍可传 `enable_rerank=true`,但 diagnostics 标注 provider 未配置;或在 UI 选择“禁用 rerank”后传 false。 + +`.env` 示例: + +```env +RERANK_BINDING=cohere +RERANK_MODEL=BAAI/bge-reranker-v2-m3 +RERANK_BINDING_HOST=http://127.0.0.1:8000/rerank +RERANK_BINDING_API_KEY=local-rerank +RERANK_BY_DEFAULT=True +MIN_RERANK_SCORE=0.0 +MAX_ASYNC_RERANK=4 +RERANK_TIMEOUT=30 +``` + +说明: + +- 本地 vLLM / rerank 服务必须先真实验证 endpoint。 +- 之前本机测过常见 NVIDIA rerank endpoint 返回 404;不能仅凭模型名就标“rerank 已启用”。 +- rerank 是 query-time 能力,启用后不要求重建 LightRAG 索引。 + +### 7.2 MNote query request + +`/api/knowledge-rag/query` 和 `/api/knowledge-rag/search` 请求 LightRAG 时应显式传: + +```json +{ + "query": "...", + "mode": "mix", + "top_k": 20, + "chunk_top_k": 20, + "include_references": true, + "include_chunk_content": true, + "enable_rerank": true +} +``` + +`enable_rerank` 来源: + +- API body 可传。 +- UI 设置可覆盖。 +- 未设置时使用 MNote 从 LightRAG health 缓存到的 provider capability。 + +### 7.3 UI diagnostics + +资料库设置 / dashboard 显示: + +```text +LightRAG: healthy +Embedding: nvidia/baai/bge-m3 +Rerank: disabled +Rerank binding: null +Rerank queue: unavailable +``` + +当用户看到检索排序异常时,第一屏就能判断当前没有 rerank。 + +## 8. Source card 与 answer citation + +借鉴 NexusRAG 的 UI contract: + +### 8.1 inline badge + +答案文本中使用短 badge: + +```text +三乙基硅基通常可作为保护基使用 [a3z1]。 +``` + +规则: + +- 每句话最多 3 个来源。 +- 只引用直接支持该句的来源。 +- badge 点击打开对应 source card,同时可直接打开 citation URL。 +- LLM 不自行编造 citation ID;由 MNote tool result 提供可用 citation set。 + +### 8.2 source card + +每个 citation card 展示: + +```text +[a3z1] 保护基.docx +定位:段落级 +标题路径:酚羟基保护 > 硅基保护 +相关性:LightRAG rerank_score / vector order / MNote score +引用片段:... +``` + +字段来源: + +- 文件名:source registry。 +- 页码:只有 locator 有 page 时显示。 +- 标题路径:sidecar `parent_headings + heading`。 +- 相关性: + - 优先 LightRAG `rerank_score`,如果 provider 返回。 + - 否则使用 MNote 现有 reference ranking score,标注为 `mnoteScore`。 +- 图片引用:sidecar drawings / image OCR 命中使用 `[IMG-xxxx]`。 + +## 9. 实施阶段 + +### Phase A:rerank capability 透出 + +- [ ] `KnowledgeRagQueryRequest` / `KnowledgeRagSearchRequest` 增加 `enable_rerank`。 +- [ ] 调 LightRAG `/query/data` / `/query/search` 时显式传 `enable_rerank`。 +- [ ] status/dashboard 暴露 LightRAG health 中的 rerank 字段。 +- [ ] diagnostics 标注 `providerRerankEnabled`、`providerRerankAvailable`、`rerankModel`。 +- [ ] 不新增 MNote reranker。 + +验收: + +- LightRAG `.env` 为 `RERANK_BINDING=null` 时,UI 明确显示 rerank disabled。 +- API result metadata 能看出本次 query 是否请求 rerank、provider 是否实际可用。 + +### Phase B:citation contract 收口 + +- [ ] 在 knowledge-rag mapped reference 上补 `citationId`。 +- [ ] 输出统一 `citations[]`,字段包含 `citationMarkdown/citationUrl/locatorPrecision/quote/headingPath`。 +- [ ] Page AI / Hermes tool result 只暴露 filtered citations,不暴露 raw chunks 给 final answer 引用。 +- [ ] source card 消费 `citations[]`,不重新解析 raw LightRAG response。 + +验收: + +- 同一回答内 citation badge 能映射到 source card。 +- unmapped / stale / deleted provider reference 不进入可引用 citation set。 + +### Phase C:DOCX paragraph locator + +- [ ] `lightrag_locator_for_reference(...)` 对 DOCX sidecar block 无 bbox 时返回 paragraph locator。 +- [ ] `locatorPrecision=paragraph` 时保留 `blockId/sourceMapPath/evidenceText/headingPath`。 +- [ ] `citationMarkdown` 对 paragraph/file 降级文案明确,但链接可点击。 +- [ ] office-preview 支持 `blockId/evidenceText` 定位,按 chunk context 打分滚动。 +- [ ] 同一 DOCX 多次点击 citation 使用 postMessage 更新定位,不重复 reload。 + +验收: + +- `吡咯烷` 不错跳到仅短词匹配的其他段落。 +- `三乙基硅` / `三甲基硅` 在 DOCX 无 bbox 时至少段落级定位。 +- 无 sidecar 或 quote 不匹配时只返回 file locator,不伪造 paragraph。 + +### Phase D:NexusRAG 式来源卡片 + +- [ ] source card 展示 citation ID、文件名、标题路径、定位精度、相关性、quote。 +- [ ] 有 page/bbox 时显示页码;无 page/bbox 不显示页码。 +- [ ] 图片 citation 使用 `[IMG-xxxx]` 并打开原图 / PDF 页。 +- [ ] answer renderer 对 citation badge 做点击联动。 + +验收: + +- 答案和搜索结果都能从同一 citation model 打开来源。 +- source card 不依赖 LLM 生成的自由文本解析。 + +## 10. 测试计划 + +### Rust 单测 + +目标文件:`rust/crates/mnote-web/src/routes/knowledge_rag.rs` + +- `mapped_reference_generates_short_citation_id` +- `docx_paraid_block_returns_paragraph_locator` +- `docx_missing_block_degrades_to_file_locator` +- `bbox_block_keeps_bbox_locator` +- `query_request_passes_enable_rerank` +- `provider_rerank_disabled_is_reported_in_diagnostics` + +### Browser smoke + +新增或扩展: + +- `scripts/task540-knowledge-rag-office-result-open-locator-smoke.js` +- `scripts/task543-knowledge-rag-pyrrolidine-top5-locator-smoke.js` + +新增断言: + +- DOCX citation click 后 resource tab 不重载。 +- paragraph locator 触发可见高亮。 +- `locatorPrecision` 与 UI 文案一致。 +- rerank disabled 状态在资料库设置中可见。 + +### Provider smoke + +只在配置真实 rerank endpoint 后执行: + +```bash +curl /health +curl /query/data -d '{"query":"...", "mode":"mix", "enable_rerank":true}' +``` + +验收: + +- `/health.configuration.enable_rerank=true`。 +- `rerank_queue_status.available=true`。 +- query 日志出现 rerank 成功或 result chunk 带 `rerank_score`。 + +## 11. 不做 + +- 不替换 LightRAG 为 NexusRAG。 +- 不在 MNote 复制 LightRAG vector / graph / rerank。 +- 不把 DOCX 无 page/bbox 的命中伪造成 PDF 精确定位。 +- 不让普通本地搜索强依赖 LightRAG。 +- 不让 LLM 自己生成 citation ID。 +- 不把 raw LightRAG chunk 暴露为 agent final answer 可直接引用材料。 + +## 12. 开放问题 + +1. LightRAG `/query/data` 当前 `convert_to_user_format(...)` 没有把 `rerank_score` 放入 `formatted_chunks`。如果上游 rerank 已经写回 chunk,是否要向 LightRAG 提 patch 暴露 `rerank_score`,还是 MNote 只显示本地 ranking score? +2. DOCX `paraid range=[null,null]` 的样本较多。是否需要在 LightRAG native parser 侧增强 paraId 恢复或写入 block ordinal,给 MNote 一个更稳定的 `blockOrder`? +3. `/query/search` 与 `/query/data` 的 reference schema 不完全一致时,MNote 是否应该先在 connector 层 normalize 为同一个 `ProviderReference`? +4. Source scope 当前仍是 MNote post-filter。长期是否需要 LightRAG provider 支持按 `file_path/doc_id` 过滤候选,以避免 raw retrieval 污染? diff --git a/rust/crates/mnote-web/browser/document-editor-adapter-runtime.js b/rust/crates/mnote-web/browser/document-editor-adapter-runtime.js index 4ce36d8e..bcb714cd 100644 --- a/rust/crates/mnote-web/browser/document-editor-adapter-runtime.js +++ b/rust/crates/mnote-web/browser/document-editor-adapter-runtime.js @@ -270,12 +270,27 @@ import { const resourceHrefFromUrlState = (rootUri, path, title) => { const fileUrl = localFileOpenUrl(rootUri, path); if (!fileUrl) return ''; - if (/\.pdf$/i.test(String(title || path || ''))) { + const name = String(title || path || ''); + if (/\.pdf$/i.test(name)) { const url = new URL('/pdf-preview', window.location.origin); url.searchParams.set('fileUrl', fileUrl); url.searchParams.set('fileName', title || path); return url.toString(); } + const officeMatch = name.match(/\.([a-z0-9]+)$/i); + const officeType = officeMatch ? officeMatch[1].toLowerCase() : ''; + if (['doc', 'docx', 'odt', 'rtf', 'xls', 'xlsx', 'ods', 'csv', 'ppt', 'pptx', 'odp'].indexOf(officeType) >= 0) { + const url = new URL('/office-preview', window.location.origin); + url.searchParams.set('fileUrl', fileUrl); + url.searchParams.set('fileName', title || path); + url.searchParams.set('fileType', officeType); + const workspaceId = currentWebShellWorkspaceId(); + const sourceKind = currentWebShellSourceKind() || 'local_folder'; + if (workspaceId) url.searchParams.set('workspaceId', workspaceId); + if (sourceKind) url.searchParams.set('sourceKind', sourceKind); + if (rootUri) url.searchParams.set('rootUri', rootUri); + return url.toString(); + } return fileUrl; }; const applyDocumentEvidenceLocatorFromUrl = (root) => { @@ -337,6 +352,7 @@ import { bbox: url.searchParams.get('bbox') || undefined, sourceMapPath: String(url.searchParams.get('sourceMapPath') || '').trim(), blockId: String(url.searchParams.get('blockId') || '').trim(), + evidenceText: String(url.searchParams.get('evidenceText') || '').trim(), lineRange: url.searchParams.get('lineRange') || null, charRange: url.searchParams.get('charRange') || null, }; diff --git a/rust/crates/mnote-web/browser/document-resource-tab-runtime.js b/rust/crates/mnote-web/browser/document-resource-tab-runtime.js index f968e816..f4dbcdc6 100644 --- a/rust/crates/mnote-web/browser/document-resource-tab-runtime.js +++ b/rust/crates/mnote-web/browser/document-resource-tab-runtime.js @@ -984,9 +984,11 @@ export const createResourceTabRuntime = (dependencies = {}) => { const bbox = normalizeEvidenceBBox(input.bbox ?? locator?.bbox ?? params.bbox); const sourceMapPath = String(input.sourceMapPath || locator?.sourceMapPath || params.sourceMapPath || '').trim(); const blockId = String(input.blockId || locator?.blockId || params.blockId || '').trim(); + const evidenceText = String(input.evidenceText || input.query || locator?.evidenceText || locator?.query || params.evidenceText || params.query || '').trim(); + const searchQuery = String(input.searchQuery || locator?.searchQuery || params.searchQuery || '').trim(); const lineRange = input.lineRange || locator?.lineRange || params.lineRange || null; const charRange = input.charRange || locator?.charRange || params.charRange || null; - if (!locator && !Number.isFinite(page) && !bbox && !sourceMapPath && !blockId && !lineRange && !charRange) return null; + if (!locator && !Number.isFinite(page) && !bbox && !sourceMapPath && !blockId && !evidenceText && !lineRange && !charRange) return null; return { schema: 'mnote.evidence_locator.v1', ...(locator || {}), @@ -994,6 +996,8 @@ export const createResourceTabRuntime = (dependencies = {}) => { bbox, sourceMapPath, blockId, + evidenceText, + searchQuery, lineRange, charRange, }; @@ -1013,13 +1017,29 @@ export const createResourceTabRuntime = (dependencies = {}) => { if (bbox) url.searchParams.set('bbox', bbox); if (locator.sourceMapPath) url.searchParams.set('sourceMapPath', String(locator.sourceMapPath)); if (locator.blockId) url.searchParams.set('blockId', String(locator.blockId)); + if (locator.evidenceText) url.searchParams.set('evidenceText', String(locator.evidenceText)); + if (locator.searchQuery) url.searchParams.set('searchQuery', String(locator.searchQuery)); if (url.pathname === '/office-preview' && frame.contentWindow) { + const currentSrc = new URL(frame.getAttribute('src') || frame.src || '', window.location.origin); + if (officePreviewBaseHref(currentSrc.toString()) !== officePreviewBaseHref(url.toString())) { + frame.src = url.toString(); + return; + } + const frameDoc = frame.contentDocument || null; + const previewReady = frameDoc?.documentElement?.getAttribute('data-mnote-office-preview-status') === '完成' + || (frameDoc?.readyState === 'complete' && Boolean(frameDoc?.body?.dataset?.fileUrl)); + if (!previewReady) { + frame.src = url.toString(); + return; + } frame.contentWindow.postMessage({ type: 'mnote:office-evidence-locator', page: locator.page, bbox, sourceMapPath: locator.sourceMapPath || '', blockId: locator.blockId || '', + evidenceText: locator.evidenceText || '', + searchQuery: locator.searchQuery || '', }, window.location.origin); return; } @@ -1095,6 +1115,7 @@ export const createResourceTabRuntime = (dependencies = {}) => { entry.panel.setAttribute('data-mnote-evidence-open', 'true'); if (Number.isFinite(Number(locator.page))) entry.panel.setAttribute('data-mnote-evidence-page', String(Number(locator.page))); if (locator.blockId) entry.panel.setAttribute('data-mnote-evidence-block-id', String(locator.blockId)); + if (locator.evidenceText) entry.panel.setAttribute('data-mnote-evidence-text', String(locator.evidenceText).slice(0, 240)); if (locator.sourceMapPath) entry.panel.setAttribute('data-mnote-evidence-source-map-path', String(locator.sourceMapPath)); const bbox = evidenceBBoxParam(locator.bbox); if (bbox) entry.panel.setAttribute('data-mnote-evidence-bbox', bbox); @@ -2286,7 +2307,7 @@ export const createResourceTabRuntime = (dependencies = {}) => { if (!raw) return ''; try { const url = new URL(raw, window.location.origin); - ['page', 'bbox', 'sourceMapPath', 'blockId', 'lineRange', 'charRange', 'mnoteResourceReload'].forEach((key) => { + ['page', 'bbox', 'sourceMapPath', 'blockId', 'lineRange', 'charRange', 'evidenceText', 'query', 'searchQuery', 'mnoteResourceReload'].forEach((key) => { url.searchParams.delete(key); }); return url.pathname + '?' + url.searchParams.toString(); diff --git a/rust/crates/mnote-web/browser/sidebar-filetree-open-runtime.js b/rust/crates/mnote-web/browser/sidebar-filetree-open-runtime.js index 90629bac..8a439842 100644 --- a/rust/crates/mnote-web/browser/sidebar-filetree-open-runtime.js +++ b/rust/crates/mnote-web/browser/sidebar-filetree-open-runtime.js @@ -240,10 +240,10 @@ export const createSidebarFileTreeOpenRuntime = (dependencies = {}) => { var relativePath = String(input.path || workspacePath && (workspacePath.relativePath || workspacePath.localRelativePath) || '').trim(); var rootUri = String(input.rootUri || workspacePath && workspacePath.rootUri || currentRootUri() || '').trim(); var fallback = String(input.fallback || '').trim(); + if (relativePath && rootUri) return 'resource:file:' + rootUri + ':' + relativePath; if (objectKind === 'mindmap' && assetId) return 'resource:mindmap:' + documentId + ':' + assetId; if ((objectKind === 'only_office' || objectKind === 'office') && assetId) return 'resource:onlyoffice:' + documentId + ':' + assetId; if (objectKind === 'pdf' && assetId) return 'resource:pdf:' + documentId + ':' + assetId; - if (relativePath && rootUri) return 'resource:file:' + rootUri + ':' + relativePath; if (assetId && documentId) return 'resource:' + (objectKind || 'attachment') + ':' + documentId + ':' + assetId; return fallback || assetId || ''; } @@ -282,26 +282,32 @@ export const createSidebarFileTreeOpenRuntime = (dependencies = {}) => { if (!relativePath || !rootUri) return false; var title = String(input && input.title || '').trim() || relativePath.split('/').pop() || relativePath; var kind = String(input && input.kind || fileTreeIconKindForFileName(title) || '').trim(); + var documentId = String(input && input.documentId || currentDocumentId() || '').trim(); + var assetId = String(input && input.assetId || '').trim() || 'local-file:' + relativePath; + var officeUrl = String(input && input.officeUrl || '').trim(); + if (!officeUrl && kind === 'office') { + officeUrl = buildLocalOnlyOfficeOpenUrl(relativePath, title, documentId, assetId, 'view'); + } var workspacePath = input && input.workspacePath && typeof input.workspacePath === 'object' ? input.workspacePath : null; var objectIdentity = resourceObjectIdentityFromWorkspacePath({ workspacePath: workspacePath, objectKind: kind, - documentId: input && input.documentId, - assetId: input && input.assetId, + documentId: documentId, + assetId: assetId, path: relativePath, rootUri: rootUri }); return await window.__mnoteDocumentPaneRuntime.openResourceInActiveTab({ objectIdentity: objectIdentity, - assetId: String(input && input.assetId || '').trim() || 'local-file:' + relativePath, + assetId: assetId, title: title, fileName: title, kind: kind, rootUri: rootUri, path: relativePath, href: String(input && input.href || buildLocalFileOpenUrl(relativePath, false) || '').trim(), - officeUrl: String(input && input.officeUrl || '').trim(), - documentId: String(input && input.documentId || currentDocumentId() || '').trim(), + officeUrl: officeUrl, + documentId: documentId, workspaceId: String(input && input.workspaceId || resolveWorkspaceId(document.body) || '').trim(), workspacePath: workspacePath, paneRole: String(input && input.paneRole || 'primary').trim() || 'primary', @@ -310,6 +316,7 @@ export const createSidebarFileTreeOpenRuntime = (dependencies = {}) => { bbox: input && input.bbox, sourceMapPath: String(input && input.sourceMapPath || '').trim(), blockId: String(input && input.blockId || '').trim(), + evidenceText: String(input && input.evidenceText || '').trim(), lineRange: input && input.lineRange, charRange: input && input.charRange }); diff --git a/rust/crates/mnote-web/browser/sidebar-page-ai-markdown-runtime.js b/rust/crates/mnote-web/browser/sidebar-page-ai-markdown-runtime.js index 9624f0fa..17e95aad 100644 --- a/rust/crates/mnote-web/browser/sidebar-page-ai-markdown-runtime.js +++ b/rust/crates/mnote-web/browser/sidebar-page-ai-markdown-runtime.js @@ -19,47 +19,260 @@ export function createSidebarPageAiMarkdownRuntime(context) { function renderPageAiMarkdownInline(text) { var html = escapeHtml(String(text || '')); var codeSpans = []; + var htmlSpans = []; + function stashHtml(value) { + var key = '\u0000HTML' + htmlSpans.length + '\u0000'; + htmlSpans.push(value); + return key; + } html = html.replace(/`([^`\n]+)`/g, function(_, code) { var key = '\u0000CODE' + codeSpans.length + '\u0000'; codeSpans.push('' + code + ''); return key; }); - html = html.replace(/\[([^\]\n]+)\]\(([^)\s]+)\)/g, function(match, label, href) { + html = html.replace(/\[((?:\\.|[^\]\n])+)\]\(([^)\n]+)\)/g, function(match, label, href) { var normalizedHref = normalizePageAiMarkdownHref(href); if (!normalizedHref) return match; var citationAttr = isMnoteCitationHref(normalizedHref) ? ' data-page-ai-citation-link="true"' : ''; - return '' + label + ''; + return stashHtml('' + unescapePageAiMarkdownLabel(label) + ''); + }); + html = html.replace(/(^|[\s(])((?:https?:\/\/|\/documents\/|mnote:\/\/open(?:Resource)?)[^\s<>()]+[^\s<>().,;:!?])/g, function(_, prefix, href) { + var normalizedHref = normalizePageAiMarkdownHref(href); + if (!normalizedHref) return prefix + href; + var citationAttr = isMnoteCitationHref(normalizedHref) ? ' data-page-ai-citation-link="true"' : ''; + return prefix + stashHtml('' + escapeHtml(normalizedHref) + ''); }); html = html.replace(/\*\*([^*\n]+)\*\*/g, '$1'); html = html.replace(/(^|[^*])\*([^*\n]+)\*/g, '$1$2'); codeSpans.forEach(function(value, index) { html = html.replace('\u0000CODE' + index + '\u0000', value); }); + htmlSpans.forEach(function(value, index) { + html = html.replace('\u0000HTML' + index + '\u0000', value); + }); return html; } function normalizePageAiMarkdownHref(value) { var href = String(value || '').trim() - .replace(/&/g, '&') + .replace(/</g, '<') + .replace(/>/g, '>') .replace(/"/g, '"') .replace(/'/g, "'"); + while (href.includes('&')) href = href.replace(/&/g, '&'); + if (href.startsWith('<') && href.endsWith('>')) href = href.slice(1, -1).trim(); if (!href || /[\u0000-\u001f<>"']/.test(href)) return ''; var lower = href.toLowerCase(); if (lower.startsWith('javascript:') || lower.startsWith('data:') || lower.startsWith('vbscript:')) return ''; + if (lower.startsWith('mnote://open')) href = normalizePageAiMnoteOpenHref(href); + href = normalizePageAiLegacyCitationHref(href); if (href.startsWith('/') || href.startsWith('#')) return href; if (/^https?:\/\//i.test(href) || /^mailto:/i.test(href)) return href; return ''; } - function isMnoteCitationHref(href) { + function unescapePageAiMarkdownLabel(value) { + return String(value || '').replace(/\\([\\\[\]()*_`])/g, '$1'); + } + + function normalizePageAiMnoteOpenHref(href) { + try { + var url = new URL(href); + if (url.protocol !== 'mnote:' || (url.hostname !== 'open' && url.hostname !== 'openResource')) return href; + var path = String(url.searchParams.get('path') || url.searchParams.get('resourcePath') || '').trim(); + if (!path) return ''; + var params = new URLSearchParams(); + var rootUri = String(url.searchParams.get('rootUri') || '').trim(); + if (!rootUri) { + try { + rootUri = new URL(window.location.href).searchParams.get('rootUri') || ''; + } catch (_locationError) {} + } + if (rootUri) params.set('rootUri', rootUri); + params.set('path', path); + ['page', 'bbox', 'sourceMapPath', 'blockId', 'evidenceText', 'lineRange', 'charRange'].forEach(function(key) { + var value = String(url.searchParams.get(key) || '').trim(); + if (value) params.set(key, value); + }); + return '/api/local-folder/files/open?' + params.toString(); + } catch (_error) { + return href; + } + } + + function normalizePageAiLegacyCitationHref(href) { try { var url = new URL(href, window.location.origin); - return url.origin === window.location.origin && url.pathname.startsWith('/documents/'); + var unwrappedHref = normalizePageAiSearchWrappedCitationHref(url); + if (unwrappedHref) return normalizePageAiLegacyCitationHref(unwrappedHref); + if (!isPageAiSameMnoteOrigin(url) && !isPageAiPortableMnoteCitationUrl(url)) return href; + if (url.pathname === '/api/local-folder/files/open') return url.pathname + url.search; + if (!url.pathname.startsWith('/documents/')) return href; + if (isPageAiUnsafeCitationDocumentId(url)) { + var fileOpenHref = normalizePageAiCitationFileOpenHref(url); + if (fileOpenHref) return fileOpenHref; + } + var decodedHash = ''; + try { + decodedHash = decodeURIComponent(url.hash || ''); + } catch (_decodeError) { + decodedHash = url.hash || ''; + } + var marker = '#resource-tab-'; + var markerIndex = decodedHash.indexOf(marker); + if (markerIndex < 0 || url.searchParams.get('resourceTab')) { + return url.pathname + url.search + url.hash; + } + var identity = decodedHash.slice(markerIndex + marker.length).replace(/^#+/, '').trim(); + if (!identity.startsWith('resource:file:')) return url.pathname + url.search + url.hash; + url.searchParams.set('resourceTab', identity); + if (!url.searchParams.get('sourceKind')) url.searchParams.set('sourceKind', 'local_folder'); + var rootUri = String(url.searchParams.get('rootUri') || '').trim(); + var prefix = 'resource:file:' + rootUri + ':'; + if (rootUri && identity.startsWith(prefix) && !url.searchParams.get('resourcePath')) { + url.searchParams.set('resourcePath', identity.slice(prefix.length).replace(/^\/+/, '')); + } + url.hash = ''; + return url.pathname + url.search; + } catch (_error) { + return href; + } + } + + function normalizePageAiSearchWrappedCitationHref(url) { + var raw = ''; + ['wd', 'q', 'query'].some(function(key) { + raw = String(url.searchParams.get(key) || '').trim(); + return Boolean(raw); + }); + if (!raw) return ''; + if (/^documents\//i.test(raw)) raw = '/' + raw; + if (!/^\/documents\//i.test(raw) && !/^https?:\/\/[^/]+\/documents\//i.test(raw) && !/^mnote:\/\/open/i.test(raw)) return ''; + if (/^mnote:\/\/open/i.test(raw)) return normalizePageAiMnoteOpenHref(raw); + var nested = new URL(raw, window.location.origin); + if (!nested.pathname.startsWith('/documents/')) return ''; + ['sourceKind', 'rootUri', 'workspaceId', 'resourceTab', 'resourcePath', 'page', 'bbox', 'sourceMapPath', 'blockId', 'evidenceText', 'lineRange', 'charRange'].forEach(function(key) { + if (nested.searchParams.get(key)) return; + var value = String(url.searchParams.get(key) || '').trim(); + if (value) nested.searchParams.set(key, value); + }); + return nested.pathname + nested.search; + } + + function normalizePageAiCitationFileOpenHref(url) { + var rootUri = String(url.searchParams.get('rootUri') || '').trim(); + var path = String(url.searchParams.get('resourcePath') || '').trim(); + if (!rootUri || !path) return ''; + var params = new URLSearchParams(); + params.set('rootUri', rootUri); + params.set('path', path); + ['page', 'bbox', 'sourceMapPath', 'blockId', 'evidenceText', 'lineRange', 'charRange'].forEach(function(key) { + var value = String(url.searchParams.get(key) || '').trim(); + if (value) params.set(key, value); + }); + return '/api/local-folder/files/open?' + params.toString(); + } + + function isPageAiUnsafeCitationDocumentId(url) { + try { + var raw = String(url.pathname || '').replace(/^\/documents\//, '').split('/')[0] || ''; + if (!raw) return false; + var decoded = decodeURIComponent(raw); + return decoded.indexOf('%') >= 0 || decoded.indexOf('\uFFFD') >= 0; + } catch (_error) { + return true; + } + } + + function isPageAiPortableMnoteCitationUrl(url) { + try { + if (url.pathname === '/api/local-folder/files/open') { + return Boolean(String(url.searchParams.get('rootUri') || '').trim()) && + Boolean(String(url.searchParams.get('path') || '').trim()); + } + if (!url.pathname.startsWith('/documents/')) return false; + var resourceTab = String(url.searchParams.get('resourceTab') || '').trim(); + return String(url.searchParams.get('sourceKind') || '').trim() === 'local_folder' || + Boolean(String(url.searchParams.get('rootUri') || '').trim()) || + Boolean(String(url.searchParams.get('resourcePath') || '').trim()) || + resourceTab.startsWith('resource:file:'); } catch (_error) { return false; } } + function isPageAiMarkdownTableDivider(line) { + var cells = splitPageAiMarkdownTableRow(line); + if (cells.length < 2) return false; + return cells.every(function(cell) { + return /^:?-{3,}:?$/.test(cell.trim()); + }); + } + + function splitPageAiMarkdownTableRow(line) { + var text = String(line || '').trim(); + if (!text.includes('|')) return []; + if (text.startsWith('|')) text = text.slice(1); + if (text.endsWith('|')) text = text.slice(0, -1); + return text.split('|').map(function(cell) { return cell.trim(); }); + } + + function renderPageAiMarkdownTable(lines, startIndex) { + if (startIndex + 1 >= lines.length || !isPageAiMarkdownTableDivider(lines[startIndex + 1])) return null; + var header = splitPageAiMarkdownTableRow(lines[startIndex]); + var divider = splitPageAiMarkdownTableRow(lines[startIndex + 1]); + if (!header.length || header.length !== divider.length) return null; + var rows = []; + var index = startIndex + 2; + while (index < lines.length && lines[index].trim() && lines[index].includes('|')) { + var cells = splitPageAiMarkdownTableRow(lines[index]); + if (!cells.length) break; + rows.push(cells); + index += 1; + } + function cellHtml(tag, value) { + return '<' + tag + '>' + renderPageAiMarkdownInline(value) + ''; + } + var head = '' + header.map(function(cell) { return cellHtml('th', cell); }).join('') + ''; + var body = rows.length + ? '' + rows.map(function(row) { + return '' + header.map(function(_, cellIndex) { return cellHtml('td', row[cellIndex] || ''); }).join('') + ''; + }).join('') + '' + : ''; + return { + html: '
' + head + body + '
', + nextIndex: index + }; + } + + function isMnoteCitationHref(href) { + try { + var url = new URL(href, window.location.origin); + if (!isPageAiSameMnoteOrigin(url)) return false; + return url.pathname.startsWith('/documents/') || url.pathname === '/api/local-folder/files/open'; + } catch (_error) { + return false; + } + } + + function isPageAiSameMnoteOrigin(url) { + try { + var current = new URL(window.location.origin); + if (url.origin === current.origin) return true; + if (url.hostname === 'mnote.local') return true; + var localNames = ['localhost', '127.0.0.1', '::1', 'mnote.local']; + return localNames.indexOf(url.hostname) >= 0 && + localNames.indexOf(current.hostname) >= 0 && + String(url.port || defaultPortForProtocol(url.protocol)) === String(current.port || defaultPortForProtocol(current.protocol)); + } catch (_error) { + return false; + } + } + + function defaultPortForProtocol(protocol) { + return protocol === 'https:' ? '443' : protocol === 'http:' ? '80' : ''; + } + function renderPageAiMarkdown(content) { var lines = String(content || '').replace(/\r\n?/g, '\n').split('\n'); var blocks = []; @@ -88,6 +301,17 @@ export function createSidebarPageAiMarkdownRuntime(context) { blocks.push('
' + escapeHtml(codeLines.join('\n')) + '
'); continue; } + if (/^\s*-{3,}\s*$/.test(line)) { + blocks.push('
'); + index += 1; + continue; + } + var table = renderPageAiMarkdownTable(lines, index); + if (table) { + blocks.push(table.html); + index = table.nextIndex; + continue; + } var heading = line.match(/^(#{1,6})\s+(.+)$/); if (heading) { var level = Math.min(6, heading[1].length); diff --git a/rust/crates/mnote-web/browser/sidebar-page-ai-render-runtime.js b/rust/crates/mnote-web/browser/sidebar-page-ai-render-runtime.js index df2ec282..8ffb8d9f 100644 --- a/rust/crates/mnote-web/browser/sidebar-page-ai-render-runtime.js +++ b/rust/crates/mnote-web/browser/sidebar-page-ai-render-runtime.js @@ -758,6 +758,7 @@ export function createSidebarPageAiRenderRuntime(context) { drawer.setAttribute('data-mnote-surface', 'page-ai'); drawer.hidden = true; drawer.innerHTML = '' + + '' + '