Improve local evidence search and AI capabilities

This commit is contained in:
lix-2026
2026-06-05 23:00:53 +08:00
parent a1dcfc9f76
commit 4dbd9a978b
52 changed files with 6415 additions and 527 deletions
+330 -1
View File
@@ -986,6 +986,14 @@ pub struct OfficePreviewQuery {
source_kind: Option<String>,
root_uri: Option<String>,
document_id: Option<String>,
#[serde(default)]
page: Option<u32>,
#[serde(default)]
bbox: Option<String>,
#[serde(default, alias = "sourceMapPath")]
source_map_path: Option<String>,
#[serde(default, alias = "blockId")]
block_id: Option<String>,
}
pub async fn office_preview_page(Query(query): Query<OfficePreviewQuery>) -> Response {
@@ -1013,6 +1021,13 @@ pub async fn office_preview_page(Query(query): Query<OfficePreviewQuery>) -> Res
let source_kind = query.source_kind.unwrap_or_default();
let root_uri = query.root_uri.unwrap_or_default();
let document_id = query.document_id.unwrap_or_default();
let target_page = query
.page
.map(|value| value.to_string())
.unwrap_or_default();
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 html = format!(
r#"<!doctype html>
<html lang="zh-CN">
@@ -1047,6 +1062,10 @@ pub async fn office_preview_page(Query(query): Query<OfficePreviewQuery>) -> Res
.mnote-office-viewer .mnote-docx-wrapper {{ width: 100%; background: transparent !important; padding: 0 !important; align-items: stretch !important; }}
.mnote-office-viewer .docx-wrapper > section.docx,
.mnote-office-viewer .mnote-docx-wrapper > section.mnote-docx {{ width: 100% !important; max-width: none !important; margin: 0 0 14px !important; box-shadow: 0 2px 10px rgba(25, 25, 22, .08); }}
.mnote-office-viewer {{ position: relative; }}
.mnote-office-viewer [data-mnote-office-evidence-target="true"] {{ outline: 0; border-radius: 2px; background: #FFE9E6; color: #D83A32; box-shadow: 0 0 0 1px rgba(216, 58, 50, .22); }}
.mnote-office-evidence-marker {{ position: absolute; z-index: 3; left: 24px; max-width: min(720px, calc(100% - 48px)); padding: 6px 10px; border: 1px solid rgba(216, 58, 50, .45); border-radius: 6px; background: rgba(255, 249, 248, .96); color: #D83A32; font-size: 13px; line-height: 1.5; box-shadow: 0 2px 10px rgba(15, 23, 42, .12); }}
.mnote-office-evidence-marker[data-mnote-office-evidence-marker-mode="range"] {{ pointer-events: none; background: rgba(255, 233, 230, .72); box-shadow: 0 0 0 1px rgba(216, 58, 50, .28); }}
.mnote-office-pptx-stage {{ width: 100%; overflow: auto; }}
.mnote-office-pptx-stage .pptx-preview-wrapper {{ max-width: 100%; background: transparent !important; }}
.mnote-office-pptx-stage .pptx-preview-slide-wrapper {{ box-shadow: 0 2px 10px rgba(25, 25, 22, .08); }}
@@ -1055,7 +1074,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}">
<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}">
<main class="mnote-office-preview">
<section class="mnote-office-viewer" id="mnote-office-viewer"></section>
</main>
@@ -1070,6 +1089,10 @@ pub async fn office_preview_page(Query(query): Query<OfficePreviewQuery>) -> Res
const fileName = body.dataset.fileName || '';
const fileType = (body.dataset.fileType || '').toLowerCase();
const pageWidthContentType = body.dataset.pageWidthContentType || 'word';
let evidencePage = Number(body.dataset.evidencePage || 0);
let evidenceBbox = body.dataset.evidenceBbox || '';
let evidenceSourceMapPath = body.dataset.evidenceSourceMapPath || '';
let evidenceBlockId = body.dataset.evidenceBlockId || '';
let currentPptxBuffer = null;
let pptxRenderToken = 0;
let pptxResizeTimer = 0;
@@ -1136,6 +1159,299 @@ pub async fn office_preview_page(Query(query): Query<OfficePreviewQuery>) -> Res
viewer.append(message);
}}
function normalizeEvidenceText(value) {{
return String(value || '').replace(/\s+/g, ' ').trim();
}}
function markEvidenceTarget(target) {{
if (!(target instanceof HTMLElement)) return false;
viewer.querySelectorAll('[data-mnote-office-evidence-target="true"]').forEach(node => {{
if (node instanceof HTMLElement) node.removeAttribute('data-mnote-office-evidence-target');
}});
target.setAttribute('data-mnote-office-evidence-target', 'true');
window.setTimeout(() => target.scrollIntoView({{ block: 'center', inline: 'nearest' }}), 0);
document.documentElement.setAttribute('data-mnote-office-evidence-applied', 'true');
return true;
}}
function normalizedTextWithRawOffsets(value) {{
const raw = String(value || '');
let text = '';
const offsets = [];
let previousWhitespace = true;
for (let index = 0; index < raw.length; index += 1) {{
const ch = raw[index];
if (/\s/.test(ch)) {{
if (text && !previousWhitespace) {{
text += ' ';
offsets.push(index);
}}
previousWhitespace = true;
}} else {{
text += ch;
offsets.push(index);
previousWhitespace = false;
}}
}}
if (text.endsWith(' ')) {{
text = text.slice(0, -1);
offsets.pop();
}}
return {{ text, offsets }};
}}
function compactTextWithRawOffsets(value) {{
const raw = String(value || '');
let text = '';
const offsets = [];
for (let index = 0; index < raw.length; index += 1) {{
const ch = raw[index];
if (/\s/.test(ch)) continue;
text += ch;
offsets.push(index);
}}
return {{ text, offsets }};
}}
function wrapEvidenceTextNode(node, needle) {{
if (!(node instanceof Text)) return null;
const raw = String(node.textContent || '');
let start = raw.indexOf(needle);
let end = start >= 0 ? start + needle.length : -1;
if (start < 0) {{
const compact = compactTextWithRawOffsets(raw);
const compactNeedle = normalizeEvidenceText(needle).replace(/\s+/g, '');
let normalizedStart = compact.text.indexOf(compactNeedle);
let sourceOffsets = compact.offsets;
if (normalizedStart < 0) {{
const mapped = normalizedTextWithRawOffsets(raw);
const mappedNeedle = normalizeEvidenceText(needle);
normalizedStart = mapped.text.indexOf(mappedNeedle);
sourceOffsets = mapped.offsets;
if (normalizedStart < 0) return null;
start = sourceOffsets[normalizedStart];
end = sourceOffsets[normalizedStart + mappedNeedle.length - 1] + 1;
if (!Number.isFinite(start) || !Number.isFinite(end) || start < 0 || end <= start) return null;
const range = document.createRange();
range.setStart(node, start);
range.setEnd(node, end);
const span = document.createElement('span');
span.setAttribute('data-mnote-office-evidence-target', 'true');
try {{
range.surroundContents(span);
return span;
}} catch (_) {{
return null;
}}
}}
if (normalizedStart < 0) return null;
start = sourceOffsets[normalizedStart];
end = sourceOffsets[normalizedStart + compactNeedle.length - 1] + 1;
}}
if (!Number.isFinite(start) || !Number.isFinite(end) || start < 0 || end <= start) return null;
const range = document.createRange();
range.setStart(node, start);
range.setEnd(node, end);
const span = document.createElement('span');
span.setAttribute('data-mnote-office-evidence-target', 'true');
try {{
range.surroundContents(span);
return span;
}} catch (_) {{
return null;
}}
}}
function markEvidenceRangeAcrossTextNodes(needle) {{
if (!viewer) return false;
const compactNeedle = normalizeEvidenceText(needle).replace(/\s+/g, '');
if (!compactNeedle) return false;
const refs = [];
let compactText = '';
const walker = document.createTreeWalker(viewer, NodeFilter.SHOW_TEXT);
let node = walker.nextNode();
while (node) {{
const raw = String(node.textContent || '');
for (let offset = 0; offset < raw.length; offset += 1) {{
const ch = raw[offset];
if (/\s/.test(ch)) continue;
compactText += ch;
refs.push({{ node, offset }});
}}
node = walker.nextNode();
}}
const startIndex = compactText.indexOf(compactNeedle);
if (startIndex < 0) return false;
const endIndex = startIndex + compactNeedle.length - 1;
const startRef = refs[startIndex];
const endRef = refs[endIndex];
if (!startRef || !endRef) return false;
const range = document.createRange();
range.setStart(startRef.node, startRef.offset);
range.setEnd(endRef.node, endRef.offset + 1);
const rect = range.getBoundingClientRect();
if (!rect || rect.width <= 0 || rect.height <= 0) return false;
let marker = viewer.querySelector('[data-mnote-office-evidence-marker="true"]');
if (!(marker instanceof HTMLElement)) {{
marker = document.createElement('div');
marker.className = 'mnote-office-evidence-marker';
marker.setAttribute('data-mnote-office-evidence-marker', 'true');
viewer.append(marker);
}}
const viewerRect = viewer.getBoundingClientRect();
marker.textContent = '';
marker.setAttribute('data-mnote-office-evidence-target-text', normalizeEvidenceText(needle));
marker.setAttribute('data-mnote-office-evidence-marker-mode', 'range');
marker.style.left = Math.max(0, Math.round(rect.left - viewerRect.left + viewer.scrollLeft)).toString() + 'px';
marker.style.top = Math.max(0, Math.round(rect.top - viewerRect.top + viewer.scrollTop)).toString() + 'px';
marker.style.width = Math.max(8, Math.round(rect.width)).toString() + 'px';
marker.style.height = Math.max(8, Math.round(rect.height)).toString() + 'px';
marker.style.maxWidth = 'none';
marker.style.padding = '0';
return markEvidenceTarget(marker);
}}
function pageForEvidenceBlock(sourceMap, block) {{
if (!sourceMap || typeof sourceMap !== 'object' || !block) return null;
const pages = Array.isArray(sourceMap.pages) ? sourceMap.pages : [];
for (const page of pages) {{
const blocks = Array.isArray(page && page.blocks) ? page.blocks : [];
if (blocks.includes(block)) return page;
}}
return null;
}}
function findEvidenceBlockInSourceMap(sourceMap) {{
if (!sourceMap || typeof sourceMap !== 'object' || !evidenceBlockId) return null;
const pages = Array.isArray(sourceMap.pages) ? sourceMap.pages : [];
for (const page of pages) {{
const blocks = Array.isArray(page && page.blocks) ? page.blocks : [];
const block = blocks.find(item => String(item && (item.id || item.blockId || item.block_id) || '') === evidenceBlockId);
if (block) return block;
}}
return null;
}}
async function fetchEvidenceSourceMap() {{
if (!evidenceSourceMapPath || !body.dataset.mnoteRootUri) return null;
const params = new URLSearchParams();
params.set('rootUri', body.dataset.mnoteRootUri);
params.set('path', evidenceSourceMapPath);
const response = await fetch('/api/local-folder/files/open?' + params.toString(), {{
credentials: 'same-origin',
headers: {{ accept: 'application/json, text/plain, */*' }}
}});
if (!response.ok) return null;
return response.json().catch(() => null);
}}
function scrollToEvidenceText(text) {{
const needle = normalizeEvidenceText(text);
if (!needle || !viewer) return false;
viewer.querySelectorAll('[data-mnote-office-evidence-target="true"]').forEach(node => {{
if (node instanceof HTMLElement) {{
if (node.tagName === 'SPAN' && node.childNodes.length === 1 && node.firstChild instanceof Text) {{
node.replaceWith(node.firstChild);
}} else {{
node.removeAttribute('data-mnote-office-evidence-target');
}}
}}
}});
const walker = document.createTreeWalker(viewer, NodeFilter.SHOW_TEXT);
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;
}}
return markEvidenceTarget(target);
}}
node = walker.nextNode();
}}
if (markEvidenceRangeAcrossTextNodes(needle)) return true;
return false;
}}
function scrollToEvidenceCoordinate(sourceMap, block) {{
if (!viewer || !sourceMap || !block) return false;
const page = pageForEvidenceBlock(sourceMap, block);
const bbox = block.bbox && typeof block.bbox === 'object' ? block.bbox : null;
const pageNumber = Number(page && page.page || evidencePage || 0);
const pageCount = Math.max(1, Number(sourceMap.pageCount || (Array.isArray(sourceMap.pages) ? sourceMap.pages.length : 0)) || 1);
if (!Number.isFinite(pageNumber) || pageNumber <= 0) return false;
const renderedPages = Array.from(viewer.querySelectorAll('section.docx, section.mnote-docx, .pptx-preview-slide-wrapper'))
.filter(node => node instanceof HTMLElement);
const pageElement = renderedPages[pageNumber - 1];
let top = 0;
if (pageElement instanceof HTMLElement) {{
const pageHeight = Math.max(1, pageElement.scrollHeight || pageElement.getBoundingClientRect().height || 1);
const sourcePageHeight = Math.max(1, Number(page && page.height || 0) || pageHeight);
top = pageElement.offsetTop + (bbox ? (Number(bbox.y0) / sourcePageHeight) * pageHeight : pageHeight / 2);
}} else {{
const contentHeight = Math.max(1, viewer.scrollHeight || document.documentElement.scrollHeight || 1);
const estimatedPageHeight = contentHeight / pageCount;
const sourcePageHeight = Math.max(1, Number(page && page.height || 0) || estimatedPageHeight);
top = estimatedPageHeight * (pageNumber - 1) + (bbox ? (Number(bbox.y0) / sourcePageHeight) * estimatedPageHeight : estimatedPageHeight / 2);
}}
let marker = viewer.querySelector('[data-mnote-office-evidence-marker="true"]');
if (!(marker instanceof HTMLElement)) {{
marker = document.createElement('div');
marker.className = 'mnote-office-evidence-marker';
marker.setAttribute('data-mnote-office-evidence-marker', 'true');
viewer.append(marker);
}}
marker.textContent = normalizeEvidenceText(block.text || evidenceBlockId || '命中位置');
marker.removeAttribute('data-mnote-office-evidence-target-text');
marker.setAttribute('data-mnote-office-evidence-marker-mode', 'estimated');
marker.style.width = '';
marker.style.height = '';
marker.style.padding = '';
marker.style.top = Math.max(0, Math.round(top)).toString() + 'px';
return markEvidenceTarget(marker);
}}
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'));
const target = pages[Math.max(0, Math.min(pages.length - 1, evidencePage - 1))];
return markEvidenceTarget(target);
}}
async function applyEvidenceLocator() {{
if (!viewer || (!evidencePage && !evidenceBlockId && !evidenceBbox)) return;
try {{
const sourceMap = await fetchEvidenceSourceMap();
const block = findEvidenceBlockInSourceMap(sourceMap);
if (block && scrollToEvidenceText(block.text)) return;
if (scrollToEvidenceCoordinate(sourceMap, block)) return;
}} catch (_) {{}}
scrollToEvidencePageFallback();
}}
function updateEvidenceLocator(locator) {{
const next = locator && typeof locator === 'object' ? locator : {{}};
evidencePage = Number(next.page || 0);
evidenceBbox = String(next.bbox || '');
evidenceSourceMapPath = String(next.sourceMapPath || '');
evidenceBlockId = String(next.blockId || '');
body.dataset.evidencePage = evidencePage ? String(evidencePage) : '';
body.dataset.evidenceBbox = evidenceBbox;
body.dataset.evidenceSourceMapPath = evidenceSourceMapPath;
body.dataset.evidenceBlockId = evidenceBlockId;
document.documentElement.removeAttribute('data-mnote-office-evidence-applied');
void applyEvidenceLocator();
}}
window.addEventListener('message', (event) => {{
if (event.origin !== window.location.origin) return;
const data = event.data && typeof event.data === 'object' ? event.data : {{}};
if (data.type === 'mnote:office-evidence-locator') updateEvidenceLocator(data);
}});
async function fetchArrayBuffer() {{
const response = await fetch(fileUrl, {{ credentials: fileUrl.startsWith('/') ? 'same-origin' : 'include' }});
if (!response.ok) throw new Error(fileReadErrorMessage(response.status));
@@ -1321,6 +1637,7 @@ pub async fn office_preview_page(Query(query): Query<OfficePreviewQuery>) -> Res
else if (fileType === 'csv') await renderCsv();
else if (fileType === 'pptx') await renderPptx();
else showMessage('当前轻量预览 POC 暂不支持 .' + fileType + ',请用 OnlyOffice 打开。');
await applyEvidenceLocator();
setStatus('完成');
}} catch (error) {{
console.warn('[mnote office preview] render failed', error);
@@ -1342,6 +1659,10 @@ pub async fn office_preview_page(Query(query): Query<OfficePreviewQuery>) -> Res
source_kind = escape_html(&source_kind),
root_uri = escape_html(&root_uri),
document_id = escape_html(&document_id),
target_page = escape_html(&target_page),
target_bbox = escape_html(&target_bbox),
target_source_map_path = escape_html(&target_source_map_path),
target_block_id = escape_html(&target_block_id),
);
let mut response = Html(html).into_response();
stamp_shell_headers(response.headers_mut(), "office-preview");
@@ -3310,6 +3631,12 @@ mod tests {
let resource_runtime = DOCUMENT_RESOURCE_TAB_RUNTIME_JS;
assert!(runtime.contains("document-resource-tab-runtime.js"));
assert!(resource_runtime.contains("resourceTabBadgeKind(input, kind)"));
assert!(resource_runtime.contains("后台任务"));
assert!(resource_runtime.contains("data-mnote-local-ocr-task-tab"));
assert!(resource_runtime.contains("data-mnote-local-ocr-task-clear-completed"));
assert!(resource_runtime.contains("role=\"progressbar\""));
assert!(resource_runtime.contains("localOcrTaskCategory(job)"));
assert!(resource_runtime.contains("localOcrTaskProgress(job)"));
assert!(resource_runtime.contains("mnote.open_editors_snapshot.v1"));
assert!(runtime.contains("getOpenEditorsSnapshot"));
assert!(resource_runtime.contains("bindMainEditorTabStrip"));
@@ -3361,6 +3688,8 @@ mod tests {
assert!(resource_runtime.contains("data-mnote-evidence-bbox"));
assert!(resource_runtime.contains("data-mnote-evidence-text-highlight"));
assert!(runtime.contains("applyDocumentEvidenceLocatorFromUrl"));
assert!(runtime.contains("sourceMapPath: String(url.searchParams.get('sourceMapPath')"));
assert!(runtime.contains("blockId: String(url.searchParams.get('blockId')"));
let sidebar_runtime = SIDEBAR_TREE_RUNTIME_JS;
assert!(sidebar_runtime.contains("openEvidenceSearchResult"));
assert!(sidebar_runtime.contains("data-evidence-locator"));