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
@@ -418,6 +418,21 @@ export const createResourceTabRuntime = (dependencies = {}) => {
}
};
const officeOpenModeForEntry = (entry) => {
if (!entry || normalizeResourceTabKind(entry) !== 'office') return '';
const href = String(entry.passiveFrameSrc || entry.officeUrl || entry.href || '').trim()
|| String(entry.panel?.querySelector?.('iframe.mnote-resource-tab-frame')?.getAttribute?.('src') || '').trim();
if (href) {
try {
const url = new URL(href, window.location.origin);
if (url.pathname === '/office-preview' || url.pathname === '/office-n') return 'preview';
if (url.pathname === '/onlyoffice' || url.pathname.startsWith('/office/')) return 'onlyoffice_live';
} catch (_) {}
}
if (entry.onlyofficeSessionId || entry.bridgeSessionId) return 'onlyoffice_live';
return 'preview';
};
const openEditorsSnapshotEntry = (entry, key, generatedAt = Date.now()) => {
const active = entry?.tab instanceof HTMLElement
? entry.tab.getAttribute('aria-selected') === 'true'
@@ -432,15 +447,23 @@ export const createResourceTabRuntime = (dependencies = {}) => {
const kind = normalizeResourceTabKind(entry);
const dirtyState = resourceTabCloseGuardReason(entry?.session);
const officeBridgeDebug = kind === 'office' ? officeBridgeDebugForEntry(entry) : null;
const officeOpenMode = kind === 'office' ? officeOpenModeForEntry(entry) : '';
const onlyofficeSessionId = String(
officeBridgeDebug?.bridgeSessionId
|| entry?.onlyofficeSessionId
|| entry?.bridgeSessionId
|| '',
).trim();
const snapshotResourceKind = kind === 'office'
? (officeOpenMode === 'onlyoffice_live' || onlyofficeSessionId ? 'only_office' : 'attachment')
: kind;
const workspacePathSeed = entry?.workspacePath && typeof entry.workspacePath === 'object'
? { ...entry.workspacePath, resourceKind: snapshotResourceKind }
: entry?.workspacePath;
return {
objectIdentity,
workspacePath: buildWorkspacePath({
workspacePath: workspacePathSeed,
workspaceId,
sourceKind,
rootUri,
@@ -448,8 +471,7 @@ export const createResourceTabRuntime = (dependencies = {}) => {
documentId,
objectIdentity,
assetId,
resourceKind: kind,
workspacePath: entry?.workspacePath,
resourceKind: snapshotResourceKind,
}),
paneRole: normalizePaneRole(entry?.paneRole),
documentId,
@@ -465,13 +487,14 @@ export const createResourceTabRuntime = (dependencies = {}) => {
dirtyGuard: dirtyState,
assetId,
path: relativePath,
officeOpenMode,
onlyofficeSessionId,
bridgeSessionId: onlyofficeSessionId,
bridgeSessionReady: Boolean(onlyofficeSessionId),
bridgeDocumentId: String(officeBridgeDebug?.documentId || '').trim(),
bridgeAssetId: String(officeBridgeDebug?.assetId || '').trim(),
lastActiveAt: Number(entry?.session?.lastActiveAt || entry?.lastActiveAt || 0) || (active ? generatedAt : 0),
preview: false,
preview: officeOpenMode === 'preview',
pinned: false,
};
};
@@ -848,6 +871,7 @@ export const createResourceTabRuntime = (dependencies = {}) => {
panel.setAttribute('data-mnote-object-identity', objectIdentity);
panel.setAttribute('data-pane-role', paneRole);
panel.setAttribute('data-resource-kind', kind);
panel.setAttribute('data-resource-path', String(input.path || '').trim());
panel.hidden = true;
nodes.strip.append(tab);
nodes.panelRoot.append(panel);
@@ -989,6 +1013,16 @@ 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 (url.pathname === '/office-preview' && frame.contentWindow) {
frame.contentWindow.postMessage({
type: 'mnote:office-evidence-locator',
page: locator.page,
bbox,
sourceMapPath: locator.sourceMapPath || '',
blockId: locator.blockId || '',
}, window.location.origin);
return;
}
frame.src = url.toString();
} catch (_) {}
};
@@ -1015,11 +1049,22 @@ export const createResourceTabRuntime = (dependencies = {}) => {
};
const applyEvidenceLocatorToTextPanel = (entry, locator) => {
if (!(entry?.panel instanceof HTMLElement) || !locator?.blockId) return;
if (!(entry?.panel instanceof HTMLElement) || !locator) return;
const root = entry.panel.querySelector('[data-testid="mnote-leptos-tiptap-island-editor-root"]');
if (!(root instanceof HTMLElement)) return;
const selector = `[data-block-id="${cssSafe(locator.blockId)}"]`;
const target = root.querySelector(selector);
const selector = locator.blockId ? `[data-block-id="${cssSafe(locator.blockId)}"]` : '';
let target = selector ? root.querySelector(selector) : null;
const query = String(locator?.openAction?.params?.query || locator?.query || '').trim();
if (!(target instanceof HTMLElement) && query) {
target = Array.from(root.querySelectorAll('.ProseMirror [data-block-id]'))
.find((node) => node instanceof HTMLElement && String(node.textContent || '').includes(query)) || null;
}
const lineStart = Number(locator?.lineRange?.start ?? locator?.line_range?.start ?? 0);
if (!(target instanceof HTMLElement) && Number.isFinite(lineStart) && lineStart > 0) {
const blocks = Array.from(root.querySelectorAll('.ProseMirror [data-block-id]'))
.filter((node) => node instanceof HTMLElement);
target = blocks[Math.max(0, Math.min(blocks.length - 1, lineStart - 1))] || null;
}
if (!(target instanceof HTMLElement)) return;
root.querySelectorAll('[data-mnote-evidence-text-highlight="true"]').forEach((node) => {
if (node instanceof HTMLElement) node.removeAttribute('data-mnote-evidence-text-highlight');
@@ -1364,6 +1409,7 @@ export const createResourceTabRuntime = (dependencies = {}) => {
rootUri: '',
jobsBySource: new Map(),
drawerOpen: false,
taskFilter: 'active',
eventSource: null,
fileTreeRefreshKeys: new Set(),
};
@@ -1467,13 +1513,36 @@ export const createResourceTabRuntime = (dependencies = {}) => {
: status || '未知';
};
const localOcrTaskCategory = (job) => {
const status = String(job?.status || '').trim();
if (['failed', 'stale', 'retry_scheduled'].includes(status)) return 'attention';
if (['done', 'succeeded', 'success'].includes(status)) return 'completed';
return 'active';
};
const localOcrTaskProgress = (job) => {
const current = Number(job?.progressCurrent ?? job?.currentProgress);
const total = Number(job?.progressTotal ?? job?.maxProgress);
if (Number.isFinite(current) && Number.isFinite(total) && total > 0) {
return Math.max(0, Math.min(100, Math.round((current / total) * 100)));
}
return null;
};
const localOcrTaskFilterLabel = (filter) => {
return filter === 'active' ? '进行中'
: filter === 'completed' ? '已完成'
: filter === 'attention' ? '需处理'
: '全部';
};
const ensureLocalOcrTaskDock = () => {
let dock = document.querySelector('[data-testid="mnote-local-ocr-task-dock"]');
if (!(dock instanceof HTMLElement)) {
dock = document.createElement('section');
dock.className = 'mnote-local-ocr-task-dock';
dock.setAttribute('data-testid', 'mnote-local-ocr-task-dock');
dock.innerHTML = '<div class="mnote-local-ocr-task-drawer" data-testid="mnote-local-ocr-task-drawer" hidden><div class="mnote-local-ocr-task-head"><strong>OCR 任务</strong><button type="button" class="mnote-local-ocr-task-close" data-mnote-local-ocr-task-close aria-label="收起 OCR 任务">×</button></div><div class="mnote-local-ocr-task-list" data-testid="mnote-local-ocr-task-list"></div></div>';
dock.innerHTML = '<div class="mnote-local-ocr-task-drawer" data-testid="mnote-local-ocr-task-drawer" hidden><div class="mnote-local-ocr-task-panel"><div class="mnote-local-ocr-task-head"><div><strong>后台任务</strong><span data-mnote-local-ocr-task-summary>暂无任务</span></div><button type="button" class="mnote-local-ocr-task-close" data-mnote-local-ocr-task-close aria-label="收起后台任务">×</button></div><div class="mnote-local-ocr-task-tabs" data-mnote-local-ocr-task-tabs></div><div class="mnote-local-ocr-task-toolbar"><span data-mnote-local-ocr-task-filter-label>进行中</span><button type="button" data-mnote-local-ocr-task-clear-completed>清除已完成</button></div><div class="mnote-local-ocr-task-list" data-testid="mnote-local-ocr-task-list"></div></div></div>';
document.body.appendChild(dock);
}
let toggle = document.querySelector('[data-testid="mnote-local-ocr-task-toggle"]');
@@ -1509,6 +1578,20 @@ export const createResourceTabRuntime = (dependencies = {}) => {
}
return;
}
const tabButton = event.target instanceof Element ? event.target.closest('[data-mnote-local-ocr-task-tab]') : null;
if (tabButton instanceof HTMLElement) {
localOcrTaskState.taskFilter = tabButton.getAttribute('data-mnote-local-ocr-task-tab') || 'active';
renderLocalOcrTaskDock();
return;
}
const clearCompleted = event.target instanceof Element ? event.target.closest('[data-mnote-local-ocr-task-clear-completed]') : null;
if (clearCompleted instanceof HTMLElement) {
Array.from(localOcrTaskState.jobsBySource.entries()).forEach(([key, job]) => {
if (localOcrTaskCategory(job) === 'completed') localOcrTaskState.jobsBySource.delete(key);
});
renderLocalOcrTaskDock();
return;
}
const deleteButton = event.target instanceof Element ? event.target.closest('[data-mnote-local-ocr-task-delete]') : null;
if (deleteButton instanceof HTMLElement) {
const sourcePath = deleteButton.getAttribute('data-mnote-local-ocr-task-delete') || '';
@@ -1568,12 +1651,18 @@ export const createResourceTabRuntime = (dependencies = {}) => {
const dock = ensureLocalOcrTaskDock();
const jobs = Array.from(localOcrTaskState.jobsBySource.values())
.sort((left, right) => Number(right.updatedAtMs || 0) - Number(left.updatedAtMs || 0));
const runningCount = jobs.filter((job) => !['done', 'failed', 'stale'].includes(String(job?.status || ''))).length;
const toggle = document.querySelector('[data-testid="mnote-local-ocr-task-toggle"]');
if (toggle instanceof HTMLButtonElement) {
const activeJobs = jobs.filter((job) => localOcrTaskCategory(job) === 'active');
const attentionJobs = jobs.filter((job) => localOcrTaskCategory(job) === 'attention');
const completedJobs = jobs.filter((job) => localOcrTaskCategory(job) === 'completed');
const runningCount = activeJobs.length;
const taskToggles = Array.from(document.querySelectorAll('[data-testid="mnote-local-ocr-task-toggle"], [data-testid="mnote-floating-task-toggle"]'))
.filter((node) => node instanceof HTMLButtonElement);
taskToggles.forEach((toggle) => {
const opensSettings = toggle.getAttribute('data-mnote-action') === 'open-ocr-settings';
const label = runningCount > 0 ? `${runningCount} 个 OCR 处理中,打开 OCR 设置` : (jobs.length > 0 ? `${jobs.length} 个 OCR 任务,打开 OCR 设置` : 'OCR 设置');
toggle.setAttribute('title', label);
toggle.setAttribute('aria-label', label);
const taskLabel = runningCount > 0 ? `${runningCount} 个后台任务正在运行` : (jobs.length > 0 ? `${jobs.length} 个后台任务` : '后台任务');
toggle.setAttribute('title', opensSettings ? label : taskLabel);
toggle.setAttribute('aria-label', opensSettings ? label : taskLabel);
toggle.setAttribute('aria-expanded', localOcrTaskState.drawerOpen ? 'true' : 'false');
toggle.classList.toggle('has-mnote-local-ocr-tasks', jobs.length > 0);
const badge = toggle.querySelector('[data-mnote-local-ocr-task-count]');
@@ -1583,28 +1672,85 @@ export const createResourceTabRuntime = (dependencies = {}) => {
} else {
toggle.textContent = runningCount > 0 ? `OCR ${runningCount} 处理中` : `OCR ${jobs.length}`;
}
}
});
const drawer = dock.querySelector('[data-testid="mnote-local-ocr-task-drawer"]');
if (drawer instanceof HTMLElement) drawer.hidden = !localOcrTaskState.drawerOpen;
const summary = dock.querySelector('[data-mnote-local-ocr-task-summary]');
if (summary instanceof HTMLElement) {
summary.textContent = `${runningCount} 进行中 · ${attentionJobs.length} 需处理 · ${completedJobs.length} 已完成`;
}
const tabs = dock.querySelector('[data-mnote-local-ocr-task-tabs]');
if (tabs instanceof HTMLElement) {
const tabItems = [
['active', '进行中', activeJobs.length],
['attention', '需处理', attentionJobs.length],
['completed', '已完成', completedJobs.length],
['all', '全部', jobs.length],
];
tabs.replaceChildren();
tabItems.forEach(([key, label, count]) => {
const button = document.createElement('button');
button.type = 'button';
button.setAttribute('data-mnote-local-ocr-task-tab', key);
button.setAttribute('aria-selected', localOcrTaskState.taskFilter === key ? 'true' : 'false');
button.textContent = `${label} ${count}`;
tabs.appendChild(button);
});
}
const filterLabel = dock.querySelector('[data-mnote-local-ocr-task-filter-label]');
if (filterLabel instanceof HTMLElement) filterLabel.textContent = localOcrTaskFilterLabel(localOcrTaskState.taskFilter);
const clearCompleted = dock.querySelector('[data-mnote-local-ocr-task-clear-completed]');
if (clearCompleted instanceof HTMLButtonElement) clearCompleted.disabled = completedJobs.length === 0;
const list = dock.querySelector('[data-testid="mnote-local-ocr-task-list"]');
if (!(list instanceof HTMLElement)) return;
list.replaceChildren();
if (!jobs.length) {
const visibleJobs = jobs.filter((job) => {
return localOcrTaskState.taskFilter === 'all' || localOcrTaskCategory(job) === localOcrTaskState.taskFilter;
});
if (!visibleJobs.length) {
const empty = document.createElement('div');
empty.className = 'mnote-local-ocr-task-empty';
empty.textContent = '暂无 OCR 任务';
empty.textContent = jobs.length ? `暂无${localOcrTaskFilterLabel(localOcrTaskState.taskFilter)}任务` : '暂无后台任务';
list.appendChild(empty);
return;
}
jobs.forEach((job) => {
visibleJobs.forEach((job) => {
const row = document.createElement('div');
row.className = 'mnote-local-ocr-task-row';
row.setAttribute('data-mnote-local-ocr-task-row', String(job.sourceRootRelativePath || ''));
row.setAttribute('data-mnote-local-ocr-task-status', String(job.status || ''));
row.setAttribute('data-mnote-local-ocr-task-category', localOcrTaskCategory(job));
const title = String(job.sourceRootRelativePath || '').split('/').filter(Boolean).pop() || 'OCR';
row.innerHTML = '<div class="mnote-local-ocr-task-main"><strong></strong><span></span></div><div class="mnote-local-ocr-task-actions"><button type="button" data-mnote-local-ocr-task-open>打开</button><button type="button" data-mnote-local-ocr-task-retry>重试</button><button type="button" data-mnote-local-ocr-task-clear>清除</button><button type="button" data-mnote-local-ocr-task-delete>删除</button></div>';
row.querySelector('strong').textContent = title;
row.querySelector('span').textContent = statusTextForLocalOcrJob(job);
const category = localOcrTaskCategory(job);
const progress = localOcrTaskProgress(job);
row.innerHTML = '<div class="mnote-local-ocr-task-main"><div class="mnote-local-ocr-task-title-line"><strong></strong><em></em></div><span></span><div class="mnote-local-ocr-task-progress" role="progressbar"><i></i></div></div><div class="mnote-local-ocr-task-actions"><button type="button" data-mnote-local-ocr-task-open>打开</button><button type="button" data-mnote-local-ocr-task-retry>重试</button><button type="button" data-mnote-local-ocr-task-clear>清除</button><button type="button" data-mnote-local-ocr-task-delete>删除</button></div>';
const titleNode = row.querySelector('strong');
if (titleNode instanceof HTMLElement) {
titleNode.textContent = title;
titleNode.setAttribute('title', String(job.sourceRootRelativePath || title));
}
const categoryNode = row.querySelector('em');
if (categoryNode instanceof HTMLElement) {
categoryNode.textContent = category === 'active' ? '进行中' : category === 'attention' ? '需处理' : '已完成';
}
const statusNode = row.querySelector('span');
if (statusNode instanceof HTMLElement) statusNode.textContent = statusTextForLocalOcrJob(job);
const progressBar = row.querySelector('.mnote-local-ocr-task-progress');
const progressValue = row.querySelector('.mnote-local-ocr-task-progress i');
if (progressBar instanceof HTMLElement && progressValue instanceof HTMLElement) {
progressBar.hidden = category !== 'active' && progress === null;
progressBar.setAttribute('aria-valuemin', '0');
progressBar.setAttribute('aria-valuemax', '100');
if (progress === null) {
progressBar.setAttribute('data-progress-mode', 'indeterminate');
progressBar.removeAttribute('aria-valuenow');
progressValue.style.width = '';
} else {
progressBar.setAttribute('data-progress-mode', 'determinate');
progressBar.setAttribute('aria-valuenow', String(progress));
progressValue.style.width = `${progress}%`;
}
}
const open = row.querySelector('[data-mnote-local-ocr-task-open]');
if (open instanceof HTMLButtonElement) {
open.setAttribute('data-mnote-local-ocr-task-open', String(job.sourceRootRelativePath || ''));
@@ -2178,6 +2324,20 @@ export const createResourceTabRuntime = (dependencies = {}) => {
return true;
};
const officePreviewBaseHref = (href) => {
const raw = String(href || '').trim();
if (!raw) return '';
try {
const url = new URL(raw, window.location.origin);
['page', 'bbox', 'sourceMapPath', 'blockId', 'lineRange', 'charRange', 'mnoteResourceReload'].forEach((key) => {
url.searchParams.delete(key);
});
return url.pathname + '?' + url.searchParams.toString();
} catch (_) {
return raw;
}
};
const refreshExistingOfficeResourceTab = (entry, input) => {
if (!entry || entry.kind !== 'office') return false;
const nextHref = String(input.officeUrl || input.href || '').trim();
@@ -2186,7 +2346,7 @@ export const createResourceTabRuntime = (dependencies = {}) => {
const currentHref = frame instanceof HTMLIFrameElement
? String(frame.getAttribute('src') || frame.src || '').trim()
: '';
if (currentHref !== nextHref) void openPassiveResourceTab(entry, input);
if (officePreviewBaseHref(currentHref) !== officePreviewBaseHref(nextHref)) void openPassiveResourceTab(entry, input);
return true;
};