feat(rag): align LightRAG native citations and MCP bridge
This commit is contained in:
+164
-90
@@ -1,8 +1,8 @@
|
||||
# 7-55 LightRAG DOCX 引用定位与 rerank 对齐设计 v1
|
||||
# 7-55 LightRAG DOCX 引用清洗与定位合同对齐设计 v2
|
||||
|
||||
> 创建时间:2026-06-08
|
||||
>
|
||||
> 当前状态:`process`
|
||||
> 当前状态:`done`
|
||||
>
|
||||
> Owner:07-ai / knowledge-rag / 03-rust-web / office-preview
|
||||
>
|
||||
@@ -15,6 +15,11 @@
|
||||
> - LightRAG:`/mnt/Data1T/Mnote_data/lightrag/LightRAG`
|
||||
> - NexusRAG:`/tmp/mnote-rag-eval-nexusrag`
|
||||
> - MNote:`rust/crates/mnote-web/src/routes/knowledge_rag.rs`
|
||||
>
|
||||
> 决策更新:
|
||||
> - 当前优先级从 rerank 下调为“统一引用清洗 + DOCX 段落定位合同”。
|
||||
> - rerank 只保留 provider capability/status 观察,不进入当前实现主路径。
|
||||
> - 后续实现必须先复用 LightRAG sidecar / parsed block 与 NexusRAG citation/source-card 合同思想,不在 UI 和 preview 各自手搓第二套清洗/定位逻辑。
|
||||
|
||||
## 1. 背景
|
||||
|
||||
@@ -27,9 +32,10 @@
|
||||
- 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 已有 reranker 入口,但本机运行态未启用,且 endpoint 未真实验证;rerank 不应挡住引用清洗和定位合同收口。
|
||||
|
||||
本设计目标是把“LightRAG 已有能力”和“MNote 必须补的引用定位层”明确切开。
|
||||
今天围绕 `吡咯烷` / `吗啉` 的连续修复说明:问题根因不是单一正则缺失,而是 raw chunk、展示文本、定位文本、用户 query、sidecar block 没有统一合同,导致搜索结果清洗、去重和 office-preview 段落定位各自补丁化。
|
||||
|
||||
## 2. CodeGraph 核对结论
|
||||
|
||||
@@ -62,6 +68,7 @@
|
||||
```
|
||||
|
||||
结论:MNote 不新增通用 reranker。MNote 只负责配置、健康状态展示、请求参数透传和结果引用消费。
|
||||
当前阶段不把 rerank 作为 Phase A;只记录 provider 状态,等真实 rerank endpoint 跑通后再单独开设计/实现。
|
||||
|
||||
### 2.2 LightRAG 已有 DOCX native parser 与 sidecar
|
||||
|
||||
@@ -108,17 +115,30 @@ NexusRAG 不是只针对 PDF。
|
||||
- MNote 已选 LightRAG 作为默认 provider,不能为了 citation UI 整套替换 RAG provider。
|
||||
- MNote 的 open-reference 必须回到 local-folder resource tab / Page Aggregate / Resource Tree,不应引入第二套文档 viewer 真相。
|
||||
|
||||
### 2.4 本轮实测暴露的合同缺口
|
||||
|
||||
已修复但仍需收口为统一合同的问题:
|
||||
|
||||
- LightRAG chunk 中混有 `<equation format="latex">`、`<drawing>`、残缺 XML/HTML 标签;搜索 UI 临时清洗后可读,但 Page AI / source card / citation markdown 仍可能各自遇到 raw 文本。
|
||||
- DOCX chunk 常把目录行、正文段和参考文献拼在一个上下文里;只用完整 chunk 做定位,容易落到后半段长文本或参考文献。
|
||||
- 只用用户 query 短词定位会错跳;只用长 chunk 定位会被无关长段覆盖。需要 query-centered、block-aware、display/locator 分离的 anchor contract。
|
||||
- 同一 resource tab 已支持 postMessage 更新 locator,不应因点击同一 DOCX 的另一个结果重新加载。
|
||||
|
||||
因此下一步不继续扩大前端正则,而是把清洗和定位输入前移到 `knowledge_rag` mapper:所有消费方拿同一份 `displayQuote` / `locatorEvidenceText` / `searchQuery` / `locatorPrecision`。
|
||||
|
||||
## 3. 设计原则
|
||||
|
||||
1. LightRAG 负责检索质量:parse、chunk、vector、graph、rerank、query。
|
||||
2. MNote 负责来源真相:source registry、权限、root-relative path、resource tab 打开、citation URL、locator 降级语义。
|
||||
3. 引用定位按精度分层,不伪造:
|
||||
3. 参考优先:优先复用 LightRAG sidecar block、heading、positions;借鉴 NexusRAG 的 citation/source-card contract;只有 viewer anchoring adapter 允许保留 MNote 自有实现。
|
||||
4. 清洗只做一次:后端 mapper 产出 raw/display/locator 三类文本,前端只展示或消费,不再各自猜 LightRAG 原始格式。
|
||||
5. 引用定位按精度分层,不伪造:
|
||||
- `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。
|
||||
6. DOCX 默认先做 paragraph locator,不把 DOCX 强制转换成 PDF viewer 管线。
|
||||
7. rerank 暂缓。MNote 可以展示 provider rerank 状态,但当前不新增 request path、UI 开关或本地 rerank。
|
||||
8. Agent final answer 只能引用 MNote 过滤和映射后的 `citations/references`,不能引用 raw LightRAG chunks。
|
||||
|
||||
## 4. 目标架构
|
||||
|
||||
@@ -129,6 +149,7 @@ LightRAG /query/data
|
||||
-> Evidence Mapping Layer
|
||||
- match provider file_path/doc_id -> source entry
|
||||
- match chunk_id/content/query -> sidecar block
|
||||
- build rawQuote/displayQuote/locatorEvidenceText
|
||||
- classify locatorPrecision
|
||||
- build citationUrl/citationMarkdown
|
||||
-> knowledge-rag result
|
||||
@@ -163,8 +184,11 @@ pub struct KnowledgeRagCitation {
|
||||
pub light_rag_chunk_id: Option<String>,
|
||||
pub block_id: Option<String>,
|
||||
pub heading_path: Vec<String>,
|
||||
pub quote: Option<String>,
|
||||
pub raw_quote: Option<String>,
|
||||
pub display_quote: Option<String>,
|
||||
pub locator_evidence_text: Option<String>,
|
||||
pub quote_source: String,
|
||||
pub search_query: String,
|
||||
pub locator_precision: LocatorPrecision,
|
||||
pub locator_degraded: bool,
|
||||
pub citation_url: String,
|
||||
@@ -177,9 +201,10 @@ pub struct KnowledgeRagCitation {
|
||||
#[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,
|
||||
pub sidecar_block_mapped: bool,
|
||||
pub display_cleaned: bool,
|
||||
pub locator_text_source: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
@@ -191,7 +216,46 @@ pub enum LocatorPrecision {
|
||||
}
|
||||
```
|
||||
|
||||
### 5.2 citation ID
|
||||
### 5.2 引用文本清洗合同
|
||||
|
||||
后端 mapper 统一产出三份文本:
|
||||
|
||||
```rust
|
||||
pub struct CitationTextBundle {
|
||||
pub raw_quote: String,
|
||||
pub display_quote: String,
|
||||
pub locator_evidence_text: String,
|
||||
pub search_query: String,
|
||||
pub normalized_fingerprint: String,
|
||||
}
|
||||
```
|
||||
|
||||
字段语义:
|
||||
|
||||
- `rawQuote`:LightRAG / sidecar 原始内容,仅诊断可见,不直接给 UI 和 LLM final answer 引用。
|
||||
- `displayQuote`:搜索结果、source card、citation preview 使用。去除 XML/HTML 外壳、`drawing`、残缺 tag;公式先转为可读 plain text,不在本阶段做 LaTeX 渲染。
|
||||
- `locatorEvidenceText`:定位使用。优先 sidecar block 的 query-centered window,保留足够上下文和 heading,但去除会干扰 anchor 的 XML/LaTeX 包装。
|
||||
- `normalizedFingerprint`:用于同段落去重和 smoke 对比,只作为当前 query 内去重辅助,不当长期主键。
|
||||
|
||||
清洗规则必须集中在 Rust 侧一个 helper 中,例如:
|
||||
|
||||
```text
|
||||
raw LightRAG chunk / sidecar block
|
||||
-> stripXmlShellPreserveText(equation)
|
||||
-> dropDrawingTags()
|
||||
-> stripBrokenTags()
|
||||
-> collapseWhitespace()
|
||||
-> normalizeLatexCommandsForDisplay()
|
||||
-> build query-centered locator window
|
||||
```
|
||||
|
||||
禁止:
|
||||
|
||||
- 搜索 UI、Page AI、office-preview 分别维护不同的 equation/drawing 正则。
|
||||
- 为了显示好看删除用户 query 或化学关键词。
|
||||
- 把 `displayQuote` 反过来当唯一定位依据;定位必须优先用 `locatorEvidenceText + searchQuery + blockId/sourceMapPath`。
|
||||
|
||||
### 5.3 citation ID
|
||||
|
||||
采用 NexusRAG 式短 ID,但 ID 只作为 UI/display contract,不作为事实主键。
|
||||
|
||||
@@ -209,7 +273,7 @@ pub enum LocatorPrecision {
|
||||
- 同一 query 内冲突则加 salt 重算。
|
||||
- citation card 使用 `citation_id` 关联 answer badge。
|
||||
|
||||
### 5.3 locator precision 计算
|
||||
### 5.4 locator precision 计算
|
||||
|
||||
```rust
|
||||
fn locator_precision_from_block(block: &serde_json::Value) -> LocatorPrecision {
|
||||
@@ -267,7 +331,8 @@ fn locator_precision_from_block(block: &serde_json::Value) -> LocatorPrecision {
|
||||
"provider": "lightrag",
|
||||
"chunkId": "doc-xxx-chunk-001",
|
||||
"searchQuery": "三乙基硅",
|
||||
"evidenceText": "query-centered chunk/block context"
|
||||
"evidenceText": "locatorEvidenceText",
|
||||
"displayQuote": "cleaned display quote"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -278,7 +343,8 @@ fn locator_precision_from_block(block: &serde_json::Value) -> LocatorPrecision {
|
||||
|
||||
- 有 `blockid` 必须传 `blockId`。
|
||||
- 有 sidecar path 必须传 `sourceMapPath`。
|
||||
- `evidenceText` 优先取 sidecar block content 的 query-centered window,其次 chunk content。
|
||||
- `evidenceText` 使用统一 `locatorEvidenceText`;优先取 sidecar block content 的 query-centered window,其次 chunk content。
|
||||
- 搜索结果展示使用 `displayQuote`,不再由 UI 从 raw `quote/snippet` 临时清洗。
|
||||
- `page/bbox` 缺失时不得写假值。
|
||||
- `citationMarkdown` 文案可带“定位降级”,但链接仍应可点击打开 resource tab。
|
||||
|
||||
@@ -288,12 +354,12 @@ office-preview / docx-preview 收到 paragraph locator 后:
|
||||
|
||||
```text
|
||||
locator.evidenceText
|
||||
-> normalize text
|
||||
-> consume backend locatorEvidenceText
|
||||
-> build anchors:
|
||||
1. query-centered phrase
|
||||
2. long rare terms
|
||||
3. heading + quote window
|
||||
4. fallback query
|
||||
1. sidecar block/query-centered leading anchor
|
||||
2. heading + quote window
|
||||
3. long rare terms
|
||||
4. fallback query only when scoped by block/window
|
||||
-> scan rendered paragraphs / table cells
|
||||
-> score by token overlap + rare term bonus + heading match
|
||||
-> scroll into view + transient highlight
|
||||
@@ -305,17 +371,42 @@ locator.evidenceText
|
||||
- DOCX 无 page/bbox 时展示成精确页码。
|
||||
- 同一 resource tab 重新加载整个 document 来响应每次 citation click;应优先 postMessage 更新 locator。
|
||||
|
||||
## 7. Rerank 对齐
|
||||
## 7. Rerank 暂缓策略
|
||||
|
||||
### 7.1 配置策略
|
||||
### 7.1 当前决策
|
||||
|
||||
MNote 不实现 rerank model 调用,只管理 LightRAG 配置状态。
|
||||
MNote 当前不推进 rerank 实现,只做状态观察和诊断记录。
|
||||
|
||||
推荐配置优先级:
|
||||
原因:
|
||||
|
||||
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。
|
||||
- 本机 LightRAG `configuration.enable_rerank=false`、`rerank_queue_status.available=false`。
|
||||
- 之前测试过常见 NVIDIA rerank endpoint 返回 404,不能把模型名当成可用能力。
|
||||
- 当前用户痛点集中在“命中能否完整、结果是否干净、点击是否定位到同一段落”,rerank 主要解决排序,不解决 DOCX 段落坐标缺失。
|
||||
- 提前接 rerank 会扩大变量,影响定位合同验收。
|
||||
|
||||
当前只要求 dashboard/status 能展示:
|
||||
|
||||
```text
|
||||
LightRAG: healthy
|
||||
Embedding: nvidia/baai/bge-m3
|
||||
Rerank: disabled
|
||||
Rerank binding: null
|
||||
Rerank queue: unavailable
|
||||
```
|
||||
|
||||
### 7.2 后置启用条件
|
||||
|
||||
只有满足以下条件,才新增 rerank 实现任务:
|
||||
|
||||
1. 有真实 endpoint,并通过 provider smoke 证明 `/health.configuration.enable_rerank=true`、`rerank_queue_status.available=true`。
|
||||
2. LightRAG query result 能返回可消费的 `rerank_score`,或确认只能显示 provider order。
|
||||
3. 7-55 的 citation/locator contract 已稳定,`三甲基硅`、`三乙基硅`、`吡咯烷`、`吗啉` smoke 通过。
|
||||
|
||||
后置实现仍遵循:
|
||||
|
||||
- 不在 MNote 调 rerank model。
|
||||
- 不复制 LightRAG vector / graph / rerank。
|
||||
- 只透传 provider 参数、展示状态、消费 provider score。
|
||||
|
||||
`.env` 示例:
|
||||
|
||||
@@ -336,42 +427,6 @@ RERANK_TIMEOUT=30
|
||||
- 之前本机测过常见 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:
|
||||
@@ -409,31 +464,32 @@ Rerank queue: unavailable
|
||||
- 页码:只有 locator 有 page 时显示。
|
||||
- 标题路径:sidecar `parent_headings + heading`。
|
||||
- 相关性:
|
||||
- 优先 LightRAG `rerank_score`,如果 provider 返回。
|
||||
- 否则使用 MNote 现有 reference ranking score,标注为 `mnoteScore`。
|
||||
- 当前显示 provider order / MNote mapping score。
|
||||
- 只有后置 rerank 验证完成后才显示 LightRAG `rerank_score`。
|
||||
- 图片引用:sidecar drawings / image OCR 命中使用 `[IMG-xxxx]`。
|
||||
|
||||
## 9. 实施阶段
|
||||
|
||||
### Phase A:rerank capability 透出
|
||||
### Phase A:统一引用文本清洗合同
|
||||
|
||||
- [ ] `KnowledgeRagQueryRequest` / `KnowledgeRagSearchRequest` 增加 `enable_rerank`。
|
||||
- [ ] 调 LightRAG `/query/data` / `/query/search` 时显式传 `enable_rerank`。
|
||||
- [ ] status/dashboard 暴露 LightRAG health 中的 rerank 字段。
|
||||
- [ ] diagnostics 标注 `providerRerankEnabled`、`providerRerankAvailable`、`rerankModel`。
|
||||
- [ ] 不新增 MNote reranker。
|
||||
- [x] 在 `knowledge_rag.rs` 增加统一 citation text helper,产出 `rawQuote/displayQuote/locatorEvidenceText/searchQuery/normalizedFingerprint`。
|
||||
- [x] search result、Page AI、Hermes tool result 全部消费 `displayQuote`,不再直接展示 raw LightRAG chunk。
|
||||
- [x] locator/openAction 全部消费 `locatorEvidenceText` 和真实 `searchQuery`。
|
||||
- [x] 去重优先使用 `sourcePath + blockId`,无 block 时再用 `sourcePath + chunkId + normalizedFingerprint`,避免同段落重复但保留同文件不同段落。
|
||||
- [x] 移除或瘦身 `sidebar-tree-runtime.js` 里的临时 equation/drawing 清洗,前端只做最后一道防御。
|
||||
|
||||
验收:
|
||||
|
||||
- LightRAG `.env` 为 `RERANK_BINDING=null` 时,UI 明确显示 rerank disabled。
|
||||
- API result metadata 能看出本次 query 是否请求 rerank、provider 是否实际可用。
|
||||
- 搜索 `吡咯烷`、`吗啉` 的 UI 结果不显示 `<equation>`、`latex`、`drawing`、残缺 XML 标签。
|
||||
- `displayQuote` 不丢失用户 query。
|
||||
- `locatorEvidenceText` 能在 diagnostics 中看到来源是 `sidecar`、`chunk` 还是 fallback。
|
||||
|
||||
### 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。
|
||||
- [x] 在 knowledge-rag mapped reference 上补 `citationId`。
|
||||
- [x] 输出统一 `citations[]`,字段包含 `citationMarkdown/citationUrl/locatorPrecision/displayQuote/locatorEvidenceText/headingPath`。
|
||||
- [x] Page AI / Hermes tool result 只暴露 filtered citations,不暴露 raw chunks 给 final answer 引用。
|
||||
- [x] source card 消费 `citations[]`,不重新解析 raw LightRAG response。
|
||||
|
||||
验收:
|
||||
|
||||
@@ -442,11 +498,11 @@ Rerank queue: unavailable
|
||||
|
||||
### 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。
|
||||
- [x] `lightrag_locator_for_reference(...)` 对 DOCX sidecar block 无 bbox 时返回 paragraph locator。
|
||||
- [x] `locatorPrecision=paragraph` 时保留 `blockId/sourceMapPath/locatorEvidenceText/headingPath/searchQuery`。
|
||||
- [x] `citationMarkdown` 对 paragraph/file 降级文案明确,但链接可点击。
|
||||
- [x] office-preview 支持 `blockId/locatorEvidenceText/searchQuery` 定位,按 sidecar block / chunk context 打分滚动。
|
||||
- [x] 同一 DOCX 多次点击 citation 使用 postMessage 更新定位,不重复 reload。
|
||||
|
||||
验收:
|
||||
|
||||
@@ -456,8 +512,8 @@ Rerank queue: unavailable
|
||||
|
||||
### Phase D:NexusRAG 式来源卡片
|
||||
|
||||
- [ ] source card 展示 citation ID、文件名、标题路径、定位精度、相关性、quote。
|
||||
- [ ] 有 page/bbox 时显示页码;无 page/bbox 不显示页码。
|
||||
- [x] source card 展示 citation ID、文件名、标题路径、定位精度、quote。
|
||||
- [x] 有 page/bbox 时显示页码;无 page/bbox 不显示页码。
|
||||
- [ ] 图片 citation 使用 `[IMG-xxxx]` 并打开原图 / PDF 页。
|
||||
- [ ] answer renderer 对 citation badge 做点击联动。
|
||||
|
||||
@@ -466,6 +522,18 @@ Rerank queue: unavailable
|
||||
- 答案和搜索结果都能从同一 citation model 打开来源。
|
||||
- source card 不依赖 LLM 生成的自由文本解析。
|
||||
|
||||
### Phase E:rerank status / provider score(后置)
|
||||
|
||||
- [x] status/dashboard 暴露 LightRAG health 中的 rerank 字段。
|
||||
- [x] diagnostics 标注 `providerRerankEnabled`、`providerRerankAvailable`、`rerankModel`。
|
||||
- [x] 只有真实 endpoint smoke 通过后,才考虑 `KnowledgeRagQueryRequest` / `KnowledgeRagSearchRequest` 增加 `enable_rerank`。
|
||||
- [x] 不新增 MNote reranker。
|
||||
|
||||
验收:
|
||||
|
||||
- LightRAG `.env` 为 `RERANK_BINDING=null` 时,UI 明确显示 rerank disabled。
|
||||
- 不会因为 rerank unavailable 改变当前 query/search 行为。
|
||||
|
||||
## 10. 测试计划
|
||||
|
||||
### Rust 单测
|
||||
@@ -473,11 +541,14 @@ Rerank queue: unavailable
|
||||
目标文件:`rust/crates/mnote-web/src/routes/knowledge_rag.rs`
|
||||
|
||||
- `mapped_reference_generates_short_citation_id`
|
||||
- `citation_text_bundle_strips_equation_and_drawing_for_display`
|
||||
- `citation_text_bundle_preserves_query_for_locator`
|
||||
- `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`
|
||||
- `same_block_same_fingerprint_deduplicates`
|
||||
- `same_file_different_block_is_not_deduplicated`
|
||||
- `provider_rerank_disabled_is_reported_in_status`
|
||||
|
||||
### Browser smoke
|
||||
|
||||
@@ -491,7 +562,8 @@ Rerank queue: unavailable
|
||||
- DOCX citation click 后 resource tab 不重载。
|
||||
- paragraph locator 触发可见高亮。
|
||||
- `locatorPrecision` 与 UI 文案一致。
|
||||
- rerank disabled 状态在资料库设置中可见。
|
||||
- 搜索结果不显示 raw equation / drawing / broken tag。
|
||||
- rerank disabled 状态在资料库设置中可见,但不影响 query/search。
|
||||
|
||||
### Provider smoke
|
||||
|
||||
@@ -512,14 +584,16 @@ curl /query/data -d '{"query":"...", "mode":"mix", "enable_rerank":true}'
|
||||
|
||||
- 不替换 LightRAG 为 NexusRAG。
|
||||
- 不在 MNote 复制 LightRAG vector / graph / rerank。
|
||||
- 当前阶段不启用、不调试、不 UI 化 rerank 参数;只展示 provider 状态。
|
||||
- 不把 DOCX 无 page/bbox 的命中伪造成 PDF 精确定位。
|
||||
- 不把 raw chunk 清洗逻辑继续分散在搜索 UI、Page AI、office-preview。
|
||||
- 不让普通本地搜索强依赖 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 污染?
|
||||
1. DOCX `paraid range=[null,null]` 的样本较多。是否需要在 LightRAG native parser 侧增强 paraId 恢复或写入 block ordinal,给 MNote 一个更稳定的 `blockOrder`?
|
||||
2. `/query/search` 与 `/query/data` 的 reference schema 不完全一致时,MNote 是否应该先在 connector 层 normalize 为同一个 `ProviderReference`?
|
||||
3. Source scope 当前仍是 MNote post-filter。长期是否需要 LightRAG provider 支持按 `file_path/doc_id` 过滤候选,以避免 raw retrieval 污染?
|
||||
4. LightRAG `/query/data` 当前 `convert_to_user_format(...)` 没有把 `rerank_score` 放入 `formatted_chunks`。该问题放到 Phase E;在真实 rerank endpoint 跑通前不处理。
|
||||
@@ -0,0 +1,228 @@
|
||||
# 7-56 LightRAG Native DOCX Sidecar 可定位块粒度优化 v1
|
||||
|
||||
> 创建时间:2026-06-09
|
||||
>
|
||||
> 当前状态:`process`
|
||||
>
|
||||
> Owner:07-ai / knowledge-rag / LightRAG native parser / office-preview
|
||||
>
|
||||
> 前置完成:
|
||||
> - `design/07-ai/done/7-55-lightrag-docx-citation-rerank-alignment-v1.md`
|
||||
>
|
||||
> 参考:
|
||||
> - LightRAG native DOCX parser:`/mnt/Data1T/Mnote_data/lightrag/LightRAG/lightrag/parser/docx/`
|
||||
> - LightRAG sidecar writer:`/mnt/Data1T/Mnote_data/lightrag/LightRAG/lightrag/sidecar/writer.py`
|
||||
> - Docling adapter 对照:`/mnt/Data1T/Mnote_data/lightrag/LightRAG/lightrag/parser/external/docling/`
|
||||
> - NexusRAG:仅作为 parse-before-index 架构佐证,不照搬 pipeline
|
||||
|
||||
## 1. 结论
|
||||
|
||||
当前 DOCX 定位问题的根因不是 MNote 缺少查询后 parser,而是 LightRAG native DOCX sidecar 把已解析到的段落信息压成了过粗的 content block。
|
||||
|
||||
实测样本:
|
||||
|
||||
```text
|
||||
/mnt/Data1T/Mnote_data/users/mnote-e2e/workspaces/my-space/新页面233155/中国化妆品新原料产业发展调研报告_03(20260410).docx
|
||||
```
|
||||
|
||||
对比结果:
|
||||
|
||||
```text
|
||||
native parser 旧行为:
|
||||
- python-docx 可读到 68 个 paragraph,且有 w14:paraId
|
||||
- sidecar 只写 1 个 content block
|
||||
- positions 只有 paraid range [第一段, 最后一段]
|
||||
|
||||
docling parser 当前行为:
|
||||
- docling raw JSON 有 70 个 texts
|
||||
- LightRAG docling IR builder 也合并为 1 个 block
|
||||
- DOCX prov/bbox 为空,不提供 Word 原生 paraId
|
||||
|
||||
native parser 优化后:
|
||||
- sidecar 写 67 个 content block
|
||||
- 每个 block 带单段 paraid range
|
||||
- positions.paraid.anchor 写 paragraph ordinal
|
||||
- 额外写 text_fingerprint position
|
||||
```
|
||||
|
||||
因此当前主线选择:**优先优化 LightRAG native DOCX sidecar,不切换到 Docling 作为 DOCX 定位主路径。**
|
||||
|
||||
Docling 保留为外部 parser 能力,适合 PDF/图片/跨格式解析对照;但对 DOCX 回跳原 Word 段落,native 能拿到 `paraId`,更适合作为定位底座。
|
||||
|
||||
## 2. 设计目标
|
||||
|
||||
把 DOCX native sidecar 从 document-sized block 改为 paragraph/section-addressable blocks:
|
||||
|
||||
```text
|
||||
DOCX native parse
|
||||
-> paragraphs[]
|
||||
text
|
||||
paraId
|
||||
paragraphOrdinal
|
||||
inferred heading
|
||||
textFingerprint
|
||||
-> blocks.jsonl
|
||||
content block per addressable paragraph/table slice
|
||||
positions: paraid + paragraph ordinal
|
||||
positions: text fingerprint
|
||||
heading / parent_headings
|
||||
-> LightRAG indexing/query
|
||||
-> MNote citation/locator
|
||||
chunk/reference -> blockid/positions -> office-preview 定位
|
||||
```
|
||||
|
||||
## 3. 当前实施
|
||||
|
||||
已在 LightRAG 本地源码完成第一步:
|
||||
|
||||
- `lightrag/parser/docx/parse_document.py`
|
||||
- 新增 `addressable_blocks` 参数。
|
||||
- 新增 Normal 样式报告标题启发式识别。
|
||||
- 新增 paragraph-level block 输出。
|
||||
- 每个 addressable block 写 `paragraph_ordinal` 与 `text_fingerprint`。
|
||||
- `lightrag/parser/docx/ir_builder.py`
|
||||
- `IRPosition(type="paraid")` 的 `anchor` 写 paragraph ordinal。
|
||||
- 追加 `IRPosition(type="text_fingerprint")`。
|
||||
- `lightrag/pipeline.py`
|
||||
- native DOCX production parse path 调用 `extract_docx_blocks(..., addressable_blocks=True)`。
|
||||
|
||||
## 4. 验证证据
|
||||
|
||||
命令:
|
||||
|
||||
```bash
|
||||
cd /mnt/Data1T/Mnote_data/lightrag/LightRAG
|
||||
.venv/bin/python -m lightrag.parser.cli \
|
||||
'/mnt/Data1T/Mnote_data/users/mnote-e2e/workspaces/my-space/新页面233155/中国化妆品新原料产业发展调研报告_03(20260410).docx' \
|
||||
--engine native \
|
||||
-o /tmp/mnote-lightrag-parser-cosmetics-native-v3 \
|
||||
--preview 8
|
||||
```
|
||||
|
||||
结果:
|
||||
|
||||
```text
|
||||
[sidecar] wrote 67 blocks
|
||||
positions example:
|
||||
[
|
||||
{"type":"paraid","anchor":4,"range":["6692C49B","6692C49B"]},
|
||||
{"type":"text_fingerprint","anchor":"1454ec0b0ffd9fc0"}
|
||||
]
|
||||
```
|
||||
|
||||
PDF 对照样本:
|
||||
|
||||
```text
|
||||
/mnt/Data1T/Mnote_data/users/mnote-e2e/workspaces/my-space/新页面233155/parsertest.pdf
|
||||
```
|
||||
|
||||
MinerU parse 已能输出 `positions.type=bbox` 与 page anchor,PDF 当前先消费现有 bbox,不作为 7-56 首要补丁对象。
|
||||
|
||||
## 5. 后续收口
|
||||
|
||||
- MNote `knowledge_rag` mapper 应优先消费 provider sidecar 中的 `blockid/positions`,逐步退役 query-time quote 反查 block。
|
||||
- office-preview 应优先使用 `paraid.anchor` 或 paraid range 定位;没有可用 paraId 时再用 `text_fingerprint/displayQuote` 做降级匹配。
|
||||
- 重新索引 DOCX 后才能让旧资料库命中使用新 sidecar 粒度。
|
||||
- LightRAG 服务需要重启后才会使用本地源码改动。
|
||||
- 本轮已执行 `systemctl --user restart mnote-lightrag.service`,`/health` 返回 healthy;当前 parser routing 仍是 `*:native-iteP,*:mineru-iteP,*:legacy-R`,DOCX 默认走 native。
|
||||
|
||||
## 6. MNote 与 LightRAG 的新分工
|
||||
|
||||
LightRAG 已提供 keyword / vector / graph / hybrid 检索、rerank、query 与 chunk/reference 返回。MNote 不应继续维护第二套召回、排序或 sidecar exact search 作为主路径。
|
||||
|
||||
新的主合同:
|
||||
|
||||
```text
|
||||
MNote
|
||||
- source registry:本地路径、allowed roots、索引状态、LightRAG doc/file 映射
|
||||
- scope:搜索目录 / 后续排除目录 / 权限过滤
|
||||
- facade:把 UI / Page AI 请求转为 LightRAG query/search/data 参数
|
||||
- citation:把 LightRAG reference/chunk 归一化为 MNote result/citation
|
||||
- locator:消费 LightRAG sidecar positions 并打开原文件
|
||||
|
||||
LightRAG
|
||||
- parse / OCR / chunk / vector / graph / rerank / hybrid retrieval
|
||||
- DOCX native 输出 paraid / paragraph ordinal / text fingerprint
|
||||
- PDF/MinerU 输出 page/bbox
|
||||
```
|
||||
|
||||
因此,MNote 允许保留的“手搓”只限定位适配;定位适配只能消费 LightRAG 已返回的 reference/chunk/source sidecar 信息,不能重新执行一套 MNote 检索或 parser:
|
||||
|
||||
- `bbox -> pdf-preview`
|
||||
- `paraid / paragraphOrdinal / textFingerprint -> office-preview`
|
||||
- `blockId / sourceMapPath / evidenceText -> provider 引用后的定位线索`
|
||||
|
||||
不再作为主路径继续扩写:
|
||||
|
||||
- MNote 独立 sidecar exact search provider
|
||||
- MNote query-time DOCX parser
|
||||
- MNote 自己 rerank / 多 provider 融合排序
|
||||
- MNote query-time sidecar quote 反查
|
||||
- 旧 `docs_search` / `mnote.evidence.search` / LiteParse local evidence search
|
||||
|
||||
## 7. 退役计划
|
||||
|
||||
阶段 A:完成 provider positions 消费。
|
||||
|
||||
- `knowledge_rag` locator 解析 `positions.type=bbox / paraid / text_fingerprint`。
|
||||
- citation URL 与 resource tab 透传 `paragraphOrdinal / paraIdStart / paraIdEnd / textFingerprint`。
|
||||
- office-preview 优先按段落定位,再回退到当前文本定位。
|
||||
|
||||
阶段 B:退役 query-time sidecar 反查与旧 evidence/docs 搜索。
|
||||
|
||||
- 删除 `find_lightrag_sidecar_quote_for_query` / `find_lightrag_sidecar_block_for_query` 这类 query-time sidecar quote 反查 helper;MNote 不再用 sidecar 作为检索补召回。
|
||||
- `docs_search`、`docs_read`、`mnote.evidence.*` Hermes tool 统一返回退役错误,引导使用 `mnote.knowledge_rag.query/open_reference`。
|
||||
- `/api/evidence/search` 只保留退役 guard,不再保留可被内部调用的 `search_payload`。
|
||||
- 搜索结果去重可以保留在 MNote locator/result 层,但召回与排序不再依赖 MNote sidecar exact search。
|
||||
- `/api/knowledge-rag/search` 的 `mode=exact` 走 LightRAG `/query/search`;`mode=mix/local/global/hybrid/naive` 走 LightRAG `/query/data`。
|
||||
- 搜索面板“全盘资料库”接入 LightRAG 检索模式:综合 `mix`、图谱混合 `hybrid`、向量 `naive`、实体 `local`、关系 `global`、关键词 `exact`。`精确匹配`开关会强制走 `exact`。
|
||||
|
||||
阶段 C:退役 MNote parser 主路径。
|
||||
|
||||
- 删除或冻结 MNote 后端 DOCX/PDF parser 参与 RAG 搜索的入口。
|
||||
- 保留 source-map / locator 兼容读取,用于历史索引和旧 OCR 结果打开。
|
||||
- 新索引统一由 LightRAG parser sidecar 产出定位信息。
|
||||
|
||||
## 8. 不做
|
||||
|
||||
- 不在 MNote 新增一套 DOCX parser。
|
||||
- 不把 DOCX 默认切到 Docling。
|
||||
- 不在 MNote 实现 rerank;rerank 仍走 LightRAG 原生能力。
|
||||
- 不把 DOCX 无 bbox 伪造成 PDF 页内精确定位。
|
||||
|
||||
## 9. Agent / MCP 边界
|
||||
|
||||
LightRAG 官方内核不提供 Skill/MCP/plugin 系统;社区已有 `lightragmcp`、`mcp-lightrag` 等 MCP server,可把 LightRAG query、文档管理、图谱查询包装成 Agent 工具。这一层适合接给 Reasonix / Hermes / Claude Desktop 这类 Agent runtime,但它不能替代 MNote 的 source registry 与引用打开能力。
|
||||
|
||||
推荐边界:
|
||||
|
||||
```text
|
||||
Reasonix / Hermes
|
||||
-> 可直接挂 LightRAG MCP:查询、问答、图谱、管理
|
||||
-> 返回 LightRAG reference/chunk/citation 数据
|
||||
|
||||
MNote
|
||||
-> 提供 mnote.knowledge_rag.open_reference 或等价 locator bridge
|
||||
-> 把 Agent 引文里的 file_path/chunk_id/blockid/positions 映射到原文件 tab、PDF bbox、DOCX paragraph
|
||||
-> 做 allowed roots / workspace source registry / citationUrl
|
||||
```
|
||||
|
||||
因此更好的长期方案不是在 MNote 内继续手搓搜索工具,而是:
|
||||
|
||||
- Agent 侧:可以挂 `lightrag_native=/mnt/Data1T/mnote/scripts/lightrag-native-mcp.sh`,让 Agent 原生调用 LightRAG 检索、`query_data`、图谱、文档和 pipeline 能力;MNote 不把第三方 MCP server 嵌入 Web runtime。
|
||||
- MNote 侧:保留极薄的 `knowledge_rag.query/search/open_reference` facade,服务 UI、权限、source registry 与可点击引用;若 Agent 已直接通过 LightRAG MCP 检索,MNote 只需要 citation bridge。
|
||||
- Citation bridge:`mnote_lightrag_bridge=/mnt/Data1T/mnote/scripts/mnote-lightrag-mcp.sh` 已提供 `open_mnote_reference`,输入 LightRAG `file_path` / `chunk_id`,输出 MNote `citationUrl` / `openAction`,不参与检索和排序。
|
||||
|
||||
当前本机配置审计:
|
||||
|
||||
- `/home/lix/.reasonix/config.json` 已配置 `lightrag_native=/mnt/Data1T/mnote/scripts/lightrag-native-mcp.sh` 与 `mnote_lightrag_bridge=/mnt/Data1T/mnote/scripts/mnote-lightrag-mcp.sh`。
|
||||
- `/home/lix/.hermes/profiles/mnote-u-mnote-e2e-default/config.yaml` 已配置 `lightrag_native` 与 `mnote_lightrag_bridge` MCP server。
|
||||
- MNote 内已有 `skills/mnote-knowledge-rag/SKILL.md`、`skills/mnote-lightrag-bridge/SKILL.md` 和 Hermes manifest 的 `mnote.knowledge_rag.status/query/open_reference`,这是当前 agent 调用 LightRAG 后回到 MNote 引用定位的 facade。
|
||||
- 包名核验:PyPI 可见 `mcp-lightrag 0.2.2` 与 `lightrag-mcp 0.1.1`,但 `mcp-lightrag 0.2.2` 对当前本机 LightRAG 版本会触发 `QueryRequest.__init__() got an unexpected keyword argument 'max_token_for_text_unit'`。当前 Reasonix/Hermes 原生 LightRAG MCP 改用 `l-pw2c-lightrag-server-mcp@1.2.2`,并保留 MNote bridge 做 citation/open-reference 映射。
|
||||
|
||||
MCP / MNote tool 变更后的强制验收:
|
||||
|
||||
- 使用 Reasonix ACP / Page AI 发起真实资料库问题,确认 AI 可见回答包含 MNote citation 链接,而不是 raw JSON、provider 内部路径或手写 `/documents`。
|
||||
- 从 AI 回答中点击 citation 链接,必须在 MNote 中打开对应资源 tab;PDF 要落到 page/bbox,DOCX 要落到 paragraph/block,降级资源至少要打开到正确 resource tab 并明确 `locatorDegraded`。
|
||||
- 验收证据必须包含 Reasonix run payload 摘要、回答截图、点击后打开定位截图、console/network 摘要和 `result.json`。
|
||||
- 当前可复用 smoke:`scripts/task530-knowledge-rag-page-ai-final-answer-smoke.js`,它会使用 Reasonix Page AI 得到 citation,并点击 AI 返回的链接验证 MNote 资源定位打开。
|
||||
@@ -402,51 +402,6 @@ struct MindmapOutlineToolPayload {
|
||||
outline: Vec<MindmapOutlineItemPayload>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct DocsSearchDatasetPayload {
|
||||
workspace_id: String,
|
||||
documents: Vec<index_fts::SearchDocumentRecord>,
|
||||
mindmaps: Vec<index_fts::SearchMindmapRecord>,
|
||||
tables: Vec<index_fts::SearchTableRecord>,
|
||||
table_rows: Vec<index_fts::SearchTableRowRecord>,
|
||||
assets: Vec<index_fts::SearchAssetRecord>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct DocsSearchToolPayload {
|
||||
query: String,
|
||||
workspace_id: Option<String>,
|
||||
limit: Option<u32>,
|
||||
include_deleted: Option<bool>,
|
||||
page_id: Option<String>,
|
||||
title_only: Option<bool>,
|
||||
exact: Option<bool>,
|
||||
include_ocr: Option<bool>,
|
||||
time_range: Option<String>,
|
||||
time_field: Option<String>,
|
||||
custom_range_from: Option<String>,
|
||||
custom_range_to: Option<String>,
|
||||
#[serde(default)]
|
||||
datasets: Vec<DocsSearchDatasetPayload>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct DocsReadToolPayload {
|
||||
document_id: String,
|
||||
max_chars: Option<u32>,
|
||||
include_content: Option<bool>,
|
||||
title: Option<String>,
|
||||
workspace_id: Option<String>,
|
||||
parent_id: Option<String>,
|
||||
updated_at: Option<String>,
|
||||
raw_text_length: Option<u64>,
|
||||
raw_text: Option<String>,
|
||||
content: Option<Value>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct RuntimeSuccess {
|
||||
@@ -4128,126 +4083,6 @@ fn execute_tool_result(
|
||||
"results": results,
|
||||
}))
|
||||
}
|
||||
"docs_search" => {
|
||||
let payload: DocsSearchToolPayload = parse_tool_input(&args, &data)?;
|
||||
let normalized_query = payload.query.trim().to_string();
|
||||
if normalized_query.is_empty() {
|
||||
return Ok(json!({
|
||||
"ok": true,
|
||||
"source": infer_tool_source(&data),
|
||||
"query": normalized_query,
|
||||
"results": [],
|
||||
"enqueueAssetIds": [],
|
||||
}));
|
||||
}
|
||||
let limit = payload.limit.unwrap_or(12).clamp(1, 30) as usize;
|
||||
let requested_workspace = payload
|
||||
.workspace_id
|
||||
.as_ref()
|
||||
.map(|value| value.trim())
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(str::to_string);
|
||||
let mut enqueue_asset_ids = Vec::<String>::new();
|
||||
let mut results = Vec::<Value>::new();
|
||||
for dataset in payload.datasets {
|
||||
if let Some(workspace_id) = requested_workspace.as_ref() {
|
||||
if dataset.workspace_id != *workspace_id {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
let evaluation = evaluate_search_documents(
|
||||
&SearchDocumentsRequest {
|
||||
query: normalized_query.clone(),
|
||||
workspace_id: dataset.workspace_id.clone(),
|
||||
page_id: payload.page_id.clone(),
|
||||
limit,
|
||||
title_only: payload.title_only.unwrap_or(false),
|
||||
exact: payload.exact.unwrap_or(false),
|
||||
include_ocr: payload.include_ocr.unwrap_or(false),
|
||||
time_range: payload.time_range.clone().unwrap_or_else(|| "any".into()),
|
||||
time_field: payload
|
||||
.time_field
|
||||
.clone()
|
||||
.unwrap_or_else(|| "updated".into()),
|
||||
custom_range_from: payload.custom_range_from.clone(),
|
||||
custom_range_to: payload.custom_range_to.clone(),
|
||||
},
|
||||
&SearchDocumentsDataset {
|
||||
documents: dataset.documents,
|
||||
mindmaps: dataset.mindmaps,
|
||||
tables: dataset.tables,
|
||||
table_rows: dataset.table_rows,
|
||||
assets: dataset.assets,
|
||||
},
|
||||
);
|
||||
enqueue_asset_ids.extend(evaluation.enqueue_asset_ids);
|
||||
for item in evaluation.results {
|
||||
results.push(json!({
|
||||
"id": item.id,
|
||||
"title": item.title,
|
||||
"snippet": item.snippet,
|
||||
"updatedAt": item.updated_at,
|
||||
"createdAt": item.created_at,
|
||||
"matchField": item.match_field,
|
||||
"hasOcr": item.has_ocr,
|
||||
"publicPath": item.public_path,
|
||||
"score": item.score,
|
||||
"workspaceId": dataset.workspace_id,
|
||||
}));
|
||||
}
|
||||
}
|
||||
results.sort_by(|left, right| {
|
||||
let left_score = left.get("score").and_then(Value::as_f64).unwrap_or(0.0);
|
||||
let right_score = right.get("score").and_then(Value::as_f64).unwrap_or(0.0);
|
||||
right_score
|
||||
.partial_cmp(&left_score)
|
||||
.unwrap_or(std::cmp::Ordering::Equal)
|
||||
.then_with(|| {
|
||||
let left_updated =
|
||||
left.get("updatedAt").and_then(Value::as_str).unwrap_or("");
|
||||
let right_updated =
|
||||
right.get("updatedAt").and_then(Value::as_str).unwrap_or("");
|
||||
right_updated.cmp(left_updated)
|
||||
})
|
||||
});
|
||||
results.truncate(limit);
|
||||
enqueue_asset_ids.sort();
|
||||
enqueue_asset_ids.dedup();
|
||||
Ok(json!({
|
||||
"ok": true,
|
||||
"source": infer_tool_source(&data),
|
||||
"query": normalized_query,
|
||||
"results": results,
|
||||
"enqueueAssetIds": enqueue_asset_ids,
|
||||
"includeDeleted": payload.include_deleted.unwrap_or(false),
|
||||
}))
|
||||
}
|
||||
"docs_read" => {
|
||||
let payload: DocsReadToolPayload = parse_tool_input(&args, &data)?;
|
||||
let max_chars = payload.max_chars.unwrap_or(2500).clamp(200, 20_000) as usize;
|
||||
let raw_text = payload.raw_text.unwrap_or_default();
|
||||
let raw_text_length = payload
|
||||
.raw_text_length
|
||||
.unwrap_or_else(|| raw_text.chars().count() as u64);
|
||||
let trimmed = trim_text_for_docs_read(&raw_text, max_chars);
|
||||
let mut result = json!({
|
||||
"ok": true,
|
||||
"source": infer_tool_source(&data),
|
||||
"documentId": payload.document_id,
|
||||
"title": payload.title.unwrap_or_else(|| "".into()),
|
||||
"workspaceId": payload.workspace_id.unwrap_or_else(|| "".into()),
|
||||
"parentId": payload.parent_id,
|
||||
"updatedAt": payload.updated_at,
|
||||
"rawTextLength": raw_text_length,
|
||||
"rawText": trimmed,
|
||||
});
|
||||
if payload.include_content.unwrap_or(false) {
|
||||
if let Some(map) = result.as_object_mut() {
|
||||
map.insert("content".into(), payload.content.unwrap_or(Value::Null));
|
||||
}
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
"doc_insert_blocks" => {
|
||||
let blocks = normalize_blocks_from_value(&data);
|
||||
let specs = parse_insert_specs(&args)?;
|
||||
@@ -4604,44 +4439,6 @@ fn build_tool_plan_steps(
|
||||
}]);
|
||||
}
|
||||
|
||||
if invocation.tool == "docs_search" {
|
||||
return Ok(vec![
|
||||
RuntimeToolPlanStep {
|
||||
kind: "transport".into(),
|
||||
name: "docs_search.dataset".into(),
|
||||
function_name: None,
|
||||
description: "通过 transport 拉取文档、导图、表格与 OCR 搜索数据集,再交由 Rust runtime 统一排序。".into(),
|
||||
args_json: tool_wire.args_json.clone(),
|
||||
},
|
||||
RuntimeToolPlanStep {
|
||||
kind: "transform".into(),
|
||||
name: "docs_search".into(),
|
||||
function_name: None,
|
||||
description: "在 Rust runtime 内复用统一搜索评估器,输出跨页文档搜索结果。".into(),
|
||||
args_json: tool_wire.args_json.clone(),
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
if invocation.tool == "docs_read" {
|
||||
return Ok(vec![
|
||||
RuntimeToolPlanStep {
|
||||
kind: "transport".into(),
|
||||
name: "docs_read.document".into(),
|
||||
function_name: None,
|
||||
description: "通过 transport 读取目标文档 meta/content,再交由 Rust runtime 统一裁剪正文与返回结构。".into(),
|
||||
args_json: tool_wire.args_json.clone(),
|
||||
},
|
||||
RuntimeToolPlanStep {
|
||||
kind: "transform".into(),
|
||||
name: "docs_read".into(),
|
||||
function_name: None,
|
||||
description: "在 Rust runtime 内标准化跨页文档读取结果。".into(),
|
||||
args_json: tool_wire.args_json.clone(),
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
if invocation.tool.starts_with("onlyoffice_") {
|
||||
let description = match invocation.tool.as_str() {
|
||||
"onlyoffice_session_resolve" => {
|
||||
@@ -7464,15 +7261,6 @@ fn strip_html_tags(value: &str) -> String {
|
||||
result.trim().to_string()
|
||||
}
|
||||
|
||||
fn trim_text_for_docs_read(value: &str, max_chars: usize) -> String {
|
||||
let taken = value.chars().take(max_chars).collect::<String>();
|
||||
if value.chars().count() > max_chars {
|
||||
format!("{taken}…")
|
||||
} else {
|
||||
taken
|
||||
}
|
||||
}
|
||||
|
||||
fn walk_mindmap_summaries(root: &MindmapTreeNode, max_nodes: usize) -> Vec<RuntimeMindmapSummary> {
|
||||
let mut list = Vec::new();
|
||||
let mut queue = VecDeque::from([(root, None::<String>, 0usize)]);
|
||||
@@ -14509,130 +14297,6 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn docs_search_tool_plan_uses_transport_and_transform_steps() {
|
||||
let plan = execute_runtime_input(RuntimeInput::Tool {
|
||||
context: demo_context(),
|
||||
tool: RuntimeToolInvocationWire {
|
||||
tool: "docs_search".into(),
|
||||
kind: "query".into(),
|
||||
mode: Some("plan".into()),
|
||||
args_json: json!({
|
||||
"query": "rust",
|
||||
}),
|
||||
target: None,
|
||||
reason: Some("跨页搜索".into()),
|
||||
refs: vec!["task-050".into()],
|
||||
},
|
||||
data: None,
|
||||
})
|
||||
.expect("docs_search plan should build");
|
||||
|
||||
match plan {
|
||||
RuntimeExecutionPlan::Tool(plan) => {
|
||||
assert_eq!(plan.tool_name, "docs_search");
|
||||
assert_eq!(plan.toolset_id, "toolset.docs_read");
|
||||
assert_eq!(plan.steps.len(), 2);
|
||||
assert_eq!(plan.steps[0].name, "docs_search.dataset");
|
||||
assert_eq!(plan.steps[1].name, "docs_search");
|
||||
}
|
||||
_ => panic!("expected tool plan"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn docs_search_tool_result_uses_rust_search_evaluation() {
|
||||
let result = execute_runtime_query(RuntimeInput::Tool {
|
||||
context: demo_context(),
|
||||
tool: RuntimeToolInvocationWire {
|
||||
tool: "docs_search".into(),
|
||||
kind: "query".into(),
|
||||
mode: Some("result".into()),
|
||||
args_json: json!({
|
||||
"query": "rust",
|
||||
"limit": 5,
|
||||
}),
|
||||
target: None,
|
||||
reason: Some("跨页搜索".into()),
|
||||
refs: vec!["task-050".into()],
|
||||
},
|
||||
data: Some(json!({
|
||||
"source": "convex",
|
||||
"datasets": [
|
||||
{
|
||||
"workspaceId": "ws_1",
|
||||
"documents": [
|
||||
{
|
||||
"id": "page_1",
|
||||
"workspaceId": "ws_1",
|
||||
"title": "Rust 文档",
|
||||
"rawText": "这里记录 rust runtime 收口",
|
||||
"createdAt": "2026-04-15T00:00:00Z",
|
||||
"updatedAt": "2026-04-16T00:00:00Z"
|
||||
}
|
||||
],
|
||||
"mindmaps": [],
|
||||
"tables": [],
|
||||
"tableRows": [],
|
||||
"assets": []
|
||||
}
|
||||
]
|
||||
})),
|
||||
})
|
||||
.expect("docs_search result should build");
|
||||
|
||||
assert_eq!(result.get("query").and_then(Value::as_str), Some("rust"));
|
||||
assert_eq!(
|
||||
result
|
||||
.get("results")
|
||||
.and_then(Value::as_array)
|
||||
.map(Vec::len),
|
||||
Some(1)
|
||||
);
|
||||
assert_eq!(result["results"][0]["id"], json!("page_1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn docs_read_tool_result_trims_text_and_keeps_content_optional() {
|
||||
let long_text = "a".repeat(260);
|
||||
let result = execute_runtime_query(RuntimeInput::Tool {
|
||||
context: demo_context(),
|
||||
tool: RuntimeToolInvocationWire {
|
||||
tool: "docs_read".into(),
|
||||
kind: "query".into(),
|
||||
mode: Some("result".into()),
|
||||
args_json: json!({
|
||||
"documentId": "page_1",
|
||||
"maxChars": 200,
|
||||
"includeContent": true,
|
||||
}),
|
||||
target: None,
|
||||
reason: Some("读取文档".into()),
|
||||
refs: vec!["task-050".into()],
|
||||
},
|
||||
data: Some(json!({
|
||||
"source": "convex",
|
||||
"title": "示例页面",
|
||||
"workspaceId": "ws_1",
|
||||
"parentId": "parent_1",
|
||||
"updatedAt": "2026-04-16T00:00:00Z",
|
||||
"rawText": long_text,
|
||||
"rawTextLength": 260,
|
||||
"content": {"blocks": [{"id": "b1"}]}
|
||||
})),
|
||||
})
|
||||
.expect("docs_read result should build");
|
||||
|
||||
assert_eq!(result["documentId"], json!("page_1"));
|
||||
let trimmed = result["rawText"]
|
||||
.as_str()
|
||||
.expect("rawText should be string");
|
||||
assert_eq!(trimmed.chars().count(), 201);
|
||||
assert!(trimmed.ends_with('…'));
|
||||
assert_eq!(result["rawTextLength"], json!(260));
|
||||
assert_eq!(result["content"]["blocks"][0]["id"], json!("b1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn doc_get_tool_plan_uses_documents_content_query() {
|
||||
let plan = execute_runtime_input(RuntimeInput::Tool {
|
||||
|
||||
@@ -86,13 +86,11 @@ pub use tool::{
|
||||
default_tool_registry, invocation_kind_label, tool_effect_label, tool_mode_label,
|
||||
InvocationKind, ToolEffect, ToolExecutionMode, ToolInvocation, ToolRegistry, ToolSetSpec,
|
||||
ToolSpec, BRIDGE_TOOL_COMMAND_GET, BRIDGE_TOOL_REQUEST_GET, BRIDGE_TOOL_TRACE_GET,
|
||||
DOCS_TOOLSET_READ, DOCS_TOOL_READ, DOCS_TOOL_SEARCH, DOC_TOOLSET_READ, DOC_TOOLSET_WRITE,
|
||||
DOC_TOOL_FIND, DOC_TOOL_GET, DOC_TOOL_INSERT_BLOCKS, DOC_TOOL_REPLACE_RANGE,
|
||||
EVIDENCE_TOOLSET_READ, EVIDENCE_TOOL_OPEN, EVIDENCE_TOOL_READ, EVIDENCE_TOOL_SEARCH,
|
||||
INDEX_TOOL_REBUILD, MINDMAP_TOOLSET_READ, MINDMAP_TOOLSET_WRITE, MINDMAP_TOOL_APPLY_OPS,
|
||||
MINDMAP_TOOL_EMPTY_TRASH, MINDMAP_TOOL_EXPAND_NODE, MINDMAP_TOOL_GET, MINDMAP_TOOL_GET_SUBTREE,
|
||||
MINDMAP_TOOL_OUTLINE_TO_MINDMAP, MINDMAP_TOOL_PUT, OBSERVE_TOOLSET_READ,
|
||||
ONLYOFFICE_TOOLSET_SERVICE, ONLYOFFICE_TOOL_PREPARE_CALLBACK,
|
||||
DOC_TOOLSET_READ, DOC_TOOLSET_WRITE, DOC_TOOL_FIND, DOC_TOOL_GET, DOC_TOOL_INSERT_BLOCKS,
|
||||
DOC_TOOL_REPLACE_RANGE, INDEX_TOOL_REBUILD, MINDMAP_TOOLSET_READ, MINDMAP_TOOLSET_WRITE,
|
||||
MINDMAP_TOOL_APPLY_OPS, MINDMAP_TOOL_EMPTY_TRASH, MINDMAP_TOOL_EXPAND_NODE, MINDMAP_TOOL_GET,
|
||||
MINDMAP_TOOL_GET_SUBTREE, MINDMAP_TOOL_OUTLINE_TO_MINDMAP, MINDMAP_TOOL_PUT,
|
||||
OBSERVE_TOOLSET_READ, ONLYOFFICE_TOOLSET_SERVICE, ONLYOFFICE_TOOL_PREPARE_CALLBACK,
|
||||
ONLYOFFICE_TOOL_PREPARE_FORCESAVE, ONLYOFFICE_TOOL_PREPARE_PROXY,
|
||||
ONLYOFFICE_TOOL_SESSION_RESOLVE, ONLYOFFICE_TOOL_SIGN, RECOVERY_TOOLSET_JOB,
|
||||
REPLAY_TOOL_EVENTS,
|
||||
@@ -140,11 +138,6 @@ mod tests {
|
||||
"search_web",
|
||||
"doc_get",
|
||||
"doc_find",
|
||||
"docs_search",
|
||||
"docs_read",
|
||||
"mnote.evidence.search",
|
||||
"mnote.evidence.read",
|
||||
"mnote.evidence.open",
|
||||
"image_read",
|
||||
"doc_insert_blocks",
|
||||
"doc_replace_range",
|
||||
@@ -204,23 +197,11 @@ mod tests {
|
||||
.expect("slash toolset should exist");
|
||||
assert!(slash.write_toolset);
|
||||
assert_eq!(slash.tool_names, &["slash_run"]);
|
||||
let docs_read = registry
|
||||
.toolset("toolset.docs_read")
|
||||
.expect("docs_read toolset should exist");
|
||||
assert!(!docs_read.write_toolset);
|
||||
assert_eq!(docs_read.tool_names, &["docs_search", "docs_read"]);
|
||||
let evidence_read = registry
|
||||
.toolset("toolset.evidence_read")
|
||||
.expect("evidence toolset should exist");
|
||||
assert!(!evidence_read.write_toolset);
|
||||
assert_eq!(
|
||||
evidence_read.tool_names,
|
||||
&[
|
||||
"mnote.evidence.search",
|
||||
"mnote.evidence.read",
|
||||
"mnote.evidence.open",
|
||||
]
|
||||
);
|
||||
assert!(registry.toolset("toolset.docs_read").is_none());
|
||||
assert!(registry.toolset("toolset.evidence_read").is_none());
|
||||
assert!(registry.tool("docs_search").is_none());
|
||||
assert!(registry.tool("docs_read").is_none());
|
||||
assert!(registry.tool("mnote.evidence.search").is_none());
|
||||
let doc_write = registry
|
||||
.toolset("toolset.doc_write")
|
||||
.expect("doc_write toolset should exist");
|
||||
|
||||
@@ -118,63 +118,6 @@ pub const DOC_TOOL_FIND: ToolSpec = ToolSpec {
|
||||
input_schema_json: r#"{"type":"object","required":["query"],"properties":{"query":{"type":"string"},"maxResults":{"type":"integer","minimum":1,"maximum":30}}}"#,
|
||||
};
|
||||
|
||||
pub const DOCS_TOOL_SEARCH: ToolSpec = ToolSpec {
|
||||
name: "docs_search",
|
||||
display_name: "跨页文档搜索",
|
||||
description: "在工作区内搜索文档标题、正文、导图、表格与 OCR 结果。",
|
||||
toolset_id: "toolset.docs_read",
|
||||
invocation_kind: InvocationKind::Query,
|
||||
effect: ToolEffect::Read,
|
||||
requires_confirmation: false,
|
||||
input_schema_json: r#"{"type":"object","required":["query"],"properties":{"query":{"type":"string"},"workspaceId":{"type":"string"},"limit":{"type":"integer","minimum":1,"maximum":30},"includeDeleted":{"type":"boolean"},"pageId":{"type":"string"},"titleOnly":{"type":"boolean"},"exact":{"type":"boolean"},"includeOcr":{"type":"boolean"},"timeRange":{"type":"string"},"timeField":{"type":"string"},"customRangeFrom":{"type":"string"},"customRangeTo":{"type":"string"}}}"#,
|
||||
};
|
||||
|
||||
pub const DOCS_TOOL_READ: ToolSpec = ToolSpec {
|
||||
name: "docs_read",
|
||||
display_name: "跨页文档读取",
|
||||
description: "读取指定文档的标题、正文摘要与可选 content 快照。",
|
||||
toolset_id: "toolset.docs_read",
|
||||
invocation_kind: InvocationKind::Query,
|
||||
effect: ToolEffect::Read,
|
||||
requires_confirmation: false,
|
||||
input_schema_json: r#"{"type":"object","required":["documentId"],"properties":{"documentId":{"type":"string"},"maxChars":{"type":"integer","minimum":200,"maximum":20000},"includeContent":{"type":"boolean"}}}"#,
|
||||
};
|
||||
|
||||
pub const EVIDENCE_TOOL_SEARCH: ToolSpec = ToolSpec {
|
||||
name: "mnote.evidence.search",
|
||||
display_name: "证据搜索",
|
||||
description:
|
||||
"在工作区内搜索可回跳原文的证据块,返回 quote、locator、openAction 与 citationMarkdown。",
|
||||
toolset_id: "toolset.evidence_read",
|
||||
invocation_kind: InvocationKind::Query,
|
||||
effect: ToolEffect::Read,
|
||||
requires_confirmation: false,
|
||||
input_schema_json: r#"{"type":"object","required":["query","scope"],"properties":{"query":{"type":"string"},"scope":{"type":"object","required":["workspaceId","rootUri"],"properties":{"workspaceId":{"type":"string"},"rootUri":{"type":"string"},"targetDocumentId":{"type":"string"},"includeResources":{"type":"boolean"},"includeOcr":{"type":"boolean"}}},"mode":{"enum":["keyword","tree","hybrid","graph"]},"topK":{"type":"integer","minimum":1,"maximum":30}}}"#,
|
||||
};
|
||||
|
||||
pub const EVIDENCE_TOOL_READ: ToolSpec = ToolSpec {
|
||||
name: "mnote.evidence.read",
|
||||
display_name: "证据读回",
|
||||
description:
|
||||
"按 EvidenceLocator 读取原文证据及周边上下文,供 AI 回答引用,并返回可点击引用链接。",
|
||||
toolset_id: "toolset.evidence_read",
|
||||
invocation_kind: InvocationKind::Query,
|
||||
effect: ToolEffect::Read,
|
||||
requires_confirmation: false,
|
||||
input_schema_json: r#"{"type":"object","required":["locator"],"properties":{"locator":{"type":"object"},"context":{"type":"object","properties":{"beforeBlocks":{"type":"integer","minimum":0,"maximum":20},"afterBlocks":{"type":"integer","minimum":0,"maximum":20},"includeSectionSummary":{"type":"boolean"}}}}}"#,
|
||||
};
|
||||
|
||||
pub const EVIDENCE_TOOL_OPEN: ToolSpec = ToolSpec {
|
||||
name: "mnote.evidence.open",
|
||||
display_name: "证据打开",
|
||||
description: "把 EvidenceLocator 归一化为 MNote 可执行的打开/定位动作,并返回 citationUrl/citationMarkdown。",
|
||||
toolset_id: "toolset.evidence_read",
|
||||
invocation_kind: InvocationKind::Query,
|
||||
effect: ToolEffect::Read,
|
||||
requires_confirmation: false,
|
||||
input_schema_json: r#"{"type":"object","required":["locator"],"properties":{"locator":{"type":"object"}}}"#,
|
||||
};
|
||||
|
||||
pub const IMAGE_READ_TOOL: ToolSpec = ToolSpec {
|
||||
name: "image_read",
|
||||
display_name: "读取图片",
|
||||
@@ -414,26 +357,6 @@ pub const DOC_TOOLSET_READ: ToolSetSpec = ToolSetSpec {
|
||||
tool_names: &["doc_get", "doc_find"],
|
||||
};
|
||||
|
||||
pub const DOCS_TOOLSET_READ: ToolSetSpec = ToolSetSpec {
|
||||
id: "toolset.docs_read",
|
||||
display_name: "跨页文档读取",
|
||||
description: "跨页面搜索与读取文档的工具集合。",
|
||||
write_toolset: false,
|
||||
tool_names: &["docs_search", "docs_read"],
|
||||
};
|
||||
|
||||
pub const EVIDENCE_TOOLSET_READ: ToolSetSpec = ToolSetSpec {
|
||||
id: "toolset.evidence_read",
|
||||
display_name: "证据读取",
|
||||
description: "搜索、读回和打开可定位原文证据的只读工具集合。",
|
||||
write_toolset: false,
|
||||
tool_names: &[
|
||||
"mnote.evidence.search",
|
||||
"mnote.evidence.read",
|
||||
"mnote.evidence.open",
|
||||
],
|
||||
};
|
||||
|
||||
pub const READONLY_TOOLSET: ToolSetSpec = ToolSetSpec {
|
||||
id: "toolset.readonly",
|
||||
display_name: "只读工具",
|
||||
@@ -527,8 +450,6 @@ pub const DOC_TOOL_REGISTRY: ToolRegistry = ToolRegistry::new(
|
||||
READONLY_TOOLSET,
|
||||
MEDIA_TOOLSET,
|
||||
SLASH_TOOLSET_WRITE,
|
||||
DOCS_TOOLSET_READ,
|
||||
EVIDENCE_TOOLSET_READ,
|
||||
DOC_TOOLSET_READ,
|
||||
DOC_TOOLSET_WRITE,
|
||||
MINDMAP_TOOLSET_READ,
|
||||
@@ -541,11 +462,6 @@ pub const DOC_TOOL_REGISTRY: ToolRegistry = ToolRegistry::new(
|
||||
SEARCH_WEB_TOOL,
|
||||
DOC_TOOL_GET,
|
||||
DOC_TOOL_FIND,
|
||||
DOCS_TOOL_SEARCH,
|
||||
DOCS_TOOL_READ,
|
||||
EVIDENCE_TOOL_SEARCH,
|
||||
EVIDENCE_TOOL_READ,
|
||||
EVIDENCE_TOOL_OPEN,
|
||||
IMAGE_READ_TOOL,
|
||||
DOC_TOOL_INSERT_BLOCKS,
|
||||
DOC_TOOL_REPLACE_RANGE,
|
||||
|
||||
@@ -1737,22 +1737,6 @@ fn load_tool_data(
|
||||
let _ = read_doc_target(args, target)?;
|
||||
Err(retired_cloud_transport_error(tool_name))
|
||||
}
|
||||
"docs_read" => {
|
||||
let document_id = args
|
||||
.get("documentId")
|
||||
.and_then(Value::as_str)
|
||||
.ok_or_else(|| CliError::validation("docs_read 缺少 documentId"))?;
|
||||
let _ = document_id;
|
||||
Err(retired_cloud_transport_error(tool_name))
|
||||
}
|
||||
"docs_search" => {
|
||||
let workspace_id = args
|
||||
.get("workspaceId")
|
||||
.and_then(Value::as_str)
|
||||
.ok_or_else(|| CliError::validation("docs_search 缺少 workspaceId"))?;
|
||||
let _ = workspace_id;
|
||||
Err(retired_cloud_transport_error(tool_name))
|
||||
}
|
||||
"mindmap_get" | "mindmap_get_subtree" | "mindmap_apply_ops" | "mindmap_put" => {
|
||||
let document_id = read_doc_target(args, target)?;
|
||||
let mindmap_id = args
|
||||
|
||||
@@ -352,6 +352,10 @@ import {
|
||||
bbox: url.searchParams.get('bbox') || undefined,
|
||||
sourceMapPath: String(url.searchParams.get('sourceMapPath') || '').trim(),
|
||||
blockId: String(url.searchParams.get('blockId') || '').trim(),
|
||||
paragraphOrdinal: String(url.searchParams.get('paragraphOrdinal') || '').trim(),
|
||||
paraIdStart: String(url.searchParams.get('paraIdStart') || '').trim(),
|
||||
paraIdEnd: String(url.searchParams.get('paraIdEnd') || '').trim(),
|
||||
textFingerprint: String(url.searchParams.get('textFingerprint') || '').trim(),
|
||||
evidenceText: String(url.searchParams.get('evidenceText') || '').trim(),
|
||||
lineRange: url.searchParams.get('lineRange') || null,
|
||||
charRange: url.searchParams.get('charRange') || null,
|
||||
|
||||
@@ -988,7 +988,11 @@ export const createResourceTabRuntime = (dependencies = {}) => {
|
||||
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 && !evidenceText && !lineRange && !charRange) return null;
|
||||
const paragraphOrdinal = String(input.paragraphOrdinal ?? locator?.paragraphOrdinal ?? params.paragraphOrdinal ?? '').trim();
|
||||
const paraIdStart = String(input.paraIdStart || locator?.paraIdStart || params.paraIdStart || '').trim();
|
||||
const paraIdEnd = String(input.paraIdEnd || locator?.paraIdEnd || params.paraIdEnd || '').trim();
|
||||
const textFingerprint = String(input.textFingerprint || locator?.textFingerprint || params.textFingerprint || '').trim();
|
||||
if (!locator && !Number.isFinite(page) && !bbox && !sourceMapPath && !blockId && !evidenceText && !lineRange && !charRange && !paragraphOrdinal && !paraIdStart && !textFingerprint) return null;
|
||||
return {
|
||||
schema: 'mnote.evidence_locator.v1',
|
||||
...(locator || {}),
|
||||
@@ -1000,6 +1004,10 @@ export const createResourceTabRuntime = (dependencies = {}) => {
|
||||
searchQuery,
|
||||
lineRange,
|
||||
charRange,
|
||||
paragraphOrdinal,
|
||||
paraIdStart,
|
||||
paraIdEnd,
|
||||
textFingerprint,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1017,6 +1025,10 @@ 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.paragraphOrdinal) url.searchParams.set('paragraphOrdinal', String(locator.paragraphOrdinal));
|
||||
if (locator.paraIdStart) url.searchParams.set('paraIdStart', String(locator.paraIdStart));
|
||||
if (locator.paraIdEnd) url.searchParams.set('paraIdEnd', String(locator.paraIdEnd));
|
||||
if (locator.textFingerprint) url.searchParams.set('textFingerprint', String(locator.textFingerprint));
|
||||
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) {
|
||||
@@ -1038,6 +1050,10 @@ export const createResourceTabRuntime = (dependencies = {}) => {
|
||||
bbox,
|
||||
sourceMapPath: locator.sourceMapPath || '',
|
||||
blockId: locator.blockId || '',
|
||||
paragraphOrdinal: locator.paragraphOrdinal || '',
|
||||
paraIdStart: locator.paraIdStart || '',
|
||||
paraIdEnd: locator.paraIdEnd || '',
|
||||
textFingerprint: locator.textFingerprint || '',
|
||||
evidenceText: locator.evidenceText || '',
|
||||
searchQuery: locator.searchQuery || '',
|
||||
}, window.location.origin);
|
||||
|
||||
@@ -2038,6 +2038,22 @@ export function createSidebarPageAiRuntime(context) {
|
||||
});
|
||||
}
|
||||
|
||||
function pageAiKnowledgeRagFallbackQuery(prompt) {
|
||||
var text = searchText(prompt);
|
||||
if (!text) return '';
|
||||
var afterColon = text.split(/[::]/).slice(1).join(':').trim();
|
||||
var candidate = afterColon || text;
|
||||
candidate = candidate
|
||||
.replace(/^(请|请用|帮我|帮忙|用)?(资料库|知识库|lightrag|rag)?(搜索|检索|查找|查询|回答|说明|解释|总结)?/i, '')
|
||||
.replace(/(请)?(给出|给我|附上|提供)?(链接|引用|来源|出处|证据|定位).*$/i, '')
|
||||
.trim();
|
||||
var stopIndex = candidate.search(/(在|的|是|有哪些|有什么|如何|怎么|用于|用途|作用|资料|文献|论文)/);
|
||||
if (stopIndex > 0) candidate = candidate.slice(0, stopIndex).trim();
|
||||
var cjkMatch = candidate.match(/[A-Za-z0-9\u4e00-\u9fff·α-ωΑ-Ω\-]{2,24}/);
|
||||
if (cjkMatch && cjkMatch[0]) return cjkMatch[0];
|
||||
return text;
|
||||
}
|
||||
|
||||
function pageAiCleanDegradedCitationNotes(content) {
|
||||
return String(content || '')
|
||||
.split('\n')
|
||||
@@ -2050,38 +2066,92 @@ export function createSidebarPageAiRuntime(context) {
|
||||
.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;
|
||||
function pageAiCitationPrecisionLabel(value) {
|
||||
var precision = searchText(value || '').toLowerCase();
|
||||
if (precision === 'bbox') return '精确定位';
|
||||
if (precision === 'paragraph') return '段落级';
|
||||
if (precision === 'file') return '文件级';
|
||||
return '';
|
||||
}
|
||||
|
||||
function pageAiCitationSourceTitle(citation) {
|
||||
if (!citation || typeof citation !== 'object') return '';
|
||||
var path = searchText(citation.sourceRootRelativePath || citation.sourcePath || citation.filePath || citation.lightRagFilePath || '');
|
||||
if (path) {
|
||||
var parts = path.split('/').filter(Boolean);
|
||||
return parts[parts.length - 1] || path;
|
||||
}
|
||||
var markdown = searchText(citation.citationMarkdown || '');
|
||||
var labelMatch = markdown.match(/^\[([^\]]+)\]/);
|
||||
return labelMatch ? labelMatch[1] : '';
|
||||
}
|
||||
|
||||
function pageAiCitationDisplayMarkdown(value) {
|
||||
if (typeof value === 'string') return value.trim();
|
||||
if (!value || typeof value !== 'object') return '';
|
||||
var markdown = searchText(value.citationMarkdown || '');
|
||||
var citationId = searchText(value.citationLabel || (value.citationId ? '[' + value.citationId + ']' : ''));
|
||||
var title = pageAiCitationSourceTitle(value);
|
||||
var headingPath = pageAiNormalizeArray(value.headingPath).map(function(item) {
|
||||
return searchText(item);
|
||||
}).filter(Boolean).join(' > ');
|
||||
var precisionLabel = pageAiCitationPrecisionLabel(value.locatorPrecision);
|
||||
var quote = searchText(value.displayQuote || value.quote || '');
|
||||
if (quote.length > 120) quote = quote.slice(0, 117) + '...';
|
||||
var head = markdown || title;
|
||||
if (citationId && head.indexOf(citationId) < 0) head = citationId + ' ' + head;
|
||||
var details = [];
|
||||
if (precisionLabel) details.push(precisionLabel);
|
||||
if (headingPath) details.push(headingPath);
|
||||
if (quote) details.push(quote);
|
||||
return [head].concat(details).filter(Boolean).join(' · ').trim();
|
||||
}
|
||||
|
||||
function pageAiFilterCitationCards(values) {
|
||||
var cards = pageAiNormalizeArray(values);
|
||||
var hasPreciseCitation = cards.some(function(value) {
|
||||
if (typeof value === 'string') return value.indexOf('来源定位降级') < 0;
|
||||
return value && typeof value === 'object' && searchText(value.citationMarkdown || '') && value.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 cards.map(pageAiCitationDisplayMarkdown).filter(function(card, index) {
|
||||
var source = cards[index];
|
||||
if (!card || seen[card]) return false;
|
||||
if (hasPreciseCitation) {
|
||||
if (typeof source === 'string' && source.indexOf('来源定位降级') >= 0) return false;
|
||||
if (source && typeof source === 'object' && source.locatorDegraded === true) return false;
|
||||
}
|
||||
seen[card] = true;
|
||||
return true;
|
||||
}).slice(0, 5);
|
||||
}
|
||||
|
||||
function pageAiCollectKnowledgeRagApiCitations(payload) {
|
||||
var structuredCitations = pageAiNormalizeArray(payload && payload.citations);
|
||||
if (structuredCitations.length) {
|
||||
return pageAiFilterCitationCards(structuredCitations);
|
||||
}
|
||||
var references = pageAiNormalizeArray(payload && payload.references);
|
||||
return pageAiFilterCitationCards(references);
|
||||
}
|
||||
|
||||
async function pageAiAppendKnowledgeRagFallbackCitations(runId, promptText) {
|
||||
if (!pageAiPromptNeedsKnowledgeRagCitations(promptText)) return;
|
||||
var rootUri = currentRootUri();
|
||||
if (!rootUri) return;
|
||||
var fallbackQuery = pageAiKnowledgeRagFallbackQuery(promptText);
|
||||
if (!fallbackQuery) return;
|
||||
try {
|
||||
var response = await fetch('/api/knowledge-rag/query', {
|
||||
var response = await fetch('/api/knowledge-rag/search', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json', 'accept': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
workspaceId: resolveWorkspaceId(document.body) || undefined,
|
||||
rootUri: rootUri,
|
||||
query: String(promptText || '').trim(),
|
||||
query: fallbackQuery,
|
||||
mode: 'mix',
|
||||
topK: 8,
|
||||
chunkTopK: 8,
|
||||
topK: 12,
|
||||
chunkTopK: 24,
|
||||
includeChunkContent: true
|
||||
})
|
||||
});
|
||||
@@ -2193,10 +2263,11 @@ export function createSidebarPageAiRuntime(context) {
|
||||
return;
|
||||
}
|
||||
if (typeof node !== 'object') return;
|
||||
if (typeof node.citationMarkdown === 'string') add(node.citationMarkdown);
|
||||
if (typeof node.citationMarkdown === 'string') add(pageAiCitationDisplayMarkdown(node));
|
||||
if (Array.isArray(node.citationMarkdowns)) {
|
||||
node.citationMarkdowns.forEach(function(value) {
|
||||
if (typeof value === 'string') add(value);
|
||||
else if (value && typeof value === 'object') add(pageAiCitationDisplayMarkdown(value));
|
||||
else visit(value);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1342,9 +1342,21 @@ export function createSidebarPageSettingsRuntime(context) {
|
||||
var config = healthBody.configuration && typeof healthBody.configuration === 'object' ? healthBody.configuration : {};
|
||||
var llmModel = String(config.llm_model || config.llmModel || '');
|
||||
var vlmModel = String(config.vlm_model || config.vlmModel || '');
|
||||
var rerank = status.rerank && typeof status.rerank === 'object' ? status.rerank : {};
|
||||
var rerankStatus = String(rerank.status || 'unknown');
|
||||
var rerankBinding = searchText(rerank.binding || '');
|
||||
var rerankModel = searchText(rerank.model || '');
|
||||
var rerankLabel = rerankStatus === 'disabled'
|
||||
? 'disabled'
|
||||
: rerankStatus === 'available'
|
||||
? 'available'
|
||||
: rerankStatus === 'unavailable'
|
||||
? 'unavailable'
|
||||
: 'unknown';
|
||||
metaNode.innerHTML = '' +
|
||||
'<div><span>Dashboard</span><code>' + escapeHtml(dashboardUrl || '未配置') + '</code></div>' +
|
||||
'<div><span>Model</span><code>' + escapeHtml(llmModel || '未上报') + (vlmModel ? ' / VLM ' + escapeHtml(vlmModel) : '') + '</code></div>' +
|
||||
'<div><span>Rerank</span><code>' + escapeHtml(rerankLabel + (rerankBinding ? ' · ' + rerankBinding : '') + (rerankModel ? ' · ' + rerankModel : '')) + '</code></div>' +
|
||||
'<div><span>Input</span><code>' + escapeHtml(inputDir || '未配置') + '</code></div>' +
|
||||
'<div><span>Storage</span><code>' + escapeHtml(workingDir || '未上报') + '</code></div>';
|
||||
}
|
||||
|
||||
@@ -2135,6 +2135,7 @@ import { createSidebarPageSettingsRuntime } from './sidebar-page-settings-runtim
|
||||
'<div class="wolai-search-options-left">' +
|
||||
'<span class="wolai-search-switch-control"><span>仅匹配标题</span><button type="button" class="wolai-search-switch" data-search-switch="title" 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="exact" role="switch" aria-checked="false" aria-label="精确匹配"></button></span>' +
|
||||
'<span class="wolai-search-sort-control"><span>资料库模式</span><select class="wolai-search-sort-value" data-search-knowledge-mode aria-label="资料库检索模式"><option value="mix">综合</option><option value="hybrid">图谱混合</option><option value="naive">向量</option><option value="local">实体</option><option value="global">关系</option><option value="exact">关键词</option></select></span>' +
|
||||
'<span class="wolai-search-sort-control"><span>按编辑时间</span><button type="button" class="wolai-search-sort-value" data-search-sort="updated" aria-label="按编辑时间范围">所有</button></span>' +
|
||||
'<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>' +
|
||||
@@ -2170,6 +2171,13 @@ import { createSidebarPageSettingsRuntime } from './sidebar-page-settings-runtim
|
||||
scheduleSearchResultsRender();
|
||||
});
|
||||
});
|
||||
var knowledgeModeSelect = overlay.querySelector('[data-search-knowledge-mode]');
|
||||
if (knowledgeModeSelect) {
|
||||
knowledgeModeSelect.addEventListener('change', function() {
|
||||
searchUiState.hasRendered = false;
|
||||
scheduleSearchResultsRender();
|
||||
});
|
||||
}
|
||||
if (closeButton) closeButton.addEventListener('click', closeSearchModal);
|
||||
overlay.addEventListener('click', function(event) {
|
||||
if (event.target === overlay) closeSearchModal();
|
||||
@@ -2186,6 +2194,7 @@ import { createSidebarPageSettingsRuntime } from './sidebar-page-settings-runtim
|
||||
metaHtml: '',
|
||||
resultsHtml: '',
|
||||
items: [],
|
||||
knowledgeMode: 'mix',
|
||||
sourceCollapsed: {},
|
||||
scrollTop: 0,
|
||||
signature: '',
|
||||
@@ -2236,6 +2245,27 @@ import { createSidebarPageSettingsRuntime } from './sidebar-page-settings-runtim
|
||||
return button instanceof HTMLElement && button.getAttribute('aria-checked') === 'true';
|
||||
}
|
||||
|
||||
function searchKnowledgeModeValue(overlay) {
|
||||
var select = overlay.querySelector('[data-search-knowledge-mode]');
|
||||
var value = select && 'value' in select ? searchText(select.value).toLowerCase() : '';
|
||||
if (['mix', 'hybrid', 'naive', 'local', 'global', 'exact'].indexOf(value) >= 0) return value;
|
||||
return 'mix';
|
||||
}
|
||||
|
||||
function knowledgeSearchRequestMode(overlay) {
|
||||
if (searchSwitchValue(overlay, 'exact')) return 'exact';
|
||||
return searchKnowledgeModeValue(overlay);
|
||||
}
|
||||
|
||||
function knowledgeSearchModeLabel(mode) {
|
||||
if (mode === 'exact') return '关键词';
|
||||
if (mode === 'naive') return '向量';
|
||||
if (mode === 'local') return '实体';
|
||||
if (mode === 'global') return '关系';
|
||||
if (mode === 'hybrid') return '图谱混合';
|
||||
return '综合';
|
||||
}
|
||||
|
||||
function readSearchCollapseSourcesDefault() {
|
||||
try {
|
||||
var stored = window.localStorage && window.localStorage.getItem(SEARCH_COLLAPSE_SOURCES_KEY);
|
||||
@@ -2284,6 +2314,7 @@ import { createSidebarPageSettingsRuntime } from './sidebar-page-settings-runtim
|
||||
title: Boolean(switches.title),
|
||||
exact: Boolean(switches.exact),
|
||||
collapseSource: Boolean(switches.collapseSource),
|
||||
knowledgeMode: searchKnowledgeModeValue(overlay),
|
||||
workspaceId: resolveWorkspaceId(document.body) || '',
|
||||
rootUri: currentRootUri() || ''
|
||||
});
|
||||
@@ -2297,6 +2328,7 @@ import { createSidebarPageSettingsRuntime } from './sidebar-page-settings-runtim
|
||||
var query = input && 'value' in input ? searchText(input.value) : searchUiState.query;
|
||||
searchUiState.query = query;
|
||||
searchUiState.switches = collectSearchSwitchState(overlay);
|
||||
searchUiState.knowledgeMode = searchKnowledgeModeValue(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;
|
||||
@@ -2312,6 +2344,8 @@ import { createSidebarPageSettingsRuntime } from './sidebar-page-settings-runtim
|
||||
var results = overlay.querySelector('[data-testid="wolai-search-results"]');
|
||||
if (input && 'value' in input) input.value = searchUiState.query || '';
|
||||
applySearchSwitchState(overlay, searchUiState.switches);
|
||||
var knowledgeModeSelect = overlay.querySelector('[data-search-knowledge-mode]');
|
||||
if (knowledgeModeSelect && 'value' in knowledgeModeSelect) knowledgeModeSelect.value = searchUiState.knowledgeMode || 'mix';
|
||||
if (options instanceof HTMLElement) options.hidden = !searchText(searchUiState.query);
|
||||
if (meta && searchUiState.metaHtml) meta.innerHTML = searchUiState.metaHtml;
|
||||
if (results && searchUiState.resultsHtml) {
|
||||
@@ -2421,7 +2455,8 @@ import { createSidebarPageSettingsRuntime } from './sidebar-page-settings-runtim
|
||||
|
||||
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 snippet = searchText(item.displayQuote || item.snippet || item.evidence && item.evidence.displayQuote || item.evidence && item.evidence.quote || (Array.isArray(item.evidence) && item.evidence[0] && (item.evidence[0].displayQuote || item.evidence[0].quote || item.evidence[0].snippet)) || '');
|
||||
if (!item.displayQuote) snippet = cleanSearchDisplayText(snippet);
|
||||
var path = searchText(item.path || item.parentTitle || item.workspaceName || currentWorkspaceName());
|
||||
var resourceType = searchText(item.resourceType || item.resourceKind || 'page');
|
||||
var locator = searchResultEvidenceLocator(item);
|
||||
@@ -2545,10 +2580,11 @@ import { createSidebarPageSettingsRuntime } from './sidebar-page-settings-runtim
|
||||
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))
|
||||
var searchQueryText = searchText(item && (item.searchQuery || item.query) || locator && locator.openAction && locator.openAction.params && locator.openAction.params.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 locatorEvidenceText = searchText(item && item.locatorEvidenceText || locator.evidenceText || locator.query || locator.openAction && locator.openAction.params && (locator.openAction.params.evidenceText || locator.openAction.params.query) || '');
|
||||
var locatorParams = locator && locator.openAction && locator.openAction.params && typeof locator.openAction.params === 'object' ? locator.openAction.params : {};
|
||||
var openTarget = event && event.altKey ? 'side' : 'active-tab';
|
||||
if (resourcePath && resourceKind) {
|
||||
var fileName = resourcePath.split('/').filter(Boolean).pop() || resourcePath;
|
||||
@@ -2573,6 +2609,10 @@ import { createSidebarPageSettingsRuntime } from './sidebar-page-settings-runtim
|
||||
bbox: locator.bbox,
|
||||
sourceMapPath: searchText(locator.sourceMapPath || locator.source_map_path),
|
||||
blockId: evidenceLocatorBlockId(locator),
|
||||
paragraphOrdinal: searchText(locator.paragraphOrdinal || locatorParams.paragraphOrdinal),
|
||||
paraIdStart: searchText(locator.paraIdStart || locatorParams.paraIdStart),
|
||||
paraIdEnd: searchText(locator.paraIdEnd || locatorParams.paraIdEnd),
|
||||
textFingerprint: searchText(locator.textFingerprint || locatorParams.textFingerprint),
|
||||
evidenceText: locatorEvidenceText,
|
||||
query: searchQueryText,
|
||||
searchQuery: searchQueryText,
|
||||
@@ -2593,6 +2633,10 @@ import { createSidebarPageSettingsRuntime } from './sidebar-page-settings-runtim
|
||||
var bbox = evidenceLocatorBbox(locator);
|
||||
if (bbox) target.searchParams.set('bbox', bbox);
|
||||
if (sourceMapPath) target.searchParams.set('sourceMapPath', sourceMapPath);
|
||||
if (locatorParams.paragraphOrdinal != null) target.searchParams.set('paragraphOrdinal', searchText(locatorParams.paragraphOrdinal));
|
||||
if (locatorParams.paraIdStart) target.searchParams.set('paraIdStart', searchText(locatorParams.paraIdStart));
|
||||
if (locatorParams.paraIdEnd) target.searchParams.set('paraIdEnd', searchText(locatorParams.paraIdEnd));
|
||||
if (locatorParams.textFingerprint) target.searchParams.set('textFingerprint', searchText(locatorParams.textFingerprint));
|
||||
window.location.assign(target.pathname + target.search + target.hash);
|
||||
} catch (_) {
|
||||
window.location.assign(url);
|
||||
@@ -2643,6 +2687,7 @@ import { createSidebarPageSettingsRuntime } from './sidebar-page-settings-runtim
|
||||
return;
|
||||
}
|
||||
var requestId = ++activeSearchRequestId;
|
||||
var knowledgeRequestMode = knowledgeMode ? knowledgeSearchRequestMode(overlay) : '';
|
||||
meta.innerHTML = '<span>搜索中…</span><span>Ctrl + Enter 新窗口打开 / Alt + Enter 右侧边栏打开</span>';
|
||||
results.innerHTML = '<div class="wolai-search-empty" data-search-loading="true">搜索中…</div>';
|
||||
try {
|
||||
@@ -2653,7 +2698,7 @@ import { createSidebarPageSettingsRuntime } from './sidebar-page-settings-runtim
|
||||
workspaceId: resolveWorkspaceId(document.body),
|
||||
rootUri: currentRootUri() || null,
|
||||
query: query,
|
||||
mode: 'hybrid',
|
||||
mode: knowledgeRequestMode,
|
||||
topK: 12,
|
||||
chunkTopK: 24,
|
||||
includeChunkContent: true
|
||||
@@ -2677,7 +2722,8 @@ 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>' + (knowledgeMode ? '资料库检索' : '工作区搜索') + ' · 共 ' + items.length + ' 条匹配结果</span><span>Ctrl + Enter 新窗口打开 / Alt + Enter 右侧边栏打开</span>';
|
||||
var ownerLabel = knowledgeMode ? ('资料库检索 · ' + knowledgeSearchModeLabel(payload.retrievalMode || knowledgeRequestMode)) : '工作区搜索';
|
||||
meta.innerHTML = '<span>' + ownerLabel + ' · 共 ' + 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 = [];
|
||||
|
||||
@@ -49,11 +49,29 @@ pub struct AcpRunBridge {
|
||||
}
|
||||
|
||||
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_citation_value(value: &Value, seen: &mut HashSet<String>, out: &mut Vec<Value>) {
|
||||
let Some(citation) = value.get("citationMarkdown").and_then(Value::as_str) else {
|
||||
return;
|
||||
};
|
||||
let citation = citation.trim();
|
||||
if citation.is_empty() || !seen.insert(citation.to_string()) {
|
||||
return;
|
||||
}
|
||||
out.push(json!({
|
||||
"schema": value.get("schema").cloned().unwrap_or_else(|| json!("mnote.knowledge_rag.citation.v1")),
|
||||
"citationMarkdown": citation,
|
||||
"citationId": value.get("citationId").cloned().unwrap_or(Value::Null),
|
||||
"citationLabel": value.get("citationLabel").cloned().unwrap_or(Value::Null),
|
||||
"sourceRootRelativePath": value.get("sourceRootRelativePath").cloned().unwrap_or(Value::Null),
|
||||
"sourcePath": value.get("sourcePath").cloned().unwrap_or(Value::Null),
|
||||
"filePath": value.get("filePath").or_else(|| value.get("lightRagFilePath")).cloned().unwrap_or(Value::Null),
|
||||
"headingPath": value.get("headingPath").cloned().unwrap_or_else(|| json!([])),
|
||||
"displayQuote": value.get("displayQuote").or_else(|| value.get("quote")).cloned().unwrap_or(Value::Null),
|
||||
"locatorEvidenceText": value.get("locatorEvidenceText").cloned().unwrap_or(Value::Null),
|
||||
"locatorPrecision": value.get("locatorPrecision").cloned().unwrap_or(Value::Null),
|
||||
"locatorDegraded": value.get("locatorDegraded").cloned().unwrap_or(Value::Bool(true)),
|
||||
"citationUrl": value.get("citationUrl").cloned().unwrap_or(Value::Null),
|
||||
}));
|
||||
}
|
||||
|
||||
fn add_reference_citations(
|
||||
@@ -73,7 +91,7 @@ fn collect_citation_markdowns_from_value(value: &Value) -> Vec<Value> {
|
||||
if out.len() >= 8 {
|
||||
break;
|
||||
}
|
||||
let Some(citation) = reference.get("citationMarkdown").and_then(Value::as_str) else {
|
||||
let Some(_citation) = reference.get("citationMarkdown").and_then(Value::as_str) else {
|
||||
continue;
|
||||
};
|
||||
if has_precise
|
||||
@@ -82,7 +100,7 @@ fn collect_citation_markdowns_from_value(value: &Value) -> Vec<Value> {
|
||||
continue;
|
||||
}
|
||||
let before = out.len();
|
||||
add_citation(citation, seen, out);
|
||||
add_citation_value(reference, seen, out);
|
||||
added = added || out.len() > before;
|
||||
}
|
||||
added
|
||||
@@ -120,9 +138,9 @@ fn collect_citation_markdowns_from_value(value: &Value) -> Vec<Value> {
|
||||
.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 let Some(_citation) = map.get("citationMarkdown").and_then(Value::as_str) {
|
||||
if !has_filtered_references {
|
||||
add_citation(citation, seen, out);
|
||||
add_citation_value(value, seen, out);
|
||||
}
|
||||
}
|
||||
for (key, item) in map {
|
||||
|
||||
@@ -1,245 +0,0 @@
|
||||
use crate::app::AppState;
|
||||
use crate::context::RequestContext;
|
||||
use crate::error::WebError;
|
||||
use crate::hermes_tools::{doc, ToolCallInput};
|
||||
use crate::routes;
|
||||
use axum::http::StatusCode;
|
||||
use core_protocol::{
|
||||
EvidenceLocator, EvidenceOpenAction, EvidenceOpenRequest, EvidenceReadRequest,
|
||||
EvidenceResourceKind, EvidenceSearchRequest, EVIDENCE_LOCATOR_SCHEMA,
|
||||
};
|
||||
use serde_json::{json, Value};
|
||||
|
||||
pub async fn evidence_search(
|
||||
state: &AppState,
|
||||
context: &RequestContext,
|
||||
input: &ToolCallInput,
|
||||
) -> Result<Value, WebError> {
|
||||
let body = evidence_search_request(input, context)?;
|
||||
routes::evidence::search_payload(state, context, body).await
|
||||
}
|
||||
|
||||
pub async fn evidence_read(
|
||||
state: &AppState,
|
||||
context: &RequestContext,
|
||||
input: &ToolCallInput,
|
||||
) -> Result<Value, WebError> {
|
||||
let args = input.args.clone().unwrap_or_else(|| json!({}));
|
||||
let body = serde_json::from_value::<EvidenceReadRequest>(args).map_err(|error| {
|
||||
WebError::bad_request_code(
|
||||
"mnote_evidence_read_payload_invalid",
|
||||
format!("Evidence read 参数无效: {error}"),
|
||||
)
|
||||
.with_context(context)
|
||||
})?;
|
||||
routes::evidence::read_payload(state, context, body).await
|
||||
}
|
||||
|
||||
pub async fn evidence_open(
|
||||
state: &AppState,
|
||||
context: &RequestContext,
|
||||
input: &ToolCallInput,
|
||||
) -> Result<Value, WebError> {
|
||||
let args = input.args.clone().unwrap_or_else(|| json!({}));
|
||||
let body = serde_json::from_value::<EvidenceOpenRequest>(args).map_err(|error| {
|
||||
WebError::bad_request_code(
|
||||
"mnote_evidence_open_payload_invalid",
|
||||
format!("Evidence open 参数无效: {error}"),
|
||||
)
|
||||
.with_context(context)
|
||||
})?;
|
||||
routes::evidence::open_payload(state, context, body).await
|
||||
}
|
||||
|
||||
pub async fn legacy_docs_search(
|
||||
state: &AppState,
|
||||
context: &RequestContext,
|
||||
input: &ToolCallInput,
|
||||
) -> Result<Value, WebError> {
|
||||
let payload = evidence_search(state, context, input).await?;
|
||||
Ok(json!({
|
||||
"ok": payload.get("ok").cloned().unwrap_or_else(|| json!(true)),
|
||||
"compatTool": "docs_search",
|
||||
"results": payload.get("results").cloned().unwrap_or_else(|| json!([])),
|
||||
"evidence": payload.get("results").cloned().unwrap_or_else(|| json!([])),
|
||||
"source": "mnote.evidence.search",
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn legacy_docs_read(
|
||||
state: &AppState,
|
||||
context: &RequestContext,
|
||||
input: &ToolCallInput,
|
||||
) -> Result<Value, WebError> {
|
||||
if input.arg_value("locator").is_some() {
|
||||
let payload = evidence_read(state, context, input).await?;
|
||||
return Ok(json!({
|
||||
"ok": payload.get("ok").cloned().unwrap_or_else(|| json!(true)),
|
||||
"compatTool": "docs_read",
|
||||
"result": payload,
|
||||
"source": "mnote.evidence.read",
|
||||
}));
|
||||
}
|
||||
|
||||
let document = doc::doc_fetch(state, context, input).await?;
|
||||
let locator = legacy_document_locator(input);
|
||||
Ok(json!({
|
||||
"ok": document.get("ok").cloned().unwrap_or_else(|| json!(true)),
|
||||
"compatTool": "docs_read",
|
||||
"documentId": input.effective_document_id(),
|
||||
"document": document,
|
||||
"evidence": locator.as_ref().map(|locator| json!({ "source": locator })),
|
||||
"source": {
|
||||
"tool": "mnote.doc.fetch",
|
||||
"locator": locator,
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
fn evidence_search_request(
|
||||
input: &ToolCallInput,
|
||||
context: &RequestContext,
|
||||
) -> Result<EvidenceSearchRequest, WebError> {
|
||||
let mut args = input.args.clone().unwrap_or_else(|| json!({}));
|
||||
if args.get("scope").is_none() {
|
||||
let query = input.arg_string("query").ok_or_else(|| {
|
||||
WebError::bad_request_code("mnote_evidence_query_required", "Evidence 搜索缺少 query")
|
||||
.with_context(context)
|
||||
})?;
|
||||
let workspace_id = input.effective_workspace_id().ok_or_else(|| {
|
||||
WebError::bad_request_code(
|
||||
"mnote_evidence_workspace_required",
|
||||
"Evidence 搜索缺少 workspaceId",
|
||||
)
|
||||
.with_context(context)
|
||||
})?;
|
||||
let root_uri = local_root_uri_for_evidence(input).ok_or_else(|| {
|
||||
WebError::bad_request_code("mnote_evidence_root_required", "Evidence 搜索缺少 rootUri")
|
||||
.with_context(context)
|
||||
})?;
|
||||
let include_resources = input
|
||||
.args
|
||||
.as_ref()
|
||||
.and_then(|value| value.get("includeResources"))
|
||||
.and_then(Value::as_bool)
|
||||
.unwrap_or(true);
|
||||
let include_ocr = input
|
||||
.args
|
||||
.as_ref()
|
||||
.and_then(|value| value.get("includeOcr"))
|
||||
.and_then(Value::as_bool)
|
||||
.unwrap_or(true);
|
||||
let target_document_id = input
|
||||
.effective_document_id()
|
||||
.or_else(|| input.arg_string("pageId"))
|
||||
.or_else(|| input.arg_string("targetDocumentId"));
|
||||
args = json!({
|
||||
"query": query,
|
||||
"scope": {
|
||||
"workspaceId": workspace_id,
|
||||
"rootUri": root_uri,
|
||||
"targetDocumentId": target_document_id,
|
||||
"includeResources": include_resources,
|
||||
"includeOcr": include_ocr,
|
||||
},
|
||||
"mode": input.arg_string("mode").unwrap_or_else(|| "hybrid".into()),
|
||||
"topK": input
|
||||
.args
|
||||
.as_ref()
|
||||
.and_then(|value| value.get("topK").or_else(|| value.get("limit")))
|
||||
.and_then(Value::as_u64)
|
||||
.unwrap_or(8),
|
||||
});
|
||||
}
|
||||
serde_json::from_value::<EvidenceSearchRequest>(args).map_err(|error| {
|
||||
WebError::new(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"mnote_evidence_search_payload_invalid",
|
||||
format!("Evidence search 参数无效: {error}"),
|
||||
)
|
||||
.with_context(context)
|
||||
})
|
||||
}
|
||||
|
||||
fn legacy_document_locator(input: &ToolCallInput) -> Option<EvidenceLocator> {
|
||||
let root_uri = local_root_uri_for_evidence(input)?;
|
||||
let document_id = input.effective_document_id()?;
|
||||
let owner_document_path =
|
||||
document_path_from_local_id(&document_id).unwrap_or_else(|| document_id.trim().to_string());
|
||||
Some(EvidenceLocator {
|
||||
schema: EVIDENCE_LOCATOR_SCHEMA.into(),
|
||||
root_uri: root_uri.clone(),
|
||||
owner_document_id: document_id,
|
||||
owner_document_path: owner_document_path.clone(),
|
||||
resource_path: Some(owner_document_path.clone()),
|
||||
resource_kind: EvidenceResourceKind::Markdown,
|
||||
page: None,
|
||||
bbox: None,
|
||||
section_path: Vec::new(),
|
||||
line_range: None,
|
||||
char_range: None,
|
||||
block_id: None,
|
||||
source_map_path: None,
|
||||
open_action: EvidenceOpenAction {
|
||||
action_type: "mnote.open_resource_locator".into(),
|
||||
url: "/".into(),
|
||||
params: json!({
|
||||
"rootUri": root_uri,
|
||||
"ownerDocumentPath": owner_document_path,
|
||||
}),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
fn local_root_uri_for_evidence(input: &ToolCallInput) -> Option<String> {
|
||||
input.effective_root_uri().or_else(|| {
|
||||
input
|
||||
.arg_value("aiAccessScope")
|
||||
.and_then(|scope| {
|
||||
scope
|
||||
.get("allowedRoots")
|
||||
.or_else(|| scope.get("allowed_roots"))
|
||||
.cloned()
|
||||
})
|
||||
.and_then(|allowed_roots| {
|
||||
allowed_roots.as_array().and_then(|roots| {
|
||||
roots
|
||||
.iter()
|
||||
.filter_map(|root| {
|
||||
root.get("rootUri")
|
||||
.or_else(|| root.get("root_uri"))
|
||||
.and_then(Value::as_str)
|
||||
})
|
||||
.map(str::trim)
|
||||
.find(|root_uri| !root_uri.is_empty())
|
||||
.map(ToOwned::to_owned)
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
fn document_path_from_local_id(document_id: &str) -> Option<String> {
|
||||
let encoded = document_id.trim().strip_prefix("local-md:")?;
|
||||
decode_local_id_segment(encoded)
|
||||
}
|
||||
|
||||
fn decode_local_id_segment(value: &str) -> Option<String> {
|
||||
let bytes = value.as_bytes();
|
||||
let mut decoded = Vec::with_capacity(bytes.len());
|
||||
let mut index = 0;
|
||||
while index < bytes.len() {
|
||||
if bytes[index] == b'~' {
|
||||
if index + 2 >= bytes.len() {
|
||||
return None;
|
||||
}
|
||||
let hex = &value[index + 1..index + 3];
|
||||
let byte = u8::from_str_radix(hex, 16).ok()?;
|
||||
decoded.push(byte);
|
||||
index += 3;
|
||||
} else {
|
||||
decoded.push(bytes[index]);
|
||||
index += 1;
|
||||
}
|
||||
}
|
||||
String::from_utf8(decoded).ok()
|
||||
}
|
||||
@@ -112,21 +112,33 @@ fn compact_query_result_for_agent(payload: Value) -> Value {
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let citation_references = citation_references_for_ui(&references);
|
||||
let citations = citation_references
|
||||
let payload_citations = payload
|
||||
.get("citations")
|
||||
.and_then(Value::as_array)
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
let citations = if payload_citations.is_empty() {
|
||||
citation_references_for_ui(&references)
|
||||
.into_iter()
|
||||
.map(compact_citation_for_agent)
|
||||
.collect::<Vec<_>>()
|
||||
} else {
|
||||
citation_values_for_ui(&payload_citations)
|
||||
.into_iter()
|
||||
.map(compact_citation_for_agent)
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
let citation_markdowns = citations
|
||||
.iter()
|
||||
.filter_map(|reference| {
|
||||
reference
|
||||
.filter_map(|citation| {
|
||||
citation
|
||||
.get("citationMarkdown")
|
||||
.and_then(Value::as_str)
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.map(ToOwned::to_owned)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let ui_citations = citations
|
||||
.iter()
|
||||
.map(|citation| json!({ "citationMarkdown": citation }))
|
||||
.collect::<Vec<_>>();
|
||||
let ui_citations = citations.clone();
|
||||
json!({
|
||||
"ok": payload.get("ok").cloned().unwrap_or_else(|| json!(true)),
|
||||
"schema": "mnote.knowledge_rag.agent_query_result.v1",
|
||||
@@ -134,6 +146,7 @@ fn compact_query_result_for_agent(payload: Value) -> Value {
|
||||
"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,
|
||||
"citationMarkdowns": citation_markdowns,
|
||||
"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())),
|
||||
@@ -148,7 +161,13 @@ fn compact_query_result_for_agent(payload: Value) -> Value {
|
||||
|
||||
fn compact_reference_for_agent(reference: &Value) -> Value {
|
||||
let quote = reference
|
||||
.get("quote")
|
||||
.get("displayQuote")
|
||||
.and_then(Value::as_str)
|
||||
.or_else(|| reference.get("quote").and_then(Value::as_str))
|
||||
.map(|value| value.chars().take(700).collect::<String>())
|
||||
.unwrap_or_default();
|
||||
let locator_evidence_text = reference
|
||||
.get("locatorEvidenceText")
|
||||
.and_then(Value::as_str)
|
||||
.map(|value| value.chars().take(700).collect::<String>())
|
||||
.unwrap_or_default();
|
||||
@@ -159,28 +178,74 @@ fn compact_reference_for_agent(reference: &Value) -> Value {
|
||||
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")),
|
||||
"citationId": reference.get("citationId").cloned().unwrap_or(Value::Null),
|
||||
"citationLabel": reference.get("citationLabel").cloned().unwrap_or(Value::Null),
|
||||
"filePath": reference.get("filePath").cloned().unwrap_or(Value::Null),
|
||||
"sourceRootRelativePath": reference.get("sourceRootRelativePath").cloned().unwrap_or(Value::Null),
|
||||
"chunkId": reference.get("chunkId").cloned().unwrap_or(Value::Null),
|
||||
"quote": quote,
|
||||
"displayQuote": reference.get("displayQuote").cloned().unwrap_or(Value::Null),
|
||||
"locatorEvidenceText": locator_evidence_text,
|
||||
"quoteSource": reference.get("quoteSource").cloned().unwrap_or(Value::Null),
|
||||
"locator": reference.get("locator").cloned().unwrap_or(Value::Null),
|
||||
"locatorPrecision": reference.get("locatorPrecision").cloned().unwrap_or(Value::Null),
|
||||
"locatorDegraded": reference.get("locatorDegraded").cloned().unwrap_or(Value::Bool(true)),
|
||||
"contentDiagnostics": quote_diagnostics,
|
||||
"citationDiagnostics": reference.get("citationDiagnostics").cloned().unwrap_or(Value::Null),
|
||||
"citationMarkdown": reference.get("citationMarkdown").cloned().unwrap_or(Value::Null),
|
||||
"citationUrl": reference.get("citationUrl").cloned().unwrap_or(Value::Null),
|
||||
})
|
||||
}
|
||||
|
||||
fn compact_citation_for_agent(citation: &Value) -> Value {
|
||||
let display_quote = citation
|
||||
.get("displayQuote")
|
||||
.and_then(Value::as_str)
|
||||
.or_else(|| citation.get("quote").and_then(Value::as_str))
|
||||
.map(|value| value.chars().take(420).collect::<String>())
|
||||
.unwrap_or_default();
|
||||
let locator_evidence_text = citation
|
||||
.get("locatorEvidenceText")
|
||||
.and_then(Value::as_str)
|
||||
.map(|value| value.chars().take(420).collect::<String>())
|
||||
.unwrap_or_default();
|
||||
json!({
|
||||
"schema": citation.get("schema").cloned().unwrap_or_else(|| json!("mnote.knowledge_rag.citation.v1")),
|
||||
"provider": citation.get("provider").cloned().unwrap_or_else(|| json!("lightrag")),
|
||||
"citationId": citation.get("citationId").cloned().unwrap_or(Value::Null),
|
||||
"citationLabel": citation.get("citationLabel").cloned().unwrap_or(Value::Null),
|
||||
"sourceId": citation.get("sourceId").cloned().unwrap_or(Value::Null),
|
||||
"sourcePath": citation.get("sourcePath").cloned().unwrap_or(Value::Null),
|
||||
"sourceRootRelativePath": citation.get("sourceRootRelativePath").cloned().unwrap_or(Value::Null),
|
||||
"filePath": citation.get("filePath").or_else(|| citation.get("lightRagFilePath")).cloned().unwrap_or(Value::Null),
|
||||
"chunkId": citation.get("chunkId").or_else(|| citation.get("lightRagChunkId")).cloned().unwrap_or(Value::Null),
|
||||
"blockId": citation.get("blockId").cloned().unwrap_or(Value::Null),
|
||||
"headingPath": citation.get("headingPath").cloned().unwrap_or_else(|| json!([])),
|
||||
"quote": display_quote,
|
||||
"displayQuote": citation.get("displayQuote").cloned().unwrap_or(Value::Null),
|
||||
"locatorEvidenceText": locator_evidence_text,
|
||||
"searchQuery": citation.get("searchQuery").cloned().unwrap_or(Value::Null),
|
||||
"locatorPrecision": citation.get("locatorPrecision").cloned().unwrap_or(Value::Null),
|
||||
"locatorDegraded": citation.get("locatorDegraded").cloned().unwrap_or(Value::Bool(true)),
|
||||
"citationMarkdown": citation.get("citationMarkdown").cloned().unwrap_or(Value::Null),
|
||||
"citationUrl": citation.get("citationUrl").cloned().unwrap_or(Value::Null),
|
||||
"diagnostics": citation.get("diagnostics").or_else(|| citation.get("citationDiagnostics")).cloned().unwrap_or(Value::Null),
|
||||
})
|
||||
}
|
||||
|
||||
fn citation_references_for_ui(references: &[Value]) -> Vec<&Value> {
|
||||
let has_precise = references.iter().any(|reference| {
|
||||
citation_values_for_ui(references)
|
||||
}
|
||||
|
||||
fn citation_values_for_ui(values: &[Value]) -> Vec<&Value> {
|
||||
let has_precise = values.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
|
||||
values
|
||||
.iter()
|
||||
.filter(|reference| {
|
||||
reference
|
||||
@@ -269,6 +334,41 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compact_query_result_prefers_structured_citations() {
|
||||
let payload = json!({
|
||||
"references": [{
|
||||
"sourceRootRelativePath": "docs/a.docx",
|
||||
"displayQuote": "raw reference should not be source card",
|
||||
"locatorDegraded": false,
|
||||
"citationMarkdown": "[docs/a.docx](/documents/a)"
|
||||
}],
|
||||
"citations": [{
|
||||
"schema": "mnote.knowledge_rag.citation.v1",
|
||||
"citationId": "c0de",
|
||||
"citationLabel": "[c0de]",
|
||||
"sourceRootRelativePath": "docs/a.docx",
|
||||
"headingPath": ["保护基", "硅基保护"],
|
||||
"displayQuote": "吡咯烷,5 h,90%",
|
||||
"locatorEvidenceText": "如果叔丁基用 TFA 裂解, 吡咯烷将不会去除 BOC 亚乙基。",
|
||||
"locatorPrecision": "paragraph",
|
||||
"locatorDegraded": false,
|
||||
"citationMarkdown": "[docs/a.docx](/documents/a)"
|
||||
}]
|
||||
});
|
||||
|
||||
let compact = compact_query_result_for_agent(payload);
|
||||
|
||||
assert_eq!(compact["citations"][0]["citationId"], "c0de");
|
||||
assert_eq!(compact["citations"][0]["headingPath"][0], "保护基");
|
||||
assert_eq!(compact["citations"][0]["displayQuote"], "吡咯烷,5 h,90%");
|
||||
assert!(compact["citations"][0].get("rawQuote").is_none());
|
||||
assert_eq!(
|
||||
compact["uiCitations"][0]["citationMarkdown"].as_str(),
|
||||
Some("[docs/a.docx](/documents/a)")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compact_reference_marks_image_placeholder_as_not_ocr_text() {
|
||||
let payload = json!({
|
||||
|
||||
@@ -290,108 +290,6 @@ fn doc_find_tool() -> Value {
|
||||
})
|
||||
}
|
||||
|
||||
// 旧 evidence 工具仅保留为历史对照;当前 manifest() 不注册这些工具,资料库问答走 mnote.knowledge_rag.*。
|
||||
#[allow(dead_code)]
|
||||
fn evidence_search_tool() -> Value {
|
||||
let mut properties = base_identity_properties();
|
||||
if let Value::Object(map) = &mut properties {
|
||||
map.insert("query".into(), json!({ "type": "string" }));
|
||||
map.insert("rootUri".into(), json!({ "type": "string" }));
|
||||
map.insert(
|
||||
"includeResources".into(),
|
||||
json!({ "type": "boolean", "default": true }),
|
||||
);
|
||||
map.insert(
|
||||
"includeOcr".into(),
|
||||
json!({ "type": "boolean", "default": true }),
|
||||
);
|
||||
map.insert(
|
||||
"mode".into(),
|
||||
json!({ "type": "string", "enum": ["keyword", "tree", "hybrid", "graph"], "default": "hybrid" }),
|
||||
);
|
||||
map.insert("topK".into(), json!({ "type": "integer", "default": 8 }));
|
||||
map.insert(
|
||||
"scope".into(),
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"workspaceId": { "type": "string" },
|
||||
"rootUri": { "type": "string" },
|
||||
"targetDocumentId": { "type": "string" },
|
||||
"includeResources": { "type": "boolean" },
|
||||
"includeOcr": { "type": "boolean" }
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
json!({
|
||||
"name": "mnote.evidence.search",
|
||||
"description": "搜索可回跳原文的文档证据,返回 quote、EvidenceLocator、openAction 与可直接放进最终回答的 citationMarkdown 链接。",
|
||||
"schemaVersion": TOOL_SCHEMA_VERSION,
|
||||
"capabilityScope": ["evidence.read", "page.read"],
|
||||
"status": "available",
|
||||
"annotations": tool_annotations(true, false, true, false),
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"required": ["workspaceId", "rootUri", "query"],
|
||||
"properties": properties
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
fn evidence_read_tool() -> Value {
|
||||
let mut properties = base_identity_properties();
|
||||
if let Value::Object(map) = &mut properties {
|
||||
map.insert("locator".into(), json!({ "type": "object" }));
|
||||
map.insert(
|
||||
"context".into(),
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"beforeBlocks": { "type": "integer", "default": 3 },
|
||||
"afterBlocks": { "type": "integer", "default": 3 },
|
||||
"includeSectionSummary": { "type": "boolean", "default": true }
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
json!({
|
||||
"name": "mnote.evidence.read",
|
||||
"description": "按 EvidenceLocator 读取原文证据及周边上下文,返回 quote/contextBlocks 与可点击引用链接供回答引用。",
|
||||
"schemaVersion": TOOL_SCHEMA_VERSION,
|
||||
"capabilityScope": ["evidence.read", "page.read"],
|
||||
"status": "available",
|
||||
"annotations": tool_annotations(true, false, true, false),
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"required": ["locator"],
|
||||
"properties": properties
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
fn evidence_open_tool() -> Value {
|
||||
let mut properties = base_identity_properties();
|
||||
if let Value::Object(map) = &mut properties {
|
||||
map.insert("locator".into(), json!({ "type": "object" }));
|
||||
}
|
||||
json!({
|
||||
"name": "mnote.evidence.open",
|
||||
"description": "把 EvidenceLocator 归一化为 MNote 可执行的打开/定位动作,并返回 citationUrl/citationMarkdown。",
|
||||
"schemaVersion": TOOL_SCHEMA_VERSION,
|
||||
"capabilityScope": ["evidence.read", "page.read"],
|
||||
"status": "available",
|
||||
"annotations": tool_annotations(true, false, true, false),
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"required": ["locator"],
|
||||
"properties": properties
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn knowledge_rag_status_tool() -> Value {
|
||||
let mut properties = base_identity_properties();
|
||||
if let Value::Object(map) = &mut properties {
|
||||
|
||||
@@ -2,7 +2,6 @@ pub mod artifact;
|
||||
pub mod block;
|
||||
pub mod context_tools;
|
||||
pub mod doc;
|
||||
pub mod evidence;
|
||||
pub mod index;
|
||||
pub mod knowledge_rag;
|
||||
pub mod manifest;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -212,7 +212,7 @@ mod tests {
|
||||
"dryRun": false
|
||||
},
|
||||
"tool": {
|
||||
"tool": "docs_search",
|
||||
"tool": "mnote.knowledge_rag.query",
|
||||
"kind": "query",
|
||||
"mode": "plan",
|
||||
"argsJson": {"query": "Rust Web"},
|
||||
|
||||
@@ -3,8 +3,8 @@ use crate::app::AppState;
|
||||
use crate::context::RequestContext;
|
||||
use crate::error::WebError;
|
||||
use crate::hermes_tools::{
|
||||
artifact, block, context_tools, doc, evidence, knowledge_rag, manifest, onlyoffice_live, page,
|
||||
resource, skill, ToolCallInput,
|
||||
artifact, block, context_tools, doc, knowledge_rag, manifest, onlyoffice_live, page, resource,
|
||||
skill, ToolCallInput,
|
||||
};
|
||||
use axum::extract::{Extension, Query, State};
|
||||
use axum::http::{HeaderMap, HeaderName, HeaderValue, StatusCode};
|
||||
@@ -360,13 +360,15 @@ pub(crate) async fn execute_mnote_tool_call(
|
||||
}
|
||||
"mnote.doc.fetch" => doc::doc_fetch(&state, &context, &input).await,
|
||||
"mnote.doc.find" => doc::doc_find(&state, &context, &input).await,
|
||||
"docs_search" => evidence::legacy_docs_search(&state, &context, &input).await,
|
||||
"docs_read" => evidence::legacy_docs_read(&state, &context, &input).await,
|
||||
"mnote.evidence.search" | "mnote.evidence.read" | "mnote.evidence.open" => {
|
||||
"docs_search"
|
||||
| "docs_read"
|
||||
| "mnote.evidence.search"
|
||||
| "mnote.evidence.read"
|
||||
| "mnote.evidence.open" => {
|
||||
Err(WebError::new(
|
||||
StatusCode::GONE,
|
||||
"mnote_evidence_tools_retired",
|
||||
"旧 evidence / LiteParse tools 已退役;请使用 mnote.knowledge_rag.query/open_reference",
|
||||
"旧 docs/evidence/LiteParse tools 已退役;请使用 mnote.knowledge_rag.query/open_reference",
|
||||
)
|
||||
.with_context(&context))
|
||||
}
|
||||
@@ -5466,38 +5468,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn hermes_tools_legacy_docs_search_forwards_to_evidence_search() {
|
||||
let root =
|
||||
std::env::temp_dir().join(format!("mnote-docs-search-evidence-{}", std::process::id()));
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
fs::create_dir_all(root.join(".mnote")).expect("metadata");
|
||||
fs::write(
|
||||
root.join(".mnote").join("workspace.json"),
|
||||
r#"{"workspaceId":"local-ws-docs-search","ownerId":"user_1","createdAt":"2026-06-03T00:00:00Z","capabilities":["local_files"]}"#,
|
||||
)
|
||||
.expect("manifest");
|
||||
fs::write(
|
||||
root.join("README.md"),
|
||||
"# Evidence Home\n\ncompat-evidence-token 正文\n",
|
||||
)
|
||||
.expect("markdown");
|
||||
let root_uri = format!("file://{}", root.display());
|
||||
crate::routes::local_search_index::write_local_index_settings(
|
||||
&root,
|
||||
&[String::from(".")],
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.expect("settings");
|
||||
crate::routes::local_search_index::refresh_local_search_index(
|
||||
&root,
|
||||
&root_uri,
|
||||
"local-ws-docs-search",
|
||||
)
|
||||
.expect("refresh");
|
||||
|
||||
async fn hermes_tools_legacy_docs_search_is_retired() {
|
||||
let response = app()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
@@ -5511,7 +5482,7 @@ mod tests {
|
||||
"toolName": "docs_search",
|
||||
"workspaceId": "local-ws-docs-search",
|
||||
"sourceKind": "local_folder",
|
||||
"rootUri": root_uri,
|
||||
"rootUri": "file:///tmp/mnote-retired-docs-search",
|
||||
"sessionId": "sess_docs_search",
|
||||
"runId": "run_docs_search",
|
||||
"toolCallId": "call_docs_search",
|
||||
@@ -5529,72 +5500,15 @@ mod tests {
|
||||
.await
|
||||
.expect("response");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
assert_eq!(response.status(), StatusCode::GONE);
|
||||
let body = to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.expect("body");
|
||||
let payload: Value = serde_json::from_slice(&body).expect("json");
|
||||
assert_eq!(payload["result"]["compatTool"], "docs_search");
|
||||
assert_eq!(payload["result"]["source"], "mnote.evidence.search");
|
||||
let result = payload["result"]["results"]
|
||||
.as_array()
|
||||
.and_then(|items| items.first())
|
||||
.expect("evidence result");
|
||||
assert_eq!(
|
||||
result["source"]["schema"].as_str(),
|
||||
Some("mnote.evidence_locator.v1")
|
||||
payload["code"].as_str(),
|
||||
Some("mnote_evidence_tools_retired")
|
||||
);
|
||||
assert_eq!(
|
||||
result["source"]["ownerDocumentPath"].as_str(),
|
||||
Some("README.md")
|
||||
);
|
||||
assert!(result["citationMarkdown"]
|
||||
.as_str()
|
||||
.is_some_and(|value| value.contains("](/documents/")));
|
||||
assert!(result["citationUrl"]
|
||||
.as_str()
|
||||
.is_some_and(|value| value.contains("resourceTab=")));
|
||||
let evidence_id = result["evidenceId"].as_str().expect("evidence id");
|
||||
assert_eq!(
|
||||
payload["result"]["evidenceIds"][0].as_str(),
|
||||
Some(evidence_id)
|
||||
);
|
||||
assert_eq!(
|
||||
payload["result"]["runReceipt"]["schema"].as_str(),
|
||||
Some("mnote.agent_run_receipt.evidence.v1")
|
||||
);
|
||||
assert_eq!(
|
||||
payload["result"]["runReceipt"]["evidenceIds"][0].as_str(),
|
||||
Some(evidence_id)
|
||||
);
|
||||
assert_eq!(payload["evidenceIds"][0].as_str(), Some(evidence_id));
|
||||
assert_eq!(
|
||||
payload["runReceipt"]["toolCallId"].as_str(),
|
||||
Some("call_docs_search")
|
||||
);
|
||||
assert_eq!(
|
||||
payload["audit"]["evidenceIds"][0].as_str(),
|
||||
Some(evidence_id)
|
||||
);
|
||||
let completed_audit =
|
||||
super::audit_events(Some("trace_docs_search"), Some("call_docs_search"))
|
||||
.into_iter()
|
||||
.find(|event| event["phase"] == "completed")
|
||||
.expect("completed audit");
|
||||
assert_eq!(
|
||||
completed_audit["audit"]["runReceipt"]["evidenceIds"][0].as_str(),
|
||||
Some(evidence_id)
|
||||
);
|
||||
assert!(payload["result"]["evidence"]
|
||||
.as_array()
|
||||
.expect("evidence")
|
||||
.iter()
|
||||
.any(|item| item["quote"]
|
||||
.as_str()
|
||||
.unwrap_or_default()
|
||||
.contains("compat-evidence-token")));
|
||||
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -5697,19 +5611,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn hermes_tools_legacy_docs_read_returns_document_with_locator() {
|
||||
let root =
|
||||
std::env::temp_dir().join(format!("mnote-docs-read-evidence-{}", std::process::id()));
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
fs::create_dir_all(root.join(".mnote")).expect("metadata");
|
||||
fs::write(
|
||||
root.join(".mnote").join("workspace.json"),
|
||||
r#"{"workspaceId":"local-ws-docs-read","ownerId":"user_1","createdAt":"2026-06-03T00:00:00Z","capabilities":["local_files"]}"#,
|
||||
)
|
||||
.expect("manifest");
|
||||
fs::write(root.join("README.md"), "# Docs Read\n\nlegacy docs read\n").expect("markdown");
|
||||
let root_uri = format!("file://{}", root.display());
|
||||
|
||||
async fn hermes_tools_legacy_docs_read_is_retired() {
|
||||
let response = app()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
@@ -5724,7 +5626,7 @@ mod tests {
|
||||
"workspaceId": "local-ws-docs-read",
|
||||
"documentId": "local-md:README.md",
|
||||
"sourceKind": "local_folder",
|
||||
"rootUri": root_uri,
|
||||
"rootUri": "file:///tmp/mnote-retired-docs-read",
|
||||
"sessionId": "sess_docs_read",
|
||||
"runId": "run_docs_read",
|
||||
"toolCallId": "call_docs_read",
|
||||
@@ -5741,25 +5643,15 @@ mod tests {
|
||||
.await
|
||||
.expect("response");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
assert_eq!(response.status(), StatusCode::GONE);
|
||||
let body = to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.expect("body");
|
||||
let payload: Value = serde_json::from_slice(&body).expect("json");
|
||||
assert_eq!(payload["result"]["compatTool"], "docs_read");
|
||||
assert_eq!(
|
||||
payload["result"]["source"]["locator"]["schema"].as_str(),
|
||||
Some("mnote.evidence_locator.v1")
|
||||
payload["code"].as_str(),
|
||||
Some("mnote_evidence_tools_retired")
|
||||
);
|
||||
assert_eq!(
|
||||
payload["result"]["source"]["locator"]["ownerDocumentPath"].as_str(),
|
||||
Some("README.md")
|
||||
);
|
||||
assert!(payload["result"]["document"]
|
||||
.to_string()
|
||||
.contains("legacy docs read"));
|
||||
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -5,9 +5,12 @@ use crate::routes::local_markdown_parser::{
|
||||
};
|
||||
use crate::routes::local_ocr;
|
||||
use control_plane::{ControlPlaneStore, UpsertUserInput, UpsertUserUiPreferenceInput};
|
||||
#[cfg(test)]
|
||||
use core_protocol::EvidenceSearchMatchInfo;
|
||||
#[cfg(test)]
|
||||
use core_protocol::{EvidenceLocator, EvidenceSearchResult};
|
||||
use core_protocol::{
|
||||
EvidenceLocator, EvidenceSearchMatchInfo, EvidenceSearchResult, ParsedResourceArtifact,
|
||||
ResourceSourceMap, SourceMapBlock, PARSED_RESOURCE_ARTIFACT_SCHEMA,
|
||||
ParsedResourceArtifact, ResourceSourceMap, SourceMapBlock, PARSED_RESOURCE_ARTIFACT_SCHEMA,
|
||||
};
|
||||
use rusqlite::{params, Connection, OptionalExtension};
|
||||
use serde::{Deserialize, Serialize};
|
||||
@@ -84,6 +87,7 @@ struct LocalSearchResource {
|
||||
updated_at: u128,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn query_local_search_index(
|
||||
root_path: &Path,
|
||||
root_uri: &str,
|
||||
@@ -636,6 +640,7 @@ pub(crate) fn query_evidence_sqlite_results(
|
||||
query_evidence_sqlite_results_with_mode(root_path, query, owner_document_id, limit, true)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn query_evidence_sqlite_results_with_mode(
|
||||
root_path: &Path,
|
||||
query: &str,
|
||||
@@ -703,6 +708,7 @@ pub(crate) fn query_evidence_sqlite_results_with_mode(
|
||||
Ok(Some(results))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn read_evidence_sqlite_context(
|
||||
root_path: &Path,
|
||||
locator: &EvidenceLocator,
|
||||
@@ -777,6 +783,7 @@ pub(crate) fn read_evidence_sqlite_context(
|
||||
Ok(Some(results))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn query_evidence_graph_results(
|
||||
root_path: &Path,
|
||||
query: &str,
|
||||
@@ -839,6 +846,7 @@ pub(crate) fn query_evidence_graph_results(
|
||||
Ok(Some(results))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn graph_edge_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<EvidenceSearchResult> {
|
||||
let edge_id: String = row.get(0)?;
|
||||
let edge_type: String = row.get(1)?;
|
||||
@@ -865,6 +873,7 @@ fn graph_edge_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<EvidenceSearchRes
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn evidence_locator_matches(
|
||||
block_id: &str,
|
||||
source: &EvidenceLocator,
|
||||
@@ -877,6 +886,7 @@ fn evidence_locator_matches(
|
||||
&& source.source_map_path == locator.source_map_path
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn query_evidence_sqlite_fts(
|
||||
connection: &Connection,
|
||||
fts_query: &str,
|
||||
@@ -929,6 +939,7 @@ fn query_evidence_sqlite_fts(
|
||||
rows.map_err(sqlite_error)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn query_evidence_sqlite_like(
|
||||
connection: &Connection,
|
||||
query: &str,
|
||||
@@ -980,6 +991,7 @@ fn query_evidence_sqlite_like(
|
||||
rows.map_err(sqlite_error)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn query_evidence_sqlite_fuzzy(
|
||||
connection: &Connection,
|
||||
query: &str,
|
||||
@@ -1075,6 +1087,7 @@ fn query_evidence_sqlite_fuzzy(
|
||||
.collect())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn evidence_sqlite_row_parts(
|
||||
row: &rusqlite::Row<'_>,
|
||||
) -> rusqlite::Result<(String, String, EvidenceLocator)> {
|
||||
@@ -1087,6 +1100,7 @@ fn evidence_sqlite_row_parts(
|
||||
Ok((block_id, text, source))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn evidence_result_from_sqlite_row(
|
||||
row: &rusqlite::Row<'_>,
|
||||
query: &str,
|
||||
@@ -1118,6 +1132,7 @@ fn evidence_result_from_sqlite_row(
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn evidence_fts_phrase(query: &str) -> String {
|
||||
format!("\"{}\"", query.replace('"', "\"\""))
|
||||
}
|
||||
@@ -3631,6 +3646,7 @@ struct EvidenceQueryTerm {
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
#[cfg_attr(not(test), allow(dead_code))]
|
||||
struct EvidenceTextMatch {
|
||||
score: f64,
|
||||
matched_terms: Vec<String>,
|
||||
@@ -3640,6 +3656,7 @@ struct EvidenceTextMatch {
|
||||
}
|
||||
|
||||
impl EvidenceTextMatch {
|
||||
#[cfg(test)]
|
||||
fn into_match_info(self, rank: Option<u32>) -> EvidenceSearchMatchInfo {
|
||||
EvidenceSearchMatchInfo {
|
||||
rank,
|
||||
@@ -3821,6 +3838,7 @@ fn push_unique(values: &mut Vec<String>, value: String) {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn ocr_search_snippet(body: &str, query: &str) -> String {
|
||||
let normalized_body = body.replace('\n', " ");
|
||||
let normalized_query = query.trim();
|
||||
@@ -3850,6 +3868,7 @@ fn ocr_search_snippet(body: &str, query: &str) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn ocr_search_snippet_for_terms(body: &str, query: &str, terms: &[String]) -> String {
|
||||
let normalized_body = body.replace('\n', " ");
|
||||
let normalized_query = query.trim();
|
||||
@@ -3873,6 +3892,7 @@ fn ocr_search_snippet_for_terms(body: &str, query: &str, terms: &[String]) -> St
|
||||
ocr_search_snippet(body, query)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn snippet_from_byte_index(body: &str, byte_index: usize, len: usize) -> String {
|
||||
let start = body[..byte_index]
|
||||
.char_indices()
|
||||
@@ -3883,6 +3903,7 @@ fn snippet_from_byte_index(body: &str, byte_index: usize, len: usize) -> String
|
||||
body[start..].chars().take(len).collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn fuzzy_search_start_byte(haystack: &str, query: &str) -> Option<usize> {
|
||||
if query.is_empty() {
|
||||
return None;
|
||||
|
||||
@@ -994,6 +994,14 @@ pub struct OfficePreviewQuery {
|
||||
source_map_path: Option<String>,
|
||||
#[serde(default, alias = "blockId")]
|
||||
block_id: Option<String>,
|
||||
#[serde(default, alias = "paragraphOrdinal")]
|
||||
paragraph_ordinal: Option<String>,
|
||||
#[serde(default, alias = "paraIdStart")]
|
||||
para_id_start: Option<String>,
|
||||
#[serde(default, alias = "paraIdEnd")]
|
||||
para_id_end: Option<String>,
|
||||
#[serde(default, alias = "textFingerprint")]
|
||||
text_fingerprint: Option<String>,
|
||||
#[serde(default, alias = "evidenceText")]
|
||||
evidence_text: Option<String>,
|
||||
#[serde(default, alias = "searchQuery")]
|
||||
@@ -1032,6 +1040,10 @@ 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_paragraph_ordinal = query.paragraph_ordinal.unwrap_or_default();
|
||||
let target_para_id_start = query.para_id_start.unwrap_or_default();
|
||||
let target_para_id_end = query.para_id_end.unwrap_or_default();
|
||||
let target_text_fingerprint = query.text_fingerprint.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!(
|
||||
@@ -1080,7 +1092,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}" data-evidence-text="{target_evidence_text}" data-evidence-search-query="{target_search_query}">
|
||||
<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-paragraph-ordinal="{target_paragraph_ordinal}" data-evidence-para-id-start="{target_para_id_start}" data-evidence-para-id-end="{target_para_id_end}" data-evidence-text-fingerprint="{target_text_fingerprint}" 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>
|
||||
@@ -1099,6 +1111,10 @@ 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 evidenceParagraphOrdinal = body.dataset.evidenceParagraphOrdinal || '';
|
||||
let evidenceParaIdStart = body.dataset.evidenceParaIdStart || '';
|
||||
let evidenceParaIdEnd = body.dataset.evidenceParaIdEnd || '';
|
||||
let evidenceTextFingerprint = body.dataset.evidenceTextFingerprint || '';
|
||||
let evidenceText = body.dataset.evidenceText || '';
|
||||
let evidenceSearchQuery = body.dataset.evidenceSearchQuery || '';
|
||||
let currentPptxBuffer = null;
|
||||
@@ -1484,6 +1500,17 @@ pub async fn office_preview_page(Query(query): Query<OfficePreviewQuery>) -> Res
|
||||
if (queryIndex >= 0 && normalized.length > 24) push(normalized.slice(0, 36), {{ allowShort: true }});
|
||||
}}
|
||||
const prefixWindow = cleaned.slice(0, 260);
|
||||
if (queryCompact.length >= 2) {{
|
||||
const prefixCompact = compactEvidenceText(prefixWindow);
|
||||
const compactIndex = prefixCompact.indexOf(queryCompact);
|
||||
if (compactIndex >= 0) {{
|
||||
const queryIndex = prefixWindow.indexOf(evidenceSearchQuery);
|
||||
const start = queryIndex >= 0 ? queryIndex : 0;
|
||||
const queryWindow = prefixWindow.slice(start, start + 96).split(/[。;;]/)[0];
|
||||
push(queryWindow, {{ allowShort: true }});
|
||||
queryWindow.split(/[,,]/).slice(0, 2).forEach(part => push(part, {{ allowShort: true }}));
|
||||
}}
|
||||
}}
|
||||
const catalogMatches = prefixWindow.match(/[^,,。;;#]{{2,56}}[,,]\s*[0-90-9]{{1,5}}/g) || [];
|
||||
catalogMatches.slice(0, 8).forEach(match => {{
|
||||
const value = normalizeEvidenceText(match);
|
||||
@@ -1711,6 +1738,42 @@ pub async fn office_preview_page(Query(query): Query<OfficePreviewQuery>) -> Res
|
||||
return markEvidenceTarget(marker);
|
||||
}}
|
||||
|
||||
function evidenceParagraphElements() {{
|
||||
if (!viewer) return [];
|
||||
const paragraphs = Array.from(viewer.querySelectorAll('p, li, td, th, blockquote'))
|
||||
.filter(node => node instanceof HTMLElement)
|
||||
.filter(node => normalizeEvidenceText(node.textContent || '').length > 0);
|
||||
if (paragraphs.length) return paragraphs;
|
||||
return Array.from(viewer.querySelectorAll('div, section.docx, section.mnote-docx'))
|
||||
.filter(node => node instanceof HTMLElement)
|
||||
.filter(node => normalizeEvidenceText(node.textContent || '').length > 0);
|
||||
}}
|
||||
|
||||
function scrollToEvidenceParagraphOrdinal() {{
|
||||
const ordinal = Number(evidenceParagraphOrdinal);
|
||||
if (!Number.isFinite(ordinal) || ordinal < 0) return false;
|
||||
const elements = evidenceParagraphElements();
|
||||
if (!elements.length) return false;
|
||||
const anchors = evidenceParagraphAnchors(evidenceText || evidenceSearchQuery);
|
||||
const indices = [];
|
||||
for (let offset = 0; offset <= 4; offset += 1) {{
|
||||
if (offset === 0) indices.push(ordinal);
|
||||
else {{
|
||||
indices.push(ordinal - offset);
|
||||
indices.push(ordinal + offset);
|
||||
}}
|
||||
}}
|
||||
let best = null;
|
||||
for (const index of indices) {{
|
||||
if (index < 0 || index >= elements.length) continue;
|
||||
const element = elements[index];
|
||||
const score = anchors.length ? scoreEvidenceParagraphElement(element, anchors) : 0;
|
||||
if (!best || score > best.score) best = {{ element, score }};
|
||||
if (score >= 2400 && evidenceElementMatchesSearchQuery(element)) break;
|
||||
}}
|
||||
return best ? markEvidenceTarget(best.element) : false;
|
||||
}}
|
||||
|
||||
function scrollToEvidencePageFallback() {{
|
||||
if (!viewer || !Number.isFinite(evidencePage) || evidencePage <= 0) return false;
|
||||
const pages = Array.from(viewer.querySelectorAll('section.docx, section.mnote-docx, .pptx-preview-slide-wrapper'));
|
||||
@@ -1719,16 +1782,18 @@ pub async fn office_preview_page(Query(query): Query<OfficePreviewQuery>) -> Res
|
||||
}}
|
||||
|
||||
async function applyEvidenceLocator() {{
|
||||
if (!viewer || (!evidencePage && !evidenceBlockId && !evidenceBbox && !evidenceText)) return;
|
||||
if (!viewer || (!evidencePage && !evidenceBlockId && !evidenceBbox && !evidenceText && !evidenceParagraphOrdinal && !evidenceTextFingerprint)) return;
|
||||
try {{
|
||||
const sourceMap = await fetchEvidenceSourceMap();
|
||||
const block = findEvidenceBlockInSourceMap(sourceMap);
|
||||
if (evidenceParagraphOrdinal && scrollToEvidenceParagraphOrdinal()) 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 (evidenceParagraphOrdinal && scrollToEvidenceParagraphOrdinal()) return;
|
||||
if (evidenceText && scrollToEvidenceParagraph(evidenceText)) return;
|
||||
if (evidenceText && scrollToEvidenceTextCandidates(evidenceText)) return;
|
||||
scrollToEvidencePageFallback();
|
||||
@@ -1740,12 +1805,20 @@ pub async fn office_preview_page(Query(query): Query<OfficePreviewQuery>) -> Res
|
||||
evidenceBbox = String(next.bbox || '');
|
||||
evidenceSourceMapPath = String(next.sourceMapPath || '');
|
||||
evidenceBlockId = String(next.blockId || '');
|
||||
evidenceParagraphOrdinal = String(next.paragraphOrdinal || '');
|
||||
evidenceParaIdStart = String(next.paraIdStart || '');
|
||||
evidenceParaIdEnd = String(next.paraIdEnd || '');
|
||||
evidenceTextFingerprint = String(next.textFingerprint || '');
|
||||
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.evidenceParagraphOrdinal = evidenceParagraphOrdinal;
|
||||
body.dataset.evidenceParaIdStart = evidenceParaIdStart;
|
||||
body.dataset.evidenceParaIdEnd = evidenceParaIdEnd;
|
||||
body.dataset.evidenceTextFingerprint = evidenceTextFingerprint;
|
||||
body.dataset.evidenceText = evidenceText;
|
||||
body.dataset.evidenceSearchQuery = evidenceSearchQuery;
|
||||
document.documentElement.removeAttribute('data-mnote-office-evidence-applied');
|
||||
@@ -1969,6 +2042,10 @@ 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_paragraph_ordinal = escape_html(&target_paragraph_ordinal),
|
||||
target_para_id_start = escape_html(&target_para_id_start),
|
||||
target_para_id_end = escape_html(&target_para_id_end),
|
||||
target_text_fingerprint = escape_html(&target_text_fingerprint),
|
||||
target_evidence_text = escape_html(&target_evidence_text),
|
||||
);
|
||||
let mut response = Html(html).into_response();
|
||||
|
||||
@@ -548,6 +548,7 @@ mod tests {
|
||||
assert!(SIDEBAR_PAGE_SETTINGS_RUNTIME_JS.contains("mnote-knowledge-rag-source-filters"));
|
||||
assert!(SIDEBAR_PAGE_SETTINGS_RUNTIME_JS.contains("filter-sources"));
|
||||
assert!(SIDEBAR_PAGE_SETTINGS_RUNTIME_JS.contains("LightRAG 未映射"));
|
||||
assert!(SIDEBAR_PAGE_SETTINGS_RUNTIME_JS.contains("Rerank"));
|
||||
assert!(SIDEBAR_PAGE_SETTINGS_RUNTIME_JS.contains("scheduleKnowledgeRagStatusBridge"));
|
||||
assert!(SIDEBAR_PAGE_SETTINGS_RUNTIME_JS.contains("knowledgeRagStatusHasInFlight"));
|
||||
assert!(SIDEBAR_PAGE_SETTINGS_RUNTIME_JS.contains("delete_submitted"));
|
||||
|
||||
+21
-21
@@ -48,33 +48,33 @@ export type InitInput = RequestInfo | URL | Response | BufferSource | WebAssembl
|
||||
|
||||
export interface InitOutput {
|
||||
readonly memory: WebAssembly.Memory;
|
||||
readonly mount: (a: any, b: any) => [number, number, number];
|
||||
readonly mount_mindmap_shell: (a: any, b: any) => [number, number, number];
|
||||
readonly unmount: (a: number) => [number, number];
|
||||
readonly unmount_mindmap_shell: (a: number) => [number, number];
|
||||
readonly __wbg_intounderlyingbytesource_free: (a: number, b: number) => void;
|
||||
readonly intounderlyingbytesource_autoAllocateChunkSize: (a: number) => number;
|
||||
readonly intounderlyingbytesource_cancel: (a: number) => void;
|
||||
readonly intounderlyingbytesource_pull: (a: number, b: any) => any;
|
||||
readonly intounderlyingbytesource_start: (a: number, b: any) => void;
|
||||
readonly intounderlyingbytesource_type: (a: number) => number;
|
||||
readonly mount: (a: any, b: any) => [number, number, number];
|
||||
readonly unmount: (a: number) => [number, number];
|
||||
readonly __wbg_intounderlyingsink_free: (a: number, b: number) => void;
|
||||
readonly intounderlyingsink_abort: (a: number, b: any) => any;
|
||||
readonly intounderlyingsink_close: (a: number) => any;
|
||||
readonly intounderlyingsink_write: (a: number, b: any) => any;
|
||||
readonly intounderlyingsink_close: (a: number) => any;
|
||||
readonly intounderlyingsink_abort: (a: number, b: any) => any;
|
||||
readonly __wbg_intounderlyingbytesource_free: (a: number, b: number) => void;
|
||||
readonly intounderlyingbytesource_type: (a: number) => number;
|
||||
readonly intounderlyingbytesource_autoAllocateChunkSize: (a: number) => number;
|
||||
readonly intounderlyingbytesource_start: (a: number, b: any) => void;
|
||||
readonly intounderlyingbytesource_pull: (a: number, b: any) => any;
|
||||
readonly intounderlyingbytesource_cancel: (a: number) => void;
|
||||
readonly __wbg_intounderlyingsource_free: (a: number, b: number) => void;
|
||||
readonly intounderlyingsource_cancel: (a: number) => void;
|
||||
readonly intounderlyingsource_pull: (a: number, b: any) => any;
|
||||
readonly wasm_bindgen__convert__closures_____invoke__h854f4676fa692669: (a: number, b: number, c: any) => [number, number];
|
||||
readonly wasm_bindgen__convert__closures_____invoke__h1a94ffe1b1ed8350: (a: number, b: number, c: any, d: any) => void;
|
||||
readonly wasm_bindgen__convert__closures_____invoke__h9213ab6f3ef747a1: (a: number, b: number, c: any) => void;
|
||||
readonly wasm_bindgen__convert__closures_____invoke__h280638b828d42e1f: (a: number, b: number, c: any) => void;
|
||||
readonly wasm_bindgen__convert__closures_____invoke__h10d35e1548938147: (a: number, b: number, c: any) => void;
|
||||
readonly wasm_bindgen__convert__closures_____invoke__h280638b828d42e1f_4: (a: number, b: number, c: any) => void;
|
||||
readonly wasm_bindgen__convert__closures_____invoke__hdc2c23cc0c047a34: (a: number, b: number, c: any) => void;
|
||||
readonly wasm_bindgen__convert__closures_____invoke__h68fbbcff54846097: (a: number, b: number) => void;
|
||||
readonly wasm_bindgen__convert__closures_____invoke__had164c21c04063ef: (a: number, b: number) => void;
|
||||
readonly wasm_bindgen__convert__closures_____invoke__h5921057d3102c7f1: (a: number, b: number) => void;
|
||||
readonly intounderlyingsource_cancel: (a: number) => void;
|
||||
readonly wasm_bindgen__convert__closures_____invoke__h3a3182d847094e12: (a: number, b: number, c: any) => [number, number];
|
||||
readonly wasm_bindgen__convert__closures_____invoke__h0accad0f5d87788a: (a: number, b: number, c: any, d: any) => void;
|
||||
readonly wasm_bindgen__convert__closures_____invoke__h396914cf76a9e7a5: (a: number, b: number, c: any) => void;
|
||||
readonly wasm_bindgen__convert__closures_____invoke__had771ddc65647798: (a: number, b: number, c: any) => void;
|
||||
readonly wasm_bindgen__convert__closures_____invoke__h0abd2b2fe4652e2a: (a: number, b: number, c: any) => void;
|
||||
readonly wasm_bindgen__convert__closures_____invoke__h396914cf76a9e7a5_4: (a: number, b: number, c: any) => void;
|
||||
readonly wasm_bindgen__convert__closures_____invoke__hd90af689bc3e71bf: (a: number, b: number, c: any) => void;
|
||||
readonly wasm_bindgen__convert__closures_____invoke__h794babeffd4f821f: (a: number, b: number) => void;
|
||||
readonly wasm_bindgen__convert__closures_____invoke__h2b648e7ac8ec1fb5: (a: number, b: number) => void;
|
||||
readonly wasm_bindgen__convert__closures_____invoke__h0b7fb40a8610550c: (a: number, b: number) => void;
|
||||
readonly __wbindgen_malloc: (a: number, b: number) => number;
|
||||
readonly __wbindgen_realloc: (a: number, b: number, c: number, d: number) => number;
|
||||
readonly __externref_table_alloc: () => number;
|
||||
|
||||
+39
-39
@@ -812,7 +812,7 @@ function __wbg_get_imports() {
|
||||
const a = state0.a;
|
||||
state0.a = 0;
|
||||
try {
|
||||
return wasm_bindgen__convert__closures_____invoke__h1a94ffe1b1ed8350(a, state0.b, arg0, arg1);
|
||||
return wasm_bindgen__convert__closures_____invoke__h0accad0f5d87788a(a, state0.b, arg0, arg1);
|
||||
} finally {
|
||||
state0.a = a;
|
||||
}
|
||||
@@ -1232,48 +1232,48 @@ function __wbg_get_imports() {
|
||||
}
|
||||
}, arguments); },
|
||||
__wbindgen_cast_0000000000000001: function(arg0, arg1) {
|
||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 1003, ret: Unit, inner_ret: Some(Unit) }, mutable: false }) -> Externref`.
|
||||
const ret = makeClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h9213ab6f3ef747a1);
|
||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 1140, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
||||
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h396914cf76a9e7a5);
|
||||
return ret;
|
||||
},
|
||||
__wbindgen_cast_0000000000000002: function(arg0, arg1) {
|
||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 1132, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
||||
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h280638b828d42e1f);
|
||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 1192, ret: Result(Unit), inner_ret: Some(Result(Unit)) }, mutable: true }) -> Externref`.
|
||||
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h3a3182d847094e12);
|
||||
return ret;
|
||||
},
|
||||
__wbindgen_cast_0000000000000003: function(arg0, arg1) {
|
||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 1194, ret: Result(Unit), inner_ret: Some(Result(Unit)) }, mutable: true }) -> Externref`.
|
||||
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h854f4676fa692669);
|
||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 969, ret: Unit, inner_ret: Some(Unit) }, mutable: false }) -> Externref`.
|
||||
const ret = makeClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__had771ddc65647798);
|
||||
return ret;
|
||||
},
|
||||
__wbindgen_cast_0000000000000004: function(arg0, arg1) {
|
||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("Event")], shim_idx: 1100, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
||||
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h10d35e1548938147);
|
||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("Event")], shim_idx: 1090, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
||||
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h0abd2b2fe4652e2a);
|
||||
return ret;
|
||||
},
|
||||
__wbindgen_cast_0000000000000005: function(arg0, arg1) {
|
||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("Event")], shim_idx: 1132, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
||||
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h280638b828d42e1f_4);
|
||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("Event")], shim_idx: 1140, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
||||
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h396914cf76a9e7a5_4);
|
||||
return ret;
|
||||
},
|
||||
__wbindgen_cast_0000000000000006: function(arg0, arg1) {
|
||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("KeyboardEvent")], shim_idx: 333, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
||||
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__hdc2c23cc0c047a34);
|
||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("KeyboardEvent")], shim_idx: 637, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
||||
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__hd90af689bc3e71bf);
|
||||
return ret;
|
||||
},
|
||||
__wbindgen_cast_0000000000000007: function(arg0, arg1) {
|
||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [], shim_idx: 1102, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
||||
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h68fbbcff54846097);
|
||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [], shim_idx: 1092, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
||||
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h794babeffd4f821f);
|
||||
return ret;
|
||||
},
|
||||
__wbindgen_cast_0000000000000008: function(arg0, arg1) {
|
||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [], shim_idx: 1117, ret: Unit, inner_ret: Some(Unit) }, mutable: false }) -> Externref`.
|
||||
const ret = makeClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__had164c21c04063ef);
|
||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [], shim_idx: 1107, ret: Unit, inner_ret: Some(Unit) }, mutable: false }) -> Externref`.
|
||||
const ret = makeClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h2b648e7ac8ec1fb5);
|
||||
return ret;
|
||||
},
|
||||
__wbindgen_cast_0000000000000009: function(arg0, arg1) {
|
||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [], shim_idx: 1134, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
||||
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h5921057d3102c7f1);
|
||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [], shim_idx: 1143, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
||||
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h0b7fb40a8610550c);
|
||||
return ret;
|
||||
},
|
||||
__wbindgen_cast_000000000000000a: function(arg0) {
|
||||
@@ -1316,47 +1316,47 @@ function __wbg_get_imports() {
|
||||
};
|
||||
}
|
||||
|
||||
function wasm_bindgen__convert__closures_____invoke__h68fbbcff54846097(arg0, arg1) {
|
||||
wasm.wasm_bindgen__convert__closures_____invoke__h68fbbcff54846097(arg0, arg1);
|
||||
function wasm_bindgen__convert__closures_____invoke__h794babeffd4f821f(arg0, arg1) {
|
||||
wasm.wasm_bindgen__convert__closures_____invoke__h794babeffd4f821f(arg0, arg1);
|
||||
}
|
||||
|
||||
function wasm_bindgen__convert__closures_____invoke__had164c21c04063ef(arg0, arg1) {
|
||||
wasm.wasm_bindgen__convert__closures_____invoke__had164c21c04063ef(arg0, arg1);
|
||||
function wasm_bindgen__convert__closures_____invoke__h2b648e7ac8ec1fb5(arg0, arg1) {
|
||||
wasm.wasm_bindgen__convert__closures_____invoke__h2b648e7ac8ec1fb5(arg0, arg1);
|
||||
}
|
||||
|
||||
function wasm_bindgen__convert__closures_____invoke__h5921057d3102c7f1(arg0, arg1) {
|
||||
wasm.wasm_bindgen__convert__closures_____invoke__h5921057d3102c7f1(arg0, arg1);
|
||||
function wasm_bindgen__convert__closures_____invoke__h0b7fb40a8610550c(arg0, arg1) {
|
||||
wasm.wasm_bindgen__convert__closures_____invoke__h0b7fb40a8610550c(arg0, arg1);
|
||||
}
|
||||
|
||||
function wasm_bindgen__convert__closures_____invoke__h9213ab6f3ef747a1(arg0, arg1, arg2) {
|
||||
wasm.wasm_bindgen__convert__closures_____invoke__h9213ab6f3ef747a1(arg0, arg1, arg2);
|
||||
function wasm_bindgen__convert__closures_____invoke__h396914cf76a9e7a5(arg0, arg1, arg2) {
|
||||
wasm.wasm_bindgen__convert__closures_____invoke__h396914cf76a9e7a5(arg0, arg1, arg2);
|
||||
}
|
||||
|
||||
function wasm_bindgen__convert__closures_____invoke__h280638b828d42e1f(arg0, arg1, arg2) {
|
||||
wasm.wasm_bindgen__convert__closures_____invoke__h280638b828d42e1f(arg0, arg1, arg2);
|
||||
function wasm_bindgen__convert__closures_____invoke__had771ddc65647798(arg0, arg1, arg2) {
|
||||
wasm.wasm_bindgen__convert__closures_____invoke__had771ddc65647798(arg0, arg1, arg2);
|
||||
}
|
||||
|
||||
function wasm_bindgen__convert__closures_____invoke__h10d35e1548938147(arg0, arg1, arg2) {
|
||||
wasm.wasm_bindgen__convert__closures_____invoke__h10d35e1548938147(arg0, arg1, arg2);
|
||||
function wasm_bindgen__convert__closures_____invoke__h0abd2b2fe4652e2a(arg0, arg1, arg2) {
|
||||
wasm.wasm_bindgen__convert__closures_____invoke__h0abd2b2fe4652e2a(arg0, arg1, arg2);
|
||||
}
|
||||
|
||||
function wasm_bindgen__convert__closures_____invoke__h280638b828d42e1f_4(arg0, arg1, arg2) {
|
||||
wasm.wasm_bindgen__convert__closures_____invoke__h280638b828d42e1f_4(arg0, arg1, arg2);
|
||||
function wasm_bindgen__convert__closures_____invoke__h396914cf76a9e7a5_4(arg0, arg1, arg2) {
|
||||
wasm.wasm_bindgen__convert__closures_____invoke__h396914cf76a9e7a5_4(arg0, arg1, arg2);
|
||||
}
|
||||
|
||||
function wasm_bindgen__convert__closures_____invoke__hdc2c23cc0c047a34(arg0, arg1, arg2) {
|
||||
wasm.wasm_bindgen__convert__closures_____invoke__hdc2c23cc0c047a34(arg0, arg1, arg2);
|
||||
function wasm_bindgen__convert__closures_____invoke__hd90af689bc3e71bf(arg0, arg1, arg2) {
|
||||
wasm.wasm_bindgen__convert__closures_____invoke__hd90af689bc3e71bf(arg0, arg1, arg2);
|
||||
}
|
||||
|
||||
function wasm_bindgen__convert__closures_____invoke__h854f4676fa692669(arg0, arg1, arg2) {
|
||||
const ret = wasm.wasm_bindgen__convert__closures_____invoke__h854f4676fa692669(arg0, arg1, arg2);
|
||||
function wasm_bindgen__convert__closures_____invoke__h3a3182d847094e12(arg0, arg1, arg2) {
|
||||
const ret = wasm.wasm_bindgen__convert__closures_____invoke__h3a3182d847094e12(arg0, arg1, arg2);
|
||||
if (ret[1]) {
|
||||
throw takeFromExternrefTable0(ret[0]);
|
||||
}
|
||||
}
|
||||
|
||||
function wasm_bindgen__convert__closures_____invoke__h1a94ffe1b1ed8350(arg0, arg1, arg2, arg3) {
|
||||
wasm.wasm_bindgen__convert__closures_____invoke__h1a94ffe1b1ed8350(arg0, arg1, arg2, arg3);
|
||||
function wasm_bindgen__convert__closures_____invoke__h0accad0f5d87788a(arg0, arg1, arg2, arg3) {
|
||||
wasm.wasm_bindgen__convert__closures_____invoke__h0accad0f5d87788a(arg0, arg1, arg2, arg3);
|
||||
}
|
||||
|
||||
|
||||
|
||||
BIN
Binary file not shown.
Vendored
+21
-21
@@ -1,33 +1,33 @@
|
||||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
export const memory: WebAssembly.Memory;
|
||||
export const mount: (a: any, b: any) => [number, number, number];
|
||||
export const mount_mindmap_shell: (a: any, b: any) => [number, number, number];
|
||||
export const unmount: (a: number) => [number, number];
|
||||
export const unmount_mindmap_shell: (a: number) => [number, number];
|
||||
export const __wbg_intounderlyingbytesource_free: (a: number, b: number) => void;
|
||||
export const intounderlyingbytesource_autoAllocateChunkSize: (a: number) => number;
|
||||
export const intounderlyingbytesource_cancel: (a: number) => void;
|
||||
export const intounderlyingbytesource_pull: (a: number, b: any) => any;
|
||||
export const intounderlyingbytesource_start: (a: number, b: any) => void;
|
||||
export const intounderlyingbytesource_type: (a: number) => number;
|
||||
export const mount: (a: any, b: any) => [number, number, number];
|
||||
export const unmount: (a: number) => [number, number];
|
||||
export const __wbg_intounderlyingsink_free: (a: number, b: number) => void;
|
||||
export const intounderlyingsink_abort: (a: number, b: any) => any;
|
||||
export const intounderlyingsink_close: (a: number) => any;
|
||||
export const intounderlyingsink_write: (a: number, b: any) => any;
|
||||
export const intounderlyingsink_close: (a: number) => any;
|
||||
export const intounderlyingsink_abort: (a: number, b: any) => any;
|
||||
export const __wbg_intounderlyingbytesource_free: (a: number, b: number) => void;
|
||||
export const intounderlyingbytesource_type: (a: number) => number;
|
||||
export const intounderlyingbytesource_autoAllocateChunkSize: (a: number) => number;
|
||||
export const intounderlyingbytesource_start: (a: number, b: any) => void;
|
||||
export const intounderlyingbytesource_pull: (a: number, b: any) => any;
|
||||
export const intounderlyingbytesource_cancel: (a: number) => void;
|
||||
export const __wbg_intounderlyingsource_free: (a: number, b: number) => void;
|
||||
export const intounderlyingsource_cancel: (a: number) => void;
|
||||
export const intounderlyingsource_pull: (a: number, b: any) => any;
|
||||
export const wasm_bindgen__convert__closures_____invoke__h854f4676fa692669: (a: number, b: number, c: any) => [number, number];
|
||||
export const wasm_bindgen__convert__closures_____invoke__h1a94ffe1b1ed8350: (a: number, b: number, c: any, d: any) => void;
|
||||
export const wasm_bindgen__convert__closures_____invoke__h9213ab6f3ef747a1: (a: number, b: number, c: any) => void;
|
||||
export const wasm_bindgen__convert__closures_____invoke__h280638b828d42e1f: (a: number, b: number, c: any) => void;
|
||||
export const wasm_bindgen__convert__closures_____invoke__h10d35e1548938147: (a: number, b: number, c: any) => void;
|
||||
export const wasm_bindgen__convert__closures_____invoke__h280638b828d42e1f_4: (a: number, b: number, c: any) => void;
|
||||
export const wasm_bindgen__convert__closures_____invoke__hdc2c23cc0c047a34: (a: number, b: number, c: any) => void;
|
||||
export const wasm_bindgen__convert__closures_____invoke__h68fbbcff54846097: (a: number, b: number) => void;
|
||||
export const wasm_bindgen__convert__closures_____invoke__had164c21c04063ef: (a: number, b: number) => void;
|
||||
export const wasm_bindgen__convert__closures_____invoke__h5921057d3102c7f1: (a: number, b: number) => void;
|
||||
export const intounderlyingsource_cancel: (a: number) => void;
|
||||
export const wasm_bindgen__convert__closures_____invoke__h3a3182d847094e12: (a: number, b: number, c: any) => [number, number];
|
||||
export const wasm_bindgen__convert__closures_____invoke__h0accad0f5d87788a: (a: number, b: number, c: any, d: any) => void;
|
||||
export const wasm_bindgen__convert__closures_____invoke__h396914cf76a9e7a5: (a: number, b: number, c: any) => void;
|
||||
export const wasm_bindgen__convert__closures_____invoke__had771ddc65647798: (a: number, b: number, c: any) => void;
|
||||
export const wasm_bindgen__convert__closures_____invoke__h0abd2b2fe4652e2a: (a: number, b: number, c: any) => void;
|
||||
export const wasm_bindgen__convert__closures_____invoke__h396914cf76a9e7a5_4: (a: number, b: number, c: any) => void;
|
||||
export const wasm_bindgen__convert__closures_____invoke__hd90af689bc3e71bf: (a: number, b: number, c: any) => void;
|
||||
export const wasm_bindgen__convert__closures_____invoke__h794babeffd4f821f: (a: number, b: number) => void;
|
||||
export const wasm_bindgen__convert__closures_____invoke__h2b648e7ac8ec1fb5: (a: number, b: number) => void;
|
||||
export const wasm_bindgen__convert__closures_____invoke__h0b7fb40a8610550c: (a: number, b: number) => void;
|
||||
export const __wbindgen_malloc: (a: number, b: number) => number;
|
||||
export const __wbindgen_realloc: (a: number, b: number, c: number, d: number) => number;
|
||||
export const __externref_table_alloc: () => number;
|
||||
|
||||
@@ -2156,8 +2156,8 @@ pub(crate) const SPIKE_STYLE: &str = r#"
|
||||
|
||||
.editor-surface .ProseMirror ul[data-type="taskList"] li[data-checked="true"] > div > p,
|
||||
.editor-surface .ProseMirror ul[data-type="taskList"] li[data-checked="true"] > div > p span {
|
||||
opacity: 0.5;
|
||||
text-decoration: line-through;
|
||||
color: rgba(60, 60, 67, 0.48);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.editor-surface .ProseMirror ul[data-type="taskList"] li label {
|
||||
@@ -2195,15 +2195,15 @@ pub(crate) const SPIKE_STYLE: &str = r#"
|
||||
transform: translate(-50%, -50%);
|
||||
width: 0.75em;
|
||||
height: 0.75em;
|
||||
background-color: #ffffff;
|
||||
background-color: #8A8F98;
|
||||
opacity: 0;
|
||||
-webkit-mask: url("data:image/svg+xml,%3Csvg%20width%3D%2224%22%20height%3D%2224%22%20viewBox%3D%220%200%2024%2024%22%20fill%3D%22currentColor%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%3Cpath%20fill-rule%3D%22evenodd%22%20clip-rule%3D%22evenodd%22%20d%3D%22M21.4142%204.58579C22.1953%205.36683%2022.1953%206.63317%2021.4142%207.41421L10.4142%2018.4142C9.63317%2019.1953%208.36684%2019.1953%207.58579%2018.4142L2.58579%2013.4142C1.80474%2012.6332%201.80474%2011.3668%202.58579%2010.5858C3.36683%209.80474%204.63317%209.80474%205.41421%2010.5858L9%2014.1716L18.5858%204.58579C19.3668%203.80474%2020.6332%203.80474%2021.4142%204.58579Z%22%20fill%3D%22currentColor%22%2F%3E%3C%2Fsvg%3E") center/contain no-repeat;
|
||||
mask: url("data:image/svg+xml,%3Csvg%20width%3D%2224%22%20height%3D%2224%22%20viewBox%3D%220%200%2024%2024%22%20fill%3D%22currentColor%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%3Cpath%20fill-rule%3D%22evenodd%22%20clip-rule%3D%22evenodd%22%20d%3D%22M21.4142%204.58579C22.1953%205.36683%2022.1953%206.63317%2021.4142%207.41421L10.4142%2018.4142C9.63317%2019.1953%208.36684%2019.1953%207.58579%2018.4142L2.58579%2013.4142C1.80474%2012.6332%201.80474%2011.3668%202.58579%2010.5858C3.36683%209.80474%204.63317%209.80474%205.41421%2010.5858L9%2014.1716L18.5858%204.58579C19.3668%203.80474%2020.6332%203.80474%2021.4142%204.58579Z%22%20fill%3D%22currentColor%22%2F%3E%3C%2Fsvg%3E") center/contain no-repeat;
|
||||
}
|
||||
|
||||
.editor-surface .ProseMirror ul[data-type="taskList"] li label input[type="checkbox"]:checked + span {
|
||||
background: var(--accent-strong);
|
||||
border-color: var(--accent-strong);
|
||||
background: transparent;
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
.editor-surface .ProseMirror ul[data-type="taskList"] li label input[type="checkbox"]:checked + span::before {
|
||||
|
||||
@@ -485,6 +485,17 @@ mod tests {
|
||||
assert!(SPIKE_STYLE.contains("z-index: 130;"));
|
||||
assert!(!SPIKE_STYLE.contains("z-index: 11;"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn task_list_checked_style_uses_gray_check_without_strike() {
|
||||
assert!(SPIKE_STYLE.contains("li[data-checked=\"true\"] > div > p"));
|
||||
assert!(SPIKE_STYLE.contains("color: rgba(60, 60, 67, 0.48);"));
|
||||
assert!(SPIKE_STYLE.contains("text-decoration: none;"));
|
||||
assert!(SPIKE_STYLE.contains("background-color: #8A8F98;"));
|
||||
assert!(SPIKE_STYLE.contains("border-color: transparent;"));
|
||||
assert!(!SPIKE_STYLE.contains("text-decoration: line-through;"));
|
||||
assert!(!SPIKE_STYLE.contains("background: var(--accent-strong);"));
|
||||
}
|
||||
}
|
||||
|
||||
fn editor_block_by_id(block_id: &str) -> Option<Element> {
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ENV_FILE="${MNOTE_LIGHTRAG_ENV_FILE:-/mnt/Data1T/Mnote_data/lightrag/LightRAG/.env}"
|
||||
NPX="${MNOTE_NPX:-npx}"
|
||||
|
||||
read_env_value() {
|
||||
local key="$1"
|
||||
local file="$2"
|
||||
if [[ ! -f "$file" ]]; then
|
||||
return 0
|
||||
fi
|
||||
grep -E "^${key}=" "$file" | tail -n 1 | sed -E "s/^${key}=//" | sed -E 's/^"(.*)"$/\1/' | sed -E "s/^'(.*)'$/\1/"
|
||||
}
|
||||
|
||||
HOST_VALUE="${LIGHTRAG_HOST:-$(read_env_value HOST "$ENV_FILE")}"
|
||||
PORT_VALUE="${LIGHTRAG_PORT:-$(read_env_value PORT "$ENV_FILE")}"
|
||||
API_KEY_VALUE="${LIGHTRAG_API_KEY:-$(read_env_value LIGHTRAG_API_KEY "$ENV_FILE")}"
|
||||
|
||||
HOST_VALUE="${HOST_VALUE:-127.0.0.1}"
|
||||
PORT_VALUE="${PORT_VALUE:-9621}"
|
||||
|
||||
export LIGHTRAG_SERVER_URL="${LIGHTRAG_SERVER_URL:-http://${HOST_VALUE}:${PORT_VALUE}}"
|
||||
export LIGHTRAG_API_KEY="$API_KEY_VALUE"
|
||||
export LIGHTRAG_TIMEOUT_MS="${LIGHTRAG_TIMEOUT_MS:-180000}"
|
||||
|
||||
exec "$NPX" -y l-pw2c-lightrag-server-mcp@1.2.2 "$@"
|
||||
@@ -0,0 +1,183 @@
|
||||
#!/usr/bin/env python3
|
||||
"""MNote LightRAG MCP facade.
|
||||
|
||||
This is intentionally thin: LightRAG owns retrieval, MNote owns citation/open
|
||||
mapping through its source registry.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
from mcp.server.fastmcp import FastMCP
|
||||
|
||||
|
||||
DEFAULT_ENV_FILE = "/mnt/Data1T/Mnote_data/lightrag/LightRAG/.env"
|
||||
DEFAULT_MNOTE_WEB_URL = "http://127.0.0.1:3000"
|
||||
DEFAULT_ROOT_URI = "file:///mnt/Data1T/Mnote_data/users/mnote-e2e/workspaces/my-space"
|
||||
DEFAULT_WORKSPACE_ID = "local-ws:mnote-e2e:my-space"
|
||||
|
||||
mcp = FastMCP("MNote-LightRAG-Server")
|
||||
|
||||
|
||||
def _read_env_value(key: str, env_file: str) -> str:
|
||||
path = Path(env_file)
|
||||
if not path.exists():
|
||||
return ""
|
||||
for line in path.read_text(encoding="utf-8").splitlines():
|
||||
if not line.startswith(f"{key}="):
|
||||
continue
|
||||
value = line.split("=", 1)[1].strip()
|
||||
if (value.startswith('"') and value.endswith('"')) or (
|
||||
value.startswith("'") and value.endswith("'")
|
||||
):
|
||||
value = value[1:-1]
|
||||
return value
|
||||
return ""
|
||||
|
||||
|
||||
def _lightrag_base_url() -> str:
|
||||
env_file = os.environ.get("MNOTE_LIGHTRAG_ENV_FILE", DEFAULT_ENV_FILE)
|
||||
host = os.environ.get("LIGHTRAG_HOST") or _read_env_value("HOST", env_file) or "127.0.0.1"
|
||||
port = os.environ.get("LIGHTRAG_PORT") or _read_env_value("PORT", env_file) or "9621"
|
||||
return f"http://{host}:{port}".rstrip("/")
|
||||
|
||||
|
||||
def _lightrag_api_key() -> str:
|
||||
env_file = os.environ.get("MNOTE_LIGHTRAG_ENV_FILE", DEFAULT_ENV_FILE)
|
||||
return os.environ.get("LIGHTRAG_API_KEY") or _read_env_value("LIGHTRAG_API_KEY", env_file)
|
||||
|
||||
|
||||
def _mnote_web_url() -> str:
|
||||
return os.environ.get("MNOTE_WEB_URL", DEFAULT_MNOTE_WEB_URL).rstrip("/")
|
||||
|
||||
|
||||
def _mnote_headers() -> dict[str, str]:
|
||||
return {
|
||||
"content-type": "application/json",
|
||||
"x-mnote-actor-id": os.environ.get("MNOTE_ACTOR_ID", "mnote-e2e"),
|
||||
"x-mnote-actor-type": os.environ.get("MNOTE_ACTOR_TYPE", "user"),
|
||||
}
|
||||
|
||||
|
||||
async def _request_lightrag(path: str, *, method: str = "GET", json_body: Any = None) -> Any:
|
||||
headers = {"accept": "application/json"}
|
||||
api_key = _lightrag_api_key()
|
||||
if api_key:
|
||||
# 当前 LightRAG /query 接受 X-API-Key;Bearer 在本机版本会返回 Invalid token。
|
||||
headers["X-API-Key"] = api_key
|
||||
async with httpx.AsyncClient(timeout=180) as client:
|
||||
response = await client.request(
|
||||
method,
|
||||
f"{_lightrag_base_url()}{path}",
|
||||
headers=headers,
|
||||
json=json_body,
|
||||
)
|
||||
try:
|
||||
payload = response.json()
|
||||
except Exception:
|
||||
payload = {"text": response.text}
|
||||
if response.status_code >= 400:
|
||||
return {"status": "error", "response": None, "error": payload, "httpStatus": response.status_code}
|
||||
return {"status": "success", "response": payload, "error": None, "httpStatus": response.status_code}
|
||||
|
||||
|
||||
@mcp.tool(
|
||||
name="verify_server_health",
|
||||
description="Check whether the configured local LightRAG server is healthy.",
|
||||
)
|
||||
async def verify_server_health() -> Any:
|
||||
return await _request_lightrag("/health")
|
||||
|
||||
|
||||
@mcp.tool(
|
||||
name="check_indexing_status",
|
||||
description="Check the LightRAG document processing pipeline status.",
|
||||
)
|
||||
async def check_indexing_status() -> Any:
|
||||
return await _request_lightrag("/documents/pipeline_status")
|
||||
|
||||
|
||||
@mcp.tool(
|
||||
name="list_all_docs",
|
||||
description="List documents currently known to LightRAG.",
|
||||
)
|
||||
async def list_all_docs() -> Any:
|
||||
return await _request_lightrag("/documents")
|
||||
|
||||
|
||||
@mcp.tool(
|
||||
name="query_knowledge_graph",
|
||||
description="Search the local LightRAG knowledge base. Use mix by default; references include provider file_path/chunk_id for MNote citation mapping.",
|
||||
)
|
||||
async def query_knowledge_graph(
|
||||
prompt: str,
|
||||
search_mode: str = "mix",
|
||||
limit: int = 60,
|
||||
context_only: bool = False,
|
||||
prompt_only: bool = False,
|
||||
include_references: bool = True,
|
||||
include_chunk_content: bool = True,
|
||||
) -> Any:
|
||||
mode_aliases = {
|
||||
"keyword": "naive",
|
||||
"semantic": "hybrid",
|
||||
}
|
||||
mode = mode_aliases.get(search_mode, search_mode)
|
||||
body = {
|
||||
"query": prompt,
|
||||
"mode": mode,
|
||||
"top_k": limit,
|
||||
"chunk_top_k": limit,
|
||||
"only_need_context": context_only,
|
||||
"only_need_prompt": prompt_only,
|
||||
"include_references": include_references,
|
||||
"include_chunk_content": include_chunk_content,
|
||||
"response_type": "Multiple Paragraphs",
|
||||
}
|
||||
return await _request_lightrag("/query", method="POST", json_body=body)
|
||||
|
||||
|
||||
@mcp.tool(
|
||||
name="open_mnote_reference",
|
||||
description="Map a LightRAG reference/file_path/chunk_id to an MNote clickable citationUrl using MNote source registry.",
|
||||
)
|
||||
async def open_mnote_reference(
|
||||
file_path: str,
|
||||
chunk_id: str = "",
|
||||
reference_id: str = "",
|
||||
workspace_id: str = DEFAULT_WORKSPACE_ID,
|
||||
root_uri: str = DEFAULT_ROOT_URI,
|
||||
include_registry: bool = False,
|
||||
) -> Any:
|
||||
body: dict[str, Any] = {
|
||||
"workspaceId": workspace_id,
|
||||
"rootUri": root_uri,
|
||||
"filePath": file_path,
|
||||
}
|
||||
if chunk_id:
|
||||
body["chunkId"] = chunk_id
|
||||
if reference_id:
|
||||
body["referenceId"] = reference_id
|
||||
async with httpx.AsyncClient(timeout=60) as client:
|
||||
response = await client.post(
|
||||
f"{_mnote_web_url()}/api/knowledge-rag/open-reference",
|
||||
headers=_mnote_headers(),
|
||||
json=body,
|
||||
)
|
||||
try:
|
||||
payload = response.json()
|
||||
except Exception:
|
||||
payload = {"text": response.text}
|
||||
if response.status_code >= 400:
|
||||
return {"status": "error", "response": None, "error": payload, "httpStatus": response.status_code}
|
||||
if not include_registry and isinstance(payload, dict):
|
||||
payload = {key: value for key, value in payload.items() if key != "registry"}
|
||||
return {"status": "success", "response": payload, "error": None, "httpStatus": response.status_code}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
mcp.run()
|
||||
@@ -0,0 +1,38 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ENV_FILE="${MNOTE_LIGHTRAG_ENV_FILE:-/mnt/Data1T/Mnote_data/lightrag/LightRAG/.env}"
|
||||
PYTHON="${MNOTE_PYTHON:-/usr/bin/python3}"
|
||||
SERVER="${MNOTE_LIGHTRAG_MCP_SERVER:-/mnt/Data1T/mnote/scripts/mnote-lightrag-mcp-server.py}"
|
||||
|
||||
read_env_value() {
|
||||
local key="$1"
|
||||
local file="$2"
|
||||
if [[ ! -f "$file" ]]; then
|
||||
return 0
|
||||
fi
|
||||
grep -E "^${key}=" "$file" | tail -n 1 | sed -E "s/^${key}=//" | sed -E 's/^"(.*)"$/\1/' | sed -E "s/^'(.*)'$/\1/"
|
||||
}
|
||||
|
||||
HOST_VALUE="${LIGHTRAG_HOST:-$(read_env_value HOST "$ENV_FILE")}"
|
||||
PORT_VALUE="${LIGHTRAG_PORT:-$(read_env_value PORT "$ENV_FILE")}"
|
||||
API_KEY_VALUE="${LIGHTRAG_API_KEY:-$(read_env_value LIGHTRAG_API_KEY "$ENV_FILE")}"
|
||||
|
||||
HOST_VALUE="${HOST_VALUE:-127.0.0.1}"
|
||||
PORT_VALUE="${PORT_VALUE:-9621}"
|
||||
|
||||
if [[ ! -x "$PYTHON" ]]; then
|
||||
echo "python not found or not executable: $PYTHON" >&2
|
||||
exit 127
|
||||
fi
|
||||
|
||||
if [[ ! -f "$SERVER" ]]; then
|
||||
echo "MNote LightRAG MCP server not found: $SERVER" >&2
|
||||
exit 127
|
||||
fi
|
||||
|
||||
export LIGHTRAG_HOST="$HOST_VALUE"
|
||||
export LIGHTRAG_PORT="$PORT_VALUE"
|
||||
export LIGHTRAG_API_KEY="$API_KEY_VALUE"
|
||||
|
||||
exec "$PYTHON" "$SERVER" "$@"
|
||||
@@ -137,10 +137,10 @@ async function main() {
|
||||
const currentRunId = route.request().url().split("/").pop() || runId;
|
||||
captured.push({ kind: "events", method: route.request().method(), body: "" });
|
||||
const evidenceEvents = Array.from({ length: 24 }, (_, index) => {
|
||||
const callId = `call_smoke_evidence_${index}`;
|
||||
const callId = `call_smoke_knowledge_rag_${index}`;
|
||||
return (
|
||||
`data: ${JSON.stringify({ event: "tool.started", run_id: currentRunId, session_id: sessionId, toolCallId: callId, name: "mnote.evidence.search", args: { query: `evidence ${index}` } })}\n\n` +
|
||||
`data: ${JSON.stringify({ event: "tool.completed", run_id: currentRunId, session_id: sessionId, toolCallId: callId, name: "mnote.evidence.search", summary: `证据 ${index}`, auditId: `audit_smoke_evidence_${index}` })}\n\n`
|
||||
`data: ${JSON.stringify({ event: "tool.started", run_id: currentRunId, session_id: sessionId, toolCallId: callId, name: "mnote.knowledge_rag.query", args: { query: `evidence ${index}` } })}\n\n` +
|
||||
`data: ${JSON.stringify({ event: "tool.completed", run_id: currentRunId, session_id: sessionId, toolCallId: callId, name: "mnote.knowledge_rag.query", summary: `资料库 ${index}`, auditId: `audit_smoke_knowledge_rag_${index}` })}\n\n`
|
||||
);
|
||||
}).join("");
|
||||
if (runRequestCount > 1) {
|
||||
|
||||
@@ -54,7 +54,7 @@ function runtimeInputToolPlan() {
|
||||
dryRun: false,
|
||||
},
|
||||
tool: {
|
||||
tool: "docs_search",
|
||||
tool: "mnote.knowledge_rag.query",
|
||||
kind: "query",
|
||||
mode: "plan",
|
||||
argsJson: { query: "Rust Web islands" },
|
||||
|
||||
@@ -13,6 +13,7 @@ const { BASE_URL, UI_TIMEOUT_MS } = require(path.join(ROOT, "scripts", "tree-she
|
||||
const OUTPUT_DIR = path.join(ROOT, "tmp", "task530-knowledge-rag-page-ai-final-answer-smoke");
|
||||
const RESULT_PATH = path.join(OUTPUT_DIR, "result.json");
|
||||
const SCREENSHOT_PATH = path.join(OUTPUT_DIR, "page-ai-knowledge-rag-answer.png");
|
||||
const CITATION_OPEN_SCREENSHOT_PATH = path.join(OUTPUT_DIR, "page-ai-knowledge-rag-citation-open.png");
|
||||
const CONTROL_PLANE_DB =
|
||||
process.env.MNOTE_CONTROL_PLANE_DB_PATH || "/mnt/Data1T/Mnote_data/control-plane/control-plane.db";
|
||||
const CHROME = process.env.MNOTE_PAGE_AI_CHROME || "/usr/bin/google-chrome";
|
||||
@@ -114,6 +115,60 @@ async function newestAssistantLinks(page, initialCount) {
|
||||
}, initialCount);
|
||||
}
|
||||
|
||||
async function clickNewestAssistantCitation(page, initialCount, expectedResource) {
|
||||
const total = await page
|
||||
.locator('[data-testid="wolai-page-ai-drawer"] .wolai-page-ai-message--assistant')
|
||||
.count();
|
||||
const latestIndex = Math.max(initialCount, total - 1);
|
||||
const latestMessage = page
|
||||
.locator('[data-testid="wolai-page-ai-drawer"] .wolai-page-ai-message--assistant')
|
||||
.nth(latestIndex);
|
||||
const citationLink = latestMessage.locator('a[data-page-ai-citation-link="true"]').first();
|
||||
await citationLink.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
|
||||
const popupPromise = page.waitForEvent("popup", { timeout: 5_000 }).catch(() => null);
|
||||
await citationLink.click({ timeout: UI_TIMEOUT_MS });
|
||||
const openedPage = (await popupPromise) || page;
|
||||
await openedPage.waitForLoadState("domcontentloaded", { timeout: UI_TIMEOUT_MS }).catch(() => undefined);
|
||||
await openedPage.waitForFunction(
|
||||
(resourcePath) => {
|
||||
const panel = document.querySelector(
|
||||
`[data-mnote-resource-tab-panel][data-resource-path="${CSS.escape(resourcePath)}"]`,
|
||||
);
|
||||
return panel && !panel.hidden;
|
||||
},
|
||||
expectedResource,
|
||||
{ timeout: UI_TIMEOUT_MS },
|
||||
);
|
||||
await openedPage.waitForTimeout(1500);
|
||||
const state = await openedPage.evaluate((resourcePath) => {
|
||||
const panel = document.querySelector(
|
||||
`[data-mnote-resource-tab-panel][data-resource-path="${CSS.escape(resourcePath)}"]`,
|
||||
);
|
||||
const activeTab = document.querySelector(".mnote-main-tab.is-active, [data-mnote-tab-kind].is-active");
|
||||
const image = panel ? panel.querySelector("img, [data-mnote-image-viewer], [data-mnote-resource-image]") : null;
|
||||
return {
|
||||
url: location.href,
|
||||
openedInPopup: window.opener != null,
|
||||
activeTabText: activeTab ? activeTab.textContent.trim().slice(0, 120) : "",
|
||||
activeTabKind: activeTab ? activeTab.getAttribute("data-mnote-tab-kind") : "",
|
||||
panelVisible: !!panel && !panel.hidden,
|
||||
panelResourcePath: panel ? panel.getAttribute("data-resource-path") : "",
|
||||
panelLocator: panel ? panel.getAttribute("data-mnote-evidence-locator") : "",
|
||||
panelBlockId: panel ? panel.getAttribute("data-mnote-evidence-block-id") : "",
|
||||
imageVisible: !!image,
|
||||
};
|
||||
}, expectedResource);
|
||||
await openedPage.screenshot({ path: CITATION_OPEN_SCREENSHOT_PATH, fullPage: true });
|
||||
assert.equal(state.panelVisible, true, `点击 AI citation 后未打开资源 panel: ${JSON.stringify(state, null, 2)}`);
|
||||
assert.equal(state.panelResourcePath, expectedResource, `点击 AI citation 后资源路径不匹配: ${JSON.stringify(state, null, 2)}`);
|
||||
assert(
|
||||
state.url.includes("resourceTab=") || state.url.includes("resourcePath="),
|
||||
`点击 AI citation 后 URL 缺少资源定位参数: ${JSON.stringify(state, null, 2)}`,
|
||||
);
|
||||
return state;
|
||||
}
|
||||
|
||||
function summarizeRun(body) {
|
||||
return {
|
||||
workspaceId: body.workspaceId || "",
|
||||
@@ -228,6 +283,7 @@ async function main() {
|
||||
);
|
||||
|
||||
await page.screenshot({ path: SCREENSHOT_PATH, fullPage: true });
|
||||
const citationOpenState = await clickNewestAssistantCitation(page, assistantCount, "新页面233155/image copy 6.png");
|
||||
const result = {
|
||||
ok: true,
|
||||
baseUrl: BASE_URL,
|
||||
@@ -238,9 +294,13 @@ async function main() {
|
||||
assistantLinks,
|
||||
capturedRuns: capturedRuns.map(summarizeRun),
|
||||
capturedRunsFullPath: path.join(OUTPUT_DIR, "captured-runs-full.json"),
|
||||
citationOpenState,
|
||||
pageErrors,
|
||||
consoleErrors,
|
||||
screenshot: SCREENSHOT_PATH,
|
||||
screenshots: {
|
||||
answer: SCREENSHOT_PATH,
|
||||
citationOpen: CITATION_OPEN_SCREENSHOT_PATH,
|
||||
},
|
||||
};
|
||||
fs.writeFileSync(
|
||||
path.join(OUTPUT_DIR, "captured-runs-full.json"),
|
||||
|
||||
@@ -78,6 +78,14 @@ async function main() {
|
||||
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 firstKnowledgeResult = knowledgePayload.results?.[0] || {};
|
||||
const firstKnowledgeCitation = knowledgePayload.citations?.[0] || {};
|
||||
assert(firstKnowledgeResult.displayQuote && firstKnowledgeResult.locatorEvidenceText, `搜索结果缺少 displayQuote/locatorEvidenceText: ${JSON.stringify(firstKnowledgeResult, null, 2)}`);
|
||||
assert(firstKnowledgeCitation.citationId && firstKnowledgeCitation.displayQuote && firstKnowledgeCitation.locatorEvidenceText, `citations[] 缺少统一引用字段: ${JSON.stringify(firstKnowledgeCitation, null, 2)}`);
|
||||
assert(
|
||||
!/(?:<\/?e(?:q(?:uation)?)?\b|<\/?drawing\b|format=["']?latex|\blatex\b)/i.test(String(firstKnowledgeResult.displayQuote || "")),
|
||||
`displayQuote 仍暴露原始公式/绘图标记: ${firstKnowledgeResult.displayQuote}`
|
||||
);
|
||||
|
||||
const tooShortSearch = await context.request.post(`${BASE_URL}/api/knowledge-rag/search`, {
|
||||
data: {
|
||||
|
||||
@@ -81,6 +81,11 @@ async function apiSearch(context) {
|
||||
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)}`);
|
||||
assert(Array.isArray(payload.citations) && payload.citations.length >= TOP_N, `资料库结果缺少 citations[]: ${JSON.stringify(payload, null, 2).slice(0, 3000)}`);
|
||||
results.slice(0, TOP_N).forEach((item, index) => {
|
||||
assert(item.displayQuote && item.locatorEvidenceText, `第 ${index + 1} 条缺少 displayQuote/locatorEvidenceText: ${JSON.stringify(item, null, 2)}`);
|
||||
assert(!/(?:<\/?e(?:q(?:uation)?)?\b|<\/?drawing\b|format=["']?latex|\blatex\b)/i.test(String(item.displayQuote || "")), `第 ${index + 1} 条 displayQuote 仍暴露原始公式/绘图标记: ${item.displayQuote}`);
|
||||
});
|
||||
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);
|
||||
|
||||
@@ -8,6 +8,8 @@
|
||||
- 用 `mnote.knowledge_rag.query` 提问。默认 `mode=mix`,需要跨资料总结时可用 `global`。指定资料细节时可传 `sourcePaths`,但当前语义是 LightRAG provider 检索后,MNote 只过滤返回的 `references`;不要把 `raw` 当作已被 scope 限制的来源。
|
||||
- 回答必须引用 `references` 中的来源;优先使用返回的 `citationMarkdown`。
|
||||
- 若需要打开来源,调用 `mnote.knowledge_rag.open_reference`,不要手拼 LightRAG dashboard、`file://` 或 provider 内部路径。
|
||||
- 如果当前 Agent runtime 额外挂载了 `lightrag_native`,可用其 `query_text` / `query_data` 完成 LightRAG 原生检索;拿到 provider 的 `file_path` / `chunk_id` 后,用 `mnote_lightrag_bridge.open_mnote_reference` 或 `mnote.knowledge_rag.open_reference` 转成 MNote 可点击定位。
|
||||
- 引入或变更 LightRAG MCP、`mnote.knowledge_rag.*` 工具、citation 渲染或 open-reference 映射后,必须跑 Reasonix/Page AI 浏览器验收:AI 可见回答包含 citation 链接,点击该链接后 MNote 能打开对应资源并落到 locator。
|
||||
|
||||
边界:
|
||||
|
||||
@@ -15,3 +17,4 @@
|
||||
- `filePath` 是 LightRAG provider-local 名称;只有 registry 命中的 reference 才能映射回 MNote source。
|
||||
- 如果返回 `locatorDegraded: true`,可以使用返回的 `citationMarkdown`,但必须明说“来源定位降级”,不要伪造页码、bbox 或 provider 内部路径。
|
||||
- LightRAG 不可用时,不要声称资料库已查到;提示用户启动或检查 LightRAG provider。旧 LiteParse / evidence 检索已退役,不作为 fallback。
|
||||
- MNote 的职责不是重排、补召回或再做一套全文搜索;MNote 只做 allowed roots、source registry、结果过滤和 citation/open-reference 映射。
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
---
|
||||
name: mnote_lightrag_bridge
|
||||
description: Use when an agent has LightRAG references or file_path/chunk_id and must convert them into MNote clickable citation links or openable local resource locators. Pair with lightrag_native for retrieval, but use this bridge for MNote source registry and citation/open-reference mapping.
|
||||
---
|
||||
|
||||
# MNote LightRAG Bridge
|
||||
|
||||
用途:把 LightRAG 原生检索结果映射成 MNote 可点击、可打开、可定位的引用。它不是检索系统本身。
|
||||
|
||||
## 工具分层
|
||||
|
||||
- `lightrag_native`:LightRAG 原生 MCP,负责 `query_text`、`query_data`、graph、documents、pipeline。
|
||||
- `mnote_lightrag_bridge`:MNote bridge,负责 source registry、`file_path/chunk_id` 映射、`citationUrl`、`openAction`。
|
||||
|
||||
## 使用流程
|
||||
|
||||
1. 先用 `lightrag_native.query_text` 或 `lightrag_native.query_data` 做资料库检索。
|
||||
2. 从返回的 `references[]` 里取 `file_path`,有 `chunk_id` 时一并保留。
|
||||
3. 调用 `mnote_lightrag_bridge.open_mnote_reference`,输入 `file_path` / `chunk_id` / `workspace_id` / `root_uri`。
|
||||
4. 最终回答引用 bridge 返回的 `citationMarkdown` 或让 MNote UI 渲染 citation;不要手写 `/documents`、`file://`、LightRAG dashboard URL。
|
||||
|
||||
## 边界
|
||||
|
||||
- `file_path` 是 LightRAG provider 内部文件名,不等于用户本地相对路径。
|
||||
- 只有 MNote registry 命中的 reference 才能映射回本地资源和页面。
|
||||
- `locatorDegraded=true` 时必须说明“来源定位降级”,不能伪造页码、bbox、段落或坐标。
|
||||
- 不要用 bridge 做召回、重排、全文 fallback 或资料管理。
|
||||
Reference in New Issue
Block a user