Improve LightRAG knowledge search locator alignment
This commit is contained in:
@@ -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 只有 `` 占位,没有把 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` 被标记为 `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__/<file>.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 双索引状态。
|
||||
@@ -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
|
||||

|
||||
```
|
||||
|
||||
这对图片入口有帮助,但不等于 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`:只能打开文件,定位信息不足。
|
||||
@@ -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<String>,
|
||||
pub source_path: String,
|
||||
pub source_root_relative_path: String,
|
||||
pub light_rag_doc_id: Option<String>,
|
||||
pub light_rag_file_path: String,
|
||||
pub light_rag_chunk_id: Option<String>,
|
||||
pub block_id: Option<String>,
|
||||
pub heading_path: Vec<String>,
|
||||
pub quote: Option<String>,
|
||||
pub quote_source: String,
|
||||
pub locator_precision: LocatorPrecision,
|
||||
pub locator_degraded: bool,
|
||||
pub citation_url: String,
|
||||
pub citation_markdown: String,
|
||||
pub relevance_score: Option<f32>,
|
||||
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 污染?
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
});
|
||||
|
||||
@@ -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>' + code + '</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 '<a href="' + escapeHtml(normalizedHref) + '" target="_blank" rel="noopener noreferrer"' + citationAttr + '>' + label + '</a>';
|
||||
return stashHtml('<a href="' + escapeHtml(normalizedHref) + '" target="_blank" rel="noopener noreferrer"' + citationAttr + '>' + unescapePageAiMarkdownLabel(label) + '</a>');
|
||||
});
|
||||
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('<a href="' + escapeHtml(normalizedHref) + '" target="_blank" rel="noopener noreferrer"' + citationAttr + '>' + escapeHtml(normalizedHref) + '</a>');
|
||||
});
|
||||
html = html.replace(/\*\*([^*\n]+)\*\*/g, '<strong>$1</strong>');
|
||||
html = html.replace(/(^|[^*])\*([^*\n]+)\*/g, '$1<em>$2</em>');
|
||||
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) + '</' + tag + '>';
|
||||
}
|
||||
var head = '<thead><tr>' + header.map(function(cell) { return cellHtml('th', cell); }).join('') + '</tr></thead>';
|
||||
var body = rows.length
|
||||
? '<tbody>' + rows.map(function(row) {
|
||||
return '<tr>' + header.map(function(_, cellIndex) { return cellHtml('td', row[cellIndex] || ''); }).join('') + '</tr>';
|
||||
}).join('') + '</tbody>'
|
||||
: '';
|
||||
return {
|
||||
html: '<div class="wolai-page-ai-markdown-table-wrap"><table>' + head + body + '</table></div>',
|
||||
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('<pre><code>' + escapeHtml(codeLines.join('\n')) + '</code></pre>');
|
||||
continue;
|
||||
}
|
||||
if (/^\s*-{3,}\s*$/.test(line)) {
|
||||
blocks.push('<hr />');
|
||||
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);
|
||||
|
||||
@@ -758,6 +758,7 @@ export function createSidebarPageAiRenderRuntime(context) {
|
||||
drawer.setAttribute('data-mnote-surface', 'page-ai');
|
||||
drawer.hidden = true;
|
||||
drawer.innerHTML = '' +
|
||||
'<div class="wolai-page-ai-resize-handle" data-page-ai-resize-handle title="拖动调整页面 AI 宽度" aria-hidden="true"></div>' +
|
||||
'<div class="wolai-page-ai-panel" role="dialog" aria-modal="false">' +
|
||||
'<div class="wolai-page-ai-header">' +
|
||||
'<div class="wolai-page-ai-header-copy">' +
|
||||
|
||||
@@ -50,6 +50,10 @@ export function createSidebarPageAiRuntime(context) {
|
||||
{ id: 'changed_files', label: '最近修改' }
|
||||
];
|
||||
var pageAiDelegatesInstalled = false;
|
||||
var PAGE_AI_DRAWER_WIDTH_STORAGE_KEY = 'mnote.page_ai.drawer_width';
|
||||
var PAGE_AI_DRAWER_DEFAULT_WIDTH = 440;
|
||||
var PAGE_AI_DRAWER_MIN_WIDTH = 340;
|
||||
var PAGE_AI_DRAWER_MAX_WIDTH = 760;
|
||||
|
||||
function clonePageAiDefaultValue(value) {
|
||||
if (Array.isArray(value)) return value.slice();
|
||||
@@ -57,6 +61,70 @@ export function createSidebarPageAiRuntime(context) {
|
||||
return value;
|
||||
}
|
||||
|
||||
function pageAiClampDrawerWidth(width) {
|
||||
var viewportMax = Math.max(PAGE_AI_DRAWER_MIN_WIDTH, Math.min(PAGE_AI_DRAWER_MAX_WIDTH, window.innerWidth - 24));
|
||||
var numeric = Number(width);
|
||||
if (!Number.isFinite(numeric) || numeric <= 0) numeric = PAGE_AI_DRAWER_DEFAULT_WIDTH;
|
||||
return Math.max(PAGE_AI_DRAWER_MIN_WIDTH, Math.min(viewportMax, Math.round(numeric)));
|
||||
}
|
||||
|
||||
function pageAiStoredDrawerWidth() {
|
||||
try {
|
||||
return pageAiClampDrawerWidth(window.localStorage ? window.localStorage.getItem(PAGE_AI_DRAWER_WIDTH_STORAGE_KEY) : PAGE_AI_DRAWER_DEFAULT_WIDTH);
|
||||
} catch (_error) {
|
||||
return pageAiClampDrawerWidth(PAGE_AI_DRAWER_DEFAULT_WIDTH);
|
||||
}
|
||||
}
|
||||
|
||||
function pageAiApplyDrawerWidth(drawer, width) {
|
||||
var target = drawer instanceof HTMLElement ? drawer : document.querySelector('[data-testid="wolai-page-ai-drawer"]');
|
||||
if (!(target instanceof HTMLElement)) return;
|
||||
var next = pageAiClampDrawerWidth(width == null ? pageAiStoredDrawerWidth() : width);
|
||||
target.style.setProperty('--mnote-page-ai-width', next + 'px');
|
||||
}
|
||||
|
||||
function handlePageAiPointerDown(event, helpers) {
|
||||
var closestAction = helpers && helpers.closestAction;
|
||||
if (typeof closestAction !== 'function') return false;
|
||||
var handle = closestAction(event.target, '[data-page-ai-resize-handle]');
|
||||
if (!(handle instanceof HTMLElement)) return false;
|
||||
var drawer = handle.closest('[data-testid="wolai-page-ai-drawer"]');
|
||||
var panel = drawer instanceof HTMLElement ? drawer.querySelector('.wolai-page-ai-panel') : null;
|
||||
if (!(drawer instanceof HTMLElement) || !(panel instanceof HTMLElement)) return false;
|
||||
event.preventDefault();
|
||||
var pointerId = event.pointerId;
|
||||
var startX = Number(event.clientX || 0);
|
||||
var startWidth = panel.getBoundingClientRect().width || pageAiStoredDrawerWidth();
|
||||
drawer.setAttribute('data-page-ai-resizing', 'true');
|
||||
document.documentElement.setAttribute('data-mnote-page-ai-resizing', 'true');
|
||||
try {
|
||||
handle.setPointerCapture(pointerId);
|
||||
} catch (_error) {}
|
||||
function move(nextEvent) {
|
||||
var nextWidth = startWidth + (startX - Number(nextEvent.clientX || 0));
|
||||
pageAiApplyDrawerWidth(drawer, nextWidth);
|
||||
}
|
||||
function finish(nextEvent) {
|
||||
move(nextEvent);
|
||||
var value = drawer.style.getPropertyValue('--mnote-page-ai-width').replace('px', '').trim();
|
||||
try {
|
||||
if (window.localStorage) window.localStorage.setItem(PAGE_AI_DRAWER_WIDTH_STORAGE_KEY, String(pageAiClampDrawerWidth(value)));
|
||||
} catch (_error) {}
|
||||
drawer.removeAttribute('data-page-ai-resizing');
|
||||
document.documentElement.removeAttribute('data-mnote-page-ai-resizing');
|
||||
window.removeEventListener('pointermove', move, true);
|
||||
window.removeEventListener('pointerup', finish, true);
|
||||
window.removeEventListener('pointercancel', finish, true);
|
||||
try {
|
||||
handle.releasePointerCapture(pointerId);
|
||||
} catch (_error) {}
|
||||
}
|
||||
window.addEventListener('pointermove', move, true);
|
||||
window.addEventListener('pointerup', finish, true);
|
||||
window.addEventListener('pointercancel', finish, true);
|
||||
return true;
|
||||
}
|
||||
|
||||
function ensurePageAiStateFacade(state) {
|
||||
var defaults = {
|
||||
pageAiOpen: false,
|
||||
@@ -743,9 +811,12 @@ export function createSidebarPageAiRuntime(context) {
|
||||
var prefix = '/documents/';
|
||||
if (!url.pathname.startsWith(prefix)) return '';
|
||||
try {
|
||||
return decodeURIComponent(url.pathname.slice(prefix.length).split('/')[0] || '');
|
||||
var raw = url.pathname.slice(prefix.length).split('/')[0] || '';
|
||||
var decoded = decodeURIComponent(raw);
|
||||
if (decoded.indexOf('%') >= 0 || decoded.indexOf('\uFFFD') >= 0) return '';
|
||||
return decoded;
|
||||
} catch (_error) {
|
||||
return url.pathname.slice(prefix.length).split('/')[0] || '';
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -753,6 +824,14 @@ export function createSidebarPageAiRuntime(context) {
|
||||
var explicitPath = String(url.searchParams.get('resourcePath') || '').trim();
|
||||
if (explicitPath) return explicitPath;
|
||||
var raw = String(url.searchParams.get('resourceTab') || '').trim();
|
||||
if (!raw) {
|
||||
try {
|
||||
var decodedHash = decodeURIComponent(String(url.hash || ''));
|
||||
var marker = '#resource-tab-';
|
||||
var markerIndex = decodedHash.indexOf(marker);
|
||||
if (markerIndex >= 0) raw = decodedHash.slice(markerIndex + marker.length).replace(/^#+/, '').trim();
|
||||
} catch (_error) {}
|
||||
}
|
||||
if (raw.indexOf('::') >= 0) raw = raw.slice(raw.indexOf('::') + 2);
|
||||
if (!raw.startsWith('resource:file:')) return '';
|
||||
var rest = raw.slice('resource:file:'.length);
|
||||
@@ -761,6 +840,40 @@ export function createSidebarPageAiRuntime(context) {
|
||||
return rest.slice(prefix.length).replace(/^\/+/, '');
|
||||
}
|
||||
|
||||
function pageAiSameMnoteOrigin(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'];
|
||||
function defaultPort(protocol) {
|
||||
return protocol === 'https:' ? '443' : protocol === 'http:' ? '80' : '';
|
||||
}
|
||||
return localNames.indexOf(url.hostname) >= 0 &&
|
||||
localNames.indexOf(current.hostname) >= 0 &&
|
||||
String(url.port || defaultPort(url.protocol)) === String(current.port || defaultPort(current.protocol));
|
||||
} catch (_error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function pageAiPortableCitationUrl(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 pageAiOpenCitationUrl(href) {
|
||||
var url;
|
||||
try {
|
||||
@@ -768,7 +881,34 @@ export function createSidebarPageAiRuntime(context) {
|
||||
} catch (_error) {
|
||||
return false;
|
||||
}
|
||||
if (url.origin !== window.location.origin || !url.pathname.startsWith('/documents/')) return false;
|
||||
var unwrappedHref = pageAiUnwrapSearchCitationUrl(url);
|
||||
if (unwrappedHref) return pageAiOpenCitationUrl(unwrappedHref);
|
||||
if (!pageAiSameMnoteOrigin(url) && !pageAiPortableCitationUrl(url)) return false;
|
||||
if (url.pathname === '/api/local-folder/files/open') {
|
||||
var fileRootUri = String(url.searchParams.get('rootUri') || currentRootUri() || '').trim();
|
||||
var filePath = String(url.searchParams.get('path') || '').trim();
|
||||
if (!filePath) return false;
|
||||
void openLocalResourceInActiveTab({
|
||||
path: filePath,
|
||||
rootUri: fileRootUri,
|
||||
documentId: currentDocumentId() || '',
|
||||
workspaceId: resolveWorkspaceId(document.body) || '',
|
||||
sourceKind: currentSourceKind() || 'local_folder',
|
||||
page: url.searchParams.get('page') || undefined,
|
||||
bbox: pageAiEvidenceBboxFromParam(url.searchParams.get('bbox')),
|
||||
sourceMapPath: String(url.searchParams.get('sourceMapPath') || '').trim(),
|
||||
blockId: String(url.searchParams.get('blockId') || '').trim(),
|
||||
evidenceText: String(url.searchParams.get('evidenceText') || '').trim(),
|
||||
lineRange: pageAiEvidenceRangeFromParam(url.searchParams.get('lineRange')),
|
||||
charRange: pageAiEvidenceRangeFromParam(url.searchParams.get('charRange')),
|
||||
openTarget: 'active-tab',
|
||||
paneRole: 'primary'
|
||||
}).then(function(opened) {
|
||||
if (!opened) window.open(url.toString(), '_blank', 'noopener,noreferrer');
|
||||
});
|
||||
return true;
|
||||
}
|
||||
if (!url.pathname.startsWith('/documents/')) return false;
|
||||
var rootUri = String(url.searchParams.get('rootUri') || currentRootUri() || '').trim();
|
||||
var resourcePath = pageAiCitationResourcePath(url, rootUri);
|
||||
var documentId = pageAiDocumentIdFromUrl(url) || currentDocumentId() || '';
|
||||
@@ -784,6 +924,7 @@ export function createSidebarPageAiRuntime(context) {
|
||||
bbox: pageAiEvidenceBboxFromParam(url.searchParams.get('bbox')),
|
||||
sourceMapPath: String(url.searchParams.get('sourceMapPath') || '').trim(),
|
||||
blockId: String(url.searchParams.get('blockId') || '').trim(),
|
||||
evidenceText: String(url.searchParams.get('evidenceText') || '').trim(),
|
||||
lineRange: pageAiEvidenceRangeFromParam(url.searchParams.get('lineRange')),
|
||||
charRange: pageAiEvidenceRangeFromParam(url.searchParams.get('charRange')),
|
||||
openTarget: 'active-tab',
|
||||
@@ -1035,6 +1176,30 @@ export function createSidebarPageAiRuntime(context) {
|
||||
return true;
|
||||
}
|
||||
|
||||
function pageAiUnwrapSearchCitationUrl(url) {
|
||||
try {
|
||||
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 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;
|
||||
} catch (_error) {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
function pageAiPreviewValue(value) {
|
||||
if (value == null || value === '') return '';
|
||||
if (typeof value === 'string') return value.length > 120 ? value.slice(0, 120) + '…' : value;
|
||||
@@ -1259,9 +1424,12 @@ export function createSidebarPageAiRuntime(context) {
|
||||
if (argsSummary) existing.argsSummary = argsSummary;
|
||||
if (resultSummary) existing.resultSummary = resultSummary;
|
||||
if (locations.length) existing.locations = locations;
|
||||
existing.rawOutput = toolEvent && toolEvent.output;
|
||||
existing.rawResult = resultSource;
|
||||
if (status === 'completed') {
|
||||
pageAiNotifyToolWriteCompleted(toolName, toolEvent, runId, runTraceId);
|
||||
}
|
||||
return existing;
|
||||
}
|
||||
|
||||
async function pageAiCancelQueuedRun(queueId) {
|
||||
@@ -1735,6 +1903,7 @@ export function createSidebarPageAiRuntime(context) {
|
||||
renderPageAiProviderButtons();
|
||||
renderPageAiControls();
|
||||
var drawer = ensurePageAiDrawer();
|
||||
pageAiApplyDrawerWidth(drawer);
|
||||
drawer.hidden = false;
|
||||
pageUiState.pageAiOpen = true;
|
||||
updatePageAiTriggerState();
|
||||
@@ -1840,13 +2009,113 @@ export function createSidebarPageAiRuntime(context) {
|
||||
return message.content;
|
||||
}
|
||||
|
||||
function pageAiFinishStreamingAssistantMessage(runId, finalText, promptText) {
|
||||
function pageAiAppendCitationSection(content, citations) {
|
||||
var text = String(content || '');
|
||||
var unique = [];
|
||||
pageAiNormalizeArray(citations).forEach(function(value) {
|
||||
var citation = String(value || '').trim();
|
||||
if (!citation || unique.indexOf(citation) >= 0) return;
|
||||
unique.push(citation);
|
||||
});
|
||||
var missing = unique.filter(function(citation) {
|
||||
return text.indexOf(citation) < 0;
|
||||
});
|
||||
if (!missing.length) return text;
|
||||
return text.replace(/\s+$/g, '') + '\n\n**引用**\n' + missing.map(function(citation) {
|
||||
return '- ' + citation;
|
||||
}).join('\n');
|
||||
}
|
||||
|
||||
function pageAiPromptNeedsKnowledgeRagCitations(prompt) {
|
||||
var text = searchText(prompt);
|
||||
if (!text) return false;
|
||||
var asksCitation = ['链接', '引用', '来源', '出处', '证据', '定位', 'link', 'citation', 'source'].some(function(word) {
|
||||
return text.indexOf(word) >= 0;
|
||||
});
|
||||
if (!asksCitation) return false;
|
||||
return ['lightrag', '资料库', '知识库', 'rag', '文献', '论文', '保护基'].some(function(word) {
|
||||
return text.indexOf(word) >= 0;
|
||||
});
|
||||
}
|
||||
|
||||
function pageAiCleanDegradedCitationNotes(content) {
|
||||
return String(content || '')
|
||||
.split('\n')
|
||||
.filter(function(line) {
|
||||
var text = String(line || '');
|
||||
return text.indexOf('来源定位降级') < 0 && text.toLowerCase().indexOf('locator degraded') < 0;
|
||||
})
|
||||
.join('\n')
|
||||
.replace(/\n{3,}/g, '\n\n')
|
||||
.trim();
|
||||
}
|
||||
|
||||
function pageAiCollectKnowledgeRagApiCitations(payload) {
|
||||
var references = pageAiNormalizeArray(payload && payload.references);
|
||||
var citations = references.map(function(reference) {
|
||||
return String(reference && reference.citationMarkdown || '').trim();
|
||||
}).filter(Boolean);
|
||||
var hasPreciseCitation = references.some(function(reference) {
|
||||
return String(reference && reference.citationMarkdown || '').trim() && reference && reference.locatorDegraded !== true;
|
||||
});
|
||||
var seen = {};
|
||||
return citations.filter(function(citation) {
|
||||
if (seen[citation]) return false;
|
||||
if (hasPreciseCitation && citation.indexOf('来源定位降级') >= 0) return false;
|
||||
seen[citation] = true;
|
||||
return true;
|
||||
}).slice(0, 5);
|
||||
}
|
||||
|
||||
async function pageAiAppendKnowledgeRagFallbackCitations(runId, promptText) {
|
||||
if (!pageAiPromptNeedsKnowledgeRagCitations(promptText)) return;
|
||||
var rootUri = currentRootUri();
|
||||
if (!rootUri) return;
|
||||
try {
|
||||
var response = await fetch('/api/knowledge-rag/query', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json', 'accept': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
workspaceId: resolveWorkspaceId(document.body) || undefined,
|
||||
rootUri: rootUri,
|
||||
query: String(promptText || '').trim(),
|
||||
mode: 'mix',
|
||||
topK: 8,
|
||||
chunkTopK: 8,
|
||||
includeChunkContent: true
|
||||
})
|
||||
});
|
||||
var payload = await response.json().catch(function() { return null; });
|
||||
if (!response.ok) return;
|
||||
var citations = pageAiCollectKnowledgeRagApiCitations(payload);
|
||||
if (!citations.length) return;
|
||||
var message = pageUiState.pageAiMessages.find(function(item) {
|
||||
return item.role === 'assistant' && item.runId === runId;
|
||||
}) || pageUiState.pageAiMessages.filter(function(item) { return item.role === 'assistant'; }).slice(-1)[0];
|
||||
if (!message) return;
|
||||
var hasPrecise = citations.some(function(citation) { return citation.indexOf('来源定位降级') < 0; });
|
||||
var baseContent = hasPrecise ? pageAiCleanDegradedCitationNotes(message.content) : String(message.content || '');
|
||||
message.content = pageAiAppendCitationSection(baseContent, citations);
|
||||
pageAiSyncCurrentSessionMessages();
|
||||
pageAiPersistSessions();
|
||||
renderPageAiConversation();
|
||||
} catch (error) {
|
||||
console.warn('MNote Page AI 自动追加 LightRAG 引用失败', error);
|
||||
}
|
||||
}
|
||||
|
||||
function pageAiFinishStreamingAssistantMessage(runId, finalText, promptText, citations) {
|
||||
var id = String(runId || pageUiState.pageAiCurrentRunId || '');
|
||||
var message = pageUiState.pageAiMessages.find(function(item) {
|
||||
return item.role === 'assistant' && item.streaming === true && item.runId === id;
|
||||
});
|
||||
var text = String(finalText || (message && message.content) || '');
|
||||
var content = humanizePageAiResponse(text, promptText);
|
||||
var hasPreciseCitation = pageAiNormalizeArray(citations).some(function(citation) {
|
||||
return String(citation || '').indexOf('来源定位降级') < 0;
|
||||
});
|
||||
var contentBase = humanizePageAiResponse(text, promptText);
|
||||
if (hasPreciseCitation) contentBase = pageAiCleanDegradedCitationNotes(contentBase);
|
||||
var content = pageAiAppendCitationSection(contentBase, citations);
|
||||
if (message) {
|
||||
message.content = content;
|
||||
message.streaming = false;
|
||||
@@ -1859,6 +2128,123 @@ export function createSidebarPageAiRuntime(context) {
|
||||
}
|
||||
pageAiSyncCurrentSessionMessages();
|
||||
pageAiPersistSessions();
|
||||
void pageAiAppendKnowledgeRagFallbackCitations(id, promptText);
|
||||
}
|
||||
|
||||
function pageAiToolOutputText(value) {
|
||||
var parts = [];
|
||||
function visit(node) {
|
||||
if (node == null) return;
|
||||
if (typeof node === 'string') {
|
||||
parts.push(node);
|
||||
return;
|
||||
}
|
||||
if (typeof node === 'number' || typeof node === 'boolean') return;
|
||||
if (Array.isArray(node)) {
|
||||
node.forEach(visit);
|
||||
return;
|
||||
}
|
||||
if (typeof node !== 'object') return;
|
||||
if (typeof node.text === 'string') parts.push(node.text);
|
||||
if (typeof node.content === 'string') parts.push(node.content);
|
||||
if (node.content && typeof node.content === 'object') visit(node.content);
|
||||
if (node.output && typeof node.output === 'object') visit(node.output);
|
||||
if (node.result && typeof node.result === 'object') visit(node.result);
|
||||
}
|
||||
visit(value);
|
||||
return parts.join('\n');
|
||||
}
|
||||
|
||||
function pageAiParseJsonMaybe(value) {
|
||||
if (value && typeof value === 'object') return value;
|
||||
var text = String(value || '').trim();
|
||||
if (!text || (text[0] !== '{' && text[0] !== '[')) return null;
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch (_error) {
|
||||
var firstLine = text.split('\n')[0].trim();
|
||||
if (firstLine && firstLine !== text && (firstLine[0] === '{' || firstLine[0] === '[')) {
|
||||
try {
|
||||
return JSON.parse(firstLine);
|
||||
} catch (_lineError) {}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function pageAiCollectCitationMarkdowns(value) {
|
||||
var citations = [];
|
||||
var seen = {};
|
||||
function add(value) {
|
||||
var citation = String(value || '').trim();
|
||||
if (!citation || seen[citation]) return;
|
||||
seen[citation] = true;
|
||||
citations.push(citation);
|
||||
}
|
||||
function visit(node) {
|
||||
if (node == null) return;
|
||||
if (typeof node === 'string') {
|
||||
var parsed = pageAiParseJsonMaybe(node);
|
||||
if (parsed) visit(parsed);
|
||||
return;
|
||||
}
|
||||
if (Array.isArray(node)) {
|
||||
node.forEach(visit);
|
||||
return;
|
||||
}
|
||||
if (typeof node !== 'object') return;
|
||||
if (typeof node.citationMarkdown === 'string') add(node.citationMarkdown);
|
||||
if (Array.isArray(node.citationMarkdowns)) {
|
||||
node.citationMarkdowns.forEach(function(value) {
|
||||
if (typeof value === 'string') add(value);
|
||||
else visit(value);
|
||||
});
|
||||
}
|
||||
Object.keys(node).forEach(function(key) {
|
||||
visit(node[key]);
|
||||
});
|
||||
}
|
||||
visit(value);
|
||||
return citations;
|
||||
}
|
||||
|
||||
function pageAiCollectKnowledgeRagCitations(toolItem, toolEvent) {
|
||||
var toolName = String((toolItem && toolItem.toolName) || (toolEvent && (toolEvent.name || toolEvent.tool || toolEvent.toolName)) || '');
|
||||
var sources = [
|
||||
toolEvent,
|
||||
toolItem,
|
||||
toolEvent && toolEvent.output,
|
||||
toolEvent && toolEvent.result,
|
||||
toolEvent && toolEvent.summary,
|
||||
toolItem && toolItem.rawOutput,
|
||||
toolItem && toolItem.rawResult,
|
||||
toolItem && toolItem.resultSummary
|
||||
];
|
||||
var citations = [];
|
||||
var outputText = '';
|
||||
sources.forEach(function(source) {
|
||||
citations = citations.concat(pageAiCollectCitationMarkdowns(source));
|
||||
var sourceText = pageAiToolOutputText(source);
|
||||
if (sourceText) outputText += '\n' + sourceText;
|
||||
var parsed = pageAiParseJsonMaybe(sourceText);
|
||||
if (parsed) citations = citations.concat(pageAiCollectCitationMarkdowns(parsed));
|
||||
});
|
||||
var looksLikeKnowledgeRag = toolName.indexOf('knowledge_rag') >= 0 ||
|
||||
toolName.indexOf('knowledge.rag') >= 0 ||
|
||||
outputText.indexOf('mnote.knowledge_rag') >= 0 ||
|
||||
outputText.indexOf('uiCitations') >= 0;
|
||||
if (!looksLikeKnowledgeRag && citations.length === 0) return [];
|
||||
var hasPreciseCitation = citations.some(function(value) {
|
||||
return String(value || '').indexOf('来源定位降级') < 0;
|
||||
});
|
||||
var seen = {};
|
||||
return citations.filter(function(value) {
|
||||
var citation = String(value || '').trim();
|
||||
if (!citation || seen[citation]) return false;
|
||||
if (hasPreciseCitation && citation.indexOf('来源定位降级') >= 0) return false;
|
||||
seen[citation] = true;
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
function pageAiLooksLikeBlockEdit(prompt) {
|
||||
@@ -2078,6 +2464,7 @@ export function createSidebarPageAiRuntime(context) {
|
||||
throw new Error(pageAiErrorMessage(eventError, 'hermes_events_failed_' + eventResponse.status));
|
||||
}
|
||||
var assistantText = '';
|
||||
var autoCitations = [];
|
||||
await streamPageAiResponse(eventResponse, function(eventName, payloadText) {
|
||||
if (eventName === 'message.delta') {
|
||||
if (pageUiState.pageAiStoppedRunIds[runId]) return;
|
||||
@@ -2222,7 +2609,15 @@ export function createSidebarPageAiRuntime(context) {
|
||||
} catch (_) {}
|
||||
}
|
||||
if (eventName === 'tool.started' || eventName === 'tool.completed' || eventName === 'tool.failed') {
|
||||
pageAiApplyToolEvent(eventName, payloadText, runId, runTraceId);
|
||||
var toolEventPayload = null;
|
||||
try {
|
||||
toolEventPayload = JSON.parse(payloadText || 'null');
|
||||
} catch (_) {}
|
||||
var toolItem = pageAiApplyToolEvent(eventName, payloadText, runId, runTraceId);
|
||||
if (eventName === 'tool.completed') {
|
||||
var completedCitations = pageAiCollectKnowledgeRagCitations(toolItem, toolEventPayload);
|
||||
if (completedCitations.length) autoCitations = completedCitations;
|
||||
}
|
||||
pageAiSyncCurrentSessionMessages();
|
||||
pageAiPersistSessions();
|
||||
renderPageAiConversation();
|
||||
@@ -2231,7 +2626,7 @@ export function createSidebarPageAiRuntime(context) {
|
||||
}
|
||||
});
|
||||
if (!pageUiState.pageAiStoppedRunIds[runId]) {
|
||||
pageAiFinishStreamingAssistantMessage(runId, assistantText, prompt);
|
||||
pageAiFinishStreamingAssistantMessage(runId, assistantText, prompt, autoCitations);
|
||||
}
|
||||
currentSession = pageAiCurrentSession();
|
||||
if (currentSession) {
|
||||
@@ -2596,6 +2991,9 @@ export function createSidebarPageAiRuntime(context) {
|
||||
document.addEventListener('click', function(event) {
|
||||
handlePageAiClick(event, helpers);
|
||||
});
|
||||
document.addEventListener('pointerdown', function(event) {
|
||||
handlePageAiPointerDown(event, helpers);
|
||||
});
|
||||
document.addEventListener('keydown', function(event) {
|
||||
handlePageAiKeyDown(event, helpers);
|
||||
});
|
||||
|
||||
@@ -1103,6 +1103,32 @@ export function createSidebarPageSettingsRuntime(context) {
|
||||
});
|
||||
}
|
||||
|
||||
function knowledgeRagPipelineProgress(status) {
|
||||
var pipeline = status && status.pipeline && typeof status.pipeline === 'object' ? status.pipeline : {};
|
||||
var progress = pipeline.progress && typeof pipeline.progress === 'object' ? pipeline.progress : null;
|
||||
if (!progress) return null;
|
||||
var current = Number(progress.current || 0);
|
||||
var total = Number(progress.total || 0);
|
||||
if (!Number.isFinite(current) || !Number.isFinite(total) || current <= 0 || total <= 0) return null;
|
||||
return {
|
||||
current: Math.min(current, total),
|
||||
total: total,
|
||||
docId: String(progress.docId || ''),
|
||||
latestMessage: String(pipeline.latestMessage || '')
|
||||
};
|
||||
}
|
||||
|
||||
function knowledgeRagSourceProgressHtml(progress) {
|
||||
if (!progress) return '';
|
||||
var percent = Math.max(0, Math.min(100, Math.round((progress.current / progress.total) * 100)));
|
||||
var label = progress.current + ' / ' + progress.total + ' · ' + percent + '%';
|
||||
return '' +
|
||||
'<div class="mnote-knowledge-rag-source-progress" role="progressbar" aria-valuemin="0" aria-valuemax="' + String(progress.total) + '" aria-valuenow="' + String(progress.current) + '" aria-label="' + escapeHtml('索引进度 ' + label) + '">' +
|
||||
'<div><i style="width:' + String(percent) + '%"></i></div>' +
|
||||
'<span>' + escapeHtml(label) + '</span>' +
|
||||
'</div>';
|
||||
}
|
||||
|
||||
function scheduleKnowledgeRagStatusBridge(reason) {
|
||||
if (!knowledgeRagIsAvailable()) return;
|
||||
if (knowledgeRagBridgeTimer) window.clearTimeout(knowledgeRagBridgeTimer);
|
||||
@@ -1207,18 +1233,21 @@ export function createSidebarPageSettingsRuntime(context) {
|
||||
sourcesNode.innerHTML = '<div class="wolai-page-settings-index-empty">当前分类没有资料来源</div>';
|
||||
return;
|
||||
}
|
||||
var pipelineProgress = knowledgeRagPipelineProgress(status || {});
|
||||
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';
|
||||
var providerProgress = pipelineProgress && pipelineProgress.docId && pipelineProgress.docId === String(doc.id || '') ? pipelineProgress : null;
|
||||
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>' +
|
||||
knowledgeRagSourceProgressHtml(providerProgress) +
|
||||
'</div>' +
|
||||
'<div class="mnote-knowledge-rag-source-actions"><button type="button" disabled>只读</button></div>' +
|
||||
'</div>';
|
||||
@@ -1234,23 +1263,25 @@ export function createSidebarPageSettingsRuntime(context) {
|
||||
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 已移除'
|
||||
var statusLabel = deletedDone ? 'LightRAG 已移除'
|
||||
: deleting ? '删除已提交'
|
||||
: deleted ? '已删除'
|
||||
: stale ? '已过期'
|
||||
: failed ? '索引失败'
|
||||
: indexed ? '已索引'
|
||||
: (docId || providerStatus ? '正在索引' : '待提交');
|
||||
var meta = docId ? status + ' · ' + docId : status;
|
||||
var meta = docId ? statusLabel + ' · ' + docId : statusLabel;
|
||||
var sourcePath = String(entry && entry.sourceRootRelativePath || '');
|
||||
var canReindex = Boolean(sourcePath && !deleting);
|
||||
var canDelete = Boolean(sourcePath && !deleted && !deletedDone && !deleting);
|
||||
var entryProgress = pipelineProgress && pipelineProgress.docId && pipelineProgress.docId === docId && !indexed && !failed && !stale && !deleted && !deletedDone ? pipelineProgress : null;
|
||||
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>' +
|
||||
'<span class="wolai-page-settings-index-status-dot" data-index-status="' + kind + '" title="' + escapeHtml(statusLabel) + '"></span>' +
|
||||
'<div class="mnote-knowledge-rag-source-main">' +
|
||||
'<strong title="' + escapeHtml(title) + '">' + escapeHtml(title || '未命名来源') + '</strong>' +
|
||||
'<span>' + escapeHtml(meta) + '</span>' +
|
||||
knowledgeRagSourceProgressHtml(entryProgress) +
|
||||
'</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>' +
|
||||
|
||||
@@ -2139,7 +2139,9 @@ import { createSidebarPageSettingsRuntime } from './sidebar-page-settings-runtim
|
||||
'<span class="wolai-search-sort-control"><span>按创建时间</span><button type="button" class="wolai-search-sort-value" data-search-sort="created" aria-label="按创建时间范围">所有</button></span>' +
|
||||
'</div>' +
|
||||
'<div class="wolai-search-options-right">' +
|
||||
'<span class="wolai-search-switch-control"><span>全盘资料库</span><button type="button" class="wolai-search-switch" data-search-switch="knowledge" role="switch" aria-checked="false" aria-label="全盘资料库检索"></button></span>' +
|
||||
'<span class="wolai-search-switch-control"><span>页面内搜索</span><button type="button" class="wolai-search-switch" data-search-switch="page" role="switch" aria-checked="false" aria-label="页面内搜索"></button></span>' +
|
||||
'<span class="wolai-search-switch-control"><span>折叠同来源</span><button type="button" class="wolai-search-switch is-on" data-search-switch="collapseSource" role="switch" aria-checked="true" aria-label="默认折叠相同来源结果"></button></span>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'<div class="wolai-search-result-meta" data-testid="wolai-search-result-meta"></div>' +
|
||||
@@ -2149,12 +2151,22 @@ import { createSidebarPageSettingsRuntime } from './sidebar-page-settings-runtim
|
||||
document.body.appendChild(overlay);
|
||||
var input = overlay.querySelector('[data-testid="wolai-search-input"]');
|
||||
var closeButton = overlay.querySelector('[data-testid="wolai-search-close"]');
|
||||
if (input) input.addEventListener('input', scheduleSearchResultsRender);
|
||||
applySearchSwitchState(overlay, { collapseSource: readSearchCollapseSourcesDefault() });
|
||||
if (input) input.addEventListener('input', function() {
|
||||
searchUiState.hasRendered = false;
|
||||
scheduleSearchResultsRender();
|
||||
});
|
||||
overlay.querySelectorAll('[data-search-switch]').forEach(function(button) {
|
||||
button.addEventListener('click', function() {
|
||||
var switchName = searchText(button.getAttribute('data-search-switch'));
|
||||
var isOn = button.getAttribute('aria-checked') !== 'true';
|
||||
button.setAttribute('aria-checked', isOn ? 'true' : 'false');
|
||||
button.classList.toggle('is-on', isOn);
|
||||
if (switchName === 'collapseSource') {
|
||||
writeSearchCollapseSourcesDefault(isOn);
|
||||
searchUiState.sourceCollapsed = {};
|
||||
}
|
||||
searchUiState.hasRendered = false;
|
||||
scheduleSearchResultsRender();
|
||||
});
|
||||
});
|
||||
@@ -2167,11 +2179,27 @@ import { createSidebarPageSettingsRuntime } from './sidebar-page-settings-runtim
|
||||
|
||||
var activeSearchRequestId = 0;
|
||||
var searchRenderTimer = 0;
|
||||
var SEARCH_COLLAPSE_SOURCES_KEY = 'mnote.search.collapseSourcesDefault.v1';
|
||||
var searchUiState = {
|
||||
query: '',
|
||||
switches: {},
|
||||
metaHtml: '',
|
||||
resultsHtml: '',
|
||||
items: [],
|
||||
sourceCollapsed: {},
|
||||
scrollTop: 0,
|
||||
signature: '',
|
||||
hasRendered: false
|
||||
};
|
||||
|
||||
function searchText(value) {
|
||||
return String(value == null ? '' : value).replace(/\s+/g, ' ').trim();
|
||||
}
|
||||
|
||||
function searchNonWhitespaceCharCount(value) {
|
||||
return String(value == null ? '' : value).replace(/\s+/g, '').length;
|
||||
}
|
||||
|
||||
function currentWorkspaceName() {
|
||||
var name = document.querySelector('.sidebar-workspace-name');
|
||||
return searchText(name && name.textContent) || '当前工作区';
|
||||
@@ -2208,6 +2236,92 @@ import { createSidebarPageSettingsRuntime } from './sidebar-page-settings-runtim
|
||||
return button instanceof HTMLElement && button.getAttribute('aria-checked') === 'true';
|
||||
}
|
||||
|
||||
function readSearchCollapseSourcesDefault() {
|
||||
try {
|
||||
var stored = window.localStorage && window.localStorage.getItem(SEARCH_COLLAPSE_SOURCES_KEY);
|
||||
if (stored === '0') return false;
|
||||
if (stored === '1') return true;
|
||||
} catch (_) {}
|
||||
return true;
|
||||
}
|
||||
|
||||
function writeSearchCollapseSourcesDefault(value) {
|
||||
try {
|
||||
if (window.localStorage) window.localStorage.setItem(SEARCH_COLLAPSE_SOURCES_KEY, value ? '1' : '0');
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
function collectSearchSwitchState(overlay) {
|
||||
var state = {};
|
||||
if (!(overlay instanceof HTMLElement)) return state;
|
||||
overlay.querySelectorAll('[data-search-switch]').forEach(function(button) {
|
||||
if (!(button instanceof HTMLElement)) return;
|
||||
var name = searchText(button.getAttribute('data-search-switch'));
|
||||
if (!name) return;
|
||||
state[name] = button.getAttribute('aria-checked') === 'true';
|
||||
});
|
||||
return state;
|
||||
}
|
||||
|
||||
function applySearchSwitchState(overlay, switches) {
|
||||
if (!(overlay instanceof HTMLElement) || !switches || typeof switches !== 'object') return;
|
||||
overlay.querySelectorAll('[data-search-switch]').forEach(function(button) {
|
||||
if (!(button instanceof HTMLElement)) return;
|
||||
var name = searchText(button.getAttribute('data-search-switch'));
|
||||
if (!name || !Object.prototype.hasOwnProperty.call(switches, name)) return;
|
||||
var isOn = Boolean(switches[name]);
|
||||
button.setAttribute('aria-checked', isOn ? 'true' : 'false');
|
||||
button.classList.toggle('is-on', isOn);
|
||||
});
|
||||
}
|
||||
|
||||
function searchRequestSignature(overlay, query) {
|
||||
var switches = collectSearchSwitchState(overlay);
|
||||
return JSON.stringify({
|
||||
query: searchText(query),
|
||||
knowledge: Boolean(switches.knowledge),
|
||||
page: Boolean(switches.page),
|
||||
title: Boolean(switches.title),
|
||||
exact: Boolean(switches.exact),
|
||||
collapseSource: Boolean(switches.collapseSource),
|
||||
workspaceId: resolveWorkspaceId(document.body) || '',
|
||||
rootUri: currentRootUri() || ''
|
||||
});
|
||||
}
|
||||
|
||||
function saveSearchUiState(overlay) {
|
||||
if (!(overlay instanceof HTMLElement)) return;
|
||||
var input = overlay.querySelector('[data-testid="wolai-search-input"]');
|
||||
var meta = overlay.querySelector('[data-testid="wolai-search-result-meta"]');
|
||||
var results = overlay.querySelector('[data-testid="wolai-search-results"]');
|
||||
var query = input && 'value' in input ? searchText(input.value) : searchUiState.query;
|
||||
searchUiState.query = query;
|
||||
searchUiState.switches = collectSearchSwitchState(overlay);
|
||||
searchUiState.metaHtml = meta ? meta.innerHTML : searchUiState.metaHtml;
|
||||
searchUiState.resultsHtml = results ? results.innerHTML : searchUiState.resultsHtml;
|
||||
searchUiState.items = Array.isArray(window.__mnoteSearchResults) ? window.__mnoteSearchResults.slice() : searchUiState.items;
|
||||
searchUiState.scrollTop = results instanceof HTMLElement ? results.scrollTop : searchUiState.scrollTop;
|
||||
searchUiState.signature = query ? searchRequestSignature(overlay, query) : '';
|
||||
}
|
||||
|
||||
function restoreSearchUiState(overlay) {
|
||||
if (!(overlay instanceof HTMLElement) || !searchUiState.hasRendered) return false;
|
||||
var input = overlay.querySelector('[data-testid="wolai-search-input"]');
|
||||
var options = overlay.querySelector('[data-testid="wolai-search-options"]');
|
||||
var meta = overlay.querySelector('[data-testid="wolai-search-result-meta"]');
|
||||
var results = overlay.querySelector('[data-testid="wolai-search-results"]');
|
||||
if (input && 'value' in input) input.value = searchUiState.query || '';
|
||||
applySearchSwitchState(overlay, searchUiState.switches);
|
||||
if (options instanceof HTMLElement) options.hidden = !searchText(searchUiState.query);
|
||||
if (meta && searchUiState.metaHtml) meta.innerHTML = searchUiState.metaHtml;
|
||||
if (results && searchUiState.resultsHtml) {
|
||||
results.innerHTML = searchUiState.resultsHtml;
|
||||
results.scrollTop = Number(searchUiState.scrollTop || 0);
|
||||
}
|
||||
window.__mnoteSearchResults = Array.isArray(searchUiState.items) ? searchUiState.items.slice() : [];
|
||||
return true;
|
||||
}
|
||||
|
||||
function searchHighlightTerms(item, query) {
|
||||
var info = item && item.matchInfo || item && item.evidence && item.evidence.matchInfo || null;
|
||||
var terms = info && Array.isArray(info.matchedTerms) ? info.matchedTerms : [];
|
||||
@@ -2258,13 +2372,113 @@ import { createSidebarPageSettingsRuntime } from './sidebar-page-settings-runtim
|
||||
return html;
|
||||
}
|
||||
|
||||
function cleanSearchDisplayText(value) {
|
||||
var text = String(value == null ? '' : value);
|
||||
text = text.replace(/<drawing\b[^>]*\/?>/gi, ' ');
|
||||
text = text.replace(/<equation\b[^>]*>([\s\S]*?)<\/equation>/gi, '$1');
|
||||
text = text.replace(/<drawing\b[^<>]*$/gi, ' ');
|
||||
text = text.replace(/<\/?equat[^<>]*$/gi, ' ');
|
||||
text = text.replace(/<\/?e(?:q(?:uation)?)?[^<>]*$/gi, ' ');
|
||||
text = text.replace(/<[^>]+>/g, ' ');
|
||||
text = text
|
||||
.replace(/\b(?:equation|latex|drawing)\b(?:\s+[a-z_-]+=(?:"[^"]*"|'[^']*'|[^\s<>]+))*/gi, ' ')
|
||||
.replace(/\\left|\\right|\\mathrm|\\text|\\operatorname/g, '')
|
||||
.replace(/\\gt/g, '>')
|
||||
.replace(/\\lt/g, '<')
|
||||
.replace(/\\sim/g, '∼')
|
||||
.replace(/\\[a-zA-Z]+/g, ' ')
|
||||
.replace(/\{([^{}]*)\}/g, '$1')
|
||||
.replace(/\{([^{}]*)\}/g, '$1')
|
||||
.replace(/[_^]/g, '')
|
||||
.replace(/[<>]\/?(?:equation|eq)\b[^<>]*>?/gi, ' ')
|
||||
.replace(/<\/?e(?:q(?:uation)?)?[^<>\s]*>?/gi, ' ')
|
||||
.replace(/\s+(?:equation|latex|drawing)\s+/gi, ' ');
|
||||
return searchText(text);
|
||||
}
|
||||
|
||||
function searchResultEvidenceLocator(item) {
|
||||
if (item && item.locator && typeof item.locator === 'object') return item.locator;
|
||||
if (item && item.evidence && item.evidence.source && typeof item.evidence.source === 'object') return item.evidence.source;
|
||||
if (item && item.source && item.source.locator && typeof item.source.locator === 'object') return item.source.locator;
|
||||
if (item && Array.isArray(item.evidence) && item.evidence[0] && item.evidence[0].source && typeof item.evidence[0].source === 'object') return item.evidence[0].source;
|
||||
return null;
|
||||
}
|
||||
|
||||
function searchResultSourceKey(item) {
|
||||
var locator = searchResultEvidenceLocator(item);
|
||||
var locatorPath = locator ? searchText(locator.resourcePath || locator.resource_path || locator.ownerDocumentPath || locator.owner_document_path) : '';
|
||||
return locatorPath || searchText(item && (item.path || item.documentId || item.nodeId || item.id)) || 'unknown-source';
|
||||
}
|
||||
|
||||
function searchResultSourceTitle(item) {
|
||||
var key = searchResultSourceKey(item);
|
||||
var locator = searchResultEvidenceLocator(item);
|
||||
var path = locator ? searchText(locator.resourcePath || locator.resource_path || locator.ownerDocumentPath || locator.owner_document_path) : '';
|
||||
var sourcePath = path || searchText(item && item.path) || key;
|
||||
var title = searchText(item && (item.title || item.name || item.documentTitle));
|
||||
return title || sourcePath.split('/').filter(Boolean).pop() || sourcePath || '未知来源';
|
||||
}
|
||||
|
||||
function renderSearchResultButton(item, index, query, exact, grouped) {
|
||||
var title = searchText(item.title || item.name || item.documentTitle || '无标题');
|
||||
var snippet = cleanSearchDisplayText(item.snippet || item.evidence && item.evidence.quote || (Array.isArray(item.evidence) && item.evidence[0] && (item.evidence[0].quote || item.evidence[0].snippet)) || '');
|
||||
var path = searchText(item.path || item.parentTitle || item.workspaceName || currentWorkspaceName());
|
||||
var resourceType = searchText(item.resourceType || item.resourceKind || 'page');
|
||||
var locator = searchResultEvidenceLocator(item);
|
||||
var locatorAttr = locator ? escapeHtml(JSON.stringify(locator)) : '';
|
||||
var highlightTerms = searchHighlightTerms(item, query);
|
||||
var titleHtml = grouped && snippet ? '' : '<span class="wolai-search-result-title">' + highlightSearchText(title, query, exact, highlightTerms) + '</span>';
|
||||
var page = locator && locator.page != null && locator.page !== '' ? String(locator.page) : '';
|
||||
var metaParts = grouped ? [] : [escapeHtml(path)];
|
||||
if (page) metaParts.push('第 ' + escapeHtml(page) + ' 页');
|
||||
if (!grouped) metaParts.push('<span class="wolai-search-result-type">' + escapeHtml(resourceType) + '</span>');
|
||||
var metaHtml = metaParts.length ? '<span class="wolai-search-result-path"><span>' + metaParts.join('</span><span>') + '</span></span>' : '';
|
||||
return '<button type="button" class="wolai-search-result-row" data-testid="wolai-search-result-row" data-search-result-owner="' + escapeHtml(item.provider || 'rust-kernel') + '" data-search-result-index="' + String(index) + '" data-document-id="' + escapeHtml(item.documentId || item.nodeId || item.id || '') + '" data-resource-type="' + escapeHtml(resourceType) + '"' + (locatorAttr ? ' data-evidence-locator="' + locatorAttr + '"' : '') + '>' +
|
||||
'<span class="material-symbols-outlined wolai-search-result-icon" data-icon="article" aria-hidden="true"></span>' +
|
||||
'<span class="wolai-search-result-main">' + titleHtml +
|
||||
'<span class="wolai-search-result-snippet">' + (snippet ? highlightSearchText(snippet, query, exact, highlightTerms) : escapeHtml(path)) + '</span>' +
|
||||
metaHtml + '</span>' +
|
||||
'</button>';
|
||||
}
|
||||
|
||||
function renderSearchResultsHtml(items, overlay, query) {
|
||||
var exact = searchSwitchValue(overlay, 'exact');
|
||||
if (!searchSwitchValue(overlay, 'collapseSource')) {
|
||||
return items.map(function(item, index) {
|
||||
return renderSearchResultButton(item, index, query, exact, false);
|
||||
}).join('');
|
||||
}
|
||||
var groups = [];
|
||||
var groupByKey = {};
|
||||
items.forEach(function(item, index) {
|
||||
var key = searchResultSourceKey(item);
|
||||
var group = groupByKey[key];
|
||||
if (!group) {
|
||||
group = { key: key, title: searchResultSourceTitle(item), path: searchText(item.path || key), resourceType: searchText(item.resourceType || item.resourceKind || 'page'), rows: [] };
|
||||
groupByKey[key] = group;
|
||||
groups.push(group);
|
||||
}
|
||||
group.rows.push({ item: item, index: index });
|
||||
});
|
||||
return groups.map(function(group) {
|
||||
var collapsed = Object.prototype.hasOwnProperty.call(searchUiState.sourceCollapsed, group.key)
|
||||
? searchUiState.sourceCollapsed[group.key]
|
||||
: true;
|
||||
var children = group.rows.map(function(row) {
|
||||
return renderSearchResultButton(row.item, row.index, query, exact, true);
|
||||
}).join('');
|
||||
return '<section class="wolai-search-source-group" data-search-source-key="' + escapeHtml(group.key) + '">' +
|
||||
'<button type="button" class="wolai-search-source-header" data-search-source-toggle="true" aria-expanded="' + (collapsed ? 'false' : 'true') + '">' +
|
||||
'<span class="material-symbols-outlined wolai-search-result-icon" data-icon="article" aria-hidden="true"></span>' +
|
||||
'<span class="wolai-search-source-main"><span class="wolai-search-result-title">' + escapeHtml(group.title) + '</span>' +
|
||||
'<span class="wolai-search-result-path"><span class="wolai-search-result-type">' + escapeHtml(group.resourceType) + '</span><span>' + String(group.rows.length) + ' 处</span></span></span>' +
|
||||
'<span class="wolai-search-source-chevron" aria-hidden="true">' + (collapsed ? '展开' : '收起') + '</span>' +
|
||||
'</button>' +
|
||||
'<div class="wolai-search-source-results" data-search-source-results="true"' + (collapsed ? ' hidden' : '') + '>' + children + '</div>' +
|
||||
'</section>';
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function evidenceLocatorResourceKind(locator, fallback) {
|
||||
return searchText(locator && (locator.resourceKind || locator.resource_kind) || fallback || '').toLowerCase();
|
||||
}
|
||||
@@ -2316,6 +2530,12 @@ import { createSidebarPageSettingsRuntime } from './sidebar-page-settings-runtim
|
||||
async function openEvidenceSearchResult(item, event) {
|
||||
var locator = searchResultEvidenceLocator(item);
|
||||
if (!locator) {
|
||||
var citationUrl = searchText(item && (item.citationUrl || item.publicPath));
|
||||
if (citationUrl) {
|
||||
closeSearchModal();
|
||||
window.location.assign(citationUrl);
|
||||
return;
|
||||
}
|
||||
var fallbackId = searchText(item && (item.documentId || item.nodeId || item.id));
|
||||
if (fallbackId) window.location.assign('/documents/' + encodeURIComponent(fallbackId));
|
||||
return;
|
||||
@@ -2323,6 +2543,12 @@ import { createSidebarPageSettingsRuntime } from './sidebar-page-settings-runtim
|
||||
var resourcePath = searchText(locator.resourcePath || locator.resource_path || locator.ownerDocumentPath || locator.owner_document_path);
|
||||
var ownerDocumentId = searchText(locator.ownerDocumentId || locator.owner_document_id || item.documentId || '');
|
||||
var resourceKind = evidenceLocatorResourceKind(locator, item && item.resourceType);
|
||||
var overlay = document.querySelector('[data-testid="wolai-search-modal"]');
|
||||
var searchInput = overlay instanceof HTMLElement ? overlay.querySelector('[data-testid="wolai-search-input"]') : null;
|
||||
var searchQueryText = searchText(item && (item.query || item.searchQuery))
|
||||
|| searchText(searchInput && 'value' in searchInput ? searchInput.value : '')
|
||||
|| searchText(searchUiState.query);
|
||||
var locatorEvidenceText = searchText(locator.evidenceText || locator.query || locator.openAction && locator.openAction.params && locator.openAction.params.query || '');
|
||||
var openTarget = event && event.altKey ? 'side' : 'active-tab';
|
||||
if (resourcePath && resourceKind) {
|
||||
var fileName = resourcePath.split('/').filter(Boolean).pop() || resourcePath;
|
||||
@@ -2347,6 +2573,9 @@ import { createSidebarPageSettingsRuntime } from './sidebar-page-settings-runtim
|
||||
bbox: locator.bbox,
|
||||
sourceMapPath: searchText(locator.sourceMapPath || locator.source_map_path),
|
||||
blockId: evidenceLocatorBlockId(locator),
|
||||
evidenceText: locatorEvidenceText,
|
||||
query: searchQueryText,
|
||||
searchQuery: searchQueryText,
|
||||
lineRange: evidenceLocatorLineRange(locator),
|
||||
charRange: evidenceLocatorCharRange(locator)
|
||||
});
|
||||
@@ -2379,6 +2608,8 @@ import { createSidebarPageSettingsRuntime } from './sidebar-page-settings-runtim
|
||||
if (results) {
|
||||
results.innerHTML = '<div class="wolai-search-recent" data-testid="wolai-search-recent">暂无最近浏览</div>';
|
||||
}
|
||||
saveSearchUiState(overlay);
|
||||
searchUiState.hasRendered = true;
|
||||
}
|
||||
|
||||
function scheduleSearchResultsRender() {
|
||||
@@ -2401,14 +2632,32 @@ import { createSidebarPageSettingsRuntime } from './sidebar-page-settings-runtim
|
||||
return;
|
||||
}
|
||||
if (options instanceof HTMLElement) options.hidden = false;
|
||||
var knowledgeMode = searchSwitchValue(overlay, 'knowledge');
|
||||
if (knowledgeMode && searchNonWhitespaceCharCount(query) < 2) {
|
||||
activeSearchRequestId += 1;
|
||||
meta.innerHTML = '<span>资料库检索</span><span>请输入至少 2 个字再搜索</span>';
|
||||
results.innerHTML = '<div class="wolai-search-empty" data-search-empty="true">请输入至少 2 个字再搜索</div>';
|
||||
window.__mnoteSearchResults = [];
|
||||
saveSearchUiState(overlay);
|
||||
searchUiState.hasRendered = true;
|
||||
return;
|
||||
}
|
||||
var requestId = ++activeSearchRequestId;
|
||||
meta.innerHTML = '<span>搜索中…</span><span>Ctrl + Enter 新窗口打开 / Alt + Enter 右侧边栏打开</span>';
|
||||
results.innerHTML = '<div class="wolai-search-empty" data-search-loading="true">搜索中…</div>';
|
||||
try {
|
||||
var response = await fetch('/api/search/documents', {
|
||||
var response = await fetch(knowledgeMode ? '/api/knowledge-rag/search' : '/api/search/documents', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
body: JSON.stringify(knowledgeMode ? {
|
||||
workspaceId: resolveWorkspaceId(document.body),
|
||||
rootUri: currentRootUri() || null,
|
||||
query: query,
|
||||
mode: 'hybrid',
|
||||
topK: 12,
|
||||
chunkTopK: 24,
|
||||
includeChunkContent: true
|
||||
} : {
|
||||
workspaceId: resolveWorkspaceId(document.body),
|
||||
sourceKind: currentSourceKind() || null,
|
||||
rootUri: currentRootUri() || null,
|
||||
@@ -2418,7 +2667,7 @@ import { createSidebarPageSettingsRuntime } from './sidebar-page-settings-runtim
|
||||
filters: {
|
||||
titleOnly: searchSwitchValue(overlay, 'title'),
|
||||
exact: searchSwitchValue(overlay, 'exact'),
|
||||
includeOcr: true,
|
||||
includeOcr: false,
|
||||
onlyCurrentPage: searchSwitchValue(overlay, 'page'),
|
||||
timeRange: 'any',
|
||||
timeField: 'updated'
|
||||
@@ -2428,33 +2677,25 @@ import { createSidebarPageSettingsRuntime } from './sidebar-page-settings-runtim
|
||||
var payload = await response.json();
|
||||
if (requestId !== activeSearchRequestId) return;
|
||||
var items = Array.isArray(payload.results) ? payload.results : [];
|
||||
meta.innerHTML = '<span>共 ' + items.length + ' 条匹配结果</span><span>Ctrl + Enter 新窗口打开 / Alt + Enter 右侧边栏打开</span>';
|
||||
meta.innerHTML = '<span>' + (knowledgeMode ? '资料库检索' : '工作区搜索') + ' · 共 ' + items.length + ' 条匹配结果</span><span>Ctrl + Enter 新窗口打开 / Alt + Enter 右侧边栏打开</span>';
|
||||
if (!items.length) {
|
||||
results.innerHTML = '<div class="wolai-search-empty" data-search-empty="true">没有搜索结果</div>';
|
||||
window.__mnoteSearchResults = [];
|
||||
saveSearchUiState(overlay);
|
||||
searchUiState.hasRendered = true;
|
||||
return;
|
||||
}
|
||||
results.innerHTML = items.map(function(item) {
|
||||
var title = searchText(item.title || item.name || item.documentTitle || '无标题');
|
||||
var snippet = searchText(item.snippet || item.evidence && item.evidence.quote || (Array.isArray(item.evidence) && item.evidence[0] && (item.evidence[0].quote || item.evidence[0].snippet)) || '');
|
||||
var path = searchText(item.path || item.parentTitle || item.workspaceName || currentWorkspaceName());
|
||||
var resourceType = searchText(item.resourceType || item.resourceKind || 'page');
|
||||
var locator = searchResultEvidenceLocator(item);
|
||||
var locatorAttr = locator ? escapeHtml(JSON.stringify(locator)) : '';
|
||||
var index = items.indexOf(item);
|
||||
var exact = searchSwitchValue(overlay, 'exact');
|
||||
var highlightTerms = searchHighlightTerms(item, query);
|
||||
return '<button type="button" class="wolai-search-result-row" data-testid="wolai-search-result-row" data-search-result-owner="rust-kernel" data-search-result-index="' + String(index) + '" data-document-id="' + escapeHtml(item.documentId || item.nodeId || item.id || '') + '" data-resource-type="' + escapeHtml(resourceType) + '"' + (locatorAttr ? ' data-evidence-locator="' + locatorAttr + '"' : '') + '>' +
|
||||
'<span class="material-symbols-outlined wolai-search-result-icon" data-icon="article" aria-hidden="true"></span>' +
|
||||
'<span class="wolai-search-result-main"><span class="wolai-search-result-title">' + highlightSearchText(title, query, exact, highlightTerms) + '</span>' +
|
||||
'<span class="wolai-search-result-snippet">' + (snippet ? highlightSearchText(snippet, query, exact, highlightTerms) : escapeHtml(path)) + '</span>' +
|
||||
'<span class="wolai-search-result-path"><span>' + escapeHtml(path) + '</span><span class="wolai-search-result-type">' + escapeHtml(resourceType) + '</span></span></span>' +
|
||||
'</button>';
|
||||
}).join('');
|
||||
window.__mnoteSearchResults = items;
|
||||
results.innerHTML = renderSearchResultsHtml(items, overlay, query);
|
||||
saveSearchUiState(overlay);
|
||||
searchUiState.hasRendered = true;
|
||||
} catch (error) {
|
||||
if (requestId !== activeSearchRequestId) return;
|
||||
meta.innerHTML = '<span>共 0 条匹配结果</span><span>Ctrl + Enter 新窗口打开 / Alt + Enter 右侧边栏打开</span>';
|
||||
results.innerHTML = '<div class="wolai-search-empty" data-search-empty="true">没有搜索结果</div>';
|
||||
window.__mnoteSearchResults = [];
|
||||
saveSearchUiState(overlay);
|
||||
searchUiState.hasRendered = true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2467,9 +2708,12 @@ import { createSidebarPageSettingsRuntime } from './sidebar-page-settings-runtim
|
||||
var overlay = ensureSearchModal();
|
||||
overlay.hidden = false;
|
||||
document.documentElement.setAttribute('data-mnote-search-modal-open', 'true');
|
||||
var restored = restoreSearchUiState(overlay);
|
||||
var input = overlay.querySelector('[data-testid="wolai-search-input"]');
|
||||
if (input) input.setAttribute('placeholder', '在 ' + currentWorkspaceName() + ' 中搜索');
|
||||
void renderSearchResults();
|
||||
var query = input && 'value' in input ? searchText(input.value) : '';
|
||||
var signature = query ? searchRequestSignature(overlay, query) : '';
|
||||
if (!restored || signature !== searchUiState.signature) void renderSearchResults();
|
||||
if (input) {
|
||||
setTimeout(function() { input.focus(); input.select(); }, 0);
|
||||
}
|
||||
@@ -2477,7 +2721,10 @@ import { createSidebarPageSettingsRuntime } from './sidebar-page-settings-runtim
|
||||
|
||||
function closeSearchModal() {
|
||||
var overlay = document.querySelector('[data-testid="wolai-search-modal"]');
|
||||
if (overlay instanceof HTMLElement) overlay.hidden = true;
|
||||
if (overlay instanceof HTMLElement) {
|
||||
saveSearchUiState(overlay);
|
||||
overlay.hidden = true;
|
||||
}
|
||||
document.documentElement.removeAttribute('data-mnote-search-modal-open');
|
||||
}
|
||||
|
||||
@@ -2486,6 +2733,17 @@ import { createSidebarPageSettingsRuntime } from './sidebar-page-settings-runtim
|
||||
else openSearchModal();
|
||||
}
|
||||
|
||||
function isSearchShortcutEvent(event) {
|
||||
return Boolean(event && (event.metaKey || event.ctrlKey) && !event.shiftKey && !event.altKey && event.key && event.key.toLowerCase() === 'p');
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', function(event) {
|
||||
if (!isSearchShortcutEvent(event)) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
openSearchModal();
|
||||
}, true);
|
||||
|
||||
const sidebarPageAi = createSidebarPageAiRuntime({
|
||||
buildLocalFileOpenUrl,
|
||||
currentDocumentId,
|
||||
@@ -2792,6 +3050,22 @@ import { createSidebarPageSettingsRuntime } from './sidebar-page-settings-runtim
|
||||
closeSearchModal();
|
||||
return;
|
||||
}
|
||||
var searchSourceToggle = closestAction(e.target, '[data-search-source-toggle]');
|
||||
if (searchSourceToggle) {
|
||||
e.preventDefault();
|
||||
var sourceGroup = searchSourceToggle.closest('.wolai-search-source-group');
|
||||
var sourceResults = sourceGroup && sourceGroup.querySelector('[data-search-source-results="true"]');
|
||||
var sourceKey = sourceGroup ? searchText(sourceGroup.getAttribute('data-search-source-key')) : '';
|
||||
var nextCollapsed = !(sourceResults instanceof HTMLElement && sourceResults.hidden);
|
||||
if (sourceResults instanceof HTMLElement) sourceResults.hidden = nextCollapsed;
|
||||
searchSourceToggle.setAttribute('aria-expanded', nextCollapsed ? 'false' : 'true');
|
||||
var chevron = searchSourceToggle.querySelector('.wolai-search-source-chevron');
|
||||
if (chevron) chevron.textContent = nextCollapsed ? '展开' : '收起';
|
||||
if (sourceKey) searchUiState.sourceCollapsed[sourceKey] = nextCollapsed;
|
||||
var overlay = document.querySelector('[data-testid="wolai-search-modal"]');
|
||||
if (overlay instanceof HTMLElement) saveSearchUiState(overlay);
|
||||
return;
|
||||
}
|
||||
var searchResultRow = closestAction(e.target, '[data-testid="wolai-search-result-row"]');
|
||||
if (searchResultRow) {
|
||||
e.preventDefault();
|
||||
@@ -3144,9 +3418,9 @@ import { createSidebarPageSettingsRuntime } from './sidebar-page-settings-runtim
|
||||
closeAllSettingsPopovers();
|
||||
return;
|
||||
}
|
||||
if ((event.metaKey || event.ctrlKey) && !event.shiftKey && event.key && event.key.toLowerCase() === 'p') {
|
||||
if (isSearchShortcutEvent(event)) {
|
||||
event.preventDefault();
|
||||
toggleSearchModal();
|
||||
openSearchModal();
|
||||
return;
|
||||
}
|
||||
if (event.key === 'Escape') {
|
||||
|
||||
@@ -11,6 +11,7 @@ use axum::body::Body;
|
||||
use axum::http::{header, StatusCode};
|
||||
use axum::response::Response;
|
||||
use serde_json::{json, Value};
|
||||
use std::collections::HashSet;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::broadcast;
|
||||
use tracing::{info, warn};
|
||||
@@ -47,6 +48,105 @@ pub struct AcpRunBridge {
|
||||
event_tx: broadcast::Sender<SseEvent>,
|
||||
}
|
||||
|
||||
fn collect_citation_markdowns_from_value(value: &Value) -> Vec<Value> {
|
||||
fn add_citation(text: &str, seen: &mut HashSet<String>, out: &mut Vec<Value>) {
|
||||
let citation = text.trim();
|
||||
if !citation.is_empty() && seen.insert(citation.to_string()) {
|
||||
out.push(json!({ "citationMarkdown": citation }));
|
||||
}
|
||||
}
|
||||
|
||||
fn add_reference_citations(
|
||||
references: &[Value],
|
||||
seen: &mut HashSet<String>,
|
||||
out: &mut Vec<Value>,
|
||||
) -> bool {
|
||||
let has_precise = references.iter().any(|reference| {
|
||||
reference
|
||||
.get("citationMarkdown")
|
||||
.and_then(Value::as_str)
|
||||
.is_some_and(|value| !value.trim().is_empty())
|
||||
&& reference.get("locatorDegraded").and_then(Value::as_bool) != Some(true)
|
||||
});
|
||||
let mut added = false;
|
||||
for reference in references {
|
||||
if out.len() >= 8 {
|
||||
break;
|
||||
}
|
||||
let Some(citation) = reference.get("citationMarkdown").and_then(Value::as_str) else {
|
||||
continue;
|
||||
};
|
||||
if has_precise
|
||||
&& reference.get("locatorDegraded").and_then(Value::as_bool) == Some(true)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let before = out.len();
|
||||
add_citation(citation, seen, out);
|
||||
added = added || out.len() > before;
|
||||
}
|
||||
added
|
||||
}
|
||||
|
||||
fn visit(value: &Value, seen: &mut HashSet<String>, out: &mut Vec<Value>) {
|
||||
if out.len() >= 8 {
|
||||
return;
|
||||
}
|
||||
match value {
|
||||
Value::String(text) => {
|
||||
let trimmed = text.trim();
|
||||
if (trimmed.starts_with('{') || trimmed.starts_with('['))
|
||||
&& trimmed.contains("citationMarkdown")
|
||||
{
|
||||
if let Ok(parsed) = serde_json::from_str::<Value>(trimmed) {
|
||||
visit(&parsed, seen, out);
|
||||
} else if let Some(first_line) = trimmed.lines().next() {
|
||||
if let Ok(parsed) = serde_json::from_str::<Value>(first_line.trim()) {
|
||||
visit(&parsed, seen, out);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Value::Array(items) => {
|
||||
for item in items {
|
||||
visit(item, seen, out);
|
||||
if out.len() >= 8 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
Value::Object(map) => {
|
||||
let has_filtered_references = map
|
||||
.get("references")
|
||||
.and_then(Value::as_array)
|
||||
.is_some_and(|references| add_reference_citations(references, seen, out));
|
||||
if let Some(citation) = map.get("citationMarkdown").and_then(Value::as_str) {
|
||||
if !has_filtered_references {
|
||||
add_citation(citation, seen, out);
|
||||
}
|
||||
}
|
||||
for (key, item) in map {
|
||||
if has_filtered_references
|
||||
&& matches!(key.as_str(), "references" | "citations" | "uiCitations")
|
||||
{
|
||||
continue;
|
||||
}
|
||||
visit(item, seen, out);
|
||||
if out.len() >= 8 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
let mut seen = HashSet::new();
|
||||
let mut out = Vec::new();
|
||||
visit(value, &mut seen, &mut out);
|
||||
out
|
||||
}
|
||||
|
||||
impl AcpRunBridge {
|
||||
/// Create a new ACP run: create session + start prompt in background.
|
||||
///
|
||||
@@ -213,6 +313,8 @@ pub fn acp_event_to_sse(event: AcpSessionEvent) -> Option<SseEvent> {
|
||||
status,
|
||||
content,
|
||||
} => {
|
||||
let output = json!(content);
|
||||
let citation_markdowns = collect_citation_markdowns_from_value(&output);
|
||||
let error = status == crate::acp_types::ToolCallStatus::Failed;
|
||||
let event = if error {
|
||||
"tool.failed"
|
||||
@@ -227,7 +329,8 @@ pub fn acp_event_to_sse(event: AcpSessionEvent) -> Option<SseEvent> {
|
||||
"toolCallId": tool_call_id,
|
||||
"status": status,
|
||||
"error": error,
|
||||
"output": content,
|
||||
"output": output,
|
||||
"citationMarkdowns": citation_markdowns,
|
||||
}),
|
||||
})
|
||||
}
|
||||
@@ -426,6 +529,48 @@ mod tests {
|
||||
assert_eq!(running.data["status"], "in_progress");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn acp_tool_completed_extracts_precise_ui_citations_from_prefixed_text() {
|
||||
let prefix = json!({
|
||||
"schema": "mnote.acp.tool_result_ui_citations.v1",
|
||||
"references": [{
|
||||
"citationMarkdown": "[来源定位降级:a.md](/documents/a)",
|
||||
"locatorDegraded": true
|
||||
}, {
|
||||
"citationMarkdown": "[b.md · p.2](/documents/b?page=2)",
|
||||
"locatorDegraded": false
|
||||
}],
|
||||
"uiCitations": [{
|
||||
"citationMarkdown": "[来源定位降级:a.md](/documents/a)"
|
||||
}]
|
||||
})
|
||||
.to_string();
|
||||
let completed = acp_event_to_sse(AcpSessionEvent::ToolCallUpdate {
|
||||
tool_call_id: "tool_2".into(),
|
||||
status: crate::acp_types::ToolCallStatus::Completed,
|
||||
content: Some(vec![crate::acp_types::ContentBlockWrapper {
|
||||
wrapper_type: "content".into(),
|
||||
content: crate::acp_types::TextContent {
|
||||
content_type: "text".into(),
|
||||
text: format!("{prefix}\n工具正文"),
|
||||
},
|
||||
}]),
|
||||
})
|
||||
.expect("tool complete");
|
||||
|
||||
assert_eq!(
|
||||
completed.data["citationMarkdowns"][0]["citationMarkdown"].as_str(),
|
||||
Some("[b.md · p.2](/documents/b?page=2)")
|
||||
);
|
||||
assert_eq!(
|
||||
completed.data["citationMarkdowns"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.len(),
|
||||
1
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn acp_session_info_update_emits_session_info_updated_sse() {
|
||||
let sse = acp_event_to_sse(AcpSessionEvent::SessionInfoUpdate {
|
||||
|
||||
@@ -112,7 +112,8 @@ fn compact_query_result_for_agent(payload: Value) -> Value {
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let citations = references
|
||||
let citation_references = citation_references_for_ui(&references);
|
||||
let citations = citation_references
|
||||
.iter()
|
||||
.filter_map(|reference| {
|
||||
reference
|
||||
@@ -122,16 +123,23 @@ fn compact_query_result_for_agent(payload: Value) -> Value {
|
||||
.map(ToOwned::to_owned)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let ui_citations = citations
|
||||
.iter()
|
||||
.map(|citation| json!({ "citationMarkdown": citation }))
|
||||
.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.",
|
||||
"answerGuidance": "Final answers should answer the user in plain text. Do not copy citationMarkdown, citationUrl, /documents, mnote://, or search-engine wrapped local links into the answer. MNote UI will render citations[] / references[].citationMarkdown after the answer as clickable source locators. Treat rawMetadata.keywords and entity/relation counts only as query analysis, not proof that a source contains those words. Use references[].quote/contentDiagnostics as evidence. If locatorDegraded is true, say the source location is degraded instead of inventing page or bbox. If contentDiagnostics.ocrTextExposed is false, say OCR text was not exposed in the returned reference instead of claiming OCR succeeded.",
|
||||
"references": references,
|
||||
"citations": citations,
|
||||
"uiCitations": ui_citations,
|
||||
"citationRendering": "MNote UI appends uiCitations/citations after the final answer; agent must not hand-write citation links.",
|
||||
"sourceScope": payload.get("sourceScope").cloned().unwrap_or(Value::Array(Vec::new())),
|
||||
"sourceScopeMode": payload.get("sourceScopeMode").cloned().unwrap_or_else(|| json!("post_filter_mapped_references")),
|
||||
"rawScopeFiltered": payload.get("rawScopeFiltered").cloned().unwrap_or(Value::Bool(false)),
|
||||
"rawMetadataMeaning": "provider metadata describes query processing/retrieval bookkeeping; it is not source text evidence unless the same text appears in references[].quote",
|
||||
"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),
|
||||
@@ -144,6 +152,10 @@ fn compact_reference_for_agent(reference: &Value) -> Value {
|
||||
.and_then(Value::as_str)
|
||||
.map(|value| value.chars().take(700).collect::<String>())
|
||||
.unwrap_or_default();
|
||||
let quote_diagnostics = reference
|
||||
.get("contentDiagnostics")
|
||||
.cloned()
|
||||
.unwrap_or_else(|| quote_diagnostics("e));
|
||||
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")),
|
||||
@@ -151,13 +163,55 @@ fn compact_reference_for_agent(reference: &Value) -> Value {
|
||||
"sourceRootRelativePath": reference.get("sourceRootRelativePath").cloned().unwrap_or(Value::Null),
|
||||
"chunkId": reference.get("chunkId").cloned().unwrap_or(Value::Null),
|
||||
"quote": quote,
|
||||
"quoteSource": reference.get("quoteSource").cloned().unwrap_or(Value::Null),
|
||||
"locator": reference.get("locator").cloned().unwrap_or(Value::Null),
|
||||
"locatorDegraded": reference.get("locatorDegraded").cloned().unwrap_or(Value::Bool(true)),
|
||||
"contentDiagnostics": quote_diagnostics,
|
||||
"citationMarkdown": reference.get("citationMarkdown").cloned().unwrap_or(Value::Null),
|
||||
"citationUrl": reference.get("citationUrl").cloned().unwrap_or(Value::Null),
|
||||
})
|
||||
}
|
||||
|
||||
fn citation_references_for_ui(references: &[Value]) -> Vec<&Value> {
|
||||
let has_precise = references.iter().any(|reference| {
|
||||
reference
|
||||
.get("citationMarkdown")
|
||||
.and_then(Value::as_str)
|
||||
.is_some_and(|value| !value.trim().is_empty())
|
||||
&& reference.get("locatorDegraded").and_then(Value::as_bool) != Some(true)
|
||||
});
|
||||
references
|
||||
.iter()
|
||||
.filter(|reference| {
|
||||
reference
|
||||
.get("citationMarkdown")
|
||||
.and_then(Value::as_str)
|
||||
.is_some_and(|value| !value.trim().is_empty())
|
||||
&& (!has_precise
|
||||
|| reference.get("locatorDegraded").and_then(Value::as_bool) != Some(true))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn quote_diagnostics(quote: &str) -> Value {
|
||||
let meaningful_lines = quote
|
||||
.lines()
|
||||
.map(str::trim)
|
||||
.filter(|line| !line.is_empty())
|
||||
.filter(|line| !line.starts_with('#'))
|
||||
.filter(|line| !is_markdown_image_line(line))
|
||||
.collect::<Vec<_>>();
|
||||
json!({
|
||||
"quoteEmpty": quote.trim().is_empty(),
|
||||
"quoteOnlyImagePlaceholder": !quote.trim().is_empty() && meaningful_lines.is_empty(),
|
||||
"ocrTextExposed": !meaningful_lines.is_empty(),
|
||||
})
|
||||
}
|
||||
|
||||
fn is_markdown_image_line(line: &str) -> bool {
|
||||
line.starts_with(" && line.ends_with(')')
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -180,6 +234,11 @@ mod tests {
|
||||
"quote": "scoped quote",
|
||||
"locatorDegraded": true,
|
||||
"citationMarkdown": "[来源定位降级:docs/a.md](/documents/local-md:docs~2Fa.md)"
|
||||
}, {
|
||||
"sourceRootRelativePath": "docs/b.md",
|
||||
"quote": "precise quote",
|
||||
"locatorDegraded": false,
|
||||
"citationMarkdown": "[docs/b.md · p.2](/documents/local-md:docs~2Fb.md?page=2)"
|
||||
}]
|
||||
});
|
||||
|
||||
@@ -191,9 +250,47 @@ mod tests {
|
||||
assert_eq!(compact["rawScopeFiltered"].as_bool(), Some(false));
|
||||
assert!(compact.get("raw").is_none());
|
||||
assert!(compact.get("chunks").is_none());
|
||||
assert_eq!(
|
||||
compact["rawMetadataMeaning"].as_str(),
|
||||
Some("provider metadata describes query processing/retrieval bookkeeping; it is not source text evidence unless the same text appears in references[].quote")
|
||||
);
|
||||
assert!(compact["answerGuidance"]
|
||||
.as_str()
|
||||
.unwrap_or_default()
|
||||
.contains("Do not copy citationMarkdown"));
|
||||
assert_eq!(
|
||||
compact["uiCitations"][0]["citationMarkdown"].as_str(),
|
||||
Some("[docs/b.md · p.2](/documents/local-md:docs~2Fb.md?page=2)")
|
||||
);
|
||||
assert_eq!(compact["uiCitations"].as_array().unwrap().len(), 1);
|
||||
assert_eq!(
|
||||
compact["references"][0]["sourceRootRelativePath"].as_str(),
|
||||
Some("docs/a.md")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compact_reference_marks_image_placeholder_as_not_ocr_text() {
|
||||
let payload = json!({
|
||||
"references": [{
|
||||
"sourceRootRelativePath": "docs/image.png",
|
||||
"quote": "# image.png\n\n",
|
||||
"quoteSource": "chunk",
|
||||
"locatorDegraded": true,
|
||||
"citationMarkdown": "[来源定位降级:image.png](/documents/local-md:docs~2FPage.md)"
|
||||
}]
|
||||
});
|
||||
|
||||
let compact = compact_query_result_for_agent(payload);
|
||||
let diagnostics = &compact["references"][0]["contentDiagnostics"];
|
||||
assert_eq!(
|
||||
diagnostics["quoteOnlyImagePlaceholder"].as_bool(),
|
||||
Some(true)
|
||||
);
|
||||
assert_eq!(diagnostics["ocrTextExposed"].as_bool(), Some(false));
|
||||
assert_eq!(
|
||||
compact["references"][0]["quoteSource"].as_str(),
|
||||
Some("chunk")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -441,7 +441,7 @@ fn knowledge_rag_query_tool() -> Value {
|
||||
}
|
||||
json!({
|
||||
"name": "mnote.knowledge_rag.query",
|
||||
"description": "向 LightRAG 资料库提问,返回 provider 原始结果和经 MNote registry 映射、sourcePaths 后过滤的引用。",
|
||||
"description": "向 LightRAG 资料库提问,返回 provider 原始结果和经 MNote registry 映射、sourcePaths 后过滤的 references/citations。用户要求链接、来源、引用或证据时优先调用;返回的 citationMarkdown 由 MNote 前端自动追加成可点击来源定位,agent 不应在最终回答中手写 /documents、mnote:// 或搜索引擎包装链接。",
|
||||
"schemaVersion": TOOL_SCHEMA_VERSION,
|
||||
"capabilityScope": ["knowledge_rag.read", "evidence.read"],
|
||||
"status": "available",
|
||||
|
||||
@@ -335,6 +335,26 @@ pub(crate) fn citation_url_for_locator(locator: &EvidenceLocator) -> String {
|
||||
if let Some(source_map_path) = locator.source_map_path.as_deref() {
|
||||
append_query_param(&mut url, "sourceMapPath", source_map_path);
|
||||
}
|
||||
if let Some(query) = locator
|
||||
.open_action
|
||||
.params
|
||||
.get("query")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
{
|
||||
append_query_param(&mut url, "evidenceText", query);
|
||||
}
|
||||
if let Some(search_query) = locator
|
||||
.open_action
|
||||
.params
|
||||
.get("searchQuery")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
{
|
||||
append_query_param(&mut url, "searchQuery", search_query);
|
||||
}
|
||||
if let Some(line_range) = &locator.line_range {
|
||||
append_query_param(
|
||||
&mut url,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -3685,10 +3685,11 @@ fn score_evidence_text_match(text: &str, query: &str) -> Option<EvidenceTextMatc
|
||||
.map(|index| (alternative.clone(), index))
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let partial_allowed = if terms.len() > 1 {
|
||||
!partial_matches.is_empty()
|
||||
let partial_allowed = !partial_matches.is_empty()
|
||||
&& if terms.len() > 1 {
|
||||
true
|
||||
} else {
|
||||
partial_matches.len() >= 2 || term.normalized.chars().count() <= 2
|
||||
partial_matches.len() >= 2
|
||||
};
|
||||
if partial_allowed {
|
||||
partial_count += 1;
|
||||
@@ -4198,6 +4199,12 @@ mod tests {
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_search_two_char_cjk_query_requires_real_match() {
|
||||
assert!(score_evidence_text_match("13. N -甲基吗啉 N -氧化物", "吗啉").is_some());
|
||||
assert!(score_evidence_text_match("Scope Alpha SOURCE SCOPE RAG", "吗啉").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_index_settings_restricts_search_and_evidence_scope() {
|
||||
let root = temp_root("mnote-local-index-settings-scope");
|
||||
|
||||
@@ -85,6 +85,7 @@ pub fn build_router(state: AppState) -> Router {
|
||||
.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/search", post(knowledge_rag::search))
|
||||
.route("/api/knowledge-rag/query", post(knowledge_rag::query_rag))
|
||||
.route(
|
||||
"/api/knowledge-rag/open-reference",
|
||||
|
||||
@@ -207,7 +207,9 @@ pub async fn documents(
|
||||
&effective_workspace_id,
|
||||
&root_path,
|
||||
)?;
|
||||
local_search_index::query_local_search_index_with_settings(
|
||||
let include_ocr = filters.include_ocr.unwrap_or(false);
|
||||
let limit = body.limit.unwrap_or(30);
|
||||
let local_result = local_search_index::query_local_search_index_with_settings(
|
||||
&root_path,
|
||||
root_uri,
|
||||
&effective_workspace_id,
|
||||
@@ -215,11 +217,12 @@ pub async fn documents(
|
||||
&user_settings,
|
||||
&normalized_query,
|
||||
page_id.as_deref(),
|
||||
body.limit.unwrap_or(30),
|
||||
limit,
|
||||
filters.title_only.unwrap_or(false),
|
||||
filters.exact.unwrap_or(false),
|
||||
filters.include_ocr.unwrap_or(false),
|
||||
)?
|
||||
include_ocr,
|
||||
)?;
|
||||
local_result
|
||||
} else {
|
||||
load_search_results_with_filters(
|
||||
state.config(),
|
||||
@@ -1019,6 +1022,18 @@ mod tests {
|
||||
payload["meta"]["boundary"]["evidenceSqliteFallback"].as_bool(),
|
||||
Some(false)
|
||||
);
|
||||
assert_eq!(
|
||||
payload["meta"]["boundary"]["kind"].as_str(),
|
||||
Some("ordinary_local_search")
|
||||
);
|
||||
assert_eq!(
|
||||
payload["meta"]["boundary"]["ocrSidecarFallback"].as_bool(),
|
||||
Some(false)
|
||||
);
|
||||
assert_eq!(
|
||||
payload["meta"]["boundary"]["knowledgeRag"].as_bool(),
|
||||
Some(false)
|
||||
);
|
||||
assert!(payload["evidence"].as_array().expect("evidence").is_empty());
|
||||
assert!(home["tags"]
|
||||
.as_array()
|
||||
|
||||
@@ -994,6 +994,10 @@ pub struct OfficePreviewQuery {
|
||||
source_map_path: Option<String>,
|
||||
#[serde(default, alias = "blockId")]
|
||||
block_id: Option<String>,
|
||||
#[serde(default, alias = "evidenceText")]
|
||||
evidence_text: Option<String>,
|
||||
#[serde(default, alias = "searchQuery")]
|
||||
search_query: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn office_preview_page(Query(query): Query<OfficePreviewQuery>) -> Response {
|
||||
@@ -1028,6 +1032,8 @@ pub async fn office_preview_page(Query(query): Query<OfficePreviewQuery>) -> Res
|
||||
let target_bbox = query.bbox.unwrap_or_default();
|
||||
let target_source_map_path = query.source_map_path.unwrap_or_default();
|
||||
let target_block_id = query.block_id.unwrap_or_default();
|
||||
let target_evidence_text = query.evidence_text.unwrap_or_default();
|
||||
let target_search_query = query.search_query.unwrap_or_default();
|
||||
let html = format!(
|
||||
r#"<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
@@ -1074,7 +1080,7 @@ pub async fn office_preview_page(Query(query): Query<OfficePreviewQuery>) -> Res
|
||||
}}
|
||||
</style>
|
||||
</head>
|
||||
<body data-file-url="{file_url}" data-file-name="{file_name}" data-file-type="{file_type}" data-page-width-content-type="{page_width_content_type}" data-workspace-id="{workspace_id}" data-mnote-source-kind="{source_kind}" data-mnote-root-uri="{root_uri}" data-document-id="{document_id}" data-evidence-page="{target_page}" data-evidence-bbox="{target_bbox}" data-evidence-source-map-path="{target_source_map_path}" data-evidence-block-id="{target_block_id}">
|
||||
<body data-file-url="{file_url}" data-file-name="{file_name}" data-file-type="{file_type}" data-page-width-content-type="{page_width_content_type}" data-workspace-id="{workspace_id}" data-mnote-source-kind="{source_kind}" data-mnote-root-uri="{root_uri}" data-document-id="{document_id}" data-evidence-page="{target_page}" data-evidence-bbox="{target_bbox}" data-evidence-source-map-path="{target_source_map_path}" data-evidence-block-id="{target_block_id}" data-evidence-text="{target_evidence_text}" data-evidence-search-query="{target_search_query}">
|
||||
<main class="mnote-office-preview">
|
||||
<section class="mnote-office-viewer" id="mnote-office-viewer"></section>
|
||||
</main>
|
||||
@@ -1093,6 +1099,8 @@ pub async fn office_preview_page(Query(query): Query<OfficePreviewQuery>) -> Res
|
||||
let evidenceBbox = body.dataset.evidenceBbox || '';
|
||||
let evidenceSourceMapPath = body.dataset.evidenceSourceMapPath || '';
|
||||
let evidenceBlockId = body.dataset.evidenceBlockId || '';
|
||||
let evidenceText = body.dataset.evidenceText || '';
|
||||
let evidenceSearchQuery = body.dataset.evidenceSearchQuery || '';
|
||||
let currentPptxBuffer = null;
|
||||
let pptxRenderToken = 0;
|
||||
let pptxResizeTimer = 0;
|
||||
@@ -1160,7 +1168,13 @@ pub async fn office_preview_page(Query(query): Query<OfficePreviewQuery>) -> Res
|
||||
}}
|
||||
|
||||
function normalizeEvidenceText(value) {{
|
||||
return String(value || '').replace(/\s+/g, ' ').trim();
|
||||
return String(value || '')
|
||||
.replace(/<[^>]+>/g, ' ')
|
||||
.replace(/ | /gi, ' ')
|
||||
.replace(/[#*_`~>\[\](){{}}]+/g, ' ')
|
||||
.replace(/[\u200B-\u200D\uFEFF]/g, '')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
}}
|
||||
|
||||
function markEvidenceTarget(target) {{
|
||||
@@ -1266,6 +1280,7 @@ pub async fn office_preview_page(Query(query): Query<OfficePreviewQuery>) -> Res
|
||||
if (!viewer) return false;
|
||||
const compactNeedle = normalizeEvidenceText(needle).replace(/\s+/g, '');
|
||||
if (!compactNeedle) return false;
|
||||
if (compactNeedle.length > 160) return false;
|
||||
const refs = [];
|
||||
let compactText = '';
|
||||
const walker = document.createTreeWalker(viewer, NodeFilter.SHOW_TEXT);
|
||||
@@ -1291,6 +1306,12 @@ pub async fn office_preview_page(Query(query): Query<OfficePreviewQuery>) -> Res
|
||||
range.setEnd(endRef.node, endRef.offset + 1);
|
||||
const rect = range.getBoundingClientRect();
|
||||
if (!rect || rect.width <= 0 || rect.height <= 0) return false;
|
||||
const rects = Array.from(range.getClientRects()).filter(item => item && item.width > 0 && item.height > 0);
|
||||
if (rects.length > 6 || rect.height > Math.min(140, window.innerHeight * 0.35)) return false;
|
||||
const paragraphTarget = startRef.node?.parentElement?.closest('p, li, td, th, blockquote');
|
||||
if (paragraphTarget instanceof HTMLElement && normalizeEvidenceText(paragraphTarget.textContent).length < 4000) {{
|
||||
return markEvidenceTarget(paragraphTarget);
|
||||
}}
|
||||
let marker = viewer.querySelector('[data-mnote-office-evidence-marker="true"]');
|
||||
if (!(marker instanceof HTMLElement)) {{
|
||||
marker = document.createElement('div');
|
||||
@@ -1361,13 +1382,17 @@ pub async fn office_preview_page(Query(query): Query<OfficePreviewQuery>) -> Res
|
||||
let node = walker.nextNode();
|
||||
while (node) {{
|
||||
if (normalizeEvidenceText(node.textContent).includes(needle)) {{
|
||||
const inlineTarget = wrapEvidenceTextNode(node, needle);
|
||||
if (inlineTarget) return markEvidenceTarget(inlineTarget);
|
||||
const target = node.parentElement && node.parentElement.closest('p, div, span, table, section') || node.parentElement;
|
||||
if (target instanceof HTMLElement) {{
|
||||
const rect = target.getBoundingClientRect();
|
||||
if (rect.height > window.innerHeight * 1.8 || normalizeEvidenceText(target.textContent).length > 4000) break;
|
||||
if (needle.length < 20) {{
|
||||
const paragraphTarget = node.parentElement && node.parentElement.closest('p, li, td, th, blockquote') || target;
|
||||
if (paragraphTarget instanceof HTMLElement) return markEvidenceTarget(paragraphTarget);
|
||||
}}
|
||||
}}
|
||||
const inlineTarget = wrapEvidenceTextNode(node, needle);
|
||||
if (inlineTarget) return markEvidenceTarget(inlineTarget);
|
||||
return markEvidenceTarget(target);
|
||||
}}
|
||||
node = walker.nextNode();
|
||||
@@ -1376,6 +1401,278 @@ pub async fn office_preview_page(Query(query): Query<OfficePreviewQuery>) -> Res
|
||||
return false;
|
||||
}}
|
||||
|
||||
function evidenceTextCandidates(text) {{
|
||||
const raw = String(text || '');
|
||||
const cleaned = raw
|
||||
.replace(/<[^>]+>/g, ' ')
|
||||
.replace(/[#*_`~>\[\](){{}}]+/g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
const specific = [];
|
||||
function pushSpecificEvidenceTerm(term) {{
|
||||
let value = normalizeEvidenceText(term);
|
||||
if (!value || value.length < 3) return;
|
||||
const anchor = value.search(/[一二三四五六七八九十甲乙丙丁戊己庚辛壬癸叔仲异正特苯][\u3400-\u9fffA-Za-z0-9()()\\-]{{0,18}}硅/);
|
||||
if (anchor > 0) value = value.slice(anchor);
|
||||
value = value.split(/[::。;;,,\n]/)[0];
|
||||
if (value.length < 3 || value.length > 48) return;
|
||||
specific.push(value);
|
||||
if (/[基酯醚]$/.test(value) && value.length > 3) specific.push(value.slice(0, -1));
|
||||
}}
|
||||
const withoutTags = raw.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim();
|
||||
const siliconTerms = cleaned.match(/[\u3400-\u9fffA-Za-z0-9()()\\-]{{0,24}}硅(?:基|酯|醚)?/g) || [];
|
||||
siliconTerms.forEach(pushSpecificEvidenceTerm);
|
||||
const cjkTerms = cleaned.match(/[\u3400-\u9fff][\u3400-\u9fffA-Za-z0-9()()\\-]{{2,48}}/g) || [];
|
||||
const fallbackTerms = [];
|
||||
cjkTerms.forEach(term => {{
|
||||
const value = normalizeEvidenceText(term);
|
||||
if (value.length < 3 || /^参考文献$/.test(value) || /^保护$/.test(value)) return;
|
||||
pushSpecificEvidenceTerm(value);
|
||||
const rawIndex = withoutTags.indexOf(value);
|
||||
if (rawIndex >= 0) {{
|
||||
const rawWindow = withoutTags.slice(rawIndex, rawIndex + value.length + 36).split(/[::。;;,,\n]/)[0];
|
||||
specific.push(normalizeEvidenceText(rawWindow));
|
||||
}}
|
||||
const cleanedIndex = cleaned.indexOf(value);
|
||||
if (cleanedIndex >= 0) {{
|
||||
const cleanedWindow = cleaned.slice(cleanedIndex, cleanedIndex + value.length + 36).split(/[::。;;,,\n]/)[0];
|
||||
specific.push(normalizeEvidenceText(cleanedWindow));
|
||||
}}
|
||||
if (value.endsWith('基') && value.length > 3) specific.push(value.slice(0, -1));
|
||||
fallbackTerms.push(value);
|
||||
}});
|
||||
const candidates = [];
|
||||
cleaned.split(/[。;;,,\n]/).forEach(part => {{
|
||||
const value = normalizeEvidenceText(part);
|
||||
if (value.length >= 8) candidates.push(value);
|
||||
if (value.length >= 28) candidates.push(value.slice(0, 28));
|
||||
}});
|
||||
candidates.push(...fallbackTerms);
|
||||
candidates.push(cleaned, raw);
|
||||
const seen = new Set();
|
||||
return specific.concat(candidates)
|
||||
.map(normalizeEvidenceText)
|
||||
.filter(value => value.length >= 3 && !seen.has(value) && seen.add(value))
|
||||
.sort((left, right) => right.length - left.length);
|
||||
}}
|
||||
|
||||
function compactEvidenceText(value) {{
|
||||
return normalizeEvidenceText(value).replace(/[\s\p{{P}}\p{{S}}]+/gu, '');
|
||||
}}
|
||||
|
||||
function compactEvidenceTextWithoutNumbers(value) {{
|
||||
return normalizeEvidenceText(value).replace(/[0-90-9]+/g, '').replace(/[\s\p{{P}}\p{{S}}]+/gu, '');
|
||||
}}
|
||||
|
||||
function evidenceLeadingAnchors(text) {{
|
||||
const raw = String(text || '');
|
||||
const cleaned = normalizeEvidenceText(raw);
|
||||
const anchors = [];
|
||||
const queryCompact = compactEvidenceText(evidenceSearchQuery);
|
||||
function push(value, options) {{
|
||||
const normalized = normalizeEvidenceText(value);
|
||||
const allowShort = options && options.allowShort === true;
|
||||
if (normalized.length < (allowShort ? 3 : 6)) return;
|
||||
anchors.push(normalized.length > 80 ? normalized.slice(0, 80) : normalized);
|
||||
if (normalized.length > 24) anchors.push(normalized.slice(0, 24));
|
||||
}}
|
||||
function pushQueryNearPrefix(value) {{
|
||||
const normalized = normalizeEvidenceText(value);
|
||||
if (!normalized || !queryCompact || !compactEvidenceText(normalized).includes(queryCompact)) return;
|
||||
push(normalized, {{ allowShort: true }});
|
||||
const queryIndex = compactEvidenceText(normalized).indexOf(queryCompact);
|
||||
if (queryIndex >= 0 && normalized.length > 24) push(normalized.slice(0, 36), {{ allowShort: true }});
|
||||
}}
|
||||
const prefixWindow = cleaned.slice(0, 260);
|
||||
const catalogMatches = prefixWindow.match(/[^,,。;;#]{{2,56}}[,,]\s*[0-90-9]{{1,5}}/g) || [];
|
||||
catalogMatches.slice(0, 8).forEach(match => {{
|
||||
const value = normalizeEvidenceText(match);
|
||||
push(value, {{ allowShort: true }});
|
||||
const withoutPage = value.replace(/[,,]\s*[0-90-9]{{1,5}}\s*$/, '');
|
||||
push(withoutPage, {{ allowShort: true }});
|
||||
pushQueryNearPrefix(withoutPage);
|
||||
}});
|
||||
const headingMatches = prefixWindow.match(/[0-90-9]+(?:\.[0-90-9]+){{1,5}}\s+[^。;;]{{2,72}}/g) || [];
|
||||
headingMatches.slice(0, 4).forEach(match => {{
|
||||
const firstPart = normalizeEvidenceText(match).split(/[,,]/)[0];
|
||||
push(firstPart, {{ allowShort: true }});
|
||||
}});
|
||||
raw.split(/[\n。;;]/).slice(0, 4).forEach(part => {{
|
||||
const value = normalizeEvidenceText(part);
|
||||
if (value) push(value);
|
||||
}});
|
||||
cleaned.split(/[。;;]/).slice(0, 4).forEach(part => {{
|
||||
const value = normalizeEvidenceText(part);
|
||||
if (value) push(value);
|
||||
}});
|
||||
evidenceTextCandidates(text).forEach(candidate => {{
|
||||
if (!queryCompact || compactEvidenceText(candidate).includes(queryCompact)) push(candidate);
|
||||
}});
|
||||
const seen = new Set();
|
||||
return anchors
|
||||
.map(normalizeEvidenceText)
|
||||
.filter(value => {{
|
||||
if (!value || seen.has(value)) return false;
|
||||
const compactValue = compactEvidenceText(value);
|
||||
const shortQueryAnchor = queryCompact.length >= 2
|
||||
&& compactValue.includes(queryCompact)
|
||||
&& compactValue.length >= queryCompact.length + 1
|
||||
&& /[0-90-9A-Za-z]/.test(value);
|
||||
if (value.length < 6 && !shortQueryAnchor) return false;
|
||||
seen.add(value);
|
||||
return true;
|
||||
}});
|
||||
}}
|
||||
|
||||
function shortLeadingAnchorTarget(element, elements, index) {{
|
||||
const normalized = normalizeEvidenceText(element && element.textContent || '');
|
||||
if (normalized.length >= 18) return element;
|
||||
for (let offset = 1; offset <= 3; offset += 1) {{
|
||||
const next = elements[index + offset];
|
||||
if (!(next instanceof HTMLElement)) continue;
|
||||
const nextText = normalizeEvidenceText(next.textContent || '');
|
||||
if (nextText.length >= 18 && evidenceElementMatchesSearchQuery(next)) return next;
|
||||
}}
|
||||
return element;
|
||||
}}
|
||||
|
||||
function scrollToEvidenceLeadingAnchor(text) {{
|
||||
if (!viewer) return false;
|
||||
const anchors = evidenceLeadingAnchors(text);
|
||||
if (!anchors.length) return false;
|
||||
const queryCompact = compactEvidenceText(evidenceSearchQuery);
|
||||
const elements = Array.from(viewer.querySelectorAll('p, li, td, th, blockquote'))
|
||||
.filter(node => node instanceof HTMLElement)
|
||||
.filter(node => {{
|
||||
const normalized = normalizeEvidenceText(node.textContent || '');
|
||||
return normalized.length >= 3 && normalized.length <= 1200;
|
||||
}});
|
||||
for (const anchor of anchors) {{
|
||||
const compactAnchor = compactEvidenceText(anchor);
|
||||
const compactAnchorWithoutNumbers = compactEvidenceTextWithoutNumbers(anchor);
|
||||
const shortQueryAnchor = queryCompact.length >= 2
|
||||
&& compactAnchor.includes(queryCompact)
|
||||
&& compactAnchor.length >= queryCompact.length + 1
|
||||
&& /[0-90-9A-Za-z]/.test(anchor);
|
||||
if (!shortQueryAnchor && compactAnchor.length < 6 && compactAnchorWithoutNumbers.length < 6) continue;
|
||||
for (const element of elements) {{
|
||||
const compactElement = compactEvidenceText(element.textContent || '');
|
||||
if ((compactAnchor.length >= 6 || shortQueryAnchor) && compactElement.includes(compactAnchor)) {{
|
||||
return markEvidenceTarget(shortQueryAnchor ? shortLeadingAnchorTarget(element, elements, elements.indexOf(element)) : element);
|
||||
}}
|
||||
if (
|
||||
compactAnchorWithoutNumbers.length >= 8
|
||||
&& /[\u3400-\u9fff]/.test(anchor)
|
||||
&& compactEvidenceTextWithoutNumbers(element.textContent || '').includes(compactAnchorWithoutNumbers)
|
||||
) {{
|
||||
return markEvidenceTarget(element);
|
||||
}}
|
||||
}}
|
||||
}}
|
||||
return false;
|
||||
}}
|
||||
|
||||
function evidenceParagraphAnchors(text) {{
|
||||
const cleaned = normalizeEvidenceText(text);
|
||||
const anchors = [];
|
||||
function push(value) {{
|
||||
const normalized = normalizeEvidenceText(value);
|
||||
if (normalized.length < 6) return;
|
||||
anchors.push(normalized.length > 140 ? normalized.slice(0, 140) : normalized);
|
||||
}}
|
||||
evidenceTextCandidates(text).forEach(push);
|
||||
cleaned.split(/[。;;,,\n]/).forEach(part => {{
|
||||
const value = normalizeEvidenceText(part);
|
||||
if (value.length >= 10) push(value);
|
||||
if (value.length >= 36) push(value.slice(0, 36));
|
||||
}});
|
||||
if (cleaned.length >= 24) {{
|
||||
for (let index = 0; index < cleaned.length; index += 48) {{
|
||||
push(cleaned.slice(index, index + 96));
|
||||
}}
|
||||
}}
|
||||
push(cleaned);
|
||||
const seen = new Set();
|
||||
return anchors
|
||||
.map(normalizeEvidenceText)
|
||||
.filter(value => value.length >= 6 && !seen.has(value) && seen.add(value))
|
||||
.sort((left, right) => right.length - left.length);
|
||||
}}
|
||||
|
||||
function scoreEvidenceParagraphElement(element, anchors) {{
|
||||
if (!(element instanceof HTMLElement)) return 0;
|
||||
const text = normalizeEvidenceText(element.textContent || '');
|
||||
if (!text || text.length < 3 || text.length > 6000) return 0;
|
||||
const compactText = compactEvidenceText(text);
|
||||
const compactSearchQuery = compactEvidenceText(evidenceSearchQuery);
|
||||
let score = 0;
|
||||
if (compactSearchQuery.length >= 2 && compactText.includes(compactSearchQuery)) {{
|
||||
score += 2400;
|
||||
}}
|
||||
for (const anchor of anchors) {{
|
||||
const compactAnchor = compactEvidenceText(anchor);
|
||||
if (!compactAnchor || compactAnchor.length < 4) continue;
|
||||
if (text.includes(anchor)) {{
|
||||
score += anchor.length * anchor.length * 4;
|
||||
continue;
|
||||
}}
|
||||
if (compactText.includes(compactAnchor)) {{
|
||||
score += compactAnchor.length * compactAnchor.length * 2;
|
||||
continue;
|
||||
}}
|
||||
if (compactAnchor.length >= 14) {{
|
||||
const prefix = compactAnchor.slice(0, Math.min(36, compactAnchor.length));
|
||||
if (prefix.length >= 8 && compactText.includes(prefix)) score += prefix.length * 20;
|
||||
}}
|
||||
}}
|
||||
return score;
|
||||
}}
|
||||
|
||||
function evidenceElementMatchesSearchQuery(element) {{
|
||||
if (!(element instanceof HTMLElement)) return false;
|
||||
const compactSearchQuery = compactEvidenceText(evidenceSearchQuery);
|
||||
if (compactSearchQuery.length < 2) return false;
|
||||
return compactEvidenceText(element.textContent || '').includes(compactSearchQuery);
|
||||
}}
|
||||
|
||||
function scrollToEvidenceParagraph(text) {{
|
||||
if (!viewer) return false;
|
||||
if (scrollToEvidenceLeadingAnchor(text)) return true;
|
||||
const anchors = evidenceParagraphAnchors(text);
|
||||
if (!anchors.length) return false;
|
||||
const selector = 'p, li, td, th, blockquote, section.docx, section.mnote-docx, div';
|
||||
const elements = Array.from(viewer.querySelectorAll(selector))
|
||||
.filter(node => node instanceof HTMLElement)
|
||||
.filter(node => {{
|
||||
const normalized = normalizeEvidenceText(node.textContent || '');
|
||||
if (normalized.length < 3 || normalized.length > 6000) return false;
|
||||
const childBlocks = Array.from(node.children || []).filter(child => child instanceof HTMLElement && /^(P|LI|TD|TH|BLOCKQUOTE)$/.test(child.tagName));
|
||||
return childBlocks.length === 0 || /^(TD|TH|SECTION)$/.test(node.tagName);
|
||||
}});
|
||||
let best = null;
|
||||
let bestWithSearchQuery = null;
|
||||
for (const element of elements) {{
|
||||
const score = scoreEvidenceParagraphElement(element, anchors);
|
||||
if (score <= 0) continue;
|
||||
if (!best || score > best.score) best = {{ element, score }};
|
||||
if (evidenceElementMatchesSearchQuery(element) && (!bestWithSearchQuery || score > bestWithSearchQuery.score)) {{
|
||||
bestWithSearchQuery = {{ element, score }};
|
||||
}}
|
||||
}}
|
||||
const threshold = Math.max(180, Math.min(800, anchors[0].length * 4));
|
||||
if (bestWithSearchQuery && bestWithSearchQuery.score >= threshold) return markEvidenceTarget(bestWithSearchQuery.element);
|
||||
if (!best) return false;
|
||||
if (best.score < threshold) return false;
|
||||
return markEvidenceTarget(best.element);
|
||||
}}
|
||||
|
||||
function scrollToEvidenceTextCandidates(text) {{
|
||||
for (const candidate of evidenceTextCandidates(text)) {{
|
||||
if (scrollToEvidenceText(candidate)) return true;
|
||||
}}
|
||||
return false;
|
||||
}}
|
||||
|
||||
function scrollToEvidenceCoordinate(sourceMap, block) {{
|
||||
if (!viewer || !sourceMap || !block) return false;
|
||||
const page = pageForEvidenceBlock(sourceMap, block);
|
||||
@@ -1422,13 +1719,18 @@ pub async fn office_preview_page(Query(query): Query<OfficePreviewQuery>) -> Res
|
||||
}}
|
||||
|
||||
async function applyEvidenceLocator() {{
|
||||
if (!viewer || (!evidencePage && !evidenceBlockId && !evidenceBbox)) return;
|
||||
if (!viewer || (!evidencePage && !evidenceBlockId && !evidenceBbox && !evidenceText)) return;
|
||||
try {{
|
||||
const sourceMap = await fetchEvidenceSourceMap();
|
||||
const block = findEvidenceBlockInSourceMap(sourceMap);
|
||||
if (block && scrollToEvidenceText(block.text)) return;
|
||||
if (evidenceText && scrollToEvidenceParagraph(evidenceText)) return;
|
||||
if (block && scrollToEvidenceParagraph(block.text)) return;
|
||||
if (evidenceText && scrollToEvidenceTextCandidates(evidenceText)) return;
|
||||
if (block && scrollToEvidenceTextCandidates(block.text)) return;
|
||||
if (scrollToEvidenceCoordinate(sourceMap, block)) return;
|
||||
}} catch (_) {{}}
|
||||
if (evidenceText && scrollToEvidenceParagraph(evidenceText)) return;
|
||||
if (evidenceText && scrollToEvidenceTextCandidates(evidenceText)) return;
|
||||
scrollToEvidencePageFallback();
|
||||
}}
|
||||
|
||||
@@ -1438,10 +1740,14 @@ pub async fn office_preview_page(Query(query): Query<OfficePreviewQuery>) -> Res
|
||||
evidenceBbox = String(next.bbox || '');
|
||||
evidenceSourceMapPath = String(next.sourceMapPath || '');
|
||||
evidenceBlockId = String(next.blockId || '');
|
||||
evidenceText = String(next.evidenceText || next.query || '');
|
||||
evidenceSearchQuery = String(next.searchQuery || '');
|
||||
body.dataset.evidencePage = evidencePage ? String(evidencePage) : '';
|
||||
body.dataset.evidenceBbox = evidenceBbox;
|
||||
body.dataset.evidenceSourceMapPath = evidenceSourceMapPath;
|
||||
body.dataset.evidenceBlockId = evidenceBlockId;
|
||||
body.dataset.evidenceText = evidenceText;
|
||||
body.dataset.evidenceSearchQuery = evidenceSearchQuery;
|
||||
document.documentElement.removeAttribute('data-mnote-office-evidence-applied');
|
||||
void applyEvidenceLocator();
|
||||
}}
|
||||
@@ -1663,6 +1969,7 @@ pub async fn office_preview_page(Query(query): Query<OfficePreviewQuery>) -> Res
|
||||
target_bbox = escape_html(&target_bbox),
|
||||
target_source_map_path = escape_html(&target_source_map_path),
|
||||
target_block_id = escape_html(&target_block_id),
|
||||
target_evidence_text = escape_html(&target_evidence_text),
|
||||
);
|
||||
let mut response = Html(html).into_response();
|
||||
stamp_shell_headers(response.headers_mut(), "office-preview");
|
||||
|
||||
@@ -759,9 +759,14 @@ mod tests {
|
||||
assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("pageAiNormalizeAcpRuntimes(acpRuntimes)"));
|
||||
assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("pageAiAcpRuntimeSelect.value || 'reasonix'"));
|
||||
assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("function handlePageAiClick"));
|
||||
assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("function handlePageAiPointerDown"));
|
||||
assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("mnote.page_ai.drawer_width"));
|
||||
assert!(SIDEBAR_PAGE_AI_RENDER_RUNTIME_JS.contains("data-page-ai-resize-handle"));
|
||||
assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("function pageAiOpenCitationUrl"));
|
||||
assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("a[data-page-ai-citation-link=\"true\"]"));
|
||||
assert!(SIDEBAR_PAGE_AI_MARKDOWN_RUNTIME_JS.contains("data-page-ai-citation-link=\"true\""));
|
||||
assert!(SIDEBAR_PAGE_AI_MARKDOWN_RUNTIME_JS.contains("renderPageAiMarkdownTable"));
|
||||
assert!(SIDEBAR_PAGE_AI_MARKDOWN_RUNTIME_JS.contains("wolai-page-ai-markdown-table-wrap"));
|
||||
assert!(SIDEBAR_PAGE_AI_MARKDOWN_RUNTIME_JS.contains("target=\"_blank\""));
|
||||
assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("function handlePageAiChange"));
|
||||
assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("function installPageAiDelegates"));
|
||||
|
||||
@@ -1243,6 +1243,14 @@ html[data-mnote-sidebar-resizing="true"] .mnote-sidebar-resizer::before {
|
||||
.wolai-search-results{overflow:auto;padding:6px}
|
||||
.wolai-search-result-row{width:100%;min-height:54px;display:flex;align-items:flex-start;gap:10px;padding:9px 10px;border:0;border-radius:4px;background:transparent;color:#37352F;cursor:pointer;text-align:left;font:inherit}
|
||||
.wolai-search-result-row:hover{background:rgba(55,53,47,.08)}
|
||||
.wolai-search-source-group{border-radius:4px}
|
||||
.wolai-search-source-group + .wolai-search-source-group{margin-top:4px}
|
||||
.wolai-search-source-header{width:100%;min-height:46px;display:flex;align-items:flex-start;gap:10px;padding:8px 10px;border:0;border-radius:4px;background:transparent;color:#37352F;cursor:pointer;text-align:left;font:inherit}
|
||||
.wolai-search-source-header:hover{background:rgba(55,53,47,.08)}
|
||||
.wolai-search-source-main{min-width:0;display:flex;flex:1 1 auto;flex-direction:column;gap:3px}
|
||||
.wolai-search-source-results{padding-left:18px}
|
||||
.wolai-search-source-results[hidden]{display:none!important}
|
||||
.wolai-search-source-chevron{flex:0 0 auto;margin-top:2px;color:#8B8780;font-size:11px;line-height:1.35}
|
||||
.wolai-search-result-icon{width:18px;height:18px;margin-top:2px;color:#8B8780}
|
||||
.wolai-search-result-main{min-width:0;display:flex;flex-direction:column;gap:3px}
|
||||
.wolai-search-result-title{color:#2F2D29;font-size:14px;line-height:1.35;word-break:break-word}
|
||||
@@ -4008,6 +4016,34 @@ body {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.mnote-knowledge-rag-source-progress {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.mnote-knowledge-rag-source-progress div {
|
||||
height: 5px;
|
||||
overflow: hidden;
|
||||
border-radius: 999px;
|
||||
background: #ECE9E3;
|
||||
}
|
||||
|
||||
.mnote-knowledge-rag-source-progress i {
|
||||
display: block;
|
||||
height: 100%;
|
||||
border-radius: inherit;
|
||||
background: #2F7D4A;
|
||||
}
|
||||
|
||||
.mnote-knowledge-rag-source-progress span {
|
||||
color: #5F5A54;
|
||||
font: 10px/14px "JetBrains Mono", "SFMono-Regular", Consolas, monospace;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.mnote-knowledge-rag-source-actions {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
@@ -4559,6 +4595,7 @@ body {
|
||||
right: 12px;
|
||||
bottom: 12px;
|
||||
z-index: 89;
|
||||
--mnote-page-ai-width: 440px;
|
||||
}
|
||||
|
||||
.wolai-page-ai-drawer[hidden] {
|
||||
@@ -4566,7 +4603,7 @@ body {
|
||||
}
|
||||
|
||||
.wolai-page-ai-panel {
|
||||
width: min(440px, calc(100vw - 24px));
|
||||
width: min(var(--mnote-page-ai-width), calc(100vw - 24px));
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -4578,6 +4615,37 @@ body {
|
||||
box-shadow: -18px 0 42px rgba(27, 28, 28, 0.14);
|
||||
}
|
||||
|
||||
.wolai-page-ai-resize-handle {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
bottom: 10px;
|
||||
left: -6px;
|
||||
width: 12px;
|
||||
cursor: col-resize;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.wolai-page-ai-resize-handle::before {
|
||||
position: absolute;
|
||||
top: 14px;
|
||||
bottom: 14px;
|
||||
left: 5px;
|
||||
width: 2px;
|
||||
border-radius: 999px;
|
||||
background: transparent;
|
||||
content: "";
|
||||
}
|
||||
|
||||
.wolai-page-ai-resize-handle:hover::before,
|
||||
.wolai-page-ai-drawer[data-page-ai-resizing="true"] .wolai-page-ai-resize-handle::before {
|
||||
background: rgba(27, 28, 28, 0.18);
|
||||
}
|
||||
|
||||
html[data-mnote-page-ai-resizing="true"] {
|
||||
cursor: col-resize;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.wolai-page-ai-header-copy {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -5191,6 +5259,96 @@ body {
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.wolai-page-ai-message-text > * {
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.wolai-page-ai-message-text p,
|
||||
.wolai-page-ai-message-text ul,
|
||||
.wolai-page-ai-message-text ol,
|
||||
.wolai-page-ai-message-text pre,
|
||||
.wolai-page-ai-message-text table {
|
||||
margin: 6px 0;
|
||||
}
|
||||
|
||||
.wolai-page-ai-message-text h1,
|
||||
.wolai-page-ai-message-text h2,
|
||||
.wolai-page-ai-message-text h3,
|
||||
.wolai-page-ai-message-text h4,
|
||||
.wolai-page-ai-message-text h5,
|
||||
.wolai-page-ai-message-text h6 {
|
||||
margin: 8px 0 4px;
|
||||
color: #1B1C1C;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.wolai-page-ai-message-text ul,
|
||||
.wolai-page-ai-message-text ol {
|
||||
padding-left: 18px;
|
||||
}
|
||||
|
||||
.wolai-page-ai-message-text a {
|
||||
color: #2563EB;
|
||||
overflow-wrap: anywhere;
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
}
|
||||
|
||||
.wolai-page-ai-message-text pre {
|
||||
overflow: auto;
|
||||
padding: 8px 10px;
|
||||
border-radius: 6px;
|
||||
background: #F7F6F4;
|
||||
font: 12px/18px "JetBrains Mono", "SFMono-Regular", Consolas, monospace;
|
||||
}
|
||||
|
||||
.wolai-page-ai-message-text code {
|
||||
border-radius: 4px;
|
||||
padding: 1px 3px;
|
||||
background: #F1F0EE;
|
||||
font: 12px/18px "JetBrains Mono", "SFMono-Regular", Consolas, monospace;
|
||||
}
|
||||
|
||||
.wolai-page-ai-message-text pre code {
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.wolai-page-ai-message-text hr {
|
||||
height: 1px;
|
||||
margin: 8px 0;
|
||||
border: 0;
|
||||
background: rgba(27, 28, 28, 0.12);
|
||||
}
|
||||
|
||||
.wolai-page-ai-markdown-table-wrap {
|
||||
max-width: 100%;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.wolai-page-ai-markdown-table-wrap table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
table-layout: auto;
|
||||
background: #FFFFFF;
|
||||
}
|
||||
|
||||
.wolai-page-ai-markdown-table-wrap th,
|
||||
.wolai-page-ai-markdown-table-wrap td {
|
||||
min-width: 72px;
|
||||
border: 1px solid rgba(27, 28, 28, 0.12);
|
||||
padding: 5px 7px;
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.wolai-page-ai-markdown-table-wrap th {
|
||||
background: #F7F6F4;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
button.wolai-page-ai-message-text {
|
||||
width: 100%;
|
||||
border: 0;
|
||||
@@ -5636,6 +5794,10 @@ button.wolai-page-ai-message-text {
|
||||
width: min(100vw - 24px, 420px);
|
||||
}
|
||||
|
||||
.wolai-page-ai-resize-handle {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.wolai-page-ai-settings-grid,
|
||||
.wolai-page-ai-settings-head {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
@@ -5710,6 +5872,10 @@ button.wolai-page-ai-message-text {
|
||||
.wolai-page-ai-panel {
|
||||
width: calc(100vw - 24px);
|
||||
}
|
||||
|
||||
.wolai-page-ai-resize-handle {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
"##;
|
||||
|
||||
@@ -37,6 +37,7 @@ function debugLog(message) {
|
||||
}
|
||||
|
||||
const toolContextStorage = new AsyncLocalStorage();
|
||||
const MNOTE_UI_CITATION_QUEUE = [];
|
||||
|
||||
const MNOTE_TOOL_NAMES = [
|
||||
'mnote.skill.read',
|
||||
@@ -408,6 +409,8 @@ let nextId = 1;
|
||||
const pendingReqs = new Map(); // id → { resolve, reject }
|
||||
const requestHandlers = new Map(); // method → handler
|
||||
const notificationHandlers = new Map(); // method → handler
|
||||
let rpcServerReady = false;
|
||||
const queuedRpcLines = [];
|
||||
|
||||
function sendMessage(msg) {
|
||||
const line = JSON.stringify(msg) + '\n';
|
||||
@@ -434,9 +437,7 @@ function onNotification(method, handler) {
|
||||
notificationHandlers.set(method, handler);
|
||||
}
|
||||
|
||||
// Start reading NDJSON from stdin
|
||||
const rl = createInterface({ input: stdin, terminal: false });
|
||||
rl.on('line', async (raw) => {
|
||||
async function handleRpcLine(raw) {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) return;
|
||||
|
||||
@@ -480,6 +481,16 @@ rl.on('line', async (raw) => {
|
||||
const handler = notificationHandlers.get(msg.method);
|
||||
if (handler) handler(msg.params);
|
||||
}
|
||||
}
|
||||
|
||||
// 先接住 stdio 输入,但等全部 handler 注册完成后再处理,避免 initialize 抢跑。
|
||||
const rl = createInterface({ input: stdin, terminal: false });
|
||||
rl.on('line', (raw) => {
|
||||
if (!rpcServerReady) {
|
||||
queuedRpcLines.push(raw);
|
||||
return;
|
||||
}
|
||||
void handleRpcLine(raw);
|
||||
});
|
||||
|
||||
// ── Helper: send ACP session/update (camelCase per protocol spec) ──
|
||||
@@ -566,7 +577,129 @@ async function callMnoteTool(toolName, args) {
|
||||
const text = await response.text().catch(() => '');
|
||||
throw new Error(`mnote tool ${toolName} failed: HTTP ${response.status} ${text}`);
|
||||
}
|
||||
return response.json();
|
||||
const result = compactMnoteToolResultForReasonix(toolName, await response.json());
|
||||
const citations = collectUiCitationMarkdowns(result).slice(0, 8);
|
||||
if (citations.length) MNOTE_UI_CITATION_QUEUE.push(citations);
|
||||
return result;
|
||||
}
|
||||
|
||||
function compactMnoteToolResultForReasonix(toolName, payload) {
|
||||
if (toolName !== 'mnote.knowledge_rag.query') return payload;
|
||||
const result = payload?.result && typeof payload.result === 'object' ? payload.result : payload;
|
||||
const citationMarkdowns = collectUiCitationMarkdowns(result).slice(0, 8);
|
||||
const citations = citationMarkdowns
|
||||
.map((citationMarkdown) => ({ citationMarkdown }))
|
||||
.slice(0, 8);
|
||||
if (!citations.length || !result || typeof result !== 'object') return payload;
|
||||
return {
|
||||
ok: payload?.ok !== false,
|
||||
schema: result.schema || 'mnote.knowledge_rag.agent_query_result.v1',
|
||||
uiCitations: citations,
|
||||
citationRendering: 'MNote UI renders uiCitations after the answer as clickable source locators. Do not copy citationMarkdown into the final answer and do not hand-write /documents links.',
|
||||
answerCitationPolicy: 'Answer the substance in plain text. Mention source titles only if useful; leave clickable citation insertion to MNote UI.',
|
||||
answerGuidance: result.answerGuidance || '',
|
||||
references: Array.isArray(result.references) ? result.references.slice(0, 8) : [],
|
||||
citations: citationMarkdowns,
|
||||
sourceScope: result.sourceScope || [],
|
||||
sourceScopeMode: result.sourceScopeMode || '',
|
||||
rawScopeFiltered: Boolean(result.rawScopeFiltered),
|
||||
};
|
||||
}
|
||||
|
||||
function collectUiCitationMarkdowns(value) {
|
||||
const references = Array.isArray(value?.references) ? value.references : [];
|
||||
const referenceCitations = [];
|
||||
if (references.length) {
|
||||
const hasPrecise = references.some((reference) =>
|
||||
typeof reference?.citationMarkdown === 'string' &&
|
||||
reference.citationMarkdown.trim() &&
|
||||
reference.locatorDegraded !== true
|
||||
);
|
||||
const seen = new Set();
|
||||
for (const reference of references) {
|
||||
const citation = String(reference?.citationMarkdown || '').trim();
|
||||
if (!citation || seen.has(citation)) continue;
|
||||
if (hasPrecise && reference?.locatorDegraded === true) continue;
|
||||
seen.add(citation);
|
||||
referenceCitations.push(citation);
|
||||
}
|
||||
if (referenceCitations.length) return referenceCitations;
|
||||
}
|
||||
const citations = collectCitationMarkdowns(value);
|
||||
const hasPrecise = citations.some((item) => !isDegradedCitationMarkdown(item));
|
||||
return citations.filter((citation) => {
|
||||
return !hasPrecise || !isDegradedCitationMarkdown(citation);
|
||||
});
|
||||
}
|
||||
|
||||
function isDegradedCitationMarkdown(value) {
|
||||
const text = String(value || '').toLowerCase();
|
||||
return text.includes('来源定位降级') || text.includes('locator degraded');
|
||||
}
|
||||
|
||||
function collectCitationMarkdowns(value) {
|
||||
const out = [];
|
||||
const seen = new Set();
|
||||
function add(text) {
|
||||
const value = String(text || '').trim();
|
||||
if (!value || seen.has(value)) return;
|
||||
seen.add(value);
|
||||
out.push(value);
|
||||
}
|
||||
function visit(node) {
|
||||
if (!node) return;
|
||||
if (typeof node === 'string') {
|
||||
const trimmed = node.trim();
|
||||
if (trimmed.startsWith('{') || trimmed.startsWith('[')) {
|
||||
try {
|
||||
visit(JSON.parse(trimmed));
|
||||
} catch {}
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (Array.isArray(node)) {
|
||||
node.forEach(visit);
|
||||
return;
|
||||
}
|
||||
if (typeof node !== 'object') return;
|
||||
if (typeof node.citationMarkdown === 'string') add(node.citationMarkdown);
|
||||
Object.values(node).forEach(visit);
|
||||
}
|
||||
visit(value);
|
||||
return out;
|
||||
}
|
||||
|
||||
function toolResultTextWithUiCitations(rawText) {
|
||||
const text = String(rawText || '');
|
||||
let citationMarkdowns = collectUiCitationMarkdownsFromText(text).slice(0, 8);
|
||||
if (!citationMarkdowns.length && MNOTE_UI_CITATION_QUEUE.length) {
|
||||
citationMarkdowns = MNOTE_UI_CITATION_QUEUE.shift();
|
||||
}
|
||||
const citations = citationMarkdowns.map((citationMarkdown) => ({ citationMarkdown })).slice(0, 8);
|
||||
if (!citations.length) return text.slice(0, 8000);
|
||||
const prefix = JSON.stringify({
|
||||
schema: 'mnote.acp.tool_result_ui_citations.v1',
|
||||
uiCitations: citations,
|
||||
citationRendering: 'MNote UI renders these citations after the answer; the model must not hand-write local citation links.',
|
||||
});
|
||||
const budget = Math.max(0, 8000 - prefix.length - 2);
|
||||
return `${prefix}\n${text.slice(0, budget)}`;
|
||||
}
|
||||
|
||||
function collectUiCitationMarkdownsFromText(text) {
|
||||
const trimmed = String(text || '').trim();
|
||||
if (trimmed.startsWith('{') || trimmed.startsWith('[')) {
|
||||
try {
|
||||
return collectUiCitationMarkdowns(JSON.parse(trimmed));
|
||||
} catch {}
|
||||
const firstLine = trimmed.split('\n')[0]?.trim();
|
||||
if (firstLine && firstLine !== trimmed && (firstLine.startsWith('{') || firstLine.startsWith('['))) {
|
||||
try {
|
||||
return collectUiCitationMarkdowns(JSON.parse(firstLine));
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
return collectCitationMarkdowns(trimmed);
|
||||
}
|
||||
|
||||
// ── Register Tools ───────────────────────────────────
|
||||
@@ -637,7 +770,7 @@ function fallbackMnoteToolSpecs() {
|
||||
{
|
||||
mnoteToolName: 'mnote.knowledge_rag.query',
|
||||
name: 'mnote_knowledge_rag_query',
|
||||
description: '向 LightRAG 资料库提问,返回 provider 原始结果和经 MNote registry 映射、sourcePaths 后过滤的 references。回答必须引用返回来源,不要引用 raw chunks。',
|
||||
description: '向 LightRAG 资料库提问,返回 provider 原始结果和经 MNote registry 映射、sourcePaths 后过滤的 references/citations。用户要求链接、来源、引用或证据时优先调用。不要在最终回答中手写 citationMarkdown、/documents、mnote:// 或搜索引擎包装链接;MNote 前端会把 uiCitations/citationMarkdown 自动追加成可点击来源。不要引用 raw chunks。',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
@@ -775,7 +908,7 @@ onRequest('session/new', async (params) => {
|
||||
'<available-skills>',
|
||||
'Use mnote_skill_read to load the full content of any skill listed by the current prompt capabilities.',
|
||||
'- mnote-current-page — Read the current MNote Markdown page when the task needs page content.',
|
||||
'- mnote-knowledge-rag — Ask the LightRAG-backed knowledge library across books, papers, PDFs, Office files, images, and attachments. Use mnote_knowledge_rag_query for knowledge-library questions and cite only returned references; sourcePaths filters returned references after provider retrieval, so do not cite raw chunks. If locatorDegraded is true, say the source location is degraded rather than inventing page/bbox.',
|
||||
'- mnote-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 answers that require links/sources/citations. Returned uiCitations/citationMarkdown values are MNote clickable source locators and are rendered by the MNote UI after the final answer. Do not copy citationMarkdown into the answer, do not hand-write /documents or mnote:// links, and never wrap local citation URLs with search engines. Mention source titles in plain text only when useful; sourcePaths filters returned references after provider retrieval, so do not cite raw chunks. If locatorDegraded is true, say the source location is degraded rather than inventing page/bbox.',
|
||||
'- mnote-local-file — Resolve MNote targets and then use native file tools inside allowed roots.',
|
||||
'- mnote-chat-only — Reply conversationally without MNote file/page tools.',
|
||||
'</available-skills>',
|
||||
@@ -841,6 +974,7 @@ onRequest('session/prompt', async (params) => {
|
||||
const announcedToolKeys = new Set();
|
||||
const preparingToolCallIds = [];
|
||||
const inflightToolCallIds = [];
|
||||
MNOTE_UI_CITATION_QUEUE.length = 0;
|
||||
|
||||
function nextToolCallId() {
|
||||
return `tc_${nextToolCallSeq++}`;
|
||||
@@ -933,7 +1067,7 @@ onRequest('session/prompt', async (params) => {
|
||||
}
|
||||
case 'tool': {
|
||||
hasToolCall = true;
|
||||
const resultText = String(ev.content || '').slice(0, 8000);
|
||||
const resultText = toolResultTextWithUiCitations(ev.content);
|
||||
emitToolResult(
|
||||
session.id,
|
||||
inflightToolCallIds.shift() || ev.callId || nextToolCallId(),
|
||||
@@ -990,4 +1124,8 @@ onNotification('session/cancel', (params) => {
|
||||
|
||||
// ── Start ────────────────────────────────────────────
|
||||
|
||||
rpcServerReady = true;
|
||||
for (const raw of queuedRpcLines.splice(0)) {
|
||||
await handleRpcLine(raw);
|
||||
}
|
||||
process.stderr.write(`[reasonix-acp-mnote] ready (mnote=${MNOTE_WEB_URL})\n`);
|
||||
|
||||
@@ -112,6 +112,21 @@ async function query(context, queryText, sourcePaths) {
|
||||
return result.payload;
|
||||
}
|
||||
|
||||
async function search(context, queryText, sourcePaths) {
|
||||
const result = await apiJson(context, "POST", `${BASE_URL}/api/knowledge-rag/search`, {
|
||||
rootUri: ROOT_URI,
|
||||
workspaceId: WORKSPACE_ID,
|
||||
query: queryText,
|
||||
mode: "mix",
|
||||
topK: 12,
|
||||
chunkTopK: 12,
|
||||
includeChunkContent: true,
|
||||
sourcePaths,
|
||||
});
|
||||
assert(result.ok, `knowledge-rag search 失败: ${result.status} ${result.text.slice(0, 1000)}`);
|
||||
return result.payload;
|
||||
}
|
||||
|
||||
async function deleteSource(context, sourcePath) {
|
||||
const result = await apiJson(context, "POST", `${BASE_URL}/api/knowledge-rag/delete-source`, {
|
||||
rootUri: ROOT_URI,
|
||||
@@ -146,6 +161,23 @@ async function main() {
|
||||
`sourcePaths scope 不应返回非 alpha 来源: ${JSON.stringify(references, null, 2).slice(0, 3000)}`,
|
||||
);
|
||||
assert(scoped.raw, "HTTP API 仍应保留 raw 供调试调用方使用");
|
||||
const searched = await search(context, marker, [sourceA]);
|
||||
const searchResults = Array.isArray(searched.results) ? searched.results : [];
|
||||
assert.equal(searched.schema, "mnote.knowledge_rag.search_results.v1", `search schema 不正确: ${JSON.stringify(searched, null, 2).slice(0, 3000)}`);
|
||||
assert.equal(searched.sourceScopeMode, "post_filter_mapped_references", `search sourceScopeMode 不正确: ${JSON.stringify(searched, null, 2).slice(0, 3000)}`);
|
||||
assert(searchResults.length > 0, `资料库 search 应至少返回 alpha: ${JSON.stringify(searched, null, 2).slice(0, 3000)}`);
|
||||
assert(
|
||||
searchResults.every((item) => item.provider === "lightrag" && item.matchSource === "lightrag_reference"),
|
||||
`资料库 search 结果必须来自 LightRAG references: ${JSON.stringify(searchResults, null, 2).slice(0, 3000)}`,
|
||||
);
|
||||
assert(
|
||||
searchResults.every((item) => item.path === sourceA),
|
||||
`资料库 search sourcePaths scope 不应返回非 alpha 来源: ${JSON.stringify(searchResults, null, 2).slice(0, 3000)}`,
|
||||
);
|
||||
assert(
|
||||
searchResults.some((item) => item.citationUrl || item.locator || item.openAction),
|
||||
`资料库 search 结果必须可打开来源: ${JSON.stringify(searchResults, null, 2).slice(0, 3000)}`,
|
||||
);
|
||||
|
||||
await deleteSource(context, sourceA);
|
||||
assert(fs.existsSync(path.join(ROOT_PATH, sourceA)), "delete-source 不应删除用户原始 source 文件");
|
||||
@@ -164,6 +196,7 @@ async function main() {
|
||||
sourceScopeMode: scoped.sourceScopeMode,
|
||||
rawScopeFiltered: scoped.rawScopeFiltered,
|
||||
scopedReferenceCount: references.length,
|
||||
searchResultCount: searchResults.length,
|
||||
alphaDocId: entryFor(indexedStatus, sourceA)?.lightRagDocId || null,
|
||||
betaDocId: entryFor(indexedStatus, sourceB)?.lightRagDocId || null,
|
||||
sourceAExistsAfterDelete: fs.existsSync(path.join(ROOT_PATH, sourceA)),
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
#!/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_OFFICE_QUERY || "三乙基硅";
|
||||
const EXPECTED_RESOURCE = process.env.MNOTE_KNOWLEDGE_RAG_EXPECTED_RESOURCE
|
||||
|| "有机合成中的保护基/[OCR]_有机合成中的保护基-酚羰基羧基巯基的保护_20250201_1908.layered_删减-2025-02-04 18-59-42.docx";
|
||||
const EXPECTED_BLOCK_ID = process.env.MNOTE_KNOWLEDGE_RAG_EXPECTED_BLOCK_ID || "0ace5daa070f0911e09d8ab37c64eeea";
|
||||
const EXPECTED_HIGHLIGHT = process.env.MNOTE_KNOWLEDGE_RAG_EXPECTED_HIGHLIGHT || "三乙基硅酯";
|
||||
const OUTPUT_DIR = path.join(process.cwd(), "tmp", "task540-knowledge-rag-office-result-open-locator-smoke");
|
||||
const RESULT_PATH = path.join(OUTPUT_DIR, "result.json");
|
||||
const SEARCH_SCREENSHOT_PATH = path.join(OUTPUT_DIR, "office-search-result.png");
|
||||
const OPEN_SCREENSHOT_PATH = path.join(OUTPUT_DIR, "office-open-locator.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 apiSearch = await context.request.post(`${BASE_URL}/api/knowledge-rag/search`, {
|
||||
data: {
|
||||
rootUri: ROOT_URI,
|
||||
workspaceId: WORKSPACE_ID,
|
||||
query: QUERY,
|
||||
mode: "mix",
|
||||
topK: 8,
|
||||
chunkTopK: 8,
|
||||
includeChunkContent: true,
|
||||
sourcePaths: [EXPECTED_RESOURCE],
|
||||
},
|
||||
});
|
||||
assert(apiSearch.ok(), `knowledge-rag search 失败: ${apiSearch.status()} ${await apiSearch.text()}`);
|
||||
const apiPayload = await apiSearch.json();
|
||||
const apiFirst = apiPayload.results?.[0];
|
||||
assert(apiFirst, `API 未返回资料库结果: ${JSON.stringify(apiPayload, null, 2).slice(0, 3000)}`);
|
||||
assert.equal(apiFirst.locator?.blockId, EXPECTED_BLOCK_ID, `API locator blockId 不匹配: ${JSON.stringify(apiFirst, null, 2)}`);
|
||||
assert(String(apiFirst.snippet || "").includes(EXPECTED_HIGHLIGHT), `API snippet 未命中 ${EXPECTED_HIGHLIGHT}: ${apiFirst.snippet}`);
|
||||
|
||||
const page = await context.newPage();
|
||||
const pageErrors = [];
|
||||
const consoleErrors = [];
|
||||
page.on("pageerror", (error) => pageErrors.push(String(error?.stack || error?.message || error)));
|
||||
page.on("console", (message) => {
|
||||
if (["error", "warning"].includes(message.type())) consoleErrors.push(`${message.type()}: ${message.text()}`);
|
||||
});
|
||||
await page.goto(`${BASE_URL}/`, { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
|
||||
await page.evaluate(() => {
|
||||
window.localStorage?.setItem("mnote.search.collapseSourcesDefault.v1", "0");
|
||||
});
|
||||
await page.waitForSelector('[data-mnote-action="open-search-modal"]', { timeout: UI_TIMEOUT_MS });
|
||||
await page.click('[data-mnote-action="open-search-modal"]');
|
||||
await page.waitForSelector('[data-testid="wolai-search-input"]', { timeout: UI_TIMEOUT_MS });
|
||||
await page.fill('[data-testid="wolai-search-input"]', QUERY);
|
||||
await page.evaluate(() => {
|
||||
const button = document.querySelector('[data-search-switch="knowledge"]');
|
||||
if (button instanceof HTMLElement) {
|
||||
button.setAttribute("aria-checked", "true");
|
||||
button.classList.add("is-on");
|
||||
}
|
||||
document.querySelector('[data-testid="wolai-search-input"]')
|
||||
?.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
});
|
||||
await page.waitForFunction(
|
||||
(expected) => {
|
||||
const first = document.querySelector('[data-testid="wolai-search-result-row"]');
|
||||
return first && first.textContent.includes(expected);
|
||||
},
|
||||
EXPECTED_HIGHLIGHT,
|
||||
{ timeout: UI_TIMEOUT_MS },
|
||||
);
|
||||
await page.screenshot({ path: SEARCH_SCREENSHOT_PATH, fullPage: true });
|
||||
await page.click('[data-testid="wolai-search-result-row"]');
|
||||
await page.waitForFunction(
|
||||
(expectedResource) => document.querySelector(`[data-mnote-resource-tab-panel][data-resource-path="${CSS.escape(expectedResource)}"]`),
|
||||
EXPECTED_RESOURCE,
|
||||
{ timeout: UI_TIMEOUT_MS },
|
||||
);
|
||||
await page.waitForTimeout(8_000);
|
||||
|
||||
const state = await page.evaluate((expectedResource) => {
|
||||
const panel = document.querySelector(`[data-mnote-resource-tab-panel][data-resource-path="${CSS.escape(expectedResource)}"]`);
|
||||
const frame = panel?.querySelector("iframe.mnote-resource-tab-frame");
|
||||
const result = {
|
||||
url: location.href,
|
||||
panelVisible: !!panel && !panel.hidden,
|
||||
panelResourcePath: panel ? panel.getAttribute("data-resource-path") : "",
|
||||
panelBlockId: panel ? panel.getAttribute("data-mnote-evidence-block-id") : "",
|
||||
panelEvidenceText: panel ? panel.getAttribute("data-mnote-evidence-text") : "",
|
||||
frameSrc: frame ? frame.getAttribute("src") : "",
|
||||
};
|
||||
if (frame?.contentDocument) {
|
||||
const doc = frame.contentDocument;
|
||||
const viewer = doc.querySelector("#mnote-office-viewer");
|
||||
const highlighted = doc.querySelector('[data-mnote-office-evidence-target="true"]');
|
||||
result.iframe = {
|
||||
readyState: doc.readyState,
|
||||
status: doc.documentElement.getAttribute("data-mnote-office-preview-status") || "",
|
||||
applied: doc.documentElement.getAttribute("data-mnote-office-evidence-applied") || "",
|
||||
bodyEvidenceText: doc.body?.dataset?.evidenceText || "",
|
||||
textHasQuery: (viewer?.textContent || "").includes("三乙基硅"),
|
||||
scrollY: frame.contentWindow?.scrollY || 0,
|
||||
highlightedText: highlighted?.textContent?.slice(0, 160) || "",
|
||||
highlightedTop: highlighted ? Math.round(highlighted.getBoundingClientRect().top) : null,
|
||||
};
|
||||
}
|
||||
return result;
|
||||
}, EXPECTED_RESOURCE);
|
||||
await page.screenshot({ path: OPEN_SCREENSHOT_PATH, fullPage: true });
|
||||
|
||||
assert.equal(state.panelVisible, true, `搜索结果未打开资源标签: ${JSON.stringify(state, null, 2)}`);
|
||||
assert.equal(state.panelResourcePath, EXPECTED_RESOURCE, `资源路径不匹配: ${JSON.stringify(state, null, 2)}`);
|
||||
assert.equal(state.panelBlockId, EXPECTED_BLOCK_ID, `资源标签 blockId 不匹配: ${JSON.stringify(state, null, 2)}`);
|
||||
assert.equal(state.iframe?.applied, "true", `Office preview 未应用 evidence locator: ${JSON.stringify(state, null, 2)}`);
|
||||
assert(String(state.iframe?.highlightedText || "").includes(EXPECTED_HIGHLIGHT), `Office preview 未高亮 ${EXPECTED_HIGHLIGHT}: ${JSON.stringify(state, null, 2)}`);
|
||||
|
||||
const result = {
|
||||
ok: true,
|
||||
baseUrl: BASE_URL,
|
||||
rootUri: ROOT_URI,
|
||||
workspaceId: WORKSPACE_ID,
|
||||
query: QUERY,
|
||||
expectedResource: EXPECTED_RESOURCE,
|
||||
expectedBlockId: EXPECTED_BLOCK_ID,
|
||||
apiFirst: {
|
||||
snippet: apiFirst.snippet,
|
||||
blockId: apiFirst.locator?.blockId,
|
||||
citationUrl: apiFirst.citationUrl,
|
||||
},
|
||||
state,
|
||||
pageErrors,
|
||||
consoleErrors,
|
||||
screenshots: {
|
||||
search: SEARCH_SCREENSHOT_PATH,
|
||||
open: OPEN_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,233 @@
|
||||
#!/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 QUERY = process.env.MNOTE_KNOWLEDGE_RAG_REPOSITION_QUERY || "三甲基硅";
|
||||
const EXPECTED_RESOURCE = process.env.MNOTE_KNOWLEDGE_RAG_EXPECTED_RESOURCE
|
||||
|| "有机合成中的保护基/[OCR]_有机合成中的保护基-酚羰基羧基巯基的保护_20250201_1908.layered_删减-2025-02-04 18-59-42.docx";
|
||||
const OUTPUT_DIR = path.join(process.cwd(), "tmp", "task541-knowledge-rag-search-panel-office-reposition-smoke");
|
||||
const RESULT_PATH = path.join(OUTPUT_DIR, "result.json");
|
||||
const SEARCH_SCREENSHOT_PATH = path.join(OUTPUT_DIR, "search-restored.png");
|
||||
const OPEN_SCREENSHOT_PATH = path.join(OUTPUT_DIR, "office-reposition.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 openKnowledgeSearch(page, query) {
|
||||
await page.click('[data-mnote-action="open-search-modal"]');
|
||||
await page.waitForSelector('[data-testid="wolai-search-input"]', { timeout: UI_TIMEOUT_MS });
|
||||
await page.evaluate(() => {
|
||||
const button = document.querySelector('[data-search-switch="knowledge"]');
|
||||
if (button instanceof HTMLElement) {
|
||||
button.setAttribute("aria-checked", "true");
|
||||
button.classList.add("is-on");
|
||||
}
|
||||
});
|
||||
await page.fill('[data-testid="wolai-search-input"]', query);
|
||||
await page.evaluate(() => {
|
||||
document.querySelector('[data-testid="wolai-search-input"]')
|
||||
?.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
});
|
||||
}
|
||||
|
||||
async function waitForOfficeRows(page) {
|
||||
await page.waitForFunction(
|
||||
({ query, expectedResource }) => {
|
||||
const rows = Array.from(document.querySelectorAll('[data-testid="wolai-search-result-row"]'));
|
||||
return rows.some((row) => {
|
||||
const locator = JSON.parse(row.getAttribute("data-evidence-locator") || "null");
|
||||
const path = String(locator?.resourcePath || locator?.resource_path || "");
|
||||
return path === expectedResource && row.textContent.includes(query);
|
||||
});
|
||||
},
|
||||
{ query: QUERY, expectedResource: EXPECTED_RESOURCE },
|
||||
{ timeout: UI_TIMEOUT_MS },
|
||||
);
|
||||
}
|
||||
|
||||
async function pickOfficeResult(page, excludeBlockId = "") {
|
||||
return page.evaluate(({ expectedResource, excludeBlockId }) => {
|
||||
const rows = Array.from(document.querySelectorAll('[data-testid="wolai-search-result-row"]'));
|
||||
for (const row of rows) {
|
||||
const locator = JSON.parse(row.getAttribute("data-evidence-locator") || "null");
|
||||
const resourcePath = String(locator?.resourcePath || locator?.resource_path || "");
|
||||
const blockId = String(locator?.blockId || locator?.block_id || "");
|
||||
if (resourcePath === expectedResource && blockId && blockId !== excludeBlockId) {
|
||||
return {
|
||||
index: Number(row.getAttribute("data-search-result-index") || -1),
|
||||
blockId,
|
||||
text: row.textContent,
|
||||
};
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}, { expectedResource: EXPECTED_RESOURCE, excludeBlockId });
|
||||
}
|
||||
|
||||
async function clickSearchResultByIndex(page, index) {
|
||||
await page.locator(`[data-testid="wolai-search-result-row"][data-search-result-index="${index}"]`).click();
|
||||
}
|
||||
|
||||
async function waitForOfficeLocator(page, expectedBlockId) {
|
||||
await page.waitForFunction(
|
||||
({ expectedResource, expectedBlockId }) => {
|
||||
const panel = document.querySelector(`[data-mnote-resource-tab-panel][data-resource-path="${CSS.escape(expectedResource)}"]`);
|
||||
return panel && panel.getAttribute("data-mnote-evidence-block-id") === expectedBlockId;
|
||||
},
|
||||
{ expectedResource: EXPECTED_RESOURCE, expectedBlockId },
|
||||
{ timeout: UI_TIMEOUT_MS },
|
||||
);
|
||||
await page.waitForTimeout(1_500);
|
||||
}
|
||||
|
||||
async function readOfficeState(page) {
|
||||
return page.evaluate((expectedResource) => {
|
||||
const panel = document.querySelector(`[data-mnote-resource-tab-panel][data-resource-path="${CSS.escape(expectedResource)}"]`);
|
||||
const frame = panel?.querySelector("iframe.mnote-resource-tab-frame");
|
||||
const result = {
|
||||
searchOpen: !document.querySelector('[data-testid="wolai-search-modal"]')?.hidden,
|
||||
panelBlockId: panel?.getAttribute("data-mnote-evidence-block-id") || "",
|
||||
frameSrc: frame?.getAttribute("src") || "",
|
||||
loadCount: window.__mnoteTask541LoadCount || 0,
|
||||
};
|
||||
if (frame?.contentDocument) {
|
||||
const doc = frame.contentDocument;
|
||||
const target = doc.querySelector('[data-mnote-office-evidence-target="true"]');
|
||||
const marker = doc.querySelector('[data-mnote-office-evidence-marker="true"]');
|
||||
result.iframe = {
|
||||
status: doc.documentElement.getAttribute("data-mnote-office-preview-status") || "",
|
||||
applied: doc.documentElement.getAttribute("data-mnote-office-evidence-applied") || "",
|
||||
highlightedText: target?.textContent?.slice(0, 200) || "",
|
||||
markerMode: marker?.getAttribute("data-mnote-office-evidence-marker-mode") || "",
|
||||
markerTargetText: marker?.getAttribute("data-mnote-office-evidence-target-text") || "",
|
||||
markerHeight: marker ? Math.round(marker.getBoundingClientRect().height) : 0,
|
||||
};
|
||||
}
|
||||
return result;
|
||||
}, EXPECTED_RESOURCE);
|
||||
}
|
||||
|
||||
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 page = await context.newPage();
|
||||
const pageErrors = [];
|
||||
const consoleErrors = [];
|
||||
page.on("pageerror", (error) => pageErrors.push(String(error?.stack || error?.message || error)));
|
||||
page.on("console", (message) => {
|
||||
if (["error", "warning"].includes(message.type())) consoleErrors.push(`${message.type()}: ${message.text()}`);
|
||||
});
|
||||
|
||||
await page.goto(`${BASE_URL}/`, { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
|
||||
await page.evaluate(() => {
|
||||
window.localStorage?.setItem("mnote.search.collapseSourcesDefault.v1", "0");
|
||||
});
|
||||
await page.waitForSelector('[data-mnote-action="open-search-modal"]', { timeout: UI_TIMEOUT_MS });
|
||||
await openKnowledgeSearch(page, QUERY);
|
||||
await waitForOfficeRows(page);
|
||||
const beforeClose = await page.evaluate(() => ({
|
||||
query: document.querySelector('[data-testid="wolai-search-input"]')?.value || "",
|
||||
count: document.querySelectorAll('[data-testid="wolai-search-result-row"]').length,
|
||||
meta: document.querySelector('[data-testid="wolai-search-result-meta"]')?.textContent || "",
|
||||
}));
|
||||
await page.click('[data-testid="wolai-search-close"]');
|
||||
await page.click('[data-mnote-action="open-search-modal"]');
|
||||
await page.waitForSelector('[data-testid="wolai-search-input"]', { timeout: UI_TIMEOUT_MS });
|
||||
const afterReopen = await page.evaluate(() => ({
|
||||
query: document.querySelector('[data-testid="wolai-search-input"]')?.value || "",
|
||||
count: document.querySelectorAll('[data-testid="wolai-search-result-row"]').length,
|
||||
meta: document.querySelector('[data-testid="wolai-search-result-meta"]')?.textContent || "",
|
||||
}));
|
||||
await page.screenshot({ path: SEARCH_SCREENSHOT_PATH, fullPage: true });
|
||||
assert.equal(afterReopen.query, QUERY, `搜索面板未恢复 query: ${JSON.stringify({ beforeClose, afterReopen })}`);
|
||||
assert(afterReopen.count >= beforeClose.count, `搜索面板重新打开后结果丢失: ${JSON.stringify({ beforeClose, afterReopen })}`);
|
||||
|
||||
const first = await pickOfficeResult(page);
|
||||
assert(first, `没有找到可打开的 Office 搜索结果`);
|
||||
await clickSearchResultByIndex(page, first.index);
|
||||
await waitForOfficeLocator(page, first.blockId);
|
||||
const firstState = await readOfficeState(page);
|
||||
assert.equal(firstState.searchOpen, false, `点击搜索结果后搜索面板应关闭但保留状态: ${JSON.stringify(firstState, null, 2)}`);
|
||||
await page.evaluate((expectedResource) => {
|
||||
const panel = document.querySelector(`[data-mnote-resource-tab-panel][data-resource-path="${CSS.escape(expectedResource)}"]`);
|
||||
const frame = panel?.querySelector("iframe.mnote-resource-tab-frame");
|
||||
window.__mnoteTask541LoadCount = 0;
|
||||
if (frame) frame.addEventListener("load", () => {
|
||||
window.__mnoteTask541LoadCount = (window.__mnoteTask541LoadCount || 0) + 1;
|
||||
});
|
||||
}, EXPECTED_RESOURCE);
|
||||
|
||||
await page.click('[data-mnote-action="open-search-modal"]');
|
||||
await waitForOfficeRows(page);
|
||||
const second = await pickOfficeResult(page, first.blockId);
|
||||
assert(second, `没有找到同一文件的第二个不同 block 搜索结果: ${JSON.stringify(first)}`);
|
||||
await clickSearchResultByIndex(page, second.index);
|
||||
await waitForOfficeLocator(page, second.blockId);
|
||||
const secondState = await readOfficeState(page);
|
||||
await page.screenshot({ path: OPEN_SCREENSHOT_PATH, fullPage: true });
|
||||
|
||||
assert.equal(secondState.loadCount, 0, `同一 Office 文件重定位触发了 iframe reload: ${JSON.stringify({ firstState, secondState }, null, 2)}`);
|
||||
assert.equal(secondState.iframe?.applied, "true", `Office preview 未应用第二次定位: ${JSON.stringify(secondState, null, 2)}`);
|
||||
const highlighted = `${secondState.iframe?.highlightedText || ""} ${secondState.iframe?.markerTargetText || ""}`;
|
||||
assert(highlighted.includes(QUERY), `Office preview 高亮未包含搜索词: ${JSON.stringify(secondState, null, 2)}`);
|
||||
assert((secondState.iframe?.markerTargetText || "").length <= 160, `Office preview range marker 文本过长: ${JSON.stringify(secondState, null, 2)}`);
|
||||
assert((secondState.iframe?.markerHeight || 0) <= 140, `Office preview range marker 框选过高: ${JSON.stringify(secondState, null, 2)}`);
|
||||
|
||||
const result = {
|
||||
ok: true,
|
||||
baseUrl: BASE_URL,
|
||||
query: QUERY,
|
||||
expectedResource: EXPECTED_RESOURCE,
|
||||
beforeClose,
|
||||
afterReopen,
|
||||
first,
|
||||
second,
|
||||
firstState,
|
||||
secondState,
|
||||
pageErrors,
|
||||
consoleErrors,
|
||||
screenshots: {
|
||||
search: SEARCH_SCREENSHOT_PATH,
|
||||
open: OPEN_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,231 @@
|
||||
#!/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 GROUP_QUERY = process.env.MNOTE_KNOWLEDGE_RAG_GROUP_QUERY || "三甲基硅";
|
||||
const SHORT_QUERY = process.env.MNOTE_KNOWLEDGE_RAG_SHORT_QUERY || "吗啉";
|
||||
const TOO_SHORT_QUERY = process.env.MNOTE_KNOWLEDGE_RAG_TOO_SHORT_QUERY || "吗";
|
||||
const OUTPUT_DIR = path.join(process.cwd(), "tmp", "task542-knowledge-rag-search-grouping-and-short-query-smoke");
|
||||
const RESULT_PATH = path.join(OUTPUT_DIR, "result.json");
|
||||
const GROUP_SCREENSHOT_PATH = path.join(OUTPUT_DIR, "grouped-collapsed.png");
|
||||
const EXPANDED_SCREENSHOT_PATH = path.join(OUTPUT_DIR, "grouped-expanded.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 localSearch = await context.request.post(`${BASE_URL}/api/search/documents`, {
|
||||
data: {
|
||||
workspaceId: WORKSPACE_ID,
|
||||
sourceKind: "local_folder",
|
||||
rootUri: ROOT_URI,
|
||||
query: SHORT_QUERY,
|
||||
limit: 30,
|
||||
filters: {
|
||||
includeOcr: false,
|
||||
titleOnly: false,
|
||||
exact: false,
|
||||
onlyCurrentPage: false,
|
||||
timeRange: "any",
|
||||
timeField: "updated",
|
||||
},
|
||||
},
|
||||
});
|
||||
assert(localSearch.ok(), `本地搜索失败: ${localSearch.status()} ${await localSearch.text()}`);
|
||||
const localPayload = await localSearch.json();
|
||||
assert.equal(localPayload.results?.length || 0, 0, `本地文档搜索不应把两字 CJK query 匹配到无关文档: ${JSON.stringify(localPayload.results?.slice(0, 3), null, 2)}`);
|
||||
|
||||
const knowledgeSearch = await context.request.post(`${BASE_URL}/api/knowledge-rag/search`, {
|
||||
data: {
|
||||
workspaceId: WORKSPACE_ID,
|
||||
rootUri: ROOT_URI,
|
||||
query: SHORT_QUERY,
|
||||
mode: "mix",
|
||||
topK: 12,
|
||||
chunkTopK: 24,
|
||||
includeChunkContent: true,
|
||||
},
|
||||
});
|
||||
assert(knowledgeSearch.ok(), `资料库短 query 检索失败: ${knowledgeSearch.status()} ${await knowledgeSearch.text()}`);
|
||||
const knowledgePayload = await knowledgeSearch.json();
|
||||
assert((knowledgePayload.results?.length || 0) >= 8, `资料库 2 字 query 应返回段落去重后的 LightRAG 检索命中: ${JSON.stringify(knowledgePayload, null, 2).slice(0, 2000)}`);
|
||||
assert(String(knowledgePayload.results?.[0]?.snippet || "").includes(SHORT_QUERY), `资料库结果未包含 ${SHORT_QUERY}: ${JSON.stringify(knowledgePayload.results?.[0], null, 2)}`);
|
||||
assert(knowledgePayload.references?.some((reference) => reference.matchSource === "lightrag_search"), `2 字 query 应走 LightRAG search provider: ${JSON.stringify(knowledgePayload.references?.slice(0, 3), null, 2)}`);
|
||||
|
||||
const tooShortSearch = await context.request.post(`${BASE_URL}/api/knowledge-rag/search`, {
|
||||
data: {
|
||||
workspaceId: WORKSPACE_ID,
|
||||
rootUri: ROOT_URI,
|
||||
query: TOO_SHORT_QUERY,
|
||||
mode: "mix",
|
||||
topK: 12,
|
||||
chunkTopK: 24,
|
||||
includeChunkContent: true,
|
||||
},
|
||||
});
|
||||
assert.equal(tooShortSearch.status(), 400, `资料库 1 字 query 应返回 400: ${tooShortSearch.status()} ${await tooShortSearch.text()}`);
|
||||
const tooShortPayload = await tooShortSearch.json();
|
||||
assert.equal(tooShortPayload.code, "knowledge_rag_search_query_too_short", `1 字 query 错误码不符合预期: ${JSON.stringify(tooShortPayload)}`);
|
||||
assert(String(tooShortPayload.message || "").includes("至少 2 个字"), `1 字 query 提示不符合预期: ${JSON.stringify(tooShortPayload)}`);
|
||||
|
||||
const page = await context.newPage();
|
||||
const pageErrors = [];
|
||||
const consoleErrors = [];
|
||||
page.on("pageerror", (error) => pageErrors.push(String(error?.stack || error?.message || error)));
|
||||
page.on("console", (message) => {
|
||||
if (["error", "warning"].includes(message.type())) consoleErrors.push(`${message.type()}: ${message.text()}`);
|
||||
});
|
||||
await page.goto(`${BASE_URL}/`, { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
|
||||
await page.evaluate(() => {
|
||||
window.localStorage?.setItem("mnote.search.collapseSourcesDefault.v1", "1");
|
||||
});
|
||||
await page.waitForSelector('[data-mnote-action="open-search-modal"]', { timeout: UI_TIMEOUT_MS });
|
||||
await page.click('[data-mnote-action="open-search-modal"]');
|
||||
await page.waitForSelector('[data-testid="wolai-search-input"]', { timeout: UI_TIMEOUT_MS });
|
||||
await page.evaluate(() => {
|
||||
const knowledge = document.querySelector('[data-search-switch="knowledge"]');
|
||||
if (knowledge instanceof HTMLElement) {
|
||||
knowledge.setAttribute("aria-checked", "true");
|
||||
knowledge.classList.add("is-on");
|
||||
}
|
||||
const collapse = document.querySelector('[data-search-switch="collapseSource"]');
|
||||
if (collapse instanceof HTMLElement) {
|
||||
collapse.setAttribute("aria-checked", "true");
|
||||
collapse.classList.add("is-on");
|
||||
}
|
||||
});
|
||||
await page.fill('[data-testid="wolai-search-input"]', TOO_SHORT_QUERY);
|
||||
await page.evaluate(() => {
|
||||
document.querySelector('[data-testid="wolai-search-input"]')
|
||||
?.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
});
|
||||
await page.waitForSelector('[data-search-empty="true"]', { timeout: UI_TIMEOUT_MS });
|
||||
const tooShortUi = await page.evaluate(() => ({
|
||||
meta: document.querySelector('[data-testid="wolai-search-result-meta"]')?.textContent || "",
|
||||
empty: document.querySelector('[data-search-empty="true"]')?.textContent || "",
|
||||
rows: document.querySelectorAll('[data-testid="wolai-search-result-row"]').length,
|
||||
}));
|
||||
assert(tooShortUi.empty.includes("至少 2 个字"), `1 字 query UI 未提示至少 2 个字: ${JSON.stringify(tooShortUi)}`);
|
||||
assert.equal(tooShortUi.rows, 0, `1 字 query 不应发起并渲染结果: ${JSON.stringify(tooShortUi)}`);
|
||||
|
||||
await page.fill('[data-testid="wolai-search-input"]', GROUP_QUERY);
|
||||
await page.evaluate(() => {
|
||||
document.querySelector('[data-testid="wolai-search-input"]')
|
||||
?.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
});
|
||||
await page.waitForSelector(".wolai-search-source-group", { timeout: UI_TIMEOUT_MS });
|
||||
const collapsed = await page.evaluate(() => ({
|
||||
switchOn: document.querySelector('[data-search-switch="collapseSource"]')?.getAttribute("aria-checked") === "true",
|
||||
groups: document.querySelectorAll(".wolai-search-source-group").length,
|
||||
visibleRows: Array.from(document.querySelectorAll('[data-testid="wolai-search-result-row"]'))
|
||||
.filter((row) => row.offsetParent !== null).length,
|
||||
meta: document.querySelector('[data-testid="wolai-search-result-meta"]')?.textContent || "",
|
||||
firstHeader: document.querySelector(".wolai-search-source-header")?.textContent || "",
|
||||
}));
|
||||
await page.screenshot({ path: GROUP_SCREENSHOT_PATH, fullPage: true });
|
||||
assert.equal(collapsed.switchOn, true, `折叠同来源开关未默认打开: ${JSON.stringify(collapsed)}`);
|
||||
assert(collapsed.groups >= 1, `未按来源分组: ${JSON.stringify(collapsed)}`);
|
||||
assert.equal(collapsed.visibleRows, 0, `默认折叠时不应展示重复来源子结果: ${JSON.stringify(collapsed)}`);
|
||||
assert(
|
||||
!String(collapsed.firstHeader || "").includes("有机合成中的保护基/[OCR]"),
|
||||
`来源组标题不应再在标题下重复显示完整来源路径: ${JSON.stringify(collapsed)}`
|
||||
);
|
||||
|
||||
await page.click(".wolai-search-source-header");
|
||||
await page.waitForFunction(() => {
|
||||
return Array.from(document.querySelectorAll('[data-testid="wolai-search-result-row"]'))
|
||||
.some((row) => row.offsetParent !== null);
|
||||
}, null, { timeout: UI_TIMEOUT_MS });
|
||||
const expanded = await page.evaluate(() => ({
|
||||
expanded: document.querySelector(".wolai-search-source-header")?.getAttribute("aria-expanded") === "true",
|
||||
visibleRows: Array.from(document.querySelectorAll('[data-testid="wolai-search-result-row"]'))
|
||||
.filter((row) => row.offsetParent !== null).length,
|
||||
firstRows: Array.from(document.querySelectorAll('[data-testid="wolai-search-result-row"]'))
|
||||
.slice(0, 3)
|
||||
.map((row) => row.textContent),
|
||||
}));
|
||||
await page.screenshot({ path: EXPANDED_SCREENSHOT_PATH, fullPage: true });
|
||||
assert.equal(expanded.expanded, true, `来源组未展开: ${JSON.stringify(expanded)}`);
|
||||
assert(expanded.visibleRows > 0, `展开后未展示来源下结果: ${JSON.stringify(expanded)}`);
|
||||
assert(
|
||||
expanded.firstRows.every((text) => !String(text || "").includes("有机合成中的保护基/[OCR]")),
|
||||
`折叠同来源展开后,子结果不应重复显示完整来源路径: ${JSON.stringify(expanded.firstRows)}`
|
||||
);
|
||||
assert(
|
||||
expanded.firstRows.every((text) => !String(text || "").trim().endsWith("docx")),
|
||||
`折叠同来源展开后,子结果不应重复显示来源类型: ${JSON.stringify(expanded.firstRows)}`
|
||||
);
|
||||
assert(
|
||||
expanded.firstRows.every((text) => !/(?:<\/?e(?:q(?:uation)?)?\b|<\/?drawing\b|format=["']?latex|\blatex\b)/i.test(String(text || ""))),
|
||||
`资料库搜索结果仍暴露原始公式/绘图标记: ${JSON.stringify(expanded.firstRows)}`
|
||||
);
|
||||
|
||||
const result = {
|
||||
ok: true,
|
||||
baseUrl: BASE_URL,
|
||||
rootUri: ROOT_URI,
|
||||
workspaceId: WORKSPACE_ID,
|
||||
shortQuery: SHORT_QUERY,
|
||||
tooShortQuery: TOO_SHORT_QUERY,
|
||||
groupQuery: GROUP_QUERY,
|
||||
localCount: localPayload.results?.length || 0,
|
||||
knowledgeCount: knowledgePayload.results?.length || 0,
|
||||
knowledgeFirst: {
|
||||
title: knowledgePayload.results?.[0]?.title,
|
||||
snippet: knowledgePayload.results?.[0]?.snippet,
|
||||
blockId: knowledgePayload.results?.[0]?.locator?.blockId,
|
||||
},
|
||||
tooShortUi,
|
||||
collapsed,
|
||||
expanded,
|
||||
pageErrors,
|
||||
consoleErrors,
|
||||
screenshots: {
|
||||
collapsed: GROUP_SCREENSHOT_PATH,
|
||||
expanded: EXPANDED_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,253 @@
|
||||
#!/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_PYRROLIDINE_QUERY || "吡咯烷";
|
||||
const EXPECTED_RESOURCE = process.env.MNOTE_KNOWLEDGE_RAG_EXPECTED_RESOURCE
|
||||
|| "有机合成中的保护基/[OCR]_有机合成中的保护基-酚羰基羧基巯基的保护_20250201_1908.layered_删减-2025-02-04 18-59-42.docx";
|
||||
const TOP_N = Number(process.env.MNOTE_KNOWLEDGE_RAG_TOP_N || 5);
|
||||
const OUTPUT_DIR = path.join(process.cwd(), "tmp", "task543-knowledge-rag-pyrrolidine-top5-locator-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 CHROMIUM_EXECUTABLE_PATH = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH
|
||||
|| ["/usr/bin/google-chrome-stable", "/usr/bin/google-chrome", "/snap/bin/chromium"]
|
||||
.find((candidate) => fs.existsSync(candidate));
|
||||
|
||||
function normalizeText(value) {
|
||||
return String(value || "")
|
||||
.replace(/<[^>]+>/g, " ")
|
||||
.replace(/[#*_`~>\[\](){}]+/g, " ")
|
||||
.replace(/[\u200B-\u200D\uFEFF]/g, "")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
}
|
||||
|
||||
function compactText(value) {
|
||||
return normalizeText(value).replace(/[0-90-9]+/g, "").replace(/[\s\p{P}\p{S}]+/gu, "");
|
||||
}
|
||||
|
||||
function longestCommonSubstringLength(left, right) {
|
||||
const a = compactText(left);
|
||||
const b = compactText(right);
|
||||
if (!a || !b) return 0;
|
||||
const shorter = a.length <= b.length ? a : b;
|
||||
const longer = a.length <= b.length ? b : a;
|
||||
for (let len = Math.min(80, shorter.length); len >= 6; len -= 1) {
|
||||
for (let start = 0; start + len <= shorter.length; start += 1) {
|
||||
if (longer.includes(shorter.slice(start, start + len))) return len;
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
async function signIn(context) {
|
||||
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()}`);
|
||||
}
|
||||
|
||||
async function apiSearch(context) {
|
||||
const response = await context.request.post(`${BASE_URL}/api/knowledge-rag/search`, {
|
||||
data: {
|
||||
rootUri: ROOT_URI,
|
||||
workspaceId: WORKSPACE_ID,
|
||||
query: QUERY,
|
||||
mode: "mix",
|
||||
topK: 20,
|
||||
chunkTopK: 20,
|
||||
includeChunkContent: true,
|
||||
sourcePaths: [EXPECTED_RESOURCE],
|
||||
},
|
||||
});
|
||||
assert(response.ok(), `knowledge-rag search 失败: ${response.status()} ${await response.text()}`);
|
||||
const payload = await response.json();
|
||||
const results = Array.isArray(payload.results) ? payload.results : [];
|
||||
assert(results.length >= TOP_N, `资料库结果不足 ${TOP_N} 条: ${JSON.stringify(payload, null, 2).slice(0, 3000)}`);
|
||||
const blockIds = results.map((item) => item?.locator?.blockId).filter(Boolean);
|
||||
assert.equal(new Set(blockIds).size, blockIds.length, `搜索结果仍有同段落重复: ${JSON.stringify(blockIds)}`);
|
||||
return results.slice(0, TOP_N);
|
||||
}
|
||||
|
||||
async function openSearchPanel(page, navigate = false) {
|
||||
if (navigate) {
|
||||
await page.goto(`${BASE_URL}/`, { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
|
||||
await page.evaluate(() => {
|
||||
window.localStorage?.setItem("mnote.search.collapseSourcesDefault.v1", "0");
|
||||
});
|
||||
}
|
||||
const inputVisible = await page.locator('[data-testid="wolai-search-input"]').isVisible().catch(() => false);
|
||||
if (!inputVisible) {
|
||||
await page.waitForSelector('[data-mnote-action="open-search-modal"]', { timeout: UI_TIMEOUT_MS });
|
||||
await page.click('[data-mnote-action="open-search-modal"]');
|
||||
}
|
||||
await page.waitForSelector('[data-testid="wolai-search-input"]', { timeout: UI_TIMEOUT_MS });
|
||||
await page.fill('[data-testid="wolai-search-input"]', QUERY);
|
||||
await page.evaluate(() => {
|
||||
const button = document.querySelector('[data-search-switch="knowledge"]');
|
||||
if (button instanceof HTMLElement) {
|
||||
button.setAttribute("aria-checked", "true");
|
||||
button.classList.add("is-on");
|
||||
}
|
||||
document.querySelector('[data-testid="wolai-search-input"]')
|
||||
?.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
});
|
||||
await page.waitForFunction(
|
||||
(topN) => document.querySelectorAll('[data-testid="wolai-search-result-row"]').length >= topN,
|
||||
TOP_N,
|
||||
{ timeout: UI_TIMEOUT_MS },
|
||||
);
|
||||
}
|
||||
|
||||
async function clickAndAuditRow(page, index, apiResult) {
|
||||
const rows = await page.$$('[data-testid="wolai-search-result-row"]');
|
||||
assert(rows[index], `缺少第 ${index + 1} 条搜索结果`);
|
||||
const rowText = await rows[index].evaluate((node) => node.textContent || "");
|
||||
assert(
|
||||
!/(?:<\/?equation\b|format=["']?latex|<\/?drawing\b|\blatex\b)/i.test(rowText),
|
||||
`第 ${index + 1} 条搜索结果仍暴露原始公式/绘图标记: ${rowText}`,
|
||||
);
|
||||
const locator = await rows[index].evaluate((node) => {
|
||||
const raw = node.getAttribute("data-evidence-locator") || "";
|
||||
try {
|
||||
return raw ? JSON.parse(raw) : null;
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
});
|
||||
assert(locator?.blockId, `第 ${index + 1} 条缺少 locator blockId: ${rowText}`);
|
||||
assert.equal(locator.blockId, apiResult?.locator?.blockId, `第 ${index + 1} 条 UI/API blockId 不一致`);
|
||||
|
||||
await rows[index].click();
|
||||
await page.waitForFunction(
|
||||
({ expectedResource, expectedBlockId }) => {
|
||||
const panel = document.querySelector(`[data-mnote-resource-tab-panel][data-resource-path="${CSS.escape(expectedResource)}"]`);
|
||||
return panel && !panel.hidden && panel.getAttribute("data-mnote-evidence-block-id") === expectedBlockId;
|
||||
},
|
||||
{ expectedResource: EXPECTED_RESOURCE, expectedBlockId: locator.blockId },
|
||||
{ timeout: UI_TIMEOUT_MS },
|
||||
);
|
||||
await page.waitForTimeout(1200);
|
||||
await page.waitForFunction(
|
||||
(expectedResource) => {
|
||||
const panel = document.querySelector(`[data-mnote-resource-tab-panel][data-resource-path="${CSS.escape(expectedResource)}"]`);
|
||||
const frame = panel?.querySelector("iframe.mnote-resource-tab-frame");
|
||||
const doc = frame?.contentDocument;
|
||||
return doc?.documentElement?.getAttribute("data-mnote-office-evidence-applied") === "true";
|
||||
},
|
||||
EXPECTED_RESOURCE,
|
||||
{ timeout: UI_TIMEOUT_MS },
|
||||
);
|
||||
|
||||
const state = await page.evaluate((expectedResource) => {
|
||||
const panel = document.querySelector(`[data-mnote-resource-tab-panel][data-resource-path="${CSS.escape(expectedResource)}"]`);
|
||||
const frame = panel?.querySelector("iframe.mnote-resource-tab-frame");
|
||||
const doc = frame?.contentDocument;
|
||||
const highlighted = doc?.querySelector('[data-mnote-office-evidence-target="true"]');
|
||||
return {
|
||||
panelBlockId: panel?.getAttribute("data-mnote-evidence-block-id") || "",
|
||||
panelEvidenceText: panel?.getAttribute("data-mnote-evidence-text") || "",
|
||||
iframeApplied: doc?.documentElement?.getAttribute("data-mnote-office-evidence-applied") || "",
|
||||
highlightedText: highlighted?.textContent || "",
|
||||
highlightedTag: highlighted?.tagName || "",
|
||||
highlightedTop: highlighted ? Math.round(highlighted.getBoundingClientRect().top) : null,
|
||||
};
|
||||
}, EXPECTED_RESOURCE);
|
||||
|
||||
const evidenceText = locator?.openAction?.params?.evidenceText || apiResult?.locator?.openAction?.params?.evidenceText || apiResult?.quote || rowText;
|
||||
const overlap = longestCommonSubstringLength(state.highlightedText, evidenceText);
|
||||
assert.equal(state.panelBlockId, locator.blockId, `第 ${index + 1} 条 panel blockId 不一致: ${JSON.stringify(state, null, 2)}`);
|
||||
assert(state.highlightedText.includes(QUERY), `第 ${index + 1} 条定位高亮未包含 query: ${JSON.stringify(state, null, 2)}`);
|
||||
assert(
|
||||
normalizeText(state.highlightedText).length >= 24 || state.highlightedTag === "P",
|
||||
`第 ${index + 1} 条仍是短词高亮,不是段落级高亮: ${JSON.stringify(state, null, 2)}`,
|
||||
);
|
||||
assert(
|
||||
overlap >= 10 || (
|
||||
state.highlightedTag === "P"
|
||||
&& compactText(state.highlightedText).includes(compactText(QUERY))
|
||||
&& compactText(state.highlightedText).length >= 6
|
||||
),
|
||||
`第 ${index + 1} 条搜索结果与实际定位上下文不一致: ${JSON.stringify({ rowText, evidenceText, overlap, state }, null, 2)}`,
|
||||
);
|
||||
|
||||
return {
|
||||
index: index + 1,
|
||||
rowText: normalizeText(rowText).slice(0, 220),
|
||||
apiBlockId: apiResult?.locator?.blockId || "",
|
||||
uiBlockId: locator.blockId,
|
||||
panelBlockId: state.panelBlockId,
|
||||
highlightedText: normalizeText(state.highlightedText).slice(0, 220),
|
||||
highlightedTag: state.highlightedTag,
|
||||
overlap,
|
||||
};
|
||||
}
|
||||
|
||||
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 {
|
||||
await signIn(context);
|
||||
const topResults = await apiSearch(context);
|
||||
const page = await context.newPage();
|
||||
const pageErrors = [];
|
||||
const consoleErrors = [];
|
||||
page.on("pageerror", (error) => pageErrors.push(String(error?.stack || error?.message || error)));
|
||||
page.on("console", (message) => {
|
||||
if (["error", "warning"].includes(message.type())) consoleErrors.push(`${message.type()}: ${message.text()}`);
|
||||
});
|
||||
await openSearchPanel(page, true);
|
||||
|
||||
const audits = [];
|
||||
for (let index = 0; index < TOP_N; index += 1) {
|
||||
await openSearchPanel(page, false);
|
||||
audits.push(await clickAndAuditRow(page, index, topResults[index]));
|
||||
}
|
||||
await page.screenshot({ path: path.join(OUTPUT_DIR, "top5-after-last-click.png"), fullPage: true });
|
||||
|
||||
const result = {
|
||||
ok: true,
|
||||
baseUrl: BASE_URL,
|
||||
rootUri: ROOT_URI,
|
||||
workspaceId: WORKSPACE_ID,
|
||||
query: QUERY,
|
||||
topN: TOP_N,
|
||||
audits,
|
||||
pageErrors,
|
||||
consoleErrors,
|
||||
};
|
||||
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);
|
||||
});
|
||||
Reference in New Issue
Block a user