Improve LightRAG knowledge search locator alignment

This commit is contained in:
lix-2026
2026-06-08 20:35:49 +08:00
parent 9551d4c1dc
commit 0e8b03daf8
28 changed files with 5769 additions and 140 deletions
+146 -1
View File
@@ -11,6 +11,7 @@ use axum::body::Body;
use axum::http::{header, StatusCode};
use axum::response::Response;
use serde_json::{json, Value};
use std::collections::HashSet;
use std::sync::Arc;
use tokio::sync::broadcast;
use tracing::{info, warn};
@@ -47,6 +48,105 @@ pub struct AcpRunBridge {
event_tx: broadcast::Sender<SseEvent>,
}
fn collect_citation_markdowns_from_value(value: &Value) -> Vec<Value> {
fn add_citation(text: &str, seen: &mut HashSet<String>, out: &mut Vec<Value>) {
let citation = text.trim();
if !citation.is_empty() && seen.insert(citation.to_string()) {
out.push(json!({ "citationMarkdown": citation }));
}
}
fn add_reference_citations(
references: &[Value],
seen: &mut HashSet<String>,
out: &mut Vec<Value>,
) -> bool {
let has_precise = references.iter().any(|reference| {
reference
.get("citationMarkdown")
.and_then(Value::as_str)
.is_some_and(|value| !value.trim().is_empty())
&& reference.get("locatorDegraded").and_then(Value::as_bool) != Some(true)
});
let mut added = false;
for reference in references {
if out.len() >= 8 {
break;
}
let Some(citation) = reference.get("citationMarkdown").and_then(Value::as_str) else {
continue;
};
if has_precise
&& reference.get("locatorDegraded").and_then(Value::as_bool) == Some(true)
{
continue;
}
let before = out.len();
add_citation(citation, seen, out);
added = added || out.len() > before;
}
added
}
fn visit(value: &Value, seen: &mut HashSet<String>, out: &mut Vec<Value>) {
if out.len() >= 8 {
return;
}
match value {
Value::String(text) => {
let trimmed = text.trim();
if (trimmed.starts_with('{') || trimmed.starts_with('['))
&& trimmed.contains("citationMarkdown")
{
if let Ok(parsed) = serde_json::from_str::<Value>(trimmed) {
visit(&parsed, seen, out);
} else if let Some(first_line) = trimmed.lines().next() {
if let Ok(parsed) = serde_json::from_str::<Value>(first_line.trim()) {
visit(&parsed, seen, out);
}
}
}
}
Value::Array(items) => {
for item in items {
visit(item, seen, out);
if out.len() >= 8 {
break;
}
}
}
Value::Object(map) => {
let has_filtered_references = map
.get("references")
.and_then(Value::as_array)
.is_some_and(|references| add_reference_citations(references, seen, out));
if let Some(citation) = map.get("citationMarkdown").and_then(Value::as_str) {
if !has_filtered_references {
add_citation(citation, seen, out);
}
}
for (key, item) in map {
if has_filtered_references
&& matches!(key.as_str(), "references" | "citations" | "uiCitations")
{
continue;
}
visit(item, seen, out);
if out.len() >= 8 {
break;
}
}
}
_ => {}
}
}
let mut seen = HashSet::new();
let mut out = Vec::new();
visit(value, &mut seen, &mut out);
out
}
impl AcpRunBridge {
/// Create a new ACP run: create session + start prompt in background.
///
@@ -213,6 +313,8 @@ pub fn acp_event_to_sse(event: AcpSessionEvent) -> Option<SseEvent> {
status,
content,
} => {
let output = json!(content);
let citation_markdowns = collect_citation_markdowns_from_value(&output);
let error = status == crate::acp_types::ToolCallStatus::Failed;
let event = if error {
"tool.failed"
@@ -227,7 +329,8 @@ pub fn acp_event_to_sse(event: AcpSessionEvent) -> Option<SseEvent> {
"toolCallId": tool_call_id,
"status": status,
"error": error,
"output": content,
"output": output,
"citationMarkdowns": citation_markdowns,
}),
})
}
@@ -426,6 +529,48 @@ mod tests {
assert_eq!(running.data["status"], "in_progress");
}
#[test]
fn acp_tool_completed_extracts_precise_ui_citations_from_prefixed_text() {
let prefix = json!({
"schema": "mnote.acp.tool_result_ui_citations.v1",
"references": [{
"citationMarkdown": "[来源定位降级:a.md](/documents/a)",
"locatorDegraded": true
}, {
"citationMarkdown": "[b.md · p.2](/documents/b?page=2)",
"locatorDegraded": false
}],
"uiCitations": [{
"citationMarkdown": "[来源定位降级:a.md](/documents/a)"
}]
})
.to_string();
let completed = acp_event_to_sse(AcpSessionEvent::ToolCallUpdate {
tool_call_id: "tool_2".into(),
status: crate::acp_types::ToolCallStatus::Completed,
content: Some(vec![crate::acp_types::ContentBlockWrapper {
wrapper_type: "content".into(),
content: crate::acp_types::TextContent {
content_type: "text".into(),
text: format!("{prefix}\n工具正文"),
},
}]),
})
.expect("tool complete");
assert_eq!(
completed.data["citationMarkdowns"][0]["citationMarkdown"].as_str(),
Some("[b.md · p.2](/documents/b?page=2)")
);
assert_eq!(
completed.data["citationMarkdowns"]
.as_array()
.unwrap()
.len(),
1
);
}
#[test]
fn acp_session_info_update_emits_session_info_updated_sse() {
let sse = acp_event_to_sse(AcpSessionEvent::SessionInfoUpdate {
@@ -112,7 +112,8 @@ fn compact_query_result_for_agent(payload: Value) -> Value {
.collect::<Vec<_>>()
})
.unwrap_or_default();
let citations = references
let citation_references = citation_references_for_ui(&references);
let citations = citation_references
.iter()
.filter_map(|reference| {
reference
@@ -122,16 +123,23 @@ fn compact_query_result_for_agent(payload: Value) -> Value {
.map(ToOwned::to_owned)
})
.collect::<Vec<_>>();
let ui_citations = citations
.iter()
.map(|citation| json!({ "citationMarkdown": citation }))
.collect::<Vec<_>>();
json!({
"ok": payload.get("ok").cloned().unwrap_or_else(|| json!(true)),
"schema": "mnote.knowledge_rag.agent_query_result.v1",
"provider": payload.get("provider").cloned().unwrap_or_else(|| json!("lightrag")),
"answerGuidance": "Final answers must cite at least one returned citationMarkdown verbatim. If locatorDegraded is true, say the source location is degraded instead of inventing page or bbox.",
"answerGuidance": "Final answers should answer the user in plain text. Do not copy citationMarkdown, citationUrl, /documents, mnote://, or search-engine wrapped local links into the answer. MNote UI will render citations[] / references[].citationMarkdown after the answer as clickable source locators. Treat rawMetadata.keywords and entity/relation counts only as query analysis, not proof that a source contains those words. Use references[].quote/contentDiagnostics as evidence. If locatorDegraded is true, say the source location is degraded instead of inventing page or bbox. If contentDiagnostics.ocrTextExposed is false, say OCR text was not exposed in the returned reference instead of claiming OCR succeeded.",
"references": references,
"citations": citations,
"uiCitations": ui_citations,
"citationRendering": "MNote UI appends uiCitations/citations after the final answer; agent must not hand-write citation links.",
"sourceScope": payload.get("sourceScope").cloned().unwrap_or(Value::Array(Vec::new())),
"sourceScopeMode": payload.get("sourceScopeMode").cloned().unwrap_or_else(|| json!("post_filter_mapped_references")),
"rawScopeFiltered": payload.get("rawScopeFiltered").cloned().unwrap_or(Value::Bool(false)),
"rawMetadataMeaning": "provider metadata describes query processing/retrieval bookkeeping; it is not source text evidence unless the same text appears in references[].quote",
"rawStatus": payload.pointer("/raw/status").cloned().unwrap_or(Value::Null),
"rawMessage": payload.pointer("/raw/message").cloned().unwrap_or(Value::Null),
"rawMetadata": payload.pointer("/raw/metadata").cloned().unwrap_or(Value::Null),
@@ -144,6 +152,10 @@ fn compact_reference_for_agent(reference: &Value) -> Value {
.and_then(Value::as_str)
.map(|value| value.chars().take(700).collect::<String>())
.unwrap_or_default();
let quote_diagnostics = reference
.get("contentDiagnostics")
.cloned()
.unwrap_or_else(|| quote_diagnostics(&quote));
json!({
"schema": reference.get("schema").cloned().unwrap_or_else(|| json!("mnote.knowledge_rag.reference.v1")),
"provider": reference.get("provider").cloned().unwrap_or_else(|| json!("lightrag")),
@@ -151,13 +163,55 @@ fn compact_reference_for_agent(reference: &Value) -> Value {
"sourceRootRelativePath": reference.get("sourceRootRelativePath").cloned().unwrap_or(Value::Null),
"chunkId": reference.get("chunkId").cloned().unwrap_or(Value::Null),
"quote": quote,
"quoteSource": reference.get("quoteSource").cloned().unwrap_or(Value::Null),
"locator": reference.get("locator").cloned().unwrap_or(Value::Null),
"locatorDegraded": reference.get("locatorDegraded").cloned().unwrap_or(Value::Bool(true)),
"contentDiagnostics": quote_diagnostics,
"citationMarkdown": reference.get("citationMarkdown").cloned().unwrap_or(Value::Null),
"citationUrl": reference.get("citationUrl").cloned().unwrap_or(Value::Null),
})
}
fn citation_references_for_ui(references: &[Value]) -> Vec<&Value> {
let has_precise = references.iter().any(|reference| {
reference
.get("citationMarkdown")
.and_then(Value::as_str)
.is_some_and(|value| !value.trim().is_empty())
&& reference.get("locatorDegraded").and_then(Value::as_bool) != Some(true)
});
references
.iter()
.filter(|reference| {
reference
.get("citationMarkdown")
.and_then(Value::as_str)
.is_some_and(|value| !value.trim().is_empty())
&& (!has_precise
|| reference.get("locatorDegraded").and_then(Value::as_bool) != Some(true))
})
.collect()
}
fn quote_diagnostics(quote: &str) -> Value {
let meaningful_lines = quote
.lines()
.map(str::trim)
.filter(|line| !line.is_empty())
.filter(|line| !line.starts_with('#'))
.filter(|line| !is_markdown_image_line(line))
.collect::<Vec<_>>();
json!({
"quoteEmpty": quote.trim().is_empty(),
"quoteOnlyImagePlaceholder": !quote.trim().is_empty() && meaningful_lines.is_empty(),
"ocrTextExposed": !meaningful_lines.is_empty(),
})
}
fn is_markdown_image_line(line: &str) -> bool {
line.starts_with("![") && line.contains("](") && line.ends_with(')')
}
#[cfg(test)]
mod tests {
use super::*;
@@ -180,6 +234,11 @@ mod tests {
"quote": "scoped quote",
"locatorDegraded": true,
"citationMarkdown": "[来源定位降级:docs/a.md](/documents/local-md:docs~2Fa.md)"
}, {
"sourceRootRelativePath": "docs/b.md",
"quote": "precise quote",
"locatorDegraded": false,
"citationMarkdown": "[docs/b.md · p.2](/documents/local-md:docs~2Fb.md?page=2)"
}]
});
@@ -191,9 +250,47 @@ mod tests {
assert_eq!(compact["rawScopeFiltered"].as_bool(), Some(false));
assert!(compact.get("raw").is_none());
assert!(compact.get("chunks").is_none());
assert_eq!(
compact["rawMetadataMeaning"].as_str(),
Some("provider metadata describes query processing/retrieval bookkeeping; it is not source text evidence unless the same text appears in references[].quote")
);
assert!(compact["answerGuidance"]
.as_str()
.unwrap_or_default()
.contains("Do not copy citationMarkdown"));
assert_eq!(
compact["uiCitations"][0]["citationMarkdown"].as_str(),
Some("[docs/b.md · p.2](/documents/local-md:docs~2Fb.md?page=2)")
);
assert_eq!(compact["uiCitations"].as_array().unwrap().len(), 1);
assert_eq!(
compact["references"][0]["sourceRootRelativePath"].as_str(),
Some("docs/a.md")
);
}
#[test]
fn compact_reference_marks_image_placeholder_as_not_ocr_text() {
let payload = json!({
"references": [{
"sourceRootRelativePath": "docs/image.png",
"quote": "# image.png\n\n![image.png](<image.png>)",
"quoteSource": "chunk",
"locatorDegraded": true,
"citationMarkdown": "[来源定位降级:image.png](/documents/local-md:docs~2FPage.md)"
}]
});
let compact = compact_query_result_for_agent(payload);
let diagnostics = &compact["references"][0]["contentDiagnostics"];
assert_eq!(
diagnostics["quoteOnlyImagePlaceholder"].as_bool(),
Some(true)
);
assert_eq!(diagnostics["ocrTextExposed"].as_bool(), Some(false));
assert_eq!(
compact["references"][0]["quoteSource"].as_str(),
Some("chunk")
);
}
}
@@ -441,7 +441,7 @@ fn knowledge_rag_query_tool() -> Value {
}
json!({
"name": "mnote.knowledge_rag.query",
"description": "向 LightRAG 资料库提问,返回 provider 原始结果和经 MNote registry 映射、sourcePaths 后过滤的引用",
"description": "向 LightRAG 资料库提问,返回 provider 原始结果和经 MNote registry 映射、sourcePaths 后过滤的 references/citations。用户要求链接、来源、引用或证据时优先调用;返回的 citationMarkdown 由 MNote 前端自动追加成可点击来源定位,agent 不应在最终回答中手写 /documents、mnote:// 或搜索引擎包装链接",
"schemaVersion": TOOL_SCHEMA_VERSION,
"capabilityScope": ["knowledge_rag.read", "evidence.read"],
"status": "available",
@@ -335,6 +335,26 @@ pub(crate) fn citation_url_for_locator(locator: &EvidenceLocator) -> String {
if let Some(source_map_path) = locator.source_map_path.as_deref() {
append_query_param(&mut url, "sourceMapPath", source_map_path);
}
if let Some(query) = locator
.open_action
.params
.get("query")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
{
append_query_param(&mut url, "evidenceText", query);
}
if let Some(search_query) = locator
.open_action
.params
.get("searchQuery")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
{
append_query_param(&mut url, "searchQuery", search_query);
}
if let Some(line_range) = &locator.line_range {
append_query_param(
&mut url,
File diff suppressed because it is too large Load Diff
@@ -3685,11 +3685,12 @@ fn score_evidence_text_match(text: &str, query: &str) -> Option<EvidenceTextMatc
.map(|index| (alternative.clone(), index))
})
.collect::<Vec<_>>();
let partial_allowed = if terms.len() > 1 {
!partial_matches.is_empty()
} else {
partial_matches.len() >= 2 || term.normalized.chars().count() <= 2
};
let partial_allowed = !partial_matches.is_empty()
&& if terms.len() > 1 {
true
} else {
partial_matches.len() >= 2
};
if partial_allowed {
partial_count += 1;
if let Some((_, index)) = partial_matches.first() {
@@ -4198,6 +4199,12 @@ mod tests {
let _ = fs::remove_dir_all(&root);
}
#[test]
fn local_search_two_char_cjk_query_requires_real_match() {
assert!(score_evidence_text_match("13. N -甲基吗啉 N -氧化物", "吗啉").is_some());
assert!(score_evidence_text_match("Scope Alpha SOURCE SCOPE RAG", "吗啉").is_none());
}
#[test]
fn local_index_settings_restricts_search_and_evidence_scope() {
let root = temp_root("mnote-local-index-settings-scope");
+1
View File
@@ -85,6 +85,7 @@ pub fn build_router(state: AppState) -> Router {
.route("/api/evidence/open", post(evidence::open))
.route("/api/knowledge-rag/status", get(knowledge_rag::status))
.route("/api/knowledge-rag/ingest", post(knowledge_rag::ingest))
.route("/api/knowledge-rag/search", post(knowledge_rag::search))
.route("/api/knowledge-rag/query", post(knowledge_rag::query_rag))
.route(
"/api/knowledge-rag/open-reference",
+19 -4
View File
@@ -207,7 +207,9 @@ pub async fn documents(
&effective_workspace_id,
&root_path,
)?;
local_search_index::query_local_search_index_with_settings(
let include_ocr = filters.include_ocr.unwrap_or(false);
let limit = body.limit.unwrap_or(30);
let local_result = local_search_index::query_local_search_index_with_settings(
&root_path,
root_uri,
&effective_workspace_id,
@@ -215,11 +217,12 @@ pub async fn documents(
&user_settings,
&normalized_query,
page_id.as_deref(),
body.limit.unwrap_or(30),
limit,
filters.title_only.unwrap_or(false),
filters.exact.unwrap_or(false),
filters.include_ocr.unwrap_or(false),
)?
include_ocr,
)?;
local_result
} else {
load_search_results_with_filters(
state.config(),
@@ -1019,6 +1022,18 @@ mod tests {
payload["meta"]["boundary"]["evidenceSqliteFallback"].as_bool(),
Some(false)
);
assert_eq!(
payload["meta"]["boundary"]["kind"].as_str(),
Some("ordinary_local_search")
);
assert_eq!(
payload["meta"]["boundary"]["ocrSidecarFallback"].as_bool(),
Some(false)
);
assert_eq!(
payload["meta"]["boundary"]["knowledgeRag"].as_bool(),
Some(false)
);
assert!(payload["evidence"].as_array().expect("evidence").is_empty());
assert!(home["tags"]
.as_array()
+313 -6
View File
@@ -994,6 +994,10 @@ pub struct OfficePreviewQuery {
source_map_path: Option<String>,
#[serde(default, alias = "blockId")]
block_id: Option<String>,
#[serde(default, alias = "evidenceText")]
evidence_text: Option<String>,
#[serde(default, alias = "searchQuery")]
search_query: Option<String>,
}
pub async fn office_preview_page(Query(query): Query<OfficePreviewQuery>) -> Response {
@@ -1028,6 +1032,8 @@ pub async fn office_preview_page(Query(query): Query<OfficePreviewQuery>) -> Res
let target_bbox = query.bbox.unwrap_or_default();
let target_source_map_path = query.source_map_path.unwrap_or_default();
let target_block_id = query.block_id.unwrap_or_default();
let target_evidence_text = query.evidence_text.unwrap_or_default();
let target_search_query = query.search_query.unwrap_or_default();
let html = format!(
r#"<!doctype html>
<html lang="zh-CN">
@@ -1074,7 +1080,7 @@ pub async fn office_preview_page(Query(query): Query<OfficePreviewQuery>) -> Res
}}
</style>
</head>
<body data-file-url="{file_url}" data-file-name="{file_name}" data-file-type="{file_type}" data-page-width-content-type="{page_width_content_type}" data-workspace-id="{workspace_id}" data-mnote-source-kind="{source_kind}" data-mnote-root-uri="{root_uri}" data-document-id="{document_id}" data-evidence-page="{target_page}" data-evidence-bbox="{target_bbox}" data-evidence-source-map-path="{target_source_map_path}" data-evidence-block-id="{target_block_id}">
<body data-file-url="{file_url}" data-file-name="{file_name}" data-file-type="{file_type}" data-page-width-content-type="{page_width_content_type}" data-workspace-id="{workspace_id}" data-mnote-source-kind="{source_kind}" data-mnote-root-uri="{root_uri}" data-document-id="{document_id}" data-evidence-page="{target_page}" data-evidence-bbox="{target_bbox}" data-evidence-source-map-path="{target_source_map_path}" data-evidence-block-id="{target_block_id}" data-evidence-text="{target_evidence_text}" data-evidence-search-query="{target_search_query}">
<main class="mnote-office-preview">
<section class="mnote-office-viewer" id="mnote-office-viewer"></section>
</main>
@@ -1093,6 +1099,8 @@ pub async fn office_preview_page(Query(query): Query<OfficePreviewQuery>) -> Res
let evidenceBbox = body.dataset.evidenceBbox || '';
let evidenceSourceMapPath = body.dataset.evidenceSourceMapPath || '';
let evidenceBlockId = body.dataset.evidenceBlockId || '';
let evidenceText = body.dataset.evidenceText || '';
let evidenceSearchQuery = body.dataset.evidenceSearchQuery || '';
let currentPptxBuffer = null;
let pptxRenderToken = 0;
let pptxResizeTimer = 0;
@@ -1160,7 +1168,13 @@ pub async fn office_preview_page(Query(query): Query<OfficePreviewQuery>) -> Res
}}
function normalizeEvidenceText(value) {{
return String(value || '').replace(/\s+/g, ' ').trim();
return String(value || '')
.replace(/<[^>]+>/g, ' ')
.replace(/&nbsp;|&#160;/gi, ' ')
.replace(/[#*_`~>\[\](){{}}]+/g, ' ')
.replace(/[\u200B-\u200D\uFEFF]/g, '')
.replace(/\s+/g, ' ')
.trim();
}}
function markEvidenceTarget(target) {{
@@ -1266,6 +1280,7 @@ pub async fn office_preview_page(Query(query): Query<OfficePreviewQuery>) -> Res
if (!viewer) return false;
const compactNeedle = normalizeEvidenceText(needle).replace(/\s+/g, '');
if (!compactNeedle) return false;
if (compactNeedle.length > 160) return false;
const refs = [];
let compactText = '';
const walker = document.createTreeWalker(viewer, NodeFilter.SHOW_TEXT);
@@ -1291,6 +1306,12 @@ pub async fn office_preview_page(Query(query): Query<OfficePreviewQuery>) -> Res
range.setEnd(endRef.node, endRef.offset + 1);
const rect = range.getBoundingClientRect();
if (!rect || rect.width <= 0 || rect.height <= 0) return false;
const rects = Array.from(range.getClientRects()).filter(item => item && item.width > 0 && item.height > 0);
if (rects.length > 6 || rect.height > Math.min(140, window.innerHeight * 0.35)) return false;
const paragraphTarget = startRef.node?.parentElement?.closest('p, li, td, th, blockquote');
if (paragraphTarget instanceof HTMLElement && normalizeEvidenceText(paragraphTarget.textContent).length < 4000) {{
return markEvidenceTarget(paragraphTarget);
}}
let marker = viewer.querySelector('[data-mnote-office-evidence-marker="true"]');
if (!(marker instanceof HTMLElement)) {{
marker = document.createElement('div');
@@ -1361,13 +1382,17 @@ pub async fn office_preview_page(Query(query): Query<OfficePreviewQuery>) -> Res
let node = walker.nextNode();
while (node) {{
if (normalizeEvidenceText(node.textContent).includes(needle)) {{
const inlineTarget = wrapEvidenceTextNode(node, needle);
if (inlineTarget) return markEvidenceTarget(inlineTarget);
const target = node.parentElement && node.parentElement.closest('p, div, span, table, section') || node.parentElement;
if (target instanceof HTMLElement) {{
const rect = target.getBoundingClientRect();
if (rect.height > window.innerHeight * 1.8 || normalizeEvidenceText(target.textContent).length > 4000) break;
if (needle.length < 20) {{
const paragraphTarget = node.parentElement && node.parentElement.closest('p, li, td, th, blockquote') || target;
if (paragraphTarget instanceof HTMLElement) return markEvidenceTarget(paragraphTarget);
}}
}}
const inlineTarget = wrapEvidenceTextNode(node, needle);
if (inlineTarget) return markEvidenceTarget(inlineTarget);
return markEvidenceTarget(target);
}}
node = walker.nextNode();
@@ -1376,6 +1401,278 @@ pub async fn office_preview_page(Query(query): Query<OfficePreviewQuery>) -> Res
return false;
}}
function evidenceTextCandidates(text) {{
const raw = String(text || '');
const cleaned = raw
.replace(/<[^>]+>/g, ' ')
.replace(/[#*_`~>\[\](){{}}]+/g, ' ')
.replace(/\s+/g, ' ')
.trim();
const specific = [];
function pushSpecificEvidenceTerm(term) {{
let value = normalizeEvidenceText(term);
if (!value || value.length < 3) return;
const anchor = value.search(/[][\u3400-\u9fffA-Za-z0-9()\\-]{{0,18}}/);
if (anchor > 0) value = value.slice(anchor);
value = value.split(/[:;,\n]/)[0];
if (value.length < 3 || value.length > 48) return;
specific.push(value);
if (/[]$/.test(value) && value.length > 3) specific.push(value.slice(0, -1));
}}
const withoutTags = raw.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim();
const siliconTerms = cleaned.match(/[\u3400-\u9fffA-Za-z0-9()\\-]{{0,24}}(?:||)?/g) || [];
siliconTerms.forEach(pushSpecificEvidenceTerm);
const cjkTerms = cleaned.match(/[\u3400-\u9fff][\u3400-\u9fffA-Za-z0-9()\\-]{{2,48}}/g) || [];
const fallbackTerms = [];
cjkTerms.forEach(term => {{
const value = normalizeEvidenceText(term);
if (value.length < 3 || /^$/.test(value) || /^$/.test(value)) return;
pushSpecificEvidenceTerm(value);
const rawIndex = withoutTags.indexOf(value);
if (rawIndex >= 0) {{
const rawWindow = withoutTags.slice(rawIndex, rawIndex + value.length + 36).split(/[:;,\n]/)[0];
specific.push(normalizeEvidenceText(rawWindow));
}}
const cleanedIndex = cleaned.indexOf(value);
if (cleanedIndex >= 0) {{
const cleanedWindow = cleaned.slice(cleanedIndex, cleanedIndex + value.length + 36).split(/[:;,\n]/)[0];
specific.push(normalizeEvidenceText(cleanedWindow));
}}
if (value.endsWith('基') && value.length > 3) specific.push(value.slice(0, -1));
fallbackTerms.push(value);
}});
const candidates = [];
cleaned.split(/[;,\n]/).forEach(part => {{
const value = normalizeEvidenceText(part);
if (value.length >= 8) candidates.push(value);
if (value.length >= 28) candidates.push(value.slice(0, 28));
}});
candidates.push(...fallbackTerms);
candidates.push(cleaned, raw);
const seen = new Set();
return specific.concat(candidates)
.map(normalizeEvidenceText)
.filter(value => value.length >= 3 && !seen.has(value) && seen.add(value))
.sort((left, right) => right.length - left.length);
}}
function compactEvidenceText(value) {{
return normalizeEvidenceText(value).replace(/[\s\p{{P}}\p{{S}}]+/gu, '');
}}
function compactEvidenceTextWithoutNumbers(value) {{
return normalizeEvidenceText(value).replace(/[0-9-]+/g, '').replace(/[\s\p{{P}}\p{{S}}]+/gu, '');
}}
function evidenceLeadingAnchors(text) {{
const raw = String(text || '');
const cleaned = normalizeEvidenceText(raw);
const anchors = [];
const queryCompact = compactEvidenceText(evidenceSearchQuery);
function push(value, options) {{
const normalized = normalizeEvidenceText(value);
const allowShort = options && options.allowShort === true;
if (normalized.length < (allowShort ? 3 : 6)) return;
anchors.push(normalized.length > 80 ? normalized.slice(0, 80) : normalized);
if (normalized.length > 24) anchors.push(normalized.slice(0, 24));
}}
function pushQueryNearPrefix(value) {{
const normalized = normalizeEvidenceText(value);
if (!normalized || !queryCompact || !compactEvidenceText(normalized).includes(queryCompact)) return;
push(normalized, {{ allowShort: true }});
const queryIndex = compactEvidenceText(normalized).indexOf(queryCompact);
if (queryIndex >= 0 && normalized.length > 24) push(normalized.slice(0, 36), {{ allowShort: true }});
}}
const prefixWindow = cleaned.slice(0, 260);
const catalogMatches = prefixWindow.match(/[^,;#]{{2,56}}[,]\s*[0-9-]{{1,5}}/g) || [];
catalogMatches.slice(0, 8).forEach(match => {{
const value = normalizeEvidenceText(match);
push(value, {{ allowShort: true }});
const withoutPage = value.replace(/[,]\s*[0-9-]{{1,5}}\s*$/, '');
push(withoutPage, {{ allowShort: true }});
pushQueryNearPrefix(withoutPage);
}});
const headingMatches = prefixWindow.match(/[0-9-]+(?:\.[0-9-]+){{1,5}}\s+[^;]{{2,72}}/g) || [];
headingMatches.slice(0, 4).forEach(match => {{
const firstPart = normalizeEvidenceText(match).split(/[,]/)[0];
push(firstPart, {{ allowShort: true }});
}});
raw.split(/[\n;]/).slice(0, 4).forEach(part => {{
const value = normalizeEvidenceText(part);
if (value) push(value);
}});
cleaned.split(/[;]/).slice(0, 4).forEach(part => {{
const value = normalizeEvidenceText(part);
if (value) push(value);
}});
evidenceTextCandidates(text).forEach(candidate => {{
if (!queryCompact || compactEvidenceText(candidate).includes(queryCompact)) push(candidate);
}});
const seen = new Set();
return anchors
.map(normalizeEvidenceText)
.filter(value => {{
if (!value || seen.has(value)) return false;
const compactValue = compactEvidenceText(value);
const shortQueryAnchor = queryCompact.length >= 2
&& compactValue.includes(queryCompact)
&& compactValue.length >= queryCompact.length + 1
&& /[0-9-A-Za-z]/.test(value);
if (value.length < 6 && !shortQueryAnchor) return false;
seen.add(value);
return true;
}});
}}
function shortLeadingAnchorTarget(element, elements, index) {{
const normalized = normalizeEvidenceText(element && element.textContent || '');
if (normalized.length >= 18) return element;
for (let offset = 1; offset <= 3; offset += 1) {{
const next = elements[index + offset];
if (!(next instanceof HTMLElement)) continue;
const nextText = normalizeEvidenceText(next.textContent || '');
if (nextText.length >= 18 && evidenceElementMatchesSearchQuery(next)) return next;
}}
return element;
}}
function scrollToEvidenceLeadingAnchor(text) {{
if (!viewer) return false;
const anchors = evidenceLeadingAnchors(text);
if (!anchors.length) return false;
const queryCompact = compactEvidenceText(evidenceSearchQuery);
const elements = Array.from(viewer.querySelectorAll('p, li, td, th, blockquote'))
.filter(node => node instanceof HTMLElement)
.filter(node => {{
const normalized = normalizeEvidenceText(node.textContent || '');
return normalized.length >= 3 && normalized.length <= 1200;
}});
for (const anchor of anchors) {{
const compactAnchor = compactEvidenceText(anchor);
const compactAnchorWithoutNumbers = compactEvidenceTextWithoutNumbers(anchor);
const shortQueryAnchor = queryCompact.length >= 2
&& compactAnchor.includes(queryCompact)
&& compactAnchor.length >= queryCompact.length + 1
&& /[0-9-A-Za-z]/.test(anchor);
if (!shortQueryAnchor && compactAnchor.length < 6 && compactAnchorWithoutNumbers.length < 6) continue;
for (const element of elements) {{
const compactElement = compactEvidenceText(element.textContent || '');
if ((compactAnchor.length >= 6 || shortQueryAnchor) && compactElement.includes(compactAnchor)) {{
return markEvidenceTarget(shortQueryAnchor ? shortLeadingAnchorTarget(element, elements, elements.indexOf(element)) : element);
}}
if (
compactAnchorWithoutNumbers.length >= 8
&& /[\u3400-\u9fff]/.test(anchor)
&& compactEvidenceTextWithoutNumbers(element.textContent || '').includes(compactAnchorWithoutNumbers)
) {{
return markEvidenceTarget(element);
}}
}}
}}
return false;
}}
function evidenceParagraphAnchors(text) {{
const cleaned = normalizeEvidenceText(text);
const anchors = [];
function push(value) {{
const normalized = normalizeEvidenceText(value);
if (normalized.length < 6) return;
anchors.push(normalized.length > 140 ? normalized.slice(0, 140) : normalized);
}}
evidenceTextCandidates(text).forEach(push);
cleaned.split(/[;,\n]/).forEach(part => {{
const value = normalizeEvidenceText(part);
if (value.length >= 10) push(value);
if (value.length >= 36) push(value.slice(0, 36));
}});
if (cleaned.length >= 24) {{
for (let index = 0; index < cleaned.length; index += 48) {{
push(cleaned.slice(index, index + 96));
}}
}}
push(cleaned);
const seen = new Set();
return anchors
.map(normalizeEvidenceText)
.filter(value => value.length >= 6 && !seen.has(value) && seen.add(value))
.sort((left, right) => right.length - left.length);
}}
function scoreEvidenceParagraphElement(element, anchors) {{
if (!(element instanceof HTMLElement)) return 0;
const text = normalizeEvidenceText(element.textContent || '');
if (!text || text.length < 3 || text.length > 6000) return 0;
const compactText = compactEvidenceText(text);
const compactSearchQuery = compactEvidenceText(evidenceSearchQuery);
let score = 0;
if (compactSearchQuery.length >= 2 && compactText.includes(compactSearchQuery)) {{
score += 2400;
}}
for (const anchor of anchors) {{
const compactAnchor = compactEvidenceText(anchor);
if (!compactAnchor || compactAnchor.length < 4) continue;
if (text.includes(anchor)) {{
score += anchor.length * anchor.length * 4;
continue;
}}
if (compactText.includes(compactAnchor)) {{
score += compactAnchor.length * compactAnchor.length * 2;
continue;
}}
if (compactAnchor.length >= 14) {{
const prefix = compactAnchor.slice(0, Math.min(36, compactAnchor.length));
if (prefix.length >= 8 && compactText.includes(prefix)) score += prefix.length * 20;
}}
}}
return score;
}}
function evidenceElementMatchesSearchQuery(element) {{
if (!(element instanceof HTMLElement)) return false;
const compactSearchQuery = compactEvidenceText(evidenceSearchQuery);
if (compactSearchQuery.length < 2) return false;
return compactEvidenceText(element.textContent || '').includes(compactSearchQuery);
}}
function scrollToEvidenceParagraph(text) {{
if (!viewer) return false;
if (scrollToEvidenceLeadingAnchor(text)) return true;
const anchors = evidenceParagraphAnchors(text);
if (!anchors.length) return false;
const selector = 'p, li, td, th, blockquote, section.docx, section.mnote-docx, div';
const elements = Array.from(viewer.querySelectorAll(selector))
.filter(node => node instanceof HTMLElement)
.filter(node => {{
const normalized = normalizeEvidenceText(node.textContent || '');
if (normalized.length < 3 || normalized.length > 6000) return false;
const childBlocks = Array.from(node.children || []).filter(child => child instanceof HTMLElement && /^(P|LI|TD|TH|BLOCKQUOTE)$/.test(child.tagName));
return childBlocks.length === 0 || /^(TD|TH|SECTION)$/.test(node.tagName);
}});
let best = null;
let bestWithSearchQuery = null;
for (const element of elements) {{
const score = scoreEvidenceParagraphElement(element, anchors);
if (score <= 0) continue;
if (!best || score > best.score) best = {{ element, score }};
if (evidenceElementMatchesSearchQuery(element) && (!bestWithSearchQuery || score > bestWithSearchQuery.score)) {{
bestWithSearchQuery = {{ element, score }};
}}
}}
const threshold = Math.max(180, Math.min(800, anchors[0].length * 4));
if (bestWithSearchQuery && bestWithSearchQuery.score >= threshold) return markEvidenceTarget(bestWithSearchQuery.element);
if (!best) return false;
if (best.score < threshold) return false;
return markEvidenceTarget(best.element);
}}
function scrollToEvidenceTextCandidates(text) {{
for (const candidate of evidenceTextCandidates(text)) {{
if (scrollToEvidenceText(candidate)) return true;
}}
return false;
}}
function scrollToEvidenceCoordinate(sourceMap, block) {{
if (!viewer || !sourceMap || !block) return false;
const page = pageForEvidenceBlock(sourceMap, block);
@@ -1422,13 +1719,18 @@ pub async fn office_preview_page(Query(query): Query<OfficePreviewQuery>) -> Res
}}
async function applyEvidenceLocator() {{
if (!viewer || (!evidencePage && !evidenceBlockId && !evidenceBbox)) return;
if (!viewer || (!evidencePage && !evidenceBlockId && !evidenceBbox && !evidenceText)) return;
try {{
const sourceMap = await fetchEvidenceSourceMap();
const block = findEvidenceBlockInSourceMap(sourceMap);
if (block && scrollToEvidenceText(block.text)) return;
if (evidenceText && scrollToEvidenceParagraph(evidenceText)) return;
if (block && scrollToEvidenceParagraph(block.text)) return;
if (evidenceText && scrollToEvidenceTextCandidates(evidenceText)) return;
if (block && scrollToEvidenceTextCandidates(block.text)) return;
if (scrollToEvidenceCoordinate(sourceMap, block)) return;
}} catch (_) {{}}
if (evidenceText && scrollToEvidenceParagraph(evidenceText)) return;
if (evidenceText && scrollToEvidenceTextCandidates(evidenceText)) return;
scrollToEvidencePageFallback();
}}
@@ -1438,10 +1740,14 @@ pub async fn office_preview_page(Query(query): Query<OfficePreviewQuery>) -> Res
evidenceBbox = String(next.bbox || '');
evidenceSourceMapPath = String(next.sourceMapPath || '');
evidenceBlockId = String(next.blockId || '');
evidenceText = String(next.evidenceText || next.query || '');
evidenceSearchQuery = String(next.searchQuery || '');
body.dataset.evidencePage = evidencePage ? String(evidencePage) : '';
body.dataset.evidenceBbox = evidenceBbox;
body.dataset.evidenceSourceMapPath = evidenceSourceMapPath;
body.dataset.evidenceBlockId = evidenceBlockId;
body.dataset.evidenceText = evidenceText;
body.dataset.evidenceSearchQuery = evidenceSearchQuery;
document.documentElement.removeAttribute('data-mnote-office-evidence-applied');
void applyEvidenceLocator();
}}
@@ -1663,6 +1969,7 @@ pub async fn office_preview_page(Query(query): Query<OfficePreviewQuery>) -> Res
target_bbox = escape_html(&target_bbox),
target_source_map_path = escape_html(&target_source_map_path),
target_block_id = escape_html(&target_block_id),
target_evidence_text = escape_html(&target_evidence_text),
);
let mut response = Html(html).into_response();
stamp_shell_headers(response.headers_mut(), "office-preview");
@@ -759,9 +759,14 @@ mod tests {
assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("pageAiNormalizeAcpRuntimes(acpRuntimes)"));
assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("pageAiAcpRuntimeSelect.value || 'reasonix'"));
assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("function handlePageAiClick"));
assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("function handlePageAiPointerDown"));
assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("mnote.page_ai.drawer_width"));
assert!(SIDEBAR_PAGE_AI_RENDER_RUNTIME_JS.contains("data-page-ai-resize-handle"));
assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("function pageAiOpenCitationUrl"));
assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("a[data-page-ai-citation-link=\"true\"]"));
assert!(SIDEBAR_PAGE_AI_MARKDOWN_RUNTIME_JS.contains("data-page-ai-citation-link=\"true\""));
assert!(SIDEBAR_PAGE_AI_MARKDOWN_RUNTIME_JS.contains("renderPageAiMarkdownTable"));
assert!(SIDEBAR_PAGE_AI_MARKDOWN_RUNTIME_JS.contains("wolai-page-ai-markdown-table-wrap"));
assert!(SIDEBAR_PAGE_AI_MARKDOWN_RUNTIME_JS.contains("target=\"_blank\""));
assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("function handlePageAiChange"));
assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("function installPageAiDelegates"));
+167 -1
View File
@@ -1243,6 +1243,14 @@ html[data-mnote-sidebar-resizing="true"] .mnote-sidebar-resizer::before {
.wolai-search-results{overflow:auto;padding:6px}
.wolai-search-result-row{width:100%;min-height:54px;display:flex;align-items:flex-start;gap:10px;padding:9px 10px;border:0;border-radius:4px;background:transparent;color:#37352F;cursor:pointer;text-align:left;font:inherit}
.wolai-search-result-row:hover{background:rgba(55,53,47,.08)}
.wolai-search-source-group{border-radius:4px}
.wolai-search-source-group + .wolai-search-source-group{margin-top:4px}
.wolai-search-source-header{width:100%;min-height:46px;display:flex;align-items:flex-start;gap:10px;padding:8px 10px;border:0;border-radius:4px;background:transparent;color:#37352F;cursor:pointer;text-align:left;font:inherit}
.wolai-search-source-header:hover{background:rgba(55,53,47,.08)}
.wolai-search-source-main{min-width:0;display:flex;flex:1 1 auto;flex-direction:column;gap:3px}
.wolai-search-source-results{padding-left:18px}
.wolai-search-source-results[hidden]{display:none!important}
.wolai-search-source-chevron{flex:0 0 auto;margin-top:2px;color:#8B8780;font-size:11px;line-height:1.35}
.wolai-search-result-icon{width:18px;height:18px;margin-top:2px;color:#8B8780}
.wolai-search-result-main{min-width:0;display:flex;flex-direction:column;gap:3px}
.wolai-search-result-title{color:#2F2D29;font-size:14px;line-height:1.35;word-break:break-word}
@@ -4008,6 +4016,34 @@ body {
white-space: nowrap;
}
.mnote-knowledge-rag-source-progress {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 8px;
align-items: center;
width: 100%;
}
.mnote-knowledge-rag-source-progress div {
height: 5px;
overflow: hidden;
border-radius: 999px;
background: #ECE9E3;
}
.mnote-knowledge-rag-source-progress i {
display: block;
height: 100%;
border-radius: inherit;
background: #2F7D4A;
}
.mnote-knowledge-rag-source-progress span {
color: #5F5A54;
font: 10px/14px "JetBrains Mono", "SFMono-Regular", Consolas, monospace;
white-space: nowrap;
}
.mnote-knowledge-rag-source-actions {
display: flex;
gap: 6px;
@@ -4559,6 +4595,7 @@ body {
right: 12px;
bottom: 12px;
z-index: 89;
--mnote-page-ai-width: 440px;
}
.wolai-page-ai-drawer[hidden] {
@@ -4566,7 +4603,7 @@ body {
}
.wolai-page-ai-panel {
width: min(440px, calc(100vw - 24px));
width: min(var(--mnote-page-ai-width), calc(100vw - 24px));
height: 100%;
display: flex;
flex-direction: column;
@@ -4578,6 +4615,37 @@ body {
box-shadow: -18px 0 42px rgba(27, 28, 28, 0.14);
}
.wolai-page-ai-resize-handle {
position: absolute;
top: 10px;
bottom: 10px;
left: -6px;
width: 12px;
cursor: col-resize;
touch-action: none;
}
.wolai-page-ai-resize-handle::before {
position: absolute;
top: 14px;
bottom: 14px;
left: 5px;
width: 2px;
border-radius: 999px;
background: transparent;
content: "";
}
.wolai-page-ai-resize-handle:hover::before,
.wolai-page-ai-drawer[data-page-ai-resizing="true"] .wolai-page-ai-resize-handle::before {
background: rgba(27, 28, 28, 0.18);
}
html[data-mnote-page-ai-resizing="true"] {
cursor: col-resize;
user-select: none;
}
.wolai-page-ai-header-copy {
display: flex;
flex-direction: column;
@@ -5191,6 +5259,96 @@ body {
white-space: pre-wrap;
}
.wolai-page-ai-message-text > * {
white-space: normal;
}
.wolai-page-ai-message-text p,
.wolai-page-ai-message-text ul,
.wolai-page-ai-message-text ol,
.wolai-page-ai-message-text pre,
.wolai-page-ai-message-text table {
margin: 6px 0;
}
.wolai-page-ai-message-text h1,
.wolai-page-ai-message-text h2,
.wolai-page-ai-message-text h3,
.wolai-page-ai-message-text h4,
.wolai-page-ai-message-text h5,
.wolai-page-ai-message-text h6 {
margin: 8px 0 4px;
color: #1B1C1C;
font-size: 14px;
font-weight: 700;
line-height: 20px;
}
.wolai-page-ai-message-text ul,
.wolai-page-ai-message-text ol {
padding-left: 18px;
}
.wolai-page-ai-message-text a {
color: #2563EB;
overflow-wrap: anywhere;
text-decoration: underline;
text-underline-offset: 2px;
}
.wolai-page-ai-message-text pre {
overflow: auto;
padding: 8px 10px;
border-radius: 6px;
background: #F7F6F4;
font: 12px/18px "JetBrains Mono", "SFMono-Regular", Consolas, monospace;
}
.wolai-page-ai-message-text code {
border-radius: 4px;
padding: 1px 3px;
background: #F1F0EE;
font: 12px/18px "JetBrains Mono", "SFMono-Regular", Consolas, monospace;
}
.wolai-page-ai-message-text pre code {
padding: 0;
background: transparent;
}
.wolai-page-ai-message-text hr {
height: 1px;
margin: 8px 0;
border: 0;
background: rgba(27, 28, 28, 0.12);
}
.wolai-page-ai-markdown-table-wrap {
max-width: 100%;
overflow-x: auto;
}
.wolai-page-ai-markdown-table-wrap table {
width: 100%;
border-collapse: collapse;
table-layout: auto;
background: #FFFFFF;
}
.wolai-page-ai-markdown-table-wrap th,
.wolai-page-ai-markdown-table-wrap td {
min-width: 72px;
border: 1px solid rgba(27, 28, 28, 0.12);
padding: 5px 7px;
text-align: left;
vertical-align: top;
}
.wolai-page-ai-markdown-table-wrap th {
background: #F7F6F4;
font-weight: 700;
}
button.wolai-page-ai-message-text {
width: 100%;
border: 0;
@@ -5636,6 +5794,10 @@ button.wolai-page-ai-message-text {
width: min(100vw - 24px, 420px);
}
.wolai-page-ai-resize-handle {
display: none;
}
.wolai-page-ai-settings-grid,
.wolai-page-ai-settings-head {
grid-template-columns: minmax(0, 1fr);
@@ -5710,6 +5872,10 @@ button.wolai-page-ai-message-text {
.wolai-page-ai-panel {
width: calc(100vw - 24px);
}
.wolai-page-ai-resize-handle {
display: none;
}
}
"##;